Spaces:
Running
Fix: 4 critical accuracy fixes from 6-agent panel audit
Browse filesFix 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)
- backtest.py +235 -177
- scorer.py +76 -48
- static/index.html +84 -25
|
@@ -1,27 +1,38 @@
|
|
| 1 |
"""Walk-forward backtester — backtest.py
|
| 2 |
|
| 3 |
-
Replays 6 months of
|
| 4 |
and signal detectors used in live trading. No lookahead bias: at bar i,
|
| 5 |
only data[0:i] is visible.
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
For each simulated trade it records:
|
| 10 |
- entry price, SL, TP1, TP2
|
| 11 |
- Markov state at entry
|
| 12 |
- direction (long/short)
|
| 13 |
-
-
|
|
|
|
| 14 |
- bars_to_outcome
|
| 15 |
|
| 16 |
Aggregates:
|
| 17 |
-
-
|
|
|
|
| 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
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
ATR_PERIOD = 14
|
| 39 |
EMA_SPAN_20 = 20
|
| 40 |
EMA_SPAN_50 = 50
|
| 41 |
|
| 42 |
-
# Trade level multipliers
|
| 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 |
-
"""
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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
|
| 132 |
spread = abs(e20 - e50) / e50 if e50 > 0 else 0
|
| 133 |
-
if spread > 0.005:
|
| 134 |
score += 1
|
| 135 |
|
| 136 |
-
# S2: RSI trend confirmation
|
| 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
|
| 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
|
| 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
|
| 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 |
-
#
|
| 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
|
| 227 |
tp1 = entry + TP1_ATR_MULT * atr
|
| 228 |
tp2 = entry + TP2_ATR_MULT * atr
|
| 229 |
else:
|
| 230 |
-
sl = entry + SL_ATR_MULT
|
| 231 |
tp1 = entry - TP1_ATR_MULT * atr
|
| 232 |
tp2 = entry - TP2_ATR_MULT * atr
|
| 233 |
|
| 234 |
-
in_trade
|
| 235 |
-
trade_entry
|
| 236 |
-
trade_sl
|
| 237 |
-
trade_tp1
|
| 238 |
-
trade_tp2
|
| 239 |
-
trade_dir
|
| 240 |
-
trade_state
|
| 241 |
-
trade_bar
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
if hit_tp2 and not hit_sl:
|
| 260 |
-
|
| 261 |
elif hit_sl and not hit_tp2:
|
| 262 |
-
|
| 263 |
elif hit_tp2 and hit_sl:
|
| 264 |
-
|
| 265 |
-
outcome = "win" if (trade_dir == "long" and closes[i] > trade_entry) else "loss"
|
| 266 |
else:
|
| 267 |
-
#
|
| 268 |
-
|
| 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-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
| 278 |
else:
|
| 279 |
-
|
| 280 |
|
| 281 |
trades.append({
|
| 282 |
-
"bar":
|
| 283 |
-
"state":
|
| 284 |
-
"direction":
|
| 285 |
-
"
|
| 286 |
-
"
|
| 287 |
-
"
|
| 288 |
-
"
|
| 289 |
-
"
|
| 290 |
-
"
|
|
|
|
|
|
|
|
|
|
| 291 |
})
|
| 292 |
|
| 293 |
-
in_trade
|
|
|
|
| 294 |
|
| 295 |
i += 1
|
| 296 |
|
| 297 |
# ── Aggregate ─────────────────────────────────────────────────────────
|
| 298 |
if not trades:
|
| 299 |
return {
|
| 300 |
-
"symbol":
|
| 301 |
-
"total_trades":
|
| 302 |
-
"win_rate":
|
| 303 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
| 306 |
-
"
|
| 307 |
-
"
|
| 308 |
-
"
|
| 309 |
-
"
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
}
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
-
win_rate =
|
| 319 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 327 |
by_state[state] = {
|
| 328 |
-
"trades":
|
| 329 |
-
"
|
| 330 |
-
"
|
| 331 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 340 |
by_dir[d] = {
|
| 341 |
-
"trades":
|
| 342 |
-
"
|
| 343 |
-
"
|
| 344 |
-
"
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
-
# Equity curve (
|
| 348 |
equity = []
|
| 349 |
cum_r = 0.0
|
| 350 |
for t in trades:
|
| 351 |
-
cum_r += t["
|
| 352 |
equity.append(round(cum_r, 3))
|
| 353 |
|
| 354 |
-
# Max drawdown
|
| 355 |
-
peak
|
| 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":
|
| 364 |
-
"total_trades":
|
| 365 |
-
"
|
| 366 |
-
"
|
| 367 |
-
"
|
| 368 |
-
"
|
| 369 |
-
"
|
| 370 |
-
"
|
| 371 |
-
"
|
| 372 |
-
"
|
| 373 |
-
"
|
| 374 |
-
"
|
| 375 |
-
"
|
| 376 |
-
"
|
| 377 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
}
|
| 379 |
|
| 380 |
|
| 381 |
-
# ───
|
| 382 |
|
| 383 |
def _fetch_klines_paginated(src, symbol: str, target_bars: int = LOOKBACK_BARS) -> "pd.DataFrame":
|
| 384 |
-
"""Fetch up to `target_bars` of
|
| 385 |
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
|
| 390 |
-
Works for BingX, Binance, Bybit
|
| 391 |
-
Falls back gracefully if the exchange doesn't support pagination params.
|
| 392 |
"""
|
| 393 |
import pandas as pd
|
| 394 |
import requests
|
| 395 |
|
| 396 |
-
CHUNK
|
| 397 |
-
INTERVAL_MS
|
|
|
|
| 398 |
|
| 399 |
-
# First
|
| 400 |
-
df_base = src.klines(symbol,
|
| 401 |
-
frames
|
| 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 |
-
|
| 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
|
| 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":
|
| 425 |
-
"interval":
|
| 426 |
-
"limit":
|
| 427 |
-
"endTime":
|
| 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":
|
| 446 |
-
"interval":
|
| 447 |
-
"limit":
|
| 448 |
-
"endTime":
|
| 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":
|
| 470 |
-
"interval": "
|
| 471 |
-
"limit":
|
| 472 |
-
"start":
|
| 473 |
-
"end":
|
| 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
|
| 489 |
|
| 490 |
if len(chunk) == 0:
|
| 491 |
break
|
| 492 |
|
| 493 |
frames.append(chunk)
|
| 494 |
-
collected
|
| 495 |
-
earliest_ms
|
| 496 |
|
| 497 |
except Exception:
|
| 498 |
-
break
|
| 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
|
| 507 |
import time as _time
|
| 508 |
-
now_ms
|
| 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
|
| 516 |
|
| 517 |
-
Cached for BT_TTL seconds
|
| 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
|
| 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":
|
| 532 |
-
"total_trades":
|
| 533 |
-
"win_rate":
|
| 534 |
-
"
|
| 535 |
-
"
|
| 536 |
-
"
|
| 537 |
-
"
|
| 538 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
# ──
|
| 523 |
-
bt_data
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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,
|
|
@@ -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
|
|
|
|
|
|
|
| 1748 |
const wrCls = wr==null?'neutral':wr>=55?'win':wr>=45?'neutral':'loss';
|
| 1749 |
-
const avgR = bt.
|
|
|
|
| 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
|
|
|
|
| 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}">${
|
| 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 |
-
|
| 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 ·
|
| 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 |
-
|
| 2103 |
-
|
| 2104 |
-
const
|
| 2105 |
-
const
|
| 2106 |
-
const
|
| 2107 |
-
const
|
| 2108 |
-
|
| 2109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2110 |
const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => {
|
| 2111 |
-
const
|
|
|
|
| 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}">${
|
| 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 |
-
|
| 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}">${
|
| 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> |
|
| 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> |
|
| 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 |
|