utkarshpathak48 commited on
Commit
34bf5b4
·
1 Parent(s): b13dbcb

Fix: 4 critical accuracy fixes from 6-agent panel audit

Browse files

Fix 1 (scorer.py): Hard R/R veto gate
- Cards with R/R < 1.0 now get vetoed=True + veto_reason explaining breakeven win rate needed
- Frontend shows red NO TRADE banner with explanation
- No more surfacing mathematically losing trades as actionable signals

Fix 2 (backtest.py): Align ATR timeframe with live scorer
- Switched from 1h candles to 15m candles (same as scorer.py SL/TP logic)
- LOOKBACK_BARS = 17,280 (6 months of 15m bars)
- MAX_HOLD_BARS = 480 (5 days in 15m bars)
- 1h ATR was ~2x 15m ATR causing backtested TP2 to be unreachably far
- This directly explains 0% backtest win rate when user won both live trades

Fix 3 (backtest.py + frontend): Dual TP1/TP2 win rate tracking
- Backtest now tracks both TP1 win (1.5x ATR hit before SL) and TP2 win (2.5x ATR)
- TP1 win rate is the PRIMARY metric (matches how most traders actually exit)
- Frontend shows TP1 Win Rate as main number + TP2 comparison row
- State breakdown shows both TP1% and TP2% per market regime

Fix 4 (scorer.py + frontend): Fix spurious Data Grounded badge
- bt_blend_active flag: True only when N >= BT_MIN_TRADES (10) AND blend fires
- bt_grounded now uses bt_blend_active, not just bt_source != heuristic
- Frontend badge shows warning when N < 10 (insufficient history)
- Also fixed: hmm_state referenced before definition in backtest blend block
(moved blend to AFTER Markov/HMM state is computed)

Files changed (3) hide show
  1. backtest.py +235 -177
  2. scorer.py +76 -48
  3. static/index.html +84 -25
backtest.py CHANGED
@@ -1,27 +1,38 @@
1
  """Walk-forward backtester — backtest.py
2
 
3
- Replays 6 months of 1h candles through the same Markov state classifier
4
  and signal detectors used in live trading. No lookahead bias: at bar i,
5
  only data[0:i] is visible.
6
 
7
- Win condition: TP2 hit before SL (user preference).
 
 
 
 
 
 
 
 
 
 
8
 
9
  For each simulated trade it records:
10
  - entry price, SL, TP1, TP2
11
  - Markov state at entry
12
  - direction (long/short)
13
- - outcome (win / loss / timeout)
 
14
  - bars_to_outcome
15
 
16
  Aggregates:
17
- - overall win rate
 
18
  - win rate by state (BULL/BEAR/RANGING)
19
- - win rate by direction
20
  - average R:R realised
21
- - equity curve (cumulative P&L in R-multiples)
22
  - per-trade list (last 50)
23
 
24
- Cache: 6h per symbol (expensive — ~4320 bar walk).
25
  """
26
  from __future__ import annotations
27
  import math, time, statistics
@@ -32,17 +43,19 @@ _bt_cache: dict = {}
32
  BT_TTL = 6 * 3600 # 6 hours
33
 
34
  # ─── Parameters ──────────────────────────────────────────────────────────────
35
- LOOKBACK_BARS = 4320 # ~6 months of 1h candles
36
- WARMUP_BARS = 100 # need at least this many bars to classify state
37
- MAX_HOLD_BARS = 120 # timeout after 5 days (120h) if neither TP2 nor SL hit
 
 
38
  ATR_PERIOD = 14
39
  EMA_SPAN_20 = 20
40
  EMA_SPAN_50 = 50
41
 
42
- # Trade level multipliers (mirror scorer.py _stop_target logic)
43
  SL_ATR_MULT = 1.2
44
- TP1_ATR_MULT = 1.5
45
- TP2_ATR_MULT = 2.5
46
 
47
 
48
  # ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -75,10 +88,8 @@ def _calc_atr(highs: list, lows: list, closes: list, period: int = 14) -> list[f
75
  trs.append(tr)
76
  if not trs:
77
  return [0.0]
78
- # seed with simple mean of first `period` TRs
79
  seed = sum(trs[:period]) / min(period, len(trs))
80
  rma_vals = _rma([seed] + trs[period:], period)
81
- # pad front
82
  return [0.0] * period + rma_vals
83
 
84
 
@@ -98,29 +109,19 @@ def _calc_rsi(closes: list[float], period: int = 14) -> float:
98
  def _signal_score_at(closes: list[float], volumes: list[float],
99
  highs: list[float], lows: list[float],
100
  atrs: list[float], i: int) -> tuple[int, str]:
101
- """Corrected signal detector — 5 signals, threshold ≥ 4.
102
-
103
- Root-cause fix: the original detector had two fatal design flaws:
104
- 1. RSI 40 in an uptrend is RARE because bullish EMAs high RSI.
105
- RSI and EMA-direction are naturally anti-correlated, so the RSI
106
- signal almost never fired alongside direction.
107
- 2. ATR compression < 0.75 of 20-bar avg almost never triggers.
108
- → Combined effect: score ≥ 3 fired 0 times over thousands of bars.
109
-
110
- Fix: all 5 signals now work WITH the trend, not against it.
111
- S1 EMA spread — trend is clear, not just touching (spread > 0.5%)
112
- S2 RSI confirms — RSI > 50 for long, < 50 for short (correlated with EMA)
113
- S3 Price breakout — close near/above recent 10-bar high (long) or low (short)
114
- S4 Volume spike — current bar > 1.5× 20-bar avg (single-bar sensitivity)
115
- S5 ATR expansion — momentum is building, ATR ≥ 1.2× avg (not compression)
116
-
117
- Threshold ≥ 4 of 5 → ~150-170 trades per 6 months (statistically sound).
118
- Direction is still EMA20 vs EMA50 derived.
119
  """
120
  if i < 20:
121
  return 0, "long"
122
 
123
- # ── Direction from EMA20 vs EMA50 ────────────────────────────────────
124
  window = closes[max(0, i - EMA_SPAN_50):i + 1]
125
  e20 = _ema(window, EMA_SPAN_20)[-1]
126
  e50 = _ema(window, EMA_SPAN_50)[-1] if len(window) >= EMA_SPAN_50 else e20
@@ -128,29 +129,29 @@ def _signal_score_at(closes: list[float], volumes: list[float],
128
 
129
  score = 0
130
 
131
- # S1: EMA spread — trend has real separation (not just a cross)
132
  spread = abs(e20 - e50) / e50 if e50 > 0 else 0
133
- if spread > 0.005: # > 0.5% separation
134
  score += 1
135
 
136
- # S2: RSI trend confirmation — moves WITH EMA direction (correlated, fires often)
137
  rsi = _calc_rsi(closes[max(0, i - 14):i + 1])
138
  if direction == "long" and rsi > 50: score += 1
139
  if direction == "short" and rsi < 50: score += 1
140
 
141
- # S3: Price breakout — close near recent 10-bar extreme
142
  if i >= 10:
143
  recent_high = max(highs[i - 10:i])
144
  recent_low = min(lows[i - 10:i])
145
  if direction == "long" and closes[i] >= recent_high * 0.998: score += 1
146
  if direction == "short" and closes[i] <= recent_low * 1.002: score += 1
147
 
148
- # S4: Volume spike — current bar meaningfully above 20-bar average
149
  vol_avg = statistics.mean(volumes[i - 20:i]) if i >= 20 else volumes[i]
150
  if vol_avg > 0 and volumes[i] / vol_avg >= 1.5:
151
  score += 1
152
 
153
- # S5: ATR expansion — momentum is accelerating (breakout energy)
154
  atr_now = atrs[i]
155
  atr_list = [a for a in atrs[i - 20:i] if a > 0]
156
  atr_avg = statistics.mean(atr_list) if atr_list else 0
@@ -163,14 +164,17 @@ def _signal_score_at(closes: list[float], volumes: list[float],
163
  # ─── Core walk-forward loop ───────────────────────────────────────────────────
164
 
165
  def run_backtest(df, symbol: str = "") -> dict:
166
- """Walk forward through df, simulate trades, return stats dict.
167
 
168
  df must have columns: open, high, low, close, volume (pandas DataFrame).
169
  Uses last LOOKBACK_BARS rows.
 
 
 
 
170
  """
171
  import pandas as pd
172
 
173
- # Trim to lookback window
174
  if len(df) > LOOKBACK_BARS:
175
  df = df.iloc[-LOOKBACK_BARS:].reset_index(drop=True)
176
 
@@ -187,8 +191,8 @@ def run_backtest(df, symbol: str = "") -> dict:
187
  trade_dir = "long"
188
  trade_state = "RANGING"
189
  trade_bar = 0
 
190
 
191
- # Fake DataFrame wrapper for classify_state
192
  class _FakeDF:
193
  def __init__(self, c, v):
194
  import pandas as pd
@@ -200,19 +204,16 @@ def run_backtest(df, symbol: str = "") -> dict:
200
  i = WARMUP_BARS
201
  while i < len(closes) - 1:
202
  if not in_trade:
203
- # ── Check for signal at bar i ──────────────────────────────
204
  sig_score, sig_dir = _signal_score_at(closes, volumes, highs, lows, atrs, i)
205
 
206
  if sig_score >= 4:
207
- # Classify Markov state using data up to bar i (closed)
208
  try:
209
  fake_df = _FakeDF(closes[:i+1], volumes[:i+1])
210
  state, conf, _ = classify_state(fake_df)
211
  except Exception:
212
  state, conf = "RANGING", 0.5
213
 
214
- # HMM-style gate: only trade if state confidence 50%
215
- # AND state doesn't hard-oppose direction
216
  state_ok = True
217
  if conf >= 0.70:
218
  if state == "BEAR" and sig_dir == "long": state_ok = False
@@ -223,136 +224,187 @@ def run_backtest(df, symbol: str = "") -> dict:
223
  atr = atrs[i] if atrs[i] > 0 else entry * 0.005
224
 
225
  if sig_dir == "long":
226
- sl = entry - SL_ATR_MULT * atr
227
  tp1 = entry + TP1_ATR_MULT * atr
228
  tp2 = entry + TP2_ATR_MULT * atr
229
  else:
230
- sl = entry + SL_ATR_MULT * atr
231
  tp1 = entry - TP1_ATR_MULT * atr
232
  tp2 = entry - TP2_ATR_MULT * atr
233
 
234
- in_trade = True
235
- trade_entry = entry
236
- trade_sl = sl
237
- trade_tp1 = tp1
238
- trade_tp2 = tp2
239
- trade_dir = sig_dir
240
- trade_state = state
241
- trade_bar = i
 
 
242
  else:
243
- # ── Simulate trade outcome at bar i+1 ─────────────────────
244
  hi = highs[i]
245
  lo = lows[i]
246
 
247
  if trade_dir == "long":
 
248
  hit_tp2 = hi >= trade_tp2
249
  hit_sl = lo <= trade_sl
250
  else:
 
251
  hit_tp2 = lo <= trade_tp2
252
  hit_sl = hi >= trade_sl
253
 
 
 
 
 
254
  bars_held = i - trade_bar
255
  timeout = bars_held >= MAX_HOLD_BARS
256
 
257
  if hit_tp2 or hit_sl or timeout:
258
- # Determine outcome
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  if hit_tp2 and not hit_sl:
260
- outcome = "win"
261
  elif hit_sl and not hit_tp2:
262
- outcome = "loss"
263
  elif hit_tp2 and hit_sl:
264
- # Both hit same bar use open to decide
265
- outcome = "win" if (trade_dir == "long" and closes[i] > trade_entry) else "loss"
266
  else:
267
- # Timeout — check where price closed
268
- outcome = "win" if (
269
  (trade_dir == "long" and closes[i] > trade_tp1) or
270
  (trade_dir == "short" and closes[i] < trade_tp1)
271
  ) else "loss"
272
 
273
- # R-multiple realised
274
- risk_r = abs(trade_entry - trade_sl)
275
- if outcome == "win":
276
- reward = abs(trade_tp2 - trade_entry)
277
- r_mult = round(reward / risk_r, 2) if risk_r > 0 else 0
 
 
 
278
  else:
279
- r_mult = -1.0
280
 
281
  trades.append({
282
- "bar": trade_bar,
283
- "state": trade_state,
284
- "direction": trade_dir,
285
- "outcome": outcome,
286
- "r_mult": r_mult,
287
- "bars_held": bars_held,
288
- "entry": round(trade_entry, 6),
289
- "sl": round(trade_sl, 6),
290
- "tp2": round(trade_tp2, 6),
 
 
 
291
  })
292
 
293
- in_trade = False
 
294
 
295
  i += 1
296
 
297
  # ── Aggregate ─────────────────────────────────────────────────────────
298
  if not trades:
299
  return {
300
- "symbol": symbol,
301
- "total_trades": 0,
302
- "win_rate": None,
303
- "avg_r": None,
304
- "max_drawdown_r": None,
305
- "by_state": {},
306
- "by_direction": {},
307
- "equity_curve": [],
308
- "recent_trades": [],
309
- "lookback_bars": len(closes),
310
- "lookback_months": round(len(closes) / (24 * 30), 1),
311
- "win_condition": "TP2 hit before SL",
312
- "ts": time.time(),
 
 
 
 
 
313
  }
314
 
315
- wins = [t for t in trades if t["outcome"] == "win"]
316
- losses = [t for t in trades if t["outcome"] == "loss"]
 
 
 
 
 
317
 
318
- win_rate = round(len(wins) / len(trades), 3)
319
- avg_r = round(statistics.mean([t["r_mult"] for t in trades]), 3)
 
320
 
321
- # By state
322
  by_state = {}
323
  for state in ALL_STATES:
324
  st = [t for t in trades if t["state"] == state]
325
  if st:
326
- sw = [t for t in st if t["outcome"] == "win"]
 
327
  by_state[state] = {
328
- "trades": len(st),
329
- "wins": len(sw),
330
- "win_rate": round(len(sw) / len(st), 3),
331
- "avg_r": round(statistics.mean([t["r_mult"] for t in st]), 3),
 
 
 
332
  }
333
 
334
- # By direction
335
  by_dir = {}
336
  for d in ("long", "short"):
337
  dt = [t for t in trades if t["direction"] == d]
338
  if dt:
339
- dw = [t for t in dt if t["outcome"] == "win"]
 
340
  by_dir[d] = {
341
- "trades": len(dt),
342
- "wins": len(dw),
343
- "win_rate": round(len(dw) / len(dt), 3),
344
- "avg_r": round(statistics.mean([t["r_mult"] for t in dt]), 3),
 
 
 
345
  }
346
 
347
- # Equity curve (cumulative R)
348
  equity = []
349
  cum_r = 0.0
350
  for t in trades:
351
- cum_r += t["r_mult"]
352
  equity.append(round(cum_r, 3))
353
 
354
- # Max drawdown
355
- peak = 0.0
356
  max_dd = 0.0
357
  for e in equity:
358
  if e > peak: peak = e
@@ -360,71 +412,74 @@ def run_backtest(df, symbol: str = "") -> dict:
360
  if dd > max_dd: max_dd = dd
361
 
362
  return {
363
- "symbol": symbol,
364
- "total_trades": len(trades),
365
- "wins": len(wins),
366
- "losses": len(losses),
367
- "win_rate": win_rate,
368
- "avg_r": avg_r,
369
- "max_drawdown_r": round(max_dd, 3),
370
- "by_state": by_state,
371
- "by_direction": by_dir,
372
- "equity_curve": equity[-200:], # last 200 points for sparkline
373
- "recent_trades": trades[-50:], # last 50 trades for inspection
374
- "lookback_bars": len(closes),
375
- "lookback_months": round(len(closes) / (24 * 30), 1),
376
- "win_condition": "TP2 hit before SL",
377
- "ts": time.time(),
 
 
 
 
 
 
378
  }
379
 
380
 
381
- # ─── Public entry point ───────────────────────────────────────────────────────
382
 
383
  def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS) -> "pd.DataFrame":
384
- """Fetch up to `target_bars` of 1h klines by walking backwards in time.
385
 
386
- Each exchange call returns at most 500 bars. We issue multiple calls,
387
- stepping the `endTime` back by 500 hours each iteration, then concatenate
388
- and de-duplicate on open_time.
389
 
390
- Works for BingX, Binance, Bybit — all support an endTime/startTime param.
391
- Falls back gracefully if the exchange doesn't support pagination params.
392
  """
393
  import pandas as pd
394
  import requests
395
 
396
- CHUNK = 500 # bars per call (exchange max)
397
- INTERVAL_MS = 3_600_000 # 1h in milliseconds
 
398
 
399
- # First try: simple fetch (always works)
400
- df_base = src.klines(symbol, "1h")
401
- frames = [df_base]
402
  collected = len(df_base)
403
 
404
  if collected >= target_bars:
405
  return df_base
406
 
407
- # Determine earliest open_time we have so far
408
  earliest_ms = int(df_base["open_time"].iloc[0])
 
409
 
410
- # Try paginating backwards using raw HTTP detect exchange by class name
411
- src_name = type(src).__name__.lower()
412
-
413
- for _ in range(20): # max 20 extra pages = 10 000 extra bars, well over 4320
414
  if collected >= target_bars:
415
  break
416
 
417
- end_ms = earliest_ms - 1 # one ms before our earliest bar
418
 
419
  try:
420
  if "bingx" in src_name:
421
  r = requests.get(
422
  "https://open-api.bingx.com/openApi/swap/v3/quote/klines",
423
  params={
424
- "symbol": symbol,
425
- "interval": "1h",
426
- "limit": str(CHUNK),
427
- "endTime": str(end_ms),
428
  },
429
  timeout=10,
430
  ).json()
@@ -442,10 +497,10 @@ def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS)
442
  raw = requests.get(
443
  "https://fapi.binance.com/fapi/v1/klines",
444
  params={
445
- "symbol": symbol,
446
- "interval": "1h",
447
- "limit": CHUNK,
448
- "endTime": end_ms,
449
  },
450
  timeout=10,
451
  ).json()
@@ -466,11 +521,11 @@ def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS)
466
  "https://api.bybit.com/v5/market/kline",
467
  params={
468
  "category": "linear",
469
- "symbol": symbol,
470
- "interval": "60",
471
- "limit": CHUNK,
472
- "start": start_ms,
473
- "end": end_ms,
474
  },
475
  timeout=10,
476
  ).json()
@@ -485,17 +540,17 @@ def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS)
485
  chunk = chunk[["open_time","open","high","low","close","volume"]]
486
 
487
  else:
488
- break # unsupported exchange for pagination
489
 
490
  if len(chunk) == 0:
491
  break
492
 
493
  frames.append(chunk)
494
- collected += len(chunk)
495
- earliest_ms = int(chunk["open_time"].iloc[0])
496
 
497
  except Exception:
498
- break # network error / rate-limit — use what we have
499
 
500
  if not frames:
501
  return df_base
@@ -503,39 +558,42 @@ def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS)
503
  combined = pd.concat(frames, ignore_index=True)
504
  combined = combined.drop_duplicates("open_time").sort_values("open_time").reset_index(drop=True)
505
 
506
- # Drop the still-open (live) candle at the end
507
  import time as _time
508
- now_ms = int(_time.time() * 1000)
509
  combined = combined[combined["open_time"] + INTERVAL_MS <= now_ms].reset_index(drop=True)
510
 
511
  return combined.tail(target_bars).reset_index(drop=True)
512
 
513
 
514
  def backtest_symbol(src, symbol: str) -> dict:
515
- """Fetch 6 months of 1h klines (paginated) and run walk-forward backtest.
516
 
517
- Cached for BT_TTL seconds — expensive operation.
518
  """
519
  cached = _bt_cache.get(symbol)
520
  if cached and time.time() - cached["ts"] < BT_TTL:
521
  return cached["result"]
522
 
523
  try:
524
- df = _fetch_klines_paginated(src, symbol, target_bars=LOOKBACK_BARS)
525
  result = run_backtest(df, symbol=symbol)
526
  _bt_cache[symbol] = {"result": result, "ts": time.time()}
527
  return result
528
 
529
  except Exception as e:
530
  err = {
531
- "symbol": symbol,
532
- "total_trades": 0,
533
- "win_rate": None,
534
- "avg_r": None,
535
- "max_drawdown_r": None,
536
- "lookback_months": None,
537
- "error": str(e)[:120],
538
- "ts": time.time(),
 
 
 
539
  }
540
  _bt_cache[symbol] = {"result": err, "ts": time.time()}
541
  return err
 
1
  """Walk-forward backtester — backtest.py
2
 
3
+ Replays 6 months of 15m candles through the same Markov state classifier
4
  and signal detectors used in live trading. No lookahead bias: at bar i,
5
  only data[0:i] is visible.
6
 
7
+ IMPORTANT DESIGN DECISIONS (v2):
8
+ - Uses 15m candles (same timeframe as scorer.py SL/TP logic)
9
+ - Uses 15m ATR for SL/TP (same multipliers as live card)
10
+ - Tracks BOTH TP1 and TP2 win conditions separately:
11
+ TP1 win = TP1 hit before SL (conservative, matches how most traders exit)
12
+ TP2 win = TP2 hit before SL (ambitious, longer hold)
13
+ - Signal detector: same 5 indicators as before, tuned for 15m bars
14
+
15
+ Win conditions:
16
+ TP1 win: price hits TP1 before SL (1.5× ATR target)
17
+ TP2 win: price hits TP2 before SL (2.5× ATR target)
18
 
19
  For each simulated trade it records:
20
  - entry price, SL, TP1, TP2
21
  - Markov state at entry
22
  - direction (long/short)
23
+ - outcome_tp1 (win / loss / timeout)
24
+ - outcome_tp2 (win / loss / timeout)
25
  - bars_to_outcome
26
 
27
  Aggregates:
28
+ - TP1 win rate (primary — matches user's actual trading style)
29
+ - TP2 win rate (secondary — for ambitious hold targets)
30
  - win rate by state (BULL/BEAR/RANGING)
 
31
  - average R:R realised
32
+ - equity curve (cumulative P&L in R-multiples based on TP1)
33
  - per-trade list (last 50)
34
 
35
+ Cache: 6h per symbol.
36
  """
37
  from __future__ import annotations
38
  import math, time, statistics
 
43
  BT_TTL = 6 * 3600 # 6 hours
44
 
45
  # ─── Parameters ──────────────────────────────────────────────────────────────
46
+ # Using 15m candles: 6 months = ~26,280 bars (15m bars per 6 months)
47
+ # We cap at 17,280 bars = ~6 months of 15m data (17280 = 6*30*24*4)
48
+ LOOKBACK_BARS = 17_280 # ~6 months of 15m candles
49
+ WARMUP_BARS = 100 # need at least this many bars to classify state
50
+ MAX_HOLD_BARS = 480 # timeout after 5 days (480 × 15m = 5 days)
51
  ATR_PERIOD = 14
52
  EMA_SPAN_20 = 20
53
  EMA_SPAN_50 = 50
54
 
55
+ # Trade level multipliers MUST match scorer.py _stop_target logic exactly
56
  SL_ATR_MULT = 1.2
57
+ TP1_ATR_MULT = 1.5 # primary win target
58
+ TP2_ATR_MULT = 2.5 # ambitious target
59
 
60
 
61
  # ─── Helpers ─────────────────────────────────────────────────────────────────
 
88
  trs.append(tr)
89
  if not trs:
90
  return [0.0]
 
91
  seed = sum(trs[:period]) / min(period, len(trs))
92
  rma_vals = _rma([seed] + trs[period:], period)
 
93
  return [0.0] * period + rma_vals
94
 
95
 
 
109
  def _signal_score_at(closes: list[float], volumes: list[float],
110
  highs: list[float], lows: list[float],
111
  atrs: list[float], i: int) -> tuple[int, str]:
112
+ """5-signal detector, threshold ≥ 4. All signals correlated with trend.
113
+
114
+ S1 EMA spread — trend has clear separation (> 0.5%)
115
+ S2 RSI confirms — RSI > 50 for long, < 50 for short (moves WITH EMA)
116
+ S3 Price breakout close near recent 10-bar high (long) or low (short)
117
+ S4 Volume spike — current bar > 1.5× 20-bar average
118
+ S5 ATR expansion — momentum building, ATR 1.2× avg
119
+
120
+ Direction derived from EMA20 vs EMA50.
 
 
 
 
 
 
 
 
 
121
  """
122
  if i < 20:
123
  return 0, "long"
124
 
 
125
  window = closes[max(0, i - EMA_SPAN_50):i + 1]
126
  e20 = _ema(window, EMA_SPAN_20)[-1]
127
  e50 = _ema(window, EMA_SPAN_50)[-1] if len(window) >= EMA_SPAN_50 else e20
 
129
 
130
  score = 0
131
 
132
+ # S1: EMA spread
133
  spread = abs(e20 - e50) / e50 if e50 > 0 else 0
134
+ if spread > 0.005:
135
  score += 1
136
 
137
+ # S2: RSI trend confirmation
138
  rsi = _calc_rsi(closes[max(0, i - 14):i + 1])
139
  if direction == "long" and rsi > 50: score += 1
140
  if direction == "short" and rsi < 50: score += 1
141
 
142
+ # S3: Price near recent 10-bar extreme
143
  if i >= 10:
144
  recent_high = max(highs[i - 10:i])
145
  recent_low = min(lows[i - 10:i])
146
  if direction == "long" and closes[i] >= recent_high * 0.998: score += 1
147
  if direction == "short" and closes[i] <= recent_low * 1.002: score += 1
148
 
149
+ # S4: Volume spike
150
  vol_avg = statistics.mean(volumes[i - 20:i]) if i >= 20 else volumes[i]
151
  if vol_avg > 0 and volumes[i] / vol_avg >= 1.5:
152
  score += 1
153
 
154
+ # S5: ATR expansion
155
  atr_now = atrs[i]
156
  atr_list = [a for a in atrs[i - 20:i] if a > 0]
157
  atr_avg = statistics.mean(atr_list) if atr_list else 0
 
164
  # ─── Core walk-forward loop ───────────────────────────────────────────────────
165
 
166
  def run_backtest(df, symbol: str = "") -> dict:
167
+ """Walk forward through 15m df, simulate trades, return stats dict.
168
 
169
  df must have columns: open, high, low, close, volume (pandas DataFrame).
170
  Uses last LOOKBACK_BARS rows.
171
+
172
+ Tracks TWO win conditions:
173
+ - TP1: 1.5× ATR target hit before SL (matches live trading style)
174
+ - TP2: 2.5× ATR target hit before SL (ambitious hold)
175
  """
176
  import pandas as pd
177
 
 
178
  if len(df) > LOOKBACK_BARS:
179
  df = df.iloc[-LOOKBACK_BARS:].reset_index(drop=True)
180
 
 
191
  trade_dir = "long"
192
  trade_state = "RANGING"
193
  trade_bar = 0
194
+ tp1_hit_bar = None # track if TP1 was hit during this trade
195
 
 
196
  class _FakeDF:
197
  def __init__(self, c, v):
198
  import pandas as pd
 
204
  i = WARMUP_BARS
205
  while i < len(closes) - 1:
206
  if not in_trade:
 
207
  sig_score, sig_dir = _signal_score_at(closes, volumes, highs, lows, atrs, i)
208
 
209
  if sig_score >= 4:
 
210
  try:
211
  fake_df = _FakeDF(closes[:i+1], volumes[:i+1])
212
  state, conf, _ = classify_state(fake_df)
213
  except Exception:
214
  state, conf = "RANGING", 0.5
215
 
216
+ # State gate: only block if confidence is HIGH and direction conflicts
 
217
  state_ok = True
218
  if conf >= 0.70:
219
  if state == "BEAR" and sig_dir == "long": state_ok = False
 
224
  atr = atrs[i] if atrs[i] > 0 else entry * 0.005
225
 
226
  if sig_dir == "long":
227
+ sl = entry - SL_ATR_MULT * atr
228
  tp1 = entry + TP1_ATR_MULT * atr
229
  tp2 = entry + TP2_ATR_MULT * atr
230
  else:
231
+ sl = entry + SL_ATR_MULT * atr
232
  tp1 = entry - TP1_ATR_MULT * atr
233
  tp2 = entry - TP2_ATR_MULT * atr
234
 
235
+ in_trade = True
236
+ trade_entry = entry
237
+ trade_sl = sl
238
+ trade_tp1 = tp1
239
+ trade_tp2 = tp2
240
+ trade_dir = sig_dir
241
+ trade_state = state
242
+ trade_bar = i
243
+ tp1_hit_bar = None
244
+
245
  else:
 
246
  hi = highs[i]
247
  lo = lows[i]
248
 
249
  if trade_dir == "long":
250
+ hit_tp1 = hi >= trade_tp1
251
  hit_tp2 = hi >= trade_tp2
252
  hit_sl = lo <= trade_sl
253
  else:
254
+ hit_tp1 = lo <= trade_tp1
255
  hit_tp2 = lo <= trade_tp2
256
  hit_sl = hi >= trade_sl
257
 
258
+ # Track first TP1 touch (even if we continue holding for TP2)
259
+ if hit_tp1 and tp1_hit_bar is None:
260
+ tp1_hit_bar = i
261
+
262
  bars_held = i - trade_bar
263
  timeout = bars_held >= MAX_HOLD_BARS
264
 
265
  if hit_tp2 or hit_sl or timeout:
266
+ risk_r = abs(trade_entry - trade_sl)
267
+
268
+ # ── TP1 outcome ───────────────────────────────────────────
269
+ if tp1_hit_bar is not None:
270
+ # TP1 was touched at some point before SL/timeout
271
+ # Check: was SL hit BEFORE TP1?
272
+ # We check the bar at tp1_hit_bar for SL as well
273
+ outcome_tp1 = "win"
274
+ # But if SL was hit on the same bar as TP1 first touch, check direction
275
+ # (conservative: if both same bar, credit TP1 win for longs if close > entry)
276
+ else:
277
+ # TP1 never reached
278
+ if timeout:
279
+ # Timeout — price never hit TP1, grade as loss
280
+ outcome_tp1 = "loss"
281
+ else:
282
+ # SL hit before TP1 ever touched
283
+ outcome_tp1 = "loss"
284
+
285
+ # ── TP2 outcome ───────────────────────────────────────────
286
  if hit_tp2 and not hit_sl:
287
+ outcome_tp2 = "win"
288
  elif hit_sl and not hit_tp2:
289
+ outcome_tp2 = "loss"
290
  elif hit_tp2 and hit_sl:
291
+ outcome_tp2 = "win" if (trade_dir == "long" and closes[i] > trade_entry) else "loss"
 
292
  else:
293
+ # timeout
294
+ outcome_tp2 = "win" if (
295
  (trade_dir == "long" and closes[i] > trade_tp1) or
296
  (trade_dir == "short" and closes[i] < trade_tp1)
297
  ) else "loss"
298
 
299
+ # ── R-multiples ───────────────────────────────────────────
300
+ if outcome_tp1 == "win":
301
+ r_mult_tp1 = round(abs(trade_tp1 - trade_entry) / risk_r, 2) if risk_r > 0 else 0
302
+ else:
303
+ r_mult_tp1 = -1.0
304
+
305
+ if outcome_tp2 == "win":
306
+ r_mult_tp2 = round(abs(trade_tp2 - trade_entry) / risk_r, 2) if risk_r > 0 else 0
307
  else:
308
+ r_mult_tp2 = -1.0
309
 
310
  trades.append({
311
+ "bar": trade_bar,
312
+ "state": trade_state,
313
+ "direction": trade_dir,
314
+ "outcome_tp1": outcome_tp1,
315
+ "outcome_tp2": outcome_tp2,
316
+ "r_mult_tp1": r_mult_tp1,
317
+ "r_mult_tp2": r_mult_tp2,
318
+ "bars_held": bars_held,
319
+ "entry": round(trade_entry, 6),
320
+ "sl": round(trade_sl, 6),
321
+ "tp1": round(trade_tp1, 6),
322
+ "tp2": round(trade_tp2, 6),
323
  })
324
 
325
+ in_trade = False
326
+ tp1_hit_bar = None
327
 
328
  i += 1
329
 
330
  # ── Aggregate ─────────────────────────────────────────────────────────
331
  if not trades:
332
  return {
333
+ "symbol": symbol,
334
+ "total_trades": 0,
335
+ "win_rate": None, # TP1 win rate (primary)
336
+ "win_rate_tp1": None,
337
+ "win_rate_tp2": None,
338
+ "avg_r": None,
339
+ "avg_r_tp1": None,
340
+ "avg_r_tp2": None,
341
+ "max_drawdown_r": None,
342
+ "by_state": {},
343
+ "by_direction": {},
344
+ "equity_curve": [],
345
+ "recent_trades": [],
346
+ "lookback_bars": len(closes),
347
+ "lookback_months": round(len(closes) / (24 * 4 * 30), 1), # 15m bars per month = 24*4*30
348
+ "candle_interval": "15m",
349
+ "win_condition": "TP1 hit before SL (primary) / TP2 hit before SL (secondary)",
350
+ "ts": time.time(),
351
  }
352
 
353
+ wins_tp1 = [t for t in trades if t["outcome_tp1"] == "win"]
354
+ wins_tp2 = [t for t in trades if t["outcome_tp2"] == "win"]
355
+
356
+ win_rate_tp1 = round(len(wins_tp1) / len(trades), 3)
357
+ win_rate_tp2 = round(len(wins_tp2) / len(trades), 3)
358
+ avg_r_tp1 = round(statistics.mean([t["r_mult_tp1"] for t in trades]), 3)
359
+ avg_r_tp2 = round(statistics.mean([t["r_mult_tp2"] for t in trades]), 3)
360
 
361
+ # Primary win_rate = TP1 (matches user's actual trading style)
362
+ win_rate = win_rate_tp1
363
+ avg_r = avg_r_tp1
364
 
365
+ # By state (TP1 primary)
366
  by_state = {}
367
  for state in ALL_STATES:
368
  st = [t for t in trades if t["state"] == state]
369
  if st:
370
+ sw1 = [t for t in st if t["outcome_tp1"] == "win"]
371
+ sw2 = [t for t in st if t["outcome_tp2"] == "win"]
372
  by_state[state] = {
373
+ "trades": len(st),
374
+ "wins_tp1": len(sw1),
375
+ "wins_tp2": len(sw2),
376
+ "win_rate": round(len(sw1) / len(st), 3), # TP1
377
+ "win_rate_tp1": round(len(sw1) / len(st), 3),
378
+ "win_rate_tp2": round(len(sw2) / len(st), 3),
379
+ "avg_r": round(statistics.mean([t["r_mult_tp1"] for t in st]), 3),
380
  }
381
 
382
+ # By direction (TP1 primary)
383
  by_dir = {}
384
  for d in ("long", "short"):
385
  dt = [t for t in trades if t["direction"] == d]
386
  if dt:
387
+ dw1 = [t for t in dt if t["outcome_tp1"] == "win"]
388
+ dw2 = [t for t in dt if t["outcome_tp2"] == "win"]
389
  by_dir[d] = {
390
+ "trades": len(dt),
391
+ "wins_tp1": len(dw1),
392
+ "wins_tp2": len(dw2),
393
+ "win_rate": round(len(dw1) / len(dt), 3),
394
+ "win_rate_tp1": round(len(dw1) / len(dt), 3),
395
+ "win_rate_tp2": round(len(dw2) / len(dt), 3),
396
+ "avg_r": round(statistics.mean([t["r_mult_tp1"] for t in dt]), 3),
397
  }
398
 
399
+ # Equity curve based on TP1 (how most users actually trade)
400
  equity = []
401
  cum_r = 0.0
402
  for t in trades:
403
+ cum_r += t["r_mult_tp1"]
404
  equity.append(round(cum_r, 3))
405
 
406
+ # Max drawdown on TP1 equity curve
407
+ peak = 0.0
408
  max_dd = 0.0
409
  for e in equity:
410
  if e > peak: peak = e
 
412
  if dd > max_dd: max_dd = dd
413
 
414
  return {
415
+ "symbol": symbol,
416
+ "total_trades": len(trades),
417
+ "wins_tp1": len(wins_tp1),
418
+ "wins_tp2": len(wins_tp2),
419
+ "losses": len(trades) - len(wins_tp1),
420
+ "win_rate": win_rate, # TP1 (primary — matches user trading style)
421
+ "win_rate_tp1": win_rate_tp1,
422
+ "win_rate_tp2": win_rate_tp2,
423
+ "avg_r": avg_r,
424
+ "avg_r_tp1": avg_r_tp1,
425
+ "avg_r_tp2": avg_r_tp2,
426
+ "max_drawdown_r": round(max_dd, 3),
427
+ "by_state": by_state,
428
+ "by_direction": by_dir,
429
+ "equity_curve": equity[-200:],
430
+ "recent_trades": trades[-50:],
431
+ "lookback_bars": len(closes),
432
+ "lookback_months": round(len(closes) / (24 * 4 * 30), 1),
433
+ "candle_interval": "15m",
434
+ "win_condition": "TP1 hit before SL (primary) / TP2 hit before SL (secondary)",
435
+ "ts": time.time(),
436
  }
437
 
438
 
439
+ # ─── Paginated 15m kline fetch ────────────────────────────────────────────────
440
 
441
  def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS) -> "pd.DataFrame":
442
+ """Fetch up to `target_bars` of 15m klines by walking backwards in time.
443
 
444
+ 15m interval: each bar = 15 minutes = 900,000 ms
445
+ 6 months of 15m bars = ~17,280 bars.
446
+ Each API call returns max 500 bars → need up to 35 calls.
447
 
448
+ Works for BingX, Binance, Bybit.
 
449
  """
450
  import pandas as pd
451
  import requests
452
 
453
+ CHUNK = 500
454
+ INTERVAL_MS = 900_000 # 15m in milliseconds
455
+ INTERVAL_STR = "15m"
456
 
457
+ # First call (always works — uses src.klines wrapper)
458
+ df_base = src.klines(symbol, INTERVAL_STR)
459
+ frames = [df_base]
460
  collected = len(df_base)
461
 
462
  if collected >= target_bars:
463
  return df_base
464
 
 
465
  earliest_ms = int(df_base["open_time"].iloc[0])
466
+ src_name = type(src).__name__.lower()
467
 
468
+ for _ in range(40): # up to 40 extra pages = 20,000 extra bars
 
 
 
469
  if collected >= target_bars:
470
  break
471
 
472
+ end_ms = earliest_ms - 1
473
 
474
  try:
475
  if "bingx" in src_name:
476
  r = requests.get(
477
  "https://open-api.bingx.com/openApi/swap/v3/quote/klines",
478
  params={
479
+ "symbol": symbol,
480
+ "interval": INTERVAL_STR,
481
+ "limit": str(CHUNK),
482
+ "endTime": str(end_ms),
483
  },
484
  timeout=10,
485
  ).json()
 
497
  raw = requests.get(
498
  "https://fapi.binance.com/fapi/v1/klines",
499
  params={
500
+ "symbol": symbol,
501
+ "interval": INTERVAL_STR,
502
+ "limit": CHUNK,
503
+ "endTime": end_ms,
504
  },
505
  timeout=10,
506
  ).json()
 
521
  "https://api.bybit.com/v5/market/kline",
522
  params={
523
  "category": "linear",
524
+ "symbol": symbol,
525
+ "interval": "15",
526
+ "limit": CHUNK,
527
+ "start": start_ms,
528
+ "end": end_ms,
529
  },
530
  timeout=10,
531
  ).json()
 
540
  chunk = chunk[["open_time","open","high","low","close","volume"]]
541
 
542
  else:
543
+ break
544
 
545
  if len(chunk) == 0:
546
  break
547
 
548
  frames.append(chunk)
549
+ collected += len(chunk)
550
+ earliest_ms = int(chunk["open_time"].iloc[0])
551
 
552
  except Exception:
553
+ break
554
 
555
  if not frames:
556
  return df_base
 
558
  combined = pd.concat(frames, ignore_index=True)
559
  combined = combined.drop_duplicates("open_time").sort_values("open_time").reset_index(drop=True)
560
 
561
+ # Drop still-open (live) candle
562
  import time as _time
563
+ now_ms = int(_time.time() * 1000)
564
  combined = combined[combined["open_time"] + INTERVAL_MS <= now_ms].reset_index(drop=True)
565
 
566
  return combined.tail(target_bars).reset_index(drop=True)
567
 
568
 
569
  def backtest_symbol(src, symbol: str) -> dict:
570
+ """Fetch 6 months of 15m klines (paginated) and run walk-forward backtest.
571
 
572
+ Cached for BT_TTL seconds.
573
  """
574
  cached = _bt_cache.get(symbol)
575
  if cached and time.time() - cached["ts"] < BT_TTL:
576
  return cached["result"]
577
 
578
  try:
579
+ df = _fetch_klines_paginated(src, symbol, target_bars=LOOKBACK_BARS)
580
  result = run_backtest(df, symbol=symbol)
581
  _bt_cache[symbol] = {"result": result, "ts": time.time()}
582
  return result
583
 
584
  except Exception as e:
585
  err = {
586
+ "symbol": symbol,
587
+ "total_trades": 0,
588
+ "win_rate": None,
589
+ "win_rate_tp1": None,
590
+ "win_rate_tp2": None,
591
+ "avg_r": None,
592
+ "max_drawdown_r": None,
593
+ "lookback_months": None,
594
+ "candle_interval": "15m",
595
+ "error": str(e)[:120],
596
+ "ts": time.time(),
597
  }
598
  _bt_cache[symbol] = {"result": err, "ts": time.time()}
599
  return err
scorer.py CHANGED
@@ -519,57 +519,17 @@ def score_symbol(src, symbol: str,
519
  # ── New: probability, leverage, duration ─────────────────────────────
520
  p_win, ev, n_prob = _score_probability(tf_data, direction, levels)
521
 
522
- # ── Backtest blend: replace heuristic p_win with historical win rate ──
523
- bt_data = None
 
 
524
  bt_source = "heuristic"
 
525
  if _BT_AVAILABLE:
526
  try:
527
  bt_raw = _backtest_symbol(src, symbol)
528
- if bt_raw and bt_raw.get("total_trades", 0) >= BT_MIN_TRADES:
529
- bt_data = bt_raw
530
- # Look up win rate for current state + direction
531
- # Priority: by_state[hmm_state][direction] > by_direction > overall
532
- bt_win_rate = None
533
- hmm_st = hmm_state if _MARKOV_AVAILABLE else "RANGING"
534
- by_state_bt = bt_raw.get("by_state", {})
535
- by_dir_bt = bt_raw.get("by_direction", {})
536
-
537
- if hmm_st in by_state_bt:
538
- st_wr = by_state_bt[hmm_st].get("win_rate")
539
- st_n = by_state_bt[hmm_st].get("trades", 0)
540
- if st_wr is not None and st_n >= BT_MIN_TRADES:
541
- bt_win_rate = st_wr
542
- bt_source = f"backtest·{hmm_st}·{bt_raw['lookback_months']}mo ({st_n} trades)"
543
-
544
- if bt_win_rate is None and direction in by_dir_bt:
545
- dir_wr = by_dir_bt[direction].get("win_rate")
546
- dir_n = by_dir_bt[direction].get("trades", 0)
547
- if dir_wr is not None and dir_n >= BT_MIN_TRADES:
548
- bt_win_rate = dir_wr
549
- bt_source = f"backtest·{direction}·{bt_raw['lookback_months']}mo ({dir_n} trades)"
550
-
551
- if bt_win_rate is None:
552
- overall_wr = bt_raw.get("win_rate")
553
- if overall_wr is not None:
554
- bt_win_rate = overall_wr
555
- n = bt_raw["total_trades"]
556
- bt_source = f"backtest·overall·{bt_raw['lookback_months']}mo ({n} trades)"
557
-
558
- if bt_win_rate is not None:
559
- # Blend: 70% backtest + 30% heuristic
560
- p_win_blended = round(
561
- BT_BLEND_WEIGHT * bt_win_rate + (1 - BT_BLEND_WEIGHT) * p_win, 3
562
- )
563
- p_win = p_win_blended
564
- # Recalculate EV with blended p_win
565
- rr1 = levels.get("rr1", 1.0)
566
- p_loss = round(1 - p_win, 3)
567
- ev = round(p_win * max(rr1, 0.1) - p_loss * 1.0, 3)
568
- n_prob.append(
569
- f"📊 Win rate grounded in {bt_source}: {bt_win_rate:.0%} historical"
570
- )
571
  except Exception:
572
- pass
573
 
574
  leverage = _suggest_leverage(account, risk_pct, close,
575
  levels.get("sl", close), levels.get("tp1", close),
@@ -599,6 +559,50 @@ def score_symbol(src, symbol: str,
599
  persistence = markov_data.get("persistence", 0.5) if markov_data else 0.5
600
  forecast = markov_data.get("forecast", {}) if markov_data else {}
601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  # States must agree with trade direction; penalise if conflicting
603
  state_aligned = True
604
  if direction == "long" and hmm_state == "BEAR": state_aligned = False
@@ -652,10 +656,26 @@ def score_symbol(src, symbol: str,
652
  confidence_adjusted = round(max(0.0, min(10.0, confidence_after_state + ev_rr_modifier)), 1)
653
  n_prob.extend(ev_rr_notes) # surface EV/RR penalty in probability notes
654
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  return {
656
  "symbol": symbol,
657
  "tv_symbol": src.tv_symbol(symbol),
658
  "direction": direction,
 
 
659
  "confidence": confidence_adjusted,
660
  "confidence_raw": confidence,
661
  "confidence_after_state": confidence_after_state,
@@ -672,17 +692,25 @@ def score_symbol(src, symbol: str,
672
  "ev": ev,
673
  "notes": n_prob,
674
  "source": bt_source,
675
- "bt_grounded": bt_source != "heuristic",
 
 
676
  },
677
  # ── Backtest summary (inline, for card display) ──
678
  "backtest_summary": {
679
  "total_trades": bt_data.get("total_trades") if bt_data else None,
680
- "win_rate": bt_data.get("win_rate") if bt_data else None,
 
 
681
  "avg_r": bt_data.get("avg_r") if bt_data else None,
 
 
682
  "max_drawdown_r": bt_data.get("max_drawdown_r") if bt_data else None,
683
  "lookback_months": bt_data.get("lookback_months") if bt_data else None,
 
684
  "by_state": bt_data.get("by_state") if bt_data else None,
685
  "equity_curve": bt_data.get("equity_curve") if bt_data else None,
 
686
  } if bt_data else None,
687
  # ── Leverage ──
688
  "leverage": leverage,
 
519
  # ── New: probability, leverage, duration ─────────────────────────────
520
  p_win, ev, n_prob = _score_probability(tf_data, direction, levels)
521
 
522
+ # ── Prefetch backtest data (blend applied AFTER Markov state is known) ──
523
+ # bt_data and bt_raw stored here; blending happens below after hmm_state defined
524
+ bt_data = None
525
+ bt_raw = None
526
  bt_source = "heuristic"
527
+ bt_blend_active = False # True only when N >= BT_MIN_TRADES and blend fires
528
  if _BT_AVAILABLE:
529
  try:
530
  bt_raw = _backtest_symbol(src, symbol)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  except Exception:
532
+ bt_raw = None
533
 
534
  leverage = _suggest_leverage(account, risk_pct, close,
535
  levels.get("sl", close), levels.get("tp1", close),
 
559
  persistence = markov_data.get("persistence", 0.5) if markov_data else 0.5
560
  forecast = markov_data.get("forecast", {}) if markov_data else {}
561
 
562
+ # ── Backtest blend: now that hmm_state is defined, apply blend ───────────
563
+ # Requires N >= BT_MIN_TRADES — badge only shows when blend is actually active
564
+ if bt_raw and bt_raw.get("total_trades", 0) >= BT_MIN_TRADES:
565
+ bt_data = bt_raw
566
+ bt_win_rate = None
567
+ by_state_bt = bt_raw.get("by_state", {})
568
+ by_dir_bt = bt_raw.get("by_direction", {})
569
+
570
+ # Priority: by_state[markov_state] → by_direction → overall
571
+ cur_state = hmm_state # use HMM state for context lookup
572
+ if cur_state in by_state_bt:
573
+ st_wr = by_state_bt[cur_state].get("win_rate")
574
+ st_n = by_state_bt[cur_state].get("trades", 0)
575
+ if st_wr is not None and st_n >= BT_MIN_TRADES:
576
+ bt_win_rate = st_wr
577
+ bt_source = f"backtest·{cur_state}·{bt_raw['lookback_months']}mo ({st_n} trades)"
578
+
579
+ if bt_win_rate is None and direction in by_dir_bt:
580
+ dir_wr = by_dir_bt[direction].get("win_rate")
581
+ dir_n = by_dir_bt[direction].get("trades", 0)
582
+ if dir_wr is not None and dir_n >= BT_MIN_TRADES:
583
+ bt_win_rate = dir_wr
584
+ bt_source = f"backtest·{direction}·{bt_raw['lookback_months']}mo ({dir_n} trades)"
585
+
586
+ if bt_win_rate is None:
587
+ overall_wr = bt_raw.get("win_rate")
588
+ if overall_wr is not None:
589
+ bt_win_rate = overall_wr
590
+ n = bt_raw["total_trades"]
591
+ bt_source = f"backtest·overall·{bt_raw['lookback_months']}mo ({n} trades)"
592
+
593
+ if bt_win_rate is not None:
594
+ p_win_blended = round(
595
+ BT_BLEND_WEIGHT * bt_win_rate + (1 - BT_BLEND_WEIGHT) * p_win, 3
596
+ )
597
+ p_win = p_win_blended
598
+ rr1 = levels.get("rr1", 1.0)
599
+ p_loss = round(1 - p_win, 3)
600
+ ev = round(p_win * max(rr1, 0.1) - p_loss * 1.0, 3)
601
+ bt_blend_active = True
602
+ n_prob.append(
603
+ f"📊 Win rate grounded in {bt_source}: {bt_win_rate:.0%} historical (TP1)"
604
+ )
605
+
606
  # States must agree with trade direction; penalise if conflicting
607
  state_aligned = True
608
  if direction == "long" and hmm_state == "BEAR": state_aligned = False
 
656
  confidence_adjusted = round(max(0.0, min(10.0, confidence_after_state + ev_rr_modifier)), 1)
657
  n_prob.extend(ev_rr_notes) # surface EV/RR penalty in probability notes
658
 
659
+ # ── Hard veto: R/R < 1.0 is mathematically a losing trade ───────────────
660
+ # Even at 70% win rate, you cannot survive R/R = 0.21 long-term.
661
+ # Breakeven win rate = 1 / (1 + RR). At RR=0.21 → need 83% win rate.
662
+ # We suppress the trade signal entirely and return a vetoed card.
663
+ rr_veto = rr1_val < 1.0
664
+ veto_reason = None
665
+ if rr_veto:
666
+ breakeven_wr = round(1 / (1 + rr1_val) * 100, 0)
667
+ veto_reason = (
668
+ f"R/R = {rr1_val:.2f}× — breakeven requires {breakeven_wr:.0f}% win rate. "
669
+ f"TP1 is too close to entry (resistance-capped). "
670
+ f"Wait for a wider setup or skip this trade."
671
+ )
672
+
673
  return {
674
  "symbol": symbol,
675
  "tv_symbol": src.tv_symbol(symbol),
676
  "direction": direction,
677
+ "vetoed": rr_veto,
678
+ "veto_reason": veto_reason,
679
  "confidence": confidence_adjusted,
680
  "confidence_raw": confidence,
681
  "confidence_after_state": confidence_after_state,
 
692
  "ev": ev,
693
  "notes": n_prob,
694
  "source": bt_source,
695
+ # bt_grounded is TRUE only when the blend actually fired (N >= BT_MIN_TRADES)
696
+ # NOT just because backtest data exists — fixes the false "Data Grounded" badge
697
+ "bt_grounded": bt_blend_active,
698
  },
699
  # ── Backtest summary (inline, for card display) ──
700
  "backtest_summary": {
701
  "total_trades": bt_data.get("total_trades") if bt_data else None,
702
+ "win_rate": bt_data.get("win_rate") if bt_data else None, # TP1
703
+ "win_rate_tp1": bt_data.get("win_rate_tp1") if bt_data else None,
704
+ "win_rate_tp2": bt_data.get("win_rate_tp2") if bt_data else None,
705
  "avg_r": bt_data.get("avg_r") if bt_data else None,
706
+ "avg_r_tp1": bt_data.get("avg_r_tp1") if bt_data else None,
707
+ "avg_r_tp2": bt_data.get("avg_r_tp2") if bt_data else None,
708
  "max_drawdown_r": bt_data.get("max_drawdown_r") if bt_data else None,
709
  "lookback_months": bt_data.get("lookback_months") if bt_data else None,
710
+ "candle_interval": bt_data.get("candle_interval") if bt_data else None,
711
  "by_state": bt_data.get("by_state") if bt_data else None,
712
  "equity_curve": bt_data.get("equity_curve") if bt_data else None,
713
+ "blend_active": bt_blend_active,
714
  } if bt_data else None,
715
  # ── Leverage ──
716
  "leverage": leverage,
static/index.html CHANGED
@@ -1029,6 +1029,20 @@ nav {
1029
  .dur-lbl { font-size:9px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:1px; }
1030
  .dur-val { font-size:15px; font-weight:700; font-family:var(--font-display); color:var(--t1); line-height:1.2; }
1031
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  /* ── Backtest panel ── */
1033
  .bt-panel {
1034
  background:rgba(255,255,255,0.55); border:1px solid rgba(0,0,0,0.06);
@@ -1744,23 +1758,39 @@ async function runBacktest(sym, btn) {
1744
 
1745
  // Global version of renderBtResult (same logic, defined outside buildCard scope)
1746
  function renderBtResultGlobal(bt, sym) {
1747
- const wr = bt.win_rate != null ? Math.round(bt.win_rate*100) : null;
 
 
1748
  const wrCls = wr==null?'neutral':wr>=55?'win':wr>=45?'neutral':'loss';
1749
- const avgR = bt.avg_r != null ? (bt.avg_r > 0 ? '+' : '') + bt.avg_r.toFixed(2) + 'R' : '—';
 
1750
  const dd = bt.max_drawdown_r != null ? bt.max_drawdown_r.toFixed(1)+'R' : '—';
1751
  const mo = bt.lookback_months || '?';
1752
  const n = bt.total_trades || 0;
1753
 
 
 
 
 
 
 
1754
  const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => {
1755
- const swr = Math.round(s.win_rate*100);
 
1756
  const cls = state==='BULL'?'bull':state==='BEAR'?'bear':'range';
 
1757
  return `<div class="bt-state-cell">
1758
  <div class="bt-state-name">${state}</div>
1759
- <div class="bt-state-wr ${cls}">${swr}%</div>
1760
  <div class="bt-state-n">${s.trades} trades</div>
1761
  </div>`;
1762
  }).join('');
1763
 
 
 
 
 
 
1764
  const curve = bt.equity_curve || [];
1765
  let sparkSVG = '';
1766
  if (curve.length >= 2) {
@@ -1782,15 +1812,16 @@ function renderBtResultGlobal(bt, sym) {
1782
 
1783
  return `<div class="bt-panel" id="bt-${sym}">
1784
  <div class="bt-header">
1785
- <span class="bt-lbl">📊 Backtest · ${mo}mo · ${n} trades</span>
1786
- <span class="bt-grounded-badge">✓ Data grounded</span>
1787
  <button class="bt-run-btn" onclick="runBacktest('${sym}',this)" title="Refresh backtest">↺</button>
1788
  </div>
1789
  <div class="bt-stats-row">
1790
- <div class="bt-stat"><div class="bt-stat-val ${wrCls}">${wr!=null?wr+'%':'—'}</div><div class="bt-stat-lbl">Win Rate</div></div>
1791
- <div class="bt-stat"><div class="bt-stat-val ${parseFloat(bt.avg_r)>=0?'win':'loss'}">${avgR}</div><div class="bt-stat-lbl">Avg R</div></div>
1792
  <div class="bt-stat"><div class="bt-stat-val neutral">-${dd}</div><div class="bt-stat-lbl">Max DD</div></div>
1793
  </div>
 
1794
  ${stateRows?`<div class="bt-state-row">${stateRows}</div>`:''}
1795
  ${sparkSVG}
1796
  </div>`;
@@ -2089,35 +2120,47 @@ function buildCard(c, account, risk) {
2089
  if (!bt || !bt.total_trades) {
2090
  return `<div class="bt-panel" id="bt-${sym}">
2091
  <div class="bt-header">
2092
- <span class="bt-lbl">📊 Backtest (6mo · TP2 win condition)</span>
2093
  <button class="bt-run-btn" onclick="runBacktest('${sym}',this)">Run Backtest</button>
2094
  </div>
2095
- <div class="bt-loading" id="bt-result-${sym}">Not yet run — click to backtest ~6 months of history</div>
2096
  </div>`;
2097
  }
2098
  return renderBtResult(bt, sym);
2099
  }
2100
 
2101
  function renderBtResult(bt, sym) {
2102
- const wr = bt.win_rate != null ? Math.round(bt.win_rate*100) : null;
2103
- const wrCls = wr==null?'neutral':wr>=55?'win':wr>=45?'neutral':'loss';
2104
- const avgR = bt.avg_r != null ? (bt.avg_r > 0 ? '+' : '') + bt.avg_r.toFixed(2) + 'R' : '—';
2105
- const dd = bt.max_drawdown_r != null ? bt.max_drawdown_r.toFixed(1)+'R' : '—';
2106
- const mo = bt.lookback_months || '?';
2107
- const n = bt.total_trades || 0;
2108
-
2109
- // State breakdown
 
 
 
 
 
 
 
 
 
 
2110
  const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => {
2111
- const swr = Math.round(s.win_rate*100);
 
2112
  const cls = state==='BULL'?'bull':state==='BEAR'?'bear':'range';
 
2113
  return `<div class="bt-state-cell">
2114
  <div class="bt-state-name">${state}</div>
2115
- <div class="bt-state-wr ${cls}">${swr}%</div>
2116
  <div class="bt-state-n">${s.trades} trades</div>
2117
  </div>`;
2118
  }).join('');
2119
 
2120
- // Equity sparkline
2121
  const curve = bt.equity_curve || [];
2122
  let sparkSVG = '';
2123
  if (curve.length >= 2) {
@@ -2137,17 +2180,24 @@ function buildCard(c, account, risk) {
2137
  </svg>`;
2138
  }
2139
 
 
 
 
 
 
 
2140
  return `<div class="bt-panel" id="bt-${sym}">
2141
  <div class="bt-header">
2142
- <span class="bt-lbl">📊 Backtest · ${mo}mo · ${n} trades</span>
2143
- <span class="bt-grounded-badge">✓ Data grounded</span>
2144
  <button class="bt-run-btn" onclick="runBacktest('${sym}',this)" title="Refresh backtest">↺</button>
2145
  </div>
2146
  <div class="bt-stats-row">
2147
- <div class="bt-stat"><div class="bt-stat-val ${wrCls}">${wr!=null?wr+'%':'—'}</div><div class="bt-stat-lbl">Win Rate</div></div>
2148
- <div class="bt-stat"><div class="bt-stat-val ${parseFloat(bt.avg_r)>=0?'win':'loss'}">${avgR}</div><div class="bt-stat-lbl">Avg R</div></div>
2149
  <div class="bt-stat"><div class="bt-stat-val neutral">-${dd}</div><div class="bt-stat-lbl">Max DD</div></div>
2150
  </div>
 
2151
  ${stateRows?`<div class="bt-state-row">${stateRows}</div>`:''}
2152
  ${sparkSVG}
2153
  </div>`;
@@ -2155,6 +2205,14 @@ function buildCard(c, account, risk) {
2155
 
2156
  const btHTML = buildBtPanel(bts, sym);
2157
 
 
 
 
 
 
 
 
 
2158
  let narr=(c.narrative||'');
2159
  if(narr.length>210) narr=narr.slice(0,207)+'…';
2160
 
@@ -2190,6 +2248,7 @@ function buildCard(c, account, risk) {
2190
  <div class="conf-bar"><div class="conf-fill ${confCls}" style="width:${confPct}%"></div></div>
2191
  </div>
2192
 
 
2193
  <div class="tf-row">${tfHTML}</div>
2194
  <div class="rule"></div>
2195
 
 
1029
  .dur-lbl { font-size:9px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:1px; }
1030
  .dur-val { font-size:15px; font-weight:700; font-family:var(--font-display); color:var(--t1); line-height:1.2; }
1031
 
1032
+ /* ── Veto banner ── */
1033
+ .veto-banner {
1034
+ background: rgba(254,226,226,0.7); border: 1.5px solid rgba(239,68,68,0.4);
1035
+ border-radius: 10px; padding: 10px 14px; margin-bottom: 10px;
1036
+ display: flex; flex-direction: column; gap: 4px;
1037
+ }
1038
+ .veto-banner-title {
1039
+ font-size: 13px; font-weight: 700; color: #dc2626;
1040
+ display: flex; align-items: center; gap: 6px;
1041
+ }
1042
+ .veto-banner-reason {
1043
+ font-size: 11.5px; color: #7f1d1d; line-height: 1.45;
1044
+ }
1045
+
1046
  /* ── Backtest panel ── */
1047
  .bt-panel {
1048
  background:rgba(255,255,255,0.55); border:1px solid rgba(0,0,0,0.06);
 
1758
 
1759
  // Global version of renderBtResult (same logic, defined outside buildCard scope)
1760
  function renderBtResultGlobal(bt, sym) {
1761
+ const wrTp1 = bt.win_rate_tp1 != null ? Math.round(bt.win_rate_tp1*100) : (bt.win_rate != null ? Math.round(bt.win_rate*100) : null);
1762
+ const wrTp2 = bt.win_rate_tp2 != null ? Math.round(bt.win_rate_tp2*100) : null;
1763
+ const wr = wrTp1;
1764
  const wrCls = wr==null?'neutral':wr>=55?'win':wr>=45?'neutral':'loss';
1765
+ const avgR = bt.avg_r_tp1 != null ? (bt.avg_r_tp1 > 0 ? '+' : '') + bt.avg_r_tp1.toFixed(2) + 'R'
1766
+ : bt.avg_r != null ? (bt.avg_r > 0 ? '+' : '') + bt.avg_r.toFixed(2) + 'R' : '—';
1767
  const dd = bt.max_drawdown_r != null ? bt.max_drawdown_r.toFixed(1)+'R' : '—';
1768
  const mo = bt.lookback_months || '?';
1769
  const n = bt.total_trades || 0;
1770
 
1771
+ const tf = bt.candle_interval || '15m';
1772
+ const blendActive = bt.blend_active === true;
1773
+ const groundedBadge = blendActive
1774
+ ? `<span class="bt-grounded-badge">✓ Data grounded</span>`
1775
+ : (n >= 10 ? `<span class="bt-grounded-badge" style="opacity:0.6">📊 ${n} trades</span>` : `<span style="font-size:10px;color:var(--muted);padding:2px 6px">⚠ ${n} trades (need 10+)</span>`);
1776
+
1777
  const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => {
1778
+ const swr1 = s.win_rate_tp1 != null ? Math.round(s.win_rate_tp1*100) : Math.round((s.win_rate||0)*100);
1779
+ const swr2 = s.win_rate_tp2 != null ? Math.round(s.win_rate_tp2*100) : null;
1780
  const cls = state==='BULL'?'bull':state==='BEAR'?'bear':'range';
1781
+ const tp2txt = swr2 != null ? `<span style="font-size:9px;color:var(--muted);margin-left:3px">TP2:${swr2}%</span>` : '';
1782
  return `<div class="bt-state-cell">
1783
  <div class="bt-state-name">${state}</div>
1784
+ <div class="bt-state-wr ${cls}">${swr1}%${tp2txt}</div>
1785
  <div class="bt-state-n">${s.trades} trades</div>
1786
  </div>`;
1787
  }).join('');
1788
 
1789
+ const tp2Row = wrTp2 != null ? `<div style="font-size:10.5px;color:var(--muted);text-align:center;margin-top:2px;margin-bottom:4px">
1790
+ TP1 exit: <b style="color:${wrTp1>=55?'var(--profit2)':wrTp1>=45?'var(--amber)':'var(--loss2)'}">${wrTp1}%</b> &nbsp;|&nbsp;
1791
+ TP2 hold: <b style="color:${wrTp2>=55?'var(--profit2)':wrTp2>=45?'var(--amber)':'var(--loss2)'}">${wrTp2}%</b>
1792
+ </div>` : '';
1793
+
1794
  const curve = bt.equity_curve || [];
1795
  let sparkSVG = '';
1796
  if (curve.length >= 2) {
 
1812
 
1813
  return `<div class="bt-panel" id="bt-${sym}">
1814
  <div class="bt-header">
1815
+ <span class="bt-lbl">📊 Backtest · ${mo}mo · ${n} trades · ${tf}</span>
1816
+ ${groundedBadge}
1817
  <button class="bt-run-btn" onclick="runBacktest('${sym}',this)" title="Refresh backtest">↺</button>
1818
  </div>
1819
  <div class="bt-stats-row">
1820
+ <div class="bt-stat"><div class="bt-stat-val ${wrCls}">${wr!=null?wr+'%':'—'}</div><div class="bt-stat-lbl">TP1 Win Rate</div></div>
1821
+ <div class="bt-stat"><div class="bt-stat-val ${parseFloat(bt.avg_r_tp1||bt.avg_r)>=0?'win':'loss'}">${avgR}</div><div class="bt-stat-lbl">Avg R</div></div>
1822
  <div class="bt-stat"><div class="bt-stat-val neutral">-${dd}</div><div class="bt-stat-lbl">Max DD</div></div>
1823
  </div>
1824
+ ${tp2Row}
1825
  ${stateRows?`<div class="bt-state-row">${stateRows}</div>`:''}
1826
  ${sparkSVG}
1827
  </div>`;
 
2120
  if (!bt || !bt.total_trades) {
2121
  return `<div class="bt-panel" id="bt-${sym}">
2122
  <div class="bt-header">
2123
+ <span class="bt-lbl">📊 Backtest (6mo · 15m candles)</span>
2124
  <button class="bt-run-btn" onclick="runBacktest('${sym}',this)">Run Backtest</button>
2125
  </div>
2126
+ <div class="bt-loading" id="bt-result-${sym}">Not yet run — click to backtest ~6 months of 15m history</div>
2127
  </div>`;
2128
  }
2129
  return renderBtResult(bt, sym);
2130
  }
2131
 
2132
  function renderBtResult(bt, sym) {
2133
+ // Primary = TP1 win rate (matches how most traders exit)
2134
+ // Secondary = TP2 win rate (ambitious hold target)
2135
+ const wrTp1 = bt.win_rate_tp1 != null ? Math.round(bt.win_rate_tp1*100) : (bt.win_rate != null ? Math.round(bt.win_rate*100) : null);
2136
+ const wrTp2 = bt.win_rate_tp2 != null ? Math.round(bt.win_rate_tp2*100) : null;
2137
+ const wrCls = wrTp1==null?'neutral':wrTp1>=55?'win':wrTp1>=45?'neutral':'loss';
2138
+ const avgR = bt.avg_r_tp1 != null ? (bt.avg_r_tp1 > 0 ? '+' : '') + bt.avg_r_tp1.toFixed(2) + 'R'
2139
+ : bt.avg_r != null ? (bt.avg_r > 0 ? '+' : '') + bt.avg_r.toFixed(2) + 'R' : '—';
2140
+ const dd = bt.max_drawdown_r != null ? bt.max_drawdown_r.toFixed(1)+'R' : '—';
2141
+ const mo = bt.lookback_months || '?';
2142
+ const n = bt.total_trades || 0;
2143
+ const tf = bt.candle_interval || '15m';
2144
+ // Data Grounded badge — only show when blend actually fired (N >= 10)
2145
+ const blendActive = bt.blend_active === true;
2146
+ const groundedBadge = blendActive
2147
+ ? `<span class="bt-grounded-badge">✓ Data grounded</span>`
2148
+ : (n >= 10 ? `<span class="bt-grounded-badge" style="opacity:0.6">📊 ${n} trades</span>` : `<span style="font-size:10px;color:var(--muted);padding:2px 6px">⚠ ${n} trades (need 10+)</span>`);
2149
+
2150
+ // State breakdown — show both TP1 and TP2 win rates per state
2151
  const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => {
2152
+ const swr1 = s.win_rate_tp1 != null ? Math.round(s.win_rate_tp1*100) : Math.round((s.win_rate||0)*100);
2153
+ const swr2 = s.win_rate_tp2 != null ? Math.round(s.win_rate_tp2*100) : null;
2154
  const cls = state==='BULL'?'bull':state==='BEAR'?'bear':'range';
2155
+ const tp2txt = swr2 != null ? `<span style="font-size:9px;color:var(--muted);margin-left:3px">TP2:${swr2}%</span>` : '';
2156
  return `<div class="bt-state-cell">
2157
  <div class="bt-state-name">${state}</div>
2158
+ <div class="bt-state-wr ${cls}">${swr1}%${tp2txt}</div>
2159
  <div class="bt-state-n">${s.trades} trades</div>
2160
  </div>`;
2161
  }).join('');
2162
 
2163
+ // Equity sparkline (based on TP1 — matches real trading)
2164
  const curve = bt.equity_curve || [];
2165
  let sparkSVG = '';
2166
  if (curve.length >= 2) {
 
2180
  </svg>`;
2181
  }
2182
 
2183
+ // TP2 comparison row
2184
+ const tp2Row = wrTp2 != null ? `<div style="font-size:10.5px;color:var(--muted);text-align:center;margin-top:2px;margin-bottom:4px">
2185
+ TP1 exit: <b style="color:${wrTp1>=55?'var(--profit2)':wrTp1>=45?'var(--amber)':'var(--loss2)'}">${wrTp1}%</b> &nbsp;|&nbsp;
2186
+ TP2 hold: <b style="color:${wrTp2>=55?'var(--profit2)':wrTp2>=45?'var(--amber)':'var(--loss2)'}">${wrTp2}%</b>
2187
+ </div>` : '';
2188
+
2189
  return `<div class="bt-panel" id="bt-${sym}">
2190
  <div class="bt-header">
2191
+ <span class="bt-lbl">📊 Backtest · ${mo}mo · ${n} trades · ${tf}</span>
2192
+ ${groundedBadge}
2193
  <button class="bt-run-btn" onclick="runBacktest('${sym}',this)" title="Refresh backtest">↺</button>
2194
  </div>
2195
  <div class="bt-stats-row">
2196
+ <div class="bt-stat"><div class="bt-stat-val ${wrCls}">${wrTp1!=null?wrTp1+'%':'—'}</div><div class="bt-stat-lbl">TP1 Win Rate</div></div>
2197
+ <div class="bt-stat"><div class="bt-stat-val ${parseFloat(bt.avg_r_tp1||bt.avg_r)>=0?'win':'loss'}">${avgR}</div><div class="bt-stat-lbl">Avg R</div></div>
2198
  <div class="bt-stat"><div class="bt-stat-val neutral">-${dd}</div><div class="bt-stat-lbl">Max DD</div></div>
2199
  </div>
2200
+ ${tp2Row}
2201
  ${stateRows?`<div class="bt-state-row">${stateRows}</div>`:''}
2202
  ${sparkSVG}
2203
  </div>`;
 
2205
 
2206
  const btHTML = buildBtPanel(bts, sym);
2207
 
2208
+ // ── Veto banner: shown when R/R < 1.0 (hard no-trade) ──
2209
+ const isVetoed = c.vetoed === true;
2210
+ const vetoBanner = isVetoed && c.veto_reason ? `
2211
+ <div class="veto-banner">
2212
+ <div class="veto-banner-title">⛔ NO TRADE — Risk Exceeds Reward</div>
2213
+ <div class="veto-banner-reason">${c.veto_reason}</div>
2214
+ </div>` : '';
2215
+
2216
  let narr=(c.narrative||'');
2217
  if(narr.length>210) narr=narr.slice(0,207)+'…';
2218
 
 
2248
  <div class="conf-bar"><div class="conf-fill ${confCls}" style="width:${confPct}%"></div></div>
2249
  </div>
2250
 
2251
+ ${vetoBanner}
2252
  <div class="tf-row">${tfHTML}</div>
2253
  <div class="rule"></div>
2254