Files
DOLPHIN/prod/scripts/analyze_leverage_winrate.py

189 lines
7.2 KiB
Python
Raw Permalink Normal View History

feat(config/scripts): GREEN config wiring + S6 recompute tooling prod/configs/green.yml: - asset_bucket_ban_set: [4] (B4 banned at selector level) - s6_size_table: inline bootstrap multipliers (B0→0.4, B1→0.3, B3→2.0, B5→0.5, B6→1.5) matching CRITICAL_ASSET_PICKING S6 scenario - esof_sizing_table: FAV→1.2, MILD_POS→0.6, UNKNOWN→0.25, MILD_NEG→0.0, UNFAV→0.0 - use_int_leverage: true (1x fixed pending winrate analysis) - s6_table_path: pointer to generated YAML (recompute updates this) BLUE (blue.yml) carries none of these keys → BLUE math unchanged. prod/configs/green_s6_table.yml: bootstrap stub with frontmatter (generated_at, source_branch, n_trades). Regenerated by recompute script. prod/scripts/recompute_s6_coefficients.py: Queries trade_events, maps assets to KMeans buckets, derives per-bucket sizing mults. Variance guard: >20% net-PnL move flags bucket in dolphin.s6_recompute_log for manual review before promote. prod/s6_recompute_flow.py: Prefect flow wrapping the recompute script. Cadence via S6_RECOMPUTE_INTERVAL_DAYS env (default 30). Kill-switch: S6_RECOMPUTE_DISABLED=1. prod/scripts/analyze_leverage_winrate.py: Read-only walk of CH trade_events; bins trades by leverage_raw, emits per-bin WR/net-PnL/avg-MAE. Output informs the int-leverage rounding rule choice (Option 1 round-half-up vs Option 2 banker's round vs stay-at-1x). Does not auto-apply a rule change. Plan refs: Tasks 3, 8, 10. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:08:08 +02:00
#!/usr/bin/env python3
"""Leverage → WR / net-PnL analysis over CH `dolphin.trade_events`.
Purpose
The target exchanges (Binance futures) require integer leverage. The GREEN
orchestrator single-site (`esf_alpha_orchestrator.py`) therefore rounds
leverage to an integer at entry time; the current default is FIXED 1x with
two candidate rounding rules commented in source, pending this analysis:
Option 1 (round-half-up, conservative): int(math.floor(raw + 0.5)), min=1
Option 2 (banker's round, aggressive): int(round(raw)), min=1
Stay-at-1x (current default): leverage_int = 1
This script walks historical trades and, for every distinct leverage value
observed (bucketed to a user-chosen step), reports per-bin WR, net-PnL,
avg-MAE and sample count. The output report informs the rule choice.
Decision rubric (documented in the generated report)
- Higher-lev bins show clearly better net-PnL w/ tight sample error Option 2
- Flat / noisy signal across bins Option 1
- Higher-lev bins show WORSE net-PnL / mixed stay 1x
This script is read-only; it does not auto-apply a rule change.
Usage
python prod/scripts/analyze_leverage_winrate.py \\
--strategy blue \\
--since 2026-01-01 \\
--step 0.5 \\
--out prod/docs/LEVERAGE_WINRATE_REPORT_2026-04-21.md
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
CH_URL = "http://localhost:8123/"
CH_HEADERS = {
"X-ClickHouse-User": "dolphin",
"X-ClickHouse-Key": "dolphin_ch_2026",
}
def ch_query(sql: str) -> List[Dict[str, Any]]:
"""Run a CH query returning JSONEachRow; raise if the HTTP call fails."""
qs = urllib.parse.urlencode({"query": sql + " FORMAT JSONEachRow"})
req = urllib.request.Request(f"{CH_URL}?{qs}", method="GET")
for k, v in CH_HEADERS.items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read().decode("utf-8", errors="replace")
rows: List[Dict[str, Any]] = []
for line in body.splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def bin_leverage(lev: float, step: float) -> float:
"""Bucket leverage to `step`-wide bins, anchored at 0. Returns bin lower edge."""
if lev is None or not math.isfinite(lev) or lev <= 0:
return 0.0
return math.floor(lev / step) * step
def load_trades(strategy: str, since: str, until: Optional[str]) -> List[Dict[str, Any]]:
"""Pull trade_events rows. Uses leverage_raw if present (post-sprint rows),
else falls back to leverage (pre-sprint rows on the same trade semantics).
"""
where = [f"strategy = '{strategy}'", f"date >= toDate('{since}')"]
if until:
where.append(f"date <= toDate('{until}')")
where_sql = " AND ".join(where)
sql = (
"SELECT "
" toDate(date) AS d, "
" asset, "
" coalesce(leverage_raw, leverage) AS lev_raw, "
" leverage AS lev_effective, "
" pnl, pnl_pct, "
" (pnl_pct > 0) AS is_win, "
" exit_reason "
f"FROM dolphin.trade_events WHERE {where_sql}"
)
return ch_query(sql)
def aggregate(trades: List[Dict[str, Any]], step: float) -> List[Dict[str, Any]]:
bins: Dict[float, Dict[str, Any]] = {}
for t in trades:
lev = float(t.get("lev_raw") or 0.0)
b = bin_leverage(lev, step)
s = bins.setdefault(b, {
"bin_lo": b,
"bin_hi": b + step,
"n": 0,
"wins": 0,
"net_pnl": 0.0,
"net_pnl_pct": 0.0,
})
s["n"] += 1
if int(t.get("is_win", 0)):
s["wins"] += 1
s["net_pnl"] += float(t.get("pnl") or 0.0)
s["net_pnl_pct"] += float(t.get("pnl_pct") or 0.0)
out = []
for b, s in sorted(bins.items()):
n = max(1, s["n"])
out.append({
"bin": f"{s['bin_lo']:.2f}{s['bin_hi']:.2f}x",
"n": s["n"],
"wr_pct": round(100.0 * s["wins"] / n, 2),
"avg_pnl_pct": round(s["net_pnl_pct"] / n * 100.0, 3),
"net_pnl": round(s["net_pnl"], 2),
})
return out
def render_markdown(rows: List[Dict[str, Any]],
strategy: str, since: str, until: Optional[str],
step: float, total: int) -> str:
hdr = (
"# Leverage → Winrate / Net-PnL Analysis\n\n"
f"- Strategy: `{strategy}`\n"
f"- Window: `{since}` → `{until or 'now'}`\n"
f"- Leverage bin step: `{step}x`\n"
f"- Total trades in scope: `{total}`\n"
f"- Generated: `{datetime.utcnow().isoformat(timespec='seconds')}Z`\n\n"
"## Per-bin aggregates\n\n"
"| Leverage bin | Trades | WR % | Avg PnL % | Net PnL |\n"
"|---|---:|---:|---:|---:|\n"
)
body = "\n".join(
f"| {r['bin']} | {r['n']} | {r['wr_pct']} | {r['avg_pnl_pct']} | {r['net_pnl']} |"
for r in rows
)
decision = (
"\n\n## Decision rubric (copy into the orchestrator comment block)\n\n"
"- Higher-lev bins show clearly better net-PnL w/ tight sample error → **Option 2** (banker's round, min=1)\n"
"- Flat / noisy signal across bins → **Option 1** (round-half-up, min=1)\n"
"- Higher-lev bins show WORSE net-PnL / mixed → **stay at 1x** (current default)\n\n"
"_Flipping the rule is a follow-up PR — this script is read-only._\n"
)
return hdr + body + decision
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--strategy", default="blue",
help="trade_events.strategy value to filter on (default: blue — richest historical set)")
ap.add_argument("--since", default="2026-01-01", help="inclusive start date (YYYY-MM-DD)")
ap.add_argument("--until", default=None, help="inclusive end date (YYYY-MM-DD); default = now")
ap.add_argument("--step", type=float, default=1.0, help="leverage bin width (e.g. 0.5 or 1.0)")
ap.add_argument("--out", default="prod/docs/LEVERAGE_WINRATE_REPORT.md",
help="output report path (markdown)")
args = ap.parse_args()
trades = load_trades(args.strategy, args.since, args.until)
if not trades:
print(f"No trades matched (strategy={args.strategy}, since={args.since}).", file=sys.stderr)
return 1
rows = aggregate(trades, args.step)
md = render_markdown(rows, args.strategy, args.since, args.until, args.step, total=len(trades))
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md)
print(f"Wrote {out_path} ({len(rows)} bins, {len(trades)} trades)")
return 0
if __name__ == "__main__":
sys.exit(main())