Add optimizer, cost model, alignment fix, and gold analysis

- Timezone-alignment fix: fetch_aligned matches signals to bars by price
  (MQL5 server time vs Python UTC ~3h offset invalidated prior backtests)
- Symbol-aware transaction costs (real spread from MT5) baked into backtest
- One-trade-per-setup (sequential) mode; % monthly/yearly returns in report
- Exit optimizer (SL x RR sweep, train/test split)
- Gold analysis scripts: per-year, ADX filter, efficiency ratio, HTF alignment
- Frozen signal snapshots (gold_signals.csv, arm_signals_EURUSD_H1.csv)

Findings: EURUSD = coin flip after costs. GOLD = modest real trend edge;
ADX>35 filter validated train+test (halves worst-year drawdown). Efficiency
Ratio and daily-trend HTF filters tested and rejected (not robust both halves).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
FXDavid
2026-06-05 23:27:30 +03:00
co-authored by Claude Opus 4.8
parent 8391d68312
commit f47ac228b1
16 changed files with 6656 additions and 17 deletions
File diff suppressed because it is too large Load Diff
+76 -15
View File
@@ -14,7 +14,7 @@ from datetime import datetime
from typing import List, Callable, Optional
from .connection import load_config
from .ohlcv import fetch_after
from .ohlcv import fetch_aligned, get_cost_price
from .signals import Signal, load_signals
@@ -31,6 +31,7 @@ class TradeResult:
r: float # realised R (+rr, -1, or 0 if open)
rr: float # planned reward:risk
bars_held: int # bars until resolution (0 if open)
exit_time: Optional[datetime] = None # bar time when SL/TP hit (None if open)
@dataclass
@@ -38,6 +39,7 @@ class BacktestReport:
symbol: str
timeframe: str
total: int
signals_skipped: int # signals ignored because a trade was already open
wins: int
losses: int
open_trades: int
@@ -52,19 +54,27 @@ class BacktestReport:
max_drawdown_r: float
gross_win: float
gross_loss: float
years: float = 0.0
risk_per_trade: float = 0.01
annual_return_pct: float = 0.0
monthly_return_pct: float = 0.0
max_drawdown_pct: float = 0.0
equity_curve: List[float] = field(default_factory=list)
by_regime: dict = field(default_factory=dict)
trades: List[TradeResult] = field(default_factory=list)
def _simulate_one(sig: Signal, bars: list, ambiguous: str) -> TradeResult:
"""Replay bars forward from entry; decide WIN / LOSS / OPEN."""
def _simulate_one(sig: Signal, bars: list, ambiguous: str, cost_price: float = 0.0) -> TradeResult:
"""Replay bars forward from entry; decide WIN / LOSS / OPEN. cost_price is
the per-trade transaction cost in price terms, charged in R."""
is_long = sig.direction == "LONG"
risk = abs(sig.entry - sig.sl)
reward = abs(sig.tp - sig.entry)
rr = (reward / risk) if risk > 0 else 0.0
cost_R = (cost_price / risk) if risk > 0 else 0.0
win_r = rr - cost_R
loss_r = -1.0 - cost_R
# Skip the entry bar itself (signal fires on its close); start the next bar.
for i, b in enumerate(bars):
if b["time"] <= sig.time:
continue
@@ -80,18 +90,18 @@ def _simulate_one(sig: Signal, bars: list, ambiguous: str) -> TradeResult:
# Ambiguous bar: cannot tell order from OHLC.
if ambiguous == "win":
return TradeResult(sig.time, sig.symbol, sig.direction, sig.entry,
sig.sl, sig.tp, sig.regime, "WIN", rr, rr, i)
sig.sl, sig.tp, sig.regime, "WIN", win_r, rr, i, b["time"])
return TradeResult(sig.time, sig.symbol, sig.direction, sig.entry,
sig.sl, sig.tp, sig.regime, "LOSS", -1.0, rr, i)
sig.sl, sig.tp, sig.regime, "LOSS", loss_r, rr, i, b["time"])
if hit_sl:
return TradeResult(sig.time, sig.symbol, sig.direction, sig.entry,
sig.sl, sig.tp, sig.regime, "LOSS", -1.0, rr, i)
sig.sl, sig.tp, sig.regime, "LOSS", loss_r, rr, i, b["time"])
if hit_tp:
return TradeResult(sig.time, sig.symbol, sig.direction, sig.entry,
sig.sl, sig.tp, sig.regime, "WIN", rr, rr, i)
sig.sl, sig.tp, sig.regime, "WIN", win_r, rr, i, b["time"])
return TradeResult(sig.time, sig.symbol, sig.direction, sig.entry,
sig.sl, sig.tp, sig.regime, "OPEN", 0.0, rr, 0)
sig.sl, sig.tp, sig.regime, "OPEN", 0.0, rr, 0, None)
def _max_drawdown(equity: List[float]) -> float:
@@ -120,14 +130,23 @@ def _streaks(results: List[TradeResult]):
def run_backtest(
signals: Optional[List[Signal]] = None,
fetch_fn: Callable = fetch_after,
fetch_fn: Callable = fetch_aligned,
ambiguous: Optional[str] = None,
forward_bars: int = 1000,
sequential: bool = True,
) -> BacktestReport:
"""
Run the backtest over all signals (loaded from the configured CSV if not
supplied). `fetch_fn` is injectable so the engine can be unit-tested
without a live MT5 connection.
sequential=True (default, realistic): one position at a time. When a trade
is open, any signal that fires before it resolves is SKIPPED — this collapses
clusters of consecutive same-setup signals into a single real trade, the way
an actual trader or single-position EA would behave.
sequential=False: every signal is evaluated independently (overlapping
trades allowed). Useful for raw per-signal expectancy, not realistic P&L.
"""
cfg = load_config()
if ambiguous is None:
@@ -136,9 +155,27 @@ def run_backtest(
signals = load_signals()
results: List[TradeResult] = []
for sig in signals:
bars = fetch_fn(sig.symbol, sig.timeframe, sig.time, forward_bars)
results.append(_simulate_one(sig, bars, ambiguous))
skipped = 0
# symbol-aware transaction cost (spread + slippage), charged per trade in R
slippage = cfg.get("slippage_points", 5.0)
cost_price = get_cost_price(signals[0].symbol, slippage) if signals else 0.0
if sequential:
busy_until = None
for sig in sorted(signals, key=lambda s: s.time):
if busy_until is not None and sig.time <= busy_until:
skipped += 1
continue
bars = fetch_fn(sig.symbol, sig.timeframe, sig.time, sig.entry, forward_bars)
res = _simulate_one(sig, bars, ambiguous, cost_price)
results.append(res)
if res.exit_time is not None:
busy_until = res.exit_time # open trade → next signal can enter
else:
for sig in signals:
bars = fetch_fn(sig.symbol, sig.timeframe, sig.time, sig.entry, forward_bars)
results.append(_simulate_one(sig, bars, ambiguous, cost_price))
closed = [r for r in results if r.outcome != "OPEN"]
wins = [r for r in closed if r.outcome == "WIN"]
@@ -174,10 +211,23 @@ def run_backtest(
sym = signals[0].symbol if signals else cfg.get("default_symbol", "")
tf = signals[0].timeframe if signals else cfg.get("default_timeframe", "")
# --- percentage returns: translate R into account % via risk-per-trade ---
risk = cfg.get("risk_per_trade", 0.01)
mdd_r = _max_drawdown(eq)
times = [r.time for r in closed]
years = 0.0
if len(times) >= 2:
years = (max(times) - min(times)).days / 365.25
annual_r = (net_r / years) if years > 0 else 0.0
annual_pct = annual_r * risk * 100
monthly_pct = annual_pct / 12
mdd_pct = mdd_r * risk * 100
return BacktestReport(
symbol = sym,
timeframe = tf,
total = len(results),
signals_skipped = skipped,
wins = len(wins),
losses = len(losses),
open_trades = len(results) - len(closed),
@@ -189,9 +239,14 @@ def run_backtest(
avg_loss = round(gross_loss / len(losses), 2) if losses else 0.0,
max_win_streak = win_streak,
max_loss_streak = loss_streak,
max_drawdown_r = round(_max_drawdown(eq), 2),
max_drawdown_r = round(mdd_r, 2),
gross_win = round(gross_win, 2),
gross_loss = round(gross_loss, 2),
years = round(years, 2),
risk_per_trade = risk,
annual_return_pct = round(annual_pct, 1),
monthly_return_pct = round(monthly_pct, 2),
max_drawdown_pct = round(mdd_pct, 1),
equity_curve = eq,
by_regime = by_regime,
trades = results,
@@ -202,7 +257,8 @@ def report_to_dict(rep: BacktestReport, include_trades: bool = False) -> dict:
"""Compact dict for returning through the MCP layer."""
d = {
"symbol": rep.symbol, "timeframe": rep.timeframe,
"total": rep.total, "wins": rep.wins, "losses": rep.losses,
"trades_taken": rep.total, "signals_skipped": rep.signals_skipped,
"wins": rep.wins, "losses": rep.losses,
"open": rep.open_trades,
"win_rate_pct": rep.win_rate,
"profit_factor": rep.profit_factor,
@@ -212,6 +268,11 @@ def report_to_dict(rep: BacktestReport, include_trades: bool = False) -> dict:
"max_win_streak": rep.max_win_streak,
"max_loss_streak": rep.max_loss_streak,
"max_drawdown_r": rep.max_drawdown_r,
"years": rep.years,
"risk_per_trade_pct": round(rep.risk_per_trade * 100, 2),
"annual_return_pct": rep.annual_return_pct,
"monthly_return_pct": rep.monthly_return_pct,
"max_drawdown_pct": rep.max_drawdown_pct,
"by_regime": rep.by_regime,
}
if include_trades:
+70
View File
@@ -49,6 +49,76 @@ def fetch_recent(symbol: str, timeframe: str, count: int = 100) -> list:
]
def get_cost_price(symbol: str, slippage_points: float = 5.0) -> float:
"""
Per-trade transaction cost in PRICE terms, symbol-aware.
Uses the broker's current spread (in points) + a slippage allowance, times
the symbol's point size. Works correctly across forex, gold, indices, etc.
"""
connect()
si = mt5.symbol_info(symbol)
if si is None:
return 0.0
return (si.spread + slippage_points) * si.point
def fetch_aligned(symbol: str, timeframe: str, signal_time: datetime,
entry_price: float, count: int = 1000, pre_hours: int = 12) -> list:
"""
Return the tradeable bars for a signal, robust to timezone/DST offsets
between the MQL5 log (broker server time) and the Python API (UTC).
Strategy: fetch a window AROUND the signal time, find the bar whose close
matches the logged entry price (the true signal bar), and return the bars
strictly AFTER it. The first returned bar is the realistic entry bar — you
enter at its OPEN, since the signal is only known at the prior bar's close.
Returns [] if the signal bar can't be located (price never matches).
"""
connect()
if timeframe not in TIMEFRAME_MAP:
raise ValueError(f"Invalid timeframe '{timeframe}'.")
pip = 0.01 if symbol.upper().endswith("JPY") else 0.0001
tol = 0.5 * pip
secs = TF_SECONDS.get(timeframe, 3600)
start = signal_time - timedelta(hours=pre_hours)
end = signal_time + timedelta(seconds=secs * count)
rates = mt5.copy_rates_range(symbol, TIMEFRAME_MAP[timeframe], start, end)
if rates is None or len(rates) == 0:
return []
# locate the signal bar: close matches entry, nearest in time to signal_time
sig_ts = signal_time.timestamp()
best_i, best_dist = None, None
for i, r in enumerate(rates):
if abs(float(r["close"]) - entry_price) <= tol:
dist = abs(int(r["time"]) - sig_ts)
if best_dist is None or dist < best_dist:
best_dist, best_i = dist, i
if best_i is None or best_i + 1 >= len(rates):
return []
# Shift all bar times into the signal's clock so the matched signal bar sits
# exactly at signal_time. This makes every downstream time comparison
# (sequential gating, exit times, equity) consistent regardless of the
# broker-server vs UTC offset.
matched_dt = datetime.fromtimestamp(int(rates[best_i]["time"]))
offset = signal_time - matched_dt
return [
{
"time": datetime.fromtimestamp(int(r["time"])) + offset,
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
}
for r in rates[best_i + 1:]
]
def fetch_after(symbol: str, timeframe: str, start: datetime, count: int = 1000) -> list:
"""
Bars at or after `start`, chronological order (oldest first).
+184
View File
@@ -0,0 +1,184 @@
"""
Exit optimizer — sweep stop-loss multiplier × reward:risk WITHOUT regenerating
signals.
Why this is cheap: the entry points never change when you only move the stop and
target. The signal log already carries entry + the original SL distance, so we
can recover each trade's raw 1×ATR (atr = |entry - sl| / orig_stop_multiplier)
and then recompute SL/TP for any candidate (M, RR). We fetch each signal's
forward bars once, cache them, and replay every combination in memory.
Anti-overfitting: optimize ONLY on the train slice, then report the chosen
config's performance on a locked test slice it never saw.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import List, Callable, Optional
from .connection import load_config
from .ohlcv import fetch_aligned, get_cost_price
from .signals import Signal, load_signals
def _pip_size(symbol: str) -> float:
return 0.01 if symbol.upper().endswith("JPY") else 0.0001
@dataclass
class _CachedSignal:
sig: Signal
one_atr: float # recovered raw 1×ATR distance in price
bars: list # aligned tradeable bars (after the signal bar)
entry_open: float # realistic fill = open of the first tradeable bar
def _build_cache(signals: List[Signal], fetch_fn: Callable,
orig_mult: float, forward_bars: int) -> List[_CachedSignal]:
cache = []
for s in signals:
stop_dist = abs(s.entry - s.sl)
regime_mult = 1.8 if s.regime.upper() == "VOLATILE" else 1.0
one_atr = stop_dist / (orig_mult * regime_mult) if stop_dist > 0 else 0.0
if one_atr <= 0:
continue
bars = fetch_fn(s.symbol, s.timeframe, s.time, s.entry, forward_bars)
if not bars: # signal bar couldn't be located
continue
cache.append(_CachedSignal(s, one_atr, bars, bars[0]["open"]))
return cache
def _replay(cs: _CachedSignal, sl: float, tp: float, ambiguous: str):
"""Return (outcome, exit_time) replaying cached bars forward from entry."""
is_long = cs.sig.direction == "LONG"
for b in cs.bars:
if b["time"] <= cs.sig.time:
continue
if is_long:
hit_sl = b["low"] <= sl
hit_tp = b["high"] >= tp
else:
hit_sl = b["high"] >= sl
hit_tp = b["low"] <= tp
if hit_sl and hit_tp:
return ("WIN" if ambiguous == "win" else "LOSS", b["time"])
if hit_sl:
return ("LOSS", b["time"])
if hit_tp:
return ("WIN", b["time"])
return ("OPEN", None)
def _evaluate(cache: List[_CachedSignal], M: float, RR: float,
cost_price: float, ambiguous: str) -> dict:
"""Sequential (one-trade-at-a-time) evaluation of one (M, RR) combo."""
busy_until = None
rs = []
wins = losses = 0
gross_win = gross_loss = 0.0
for cs in cache: # cache is pre-sorted chronologically
if busy_until is not None and cs.sig.time <= busy_until:
continue
stop_dist = M * cs.one_atr
tp_dist = RR * stop_dist
if stop_dist <= 0:
continue
is_long = cs.sig.direction == "LONG"
e = cs.entry_open # realistic fill
sl = e - stop_dist if is_long else e + stop_dist
tp = e + tp_dist if is_long else e - tp_dist
outcome, exit_time = _replay(cs, sl, tp, ambiguous)
cost_R = cost_price / stop_dist
if outcome == "WIN":
r = RR - cost_R; wins += 1; gross_win += r
elif outcome == "LOSS":
r = -1.0 - cost_R; losses += 1; gross_loss += abs(r)
else:
continue # open trade, not counted; doesn't block forever
rs.append(r)
busy_until = exit_time
closed = wins + losses
net = sum(rs)
# max drawdown on equity curve
peak = run = mdd = 0.0
for r in rs:
run += r; peak = max(peak, run); mdd = max(mdd, peak - run)
return {
"M": M, "RR": RR,
"trades": closed, "wins": wins, "losses": losses,
"win_rate": round(wins / closed * 100, 1) if closed else 0.0,
"profit_factor": round(gross_win / gross_loss, 2) if gross_loss > 0 else float("inf"),
"expectancy": round(net / closed, 4) if closed else 0.0,
"net_r": round(net, 1),
"max_dd_r": round(mdd, 1),
}
def optimize_exits(
signals: Optional[List[Signal]] = None,
stop_mults: Optional[List[float]] = None,
rr_ratios: Optional[List[float]] = None,
train_frac: float = 0.70,
min_trades: int = 100,
forward_bars: int = 1000,
fetch_fn: Callable = fetch_aligned,
) -> dict:
"""
Sweep SL multiplier × RR. Optimize on the train slice, confirm on the locked
test slice. Returns the full grid, the train-chosen best, and its test result.
"""
cfg = load_config()
if signals is None:
signals = load_signals()
if stop_mults is None:
stop_mults = [1.0, 1.5, 2.0, 2.5, 3.0]
if rr_ratios is None:
rr_ratios = [1.0, 1.5, 2.0, 2.5, 3.0, 3.5]
ambiguous = cfg.get("ambiguous_bar", "loss")
orig_mult = cfg.get("orig_stop_multiplier", 2.5)
slippage = cfg.get("slippage_points", 5.0)
# chronological order; split by time
signals = sorted(signals, key=lambda s: s.time)
split = int(len(signals) * train_frac)
train_sigs, test_sigs = signals[:split], signals[split:]
cost_price = get_cost_price(signals[0].symbol, slippage) if signals else 0.0
train_cache = _build_cache(train_sigs, fetch_fn, orig_mult, forward_bars)
test_cache = _build_cache(test_sigs, fetch_fn, orig_mult, forward_bars)
# sweep on train
grid = []
for M in stop_mults:
for RR in rr_ratios:
grid.append(_evaluate(train_cache, M, RR, cost_price, ambiguous))
# choose best on train: highest expectancy among combos with enough trades
eligible = [g for g in grid if g["trades"] >= min_trades]
pool = eligible if eligible else grid
best = max(pool, key=lambda g: g["expectancy"])
# confirm on locked test slice
test_result = _evaluate(test_cache, best["M"], best["RR"], cost_price, ambiguous)
# baseline (current live config) on test, for reference
baseline = _evaluate(test_cache, orig_mult, 3.5, cost_price, ambiguous)
return {
"train_period": f"{train_sigs[0].time:%Y-%m-%d} → {train_sigs[-1].time:%Y-%m-%d}",
"test_period": f"{test_sigs[0].time:%Y-%m-%d} → {test_sigs[-1].time:%Y-%m-%d}",
"cost_price_per_trade": round(cost_price, 5),
"grid": grid,
"train_best": best,
"test_result": test_result,
"baseline_on_test": baseline,
}
+8 -2
View File
@@ -36,12 +36,17 @@ def render(rep: BacktestReport, filename: str = None) -> str:
pf_str = "∞" if rep.profit_factor == float("inf") else f"{rep.profit_factor:.2f}"
cards = "".join([
_metric_card("Total Trades", str(rep.total)),
_metric_card("Trades Taken", str(rep.total)),
_metric_card("Signals Skipped", str(rep.signals_skipped)),
_metric_card("Win Rate", f"{rep.win_rate:.1f}%", rep.win_rate >= 50),
_metric_card("Profit Factor", pf_str, rep.profit_factor >= 1.5),
_metric_card("Expectancy", f"{rep.expectancy:+.2f}R", rep.expectancy > 0),
_metric_card("Net Result", f"{rep.net_r:+.1f}R", rep.net_r > 0),
_metric_card(f"Return / Year @ {rep.risk_per_trade*100:.0f}% risk",
f"{rep.annual_return_pct:+.1f}%", rep.annual_return_pct > 0),
_metric_card("Return / Month", f"{rep.monthly_return_pct:+.2f}%", rep.monthly_return_pct > 0),
_metric_card("Max Drawdown", f"-{rep.max_drawdown_r:.1f}R", False),
_metric_card("Max Drawdown %", f"-{rep.max_drawdown_pct:.1f}%", False),
_metric_card("Avg Win", f"+{rep.avg_win:.2f}R", True),
_metric_card("Avg Loss", f"-{rep.avg_loss:.2f}R", False),
_metric_card("Max Win Streak", str(rep.max_win_streak)),
@@ -98,7 +103,8 @@ def render(rep: BacktestReport, filename: str = None) -> str:
</head>
<body>
<h1>Backtest Report — {rep.symbol} {rep.timeframe}</h1>
<div class="sub">{rep.total} signals · generated {datetime.now().strftime('%Y-%m-%d %H:%M')}
<div class="sub">{rep.total} trades taken · {rep.signals_skipped} signals skipped (trade already open)
· generated {datetime.now().strftime('%Y-%m-%d %H:%M')}
· ambiguous bars counted as losses (conservative)</div>
<div class="grid">{cards}</div>
+2360
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
"""
Diagnose 2021 — the one losing year. Join each 2021 trade's outcome to the
regime/ADX/momentum values the indicator logged, to see whether a stricter
trend filter could have kept us out of the chop.
Compare the feature profile of 2021 vs the profitable years.
"""
import csv
from datetime import datetime
from collections import defaultdict
from core.connection import signal_file_path
from core.backtest import run_backtest
# outcomes per signal time
rep = run_backtest()
outcome = {t.time.strftime("%Y-%m-%d %H:%M"): t for t in rep.trades if t.outcome in ("WIN","LOSS")}
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
rows = []
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try: dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError: continue
key = dt.strftime("%Y-%m-%d %H:%M")
if key not in outcome: continue
t = outcome[key]
rows.append({"year": dt.year, "win": 1 if t.outcome=="WIN" else 0,
"r": t.r, "adx": float(d["adx"]),
"mom_fast": abs(float(d["mom_fast"])),
"vol_ratio": float(d["vol_ratio"]),
"regime": d["regime"]})
def profile(label, items):
n=len(items)
if not n: return
wr=sum(x["win"] for x in items)/n*100
net=sum(x["r"] for x in items)
adx=sum(x["adx"] for x in items)/n
mf=sum(x["mom_fast"] for x in items)/n
vr=sum(x["vol_ratio"] for x in items)/n
print(f"{label:<22} n={n:>4} WR={wr:5.1f}% net={net:>7.1f}R | avgADX={adx:5.1f} avgMomFast={mf:.3f} avgVolRatio={vr:.3f}")
y2021=[x for x in rows if x["year"]==2021]
other=[x for x in rows if x["year"]!=2021]
print("Feature profile: 2021 (the bad year) vs all other years\n")
profile("2021 (losing)", y2021)
profile("all other years", other)
print("\n2021 win rate by ADX bucket (is high ADX still bad in 2021?):")
buckets=defaultdict(list)
for x in y2021:
b = "ADX<25" if x["adx"]<25 else ("ADX 25-35" if x["adx"]<35 else "ADX>35")
buckets[b].append(x)
for b in ["ADX<25","ADX 25-35","ADX>35"]:
if buckets[b]:
g=buckets[b]; wr=sum(z["win"] for z in g)/len(g)*100; net=sum(z["r"] for z in g)
print(f" {b:<10} n={len(g):>3} WR={wr:5.1f}% net={net:>6.1f}R")
print("\nADX>35 (strong-trend only) — does filtering to it rescue each year?")
for y in sorted(set(x['year'] for x in rows)):
g=[x for x in rows if x['year']==y and x['adx']>35]
if g:
net=sum(z['r'] for z in g); wr=sum(z['win'] for z in g)/len(g)*100
print(f" {y}: n={len(g):>3} WR={wr:5.1f}% net={net:>6.1f}R")
+45
View File
@@ -0,0 +1,45 @@
"""
Validate the ADX>35 (strong-trend-only) filter, train/test honest split.
Compares baseline vs filtered on BOTH halves. The filter is a fixed structural
rule (only trade strong trends), not a tuned number.
"""
import csv
from datetime import datetime
from core.connection import signal_file_path
from core.signals import load_signals
from core.backtest import run_backtest, report_to_dict
ADX_MIN = 35.0
# adx by signal time
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
adx_by_time = {}
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try: dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError: continue
adx_by_time[dt] = float(d["adx"])
signals = sorted(load_signals(), key=lambda s: s.time)
split = int(len(signals) * 0.70)
train, test = signals[:split], signals[split:]
print(f"train: {train[0].time.date()} .. {train[-1].time.date()} ({len(train)} signals)")
print(f"test : {test[0].time.date()} .. {test[-1].time.date()} ({len(test)} signals)\n")
def show(label, sigs):
if not sigs:
print(f"{label:<26} (no signals)"); return
rep = run_backtest(signals=sigs)
d = report_to_dict(rep)
print(f"{label:<26} trades={d['trades_taken']:>4} WR={d['win_rate_pct']:>5}% "
f"PF={d['profit_factor']:>5} net={d['net_r']:>7}R "
f"maxDD={d['max_drawdown_r']:>6}R ann={d['annual_return_pct']:>5}%")
for label, subset in [("TRAIN", train), ("TEST", test)]:
print(f"=== {label} ===")
show(f" baseline (all)", subset)
show(f" ADX>{ADX_MIN:.0f} only", [s for s in subset if adx_by_time.get(s.time, 0) > ADX_MIN])
print()
+31
View File
@@ -0,0 +1,31 @@
"""
Per-year performance breakdown — is the edge broad, or just the 2024-26 trend?
Uses the baseline backtest (each signal's logged 2.5/3.5 exits) with costs,
one-trade-at-a-time, grouped by calendar year.
"""
from collections import defaultdict
from core.backtest import run_backtest
rep = run_backtest()
by_year = defaultdict(list)
for t in rep.trades:
if t.outcome in ("WIN", "LOSS"):
by_year[t.time.year].append(t)
print(f"{rep.symbol} {rep.timeframe} — baseline 2.5/3.5, costs in, per year\n")
print(f"{'year':>6}{'trades':>8}{'WR%':>7}{'PF':>7}{'net_R':>9}{'equity_R':>10}")
print("-" * 47)
cum = 0.0
for y in sorted(by_year):
ts = by_year[y]
wins = [t for t in ts if t.outcome == "WIN"]
gw = sum(t.r for t in wins)
gl = abs(sum(t.r for t in ts if t.outcome == "LOSS"))
net = sum(t.r for t in ts)
cum += net
pf = (gw / gl) if gl > 0 else float("inf")
wr = len(wins) / len(ts) * 100 if ts else 0
print(f"{y:>6}{len(ts):>8}{wr:>7.1f}{pf:>7.2f}{net:>9.1f}{cum:>10.1f}")
pos = sum(1 for y in by_year if sum(t.r for t in by_year[y]) > 0)
print(f"\nprofitable years: {pos}/{len(by_year)}")
+81
View File
@@ -0,0 +1,81 @@
"""
Test the Kaufman Efficiency Ratio filter on top of ADX>35, honest train/test.
ER over N bars = |close[t] - close[t-N]| / sum(|close[i]-close[i-1]|).
~1 = clean trend, ~0 = chop. Cutoff derived from TRAIN only, applied to TEST.
ER is computed from price (signals unchanged), matched to each signal's bar by
price (robust to the server/UTC time offset).
"""
import csv
from datetime import datetime
import MetaTrader5 as mt5
from core.connection import load_config, signal_file_path
from core.signals import load_signals
from core.backtest import run_backtest, report_to_dict
ER_PERIOD = 20
ADX_MIN = 35.0
SYMBOL = "GOLD"
cfg = load_config()
mt5.initialize(path=cfg["mt5_path"])
mt5.symbol_select(SYMBOL, True)
rates = mt5.copy_rates_from_pos(SYMBOL, mt5.TIMEFRAME_H1, 0, 60000)
closes = [float(r["close"]) for r in rates]
mt5.shutdown()
# map close (rounded to cent) -> list of indices, to locate each signal's bar
from collections import defaultdict
idx_by_close = defaultdict(list)
for i, c in enumerate(closes):
idx_by_close[round(c, 2)].append(i)
def efficiency_at(entry_price):
"""ER over the ER_PERIOD bars ending at the bar whose close == entry."""
cands = idx_by_close.get(round(entry_price, 2), [])
if not cands:
return None
idx = cands[len(cands)//2] # any matching bar; ER is local, exact bar ~equivalent
if idx < ER_PERIOD:
return None
net = abs(closes[idx] - closes[idx - ER_PERIOD])
path = sum(abs(closes[j] - closes[j-1]) for j in range(idx - ER_PERIOD + 1, idx + 1))
return (net / path) if path > 0 else 0.0
# adx + er per signal time
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
adx_by_time, er_by_time = {}, {}
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try: dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError: continue
adx_by_time[dt] = float(d["adx"])
er_by_time[dt] = efficiency_at(float(d["entry"]))
signals = sorted(load_signals(), key=lambda s: s.time)
split = int(len(signals) * 0.70)
train, test = signals[:split], signals[split:]
# ER cutoff from TRAIN signals that pass ADX>35 (median = above-typical efficiency)
train_ers = sorted(er_by_time[s.time] for s in train
if adx_by_time.get(s.time,0) > ADX_MIN and er_by_time.get(s.time) is not None)
cutoff = train_ers[len(train_ers)//2]
print(f"ER period={ER_PERIOD}, ADX>{ADX_MIN:.0f}; ER cutoff (train median) = {cutoff:.3f}\n")
def show(label, sigs):
if not sigs:
print(f"{label:<34} (none)"); return
d = report_to_dict(run_backtest(signals=sigs))
print(f"{label:<34} trades={d['trades_taken']:>4} WR={d['win_rate_pct']:>5}% "
f"PF={d['profit_factor']:>5} net={d['net_r']:>7}R maxDD={d['max_drawdown_r']:>6}R")
for label, subset in [("TRAIN", train), ("TEST", test)]:
print(f"=== {label} ===")
adx = [s for s in subset if adx_by_time.get(s.time,0) > ADX_MIN]
adx_er = [s for s in adx if er_by_time.get(s.time) is not None and er_by_time[s.time] > cutoff]
show(" ADX>35 (current best)", adx)
show(" ADX>35 + ER>cutoff", adx_er)
print()
+42
View File
@@ -0,0 +1,42 @@
from core.optimize_exits import optimize_exits
import time
t0 = time.time()
r = optimize_exits()
print(f"took {round(time.time()-t0,1)}s")
print("train:", r["train_period"].replace("→", "to"))
print("test :", r["test_period"].replace("→", "to"))
print("cost/trade (price):", r["cost_price_per_trade"])
print()
rrs = sorted(set(g["RR"] for g in r["grid"]))
ms = sorted(set(g["M"] for g in r["grid"]))
print("=== TRAIN: expectancy (R per trade) — rows=SL mult, cols=RR ===")
header = "SL\\RR".ljust(7) + "".join(f"{rr:>8}" for rr in rrs)
print(header)
for M in ms:
row = f"{M:>6} "
for rr in rrs:
g = next(x for x in r["grid"] if x["M"] == M and x["RR"] == rr)
row += f"{g['expectancy']:>8.3f}"
print(row)
print()
print("=== TRAIN: net R — rows=SL mult, cols=RR ===")
print(header)
for M in ms:
row = f"{M:>6} "
for rr in rrs:
g = next(x for x in r["grid"] if x["M"] == M and x["RR"] == rr)
row += f"{g['net_r']:>8.0f}"
print(row)
def show(label, d):
print(f"\n{label}: M={d['M']} RR={d['RR']} | trades={d['trades']} "
f"WR={d['win_rate']}% PF={d['profit_factor']} "
f"exp={d['expectancy']}R net={d['net_r']}R maxDD={d['max_dd_r']}R")
show("TRAIN BEST", r["train_best"])
show(" -> on LOCKED TEST", r["test_result"])
show("BASELINE 2.5/3.5 on TEST", r["baseline_on_test"])
+124
View File
@@ -0,0 +1,124 @@
"""
Fade-high-momentum strategy: consistency check + exit optimization.
1. Confirm FADE-HI is profitable in the TRAIN years too (not just test).
2. Optimize the exit (SL mult x RR) on TRAIN, confirm the pick on the locked TEST.
3. Generate an HTML report (out-of-sample) with % returns.
"""
import csv
from datetime import datetime
from core.connection import load_config, signal_file_path
from core.signals import load_signals, Signal
from core.optimize_exits import _build_cache, _pip_size
from core.ohlcv import fetch_aligned
from core.backtest import run_backtest, report_to_dict
from core.report_html import render
cfg = load_config()
amb = cfg.get("ambiguous_bar", "loss")
om = cfg.get("orig_stop_multiplier", 2.5)
risk = cfg.get("risk_per_trade", 0.01)
cost_pips = cfg.get("spread_pips", 1.0) + cfg.get("slippage_pips", 0.5)
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
mom = {}
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try: dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError: continue
mom[dt] = abs(float(d["mom_med"]))
signals = sorted(load_signals(), key=lambda s: s.time)
cache = _build_cache(signals, fetch_aligned, om, 1000)
cache = [(cs, mom.get(cs.sig.time, 0.0)) for cs in cache]
split = int(len(cache) * 0.70)
train, test = cache[:split], cache[split:]
hi_cut = sorted(m for _, m in train)[int(len(train) * 2 / 3)]
pip = _pip_size(signals[0].symbol)
cost = cost_pips * pip
train_hi = [x for x in train if x[1] > hi_cut]
test_hi = [x for x in test if x[1] > hi_cut]
def evaluate(items, M, RR):
busy = None; wins = losses = 0; gw = gl = 0.0; rs = []; dates = []
for cs, _ in items:
if busy is not None and cs.sig.time <= busy: continue
direction = "SHORT" if cs.sig.direction == "LONG" else "LONG" # FADE
is_long = direction == "LONG"
stop = M * cs.one_atr
if stop <= 0: continue
tpd = RR * stop; e = cs.entry_open
sl = e - stop if is_long else e + stop
tp = e + tpd if is_long else e - tpd
oc = "OPEN"; et = None
for b in cs.bars:
if b["time"] <= cs.sig.time: continue
if is_long: hsl, htp = b["low"] <= sl, b["high"] >= tp
else: hsl, htp = b["high"] >= sl, b["low"] <= tp
if hsl and htp: oc = "LOSS" if amb != "win" else "WIN"; et = b["time"]; break
if hsl: oc = "LOSS"; et = b["time"]; break
if htp: oc = "WIN"; et = b["time"]; break
cR = cost / stop
if oc == "WIN": rs.append(RR - cR); wins += 1; gw += RR - cR; dates.append(cs.sig.time); busy = et
elif oc == "LOSS": rs.append(-1 - cR); losses += 1; gl += 1 + cR; dates.append(cs.sig.time); busy = et
closed = wins + losses
net = sum(rs)
peak = run_ = mdd = 0.0
for r in rs:
run_ += r; peak = max(peak, run_); mdd = max(mdd, peak - run_)
years = (max(dates) - min(dates)).days / 365.25 if len(dates) >= 2 else 0
ann = (net / years * risk * 100) if years else 0
return {"M": M, "RR": RR, "trades": closed,
"WR": round(wins/closed*100,1) if closed else 0,
"PF": round(gw/gl,2) if gl>0 else 99,
"exp": round(net/closed,3) if closed else 0,
"net_r": round(net,1), "mdd_r": round(mdd,1),
"yrs": round(years,1),
"ann_pct": round(ann,1), "mo_pct": round(ann/12,2),
"mdd_pct": round(mdd*risk*100,1)}
print(f"hi-momentum cutoff (train top tercile): mom_med > {hi_cut:.3f}")
print(f"risk/trade for %: {risk*100:.0f}% cost: {cost_pips} pips\n")
print("=== 1) CONSISTENCY (fixed exit M=3.0 RR=2.0) ===")
for label, items in [("TRAIN", train_hi), ("TEST ", test_hi)]:
r = evaluate(items, 3.0, 2.0)
print(f" {label}: {r['trades']} trades WR={r['WR']}% PF={r['PF']} "
f"exp={r['exp']}R ann={r['ann_pct']}% maxDD={r['mdd_pct']}%")
print("\n=== 2) EXIT OPTIMIZATION (optimize on TRAIN, confirm on TEST) ===")
best = None
for M in [2.0, 2.5, 3.0, 3.5, 4.0]:
for RR in [1.0, 1.5, 2.0, 2.5, 3.0]:
r = evaluate(train_hi, M, RR)
if r["trades"] >= 60 and (best is None or r["exp"] > best["exp"]):
best = r
print(f" TRAIN best: M={best['M']} RR={best['RR']} "
f"trades={best['trades']} WR={best['WR']}% PF={best['PF']} "
f"exp={best['exp']}R ann={best['ann_pct']}% maxDD={best['mdd_pct']}%")
t = evaluate(test_hi, best["M"], best["RR"])
print(f" -> LOCKED TEST: trades={t['trades']} WR={t['WR']}% PF={t['PF']} "
f"exp={t['exp']}R ann={t['ann_pct']}% mo={t['mo_pct']}% maxDD={t['mdd_pct']}% ({t['yrs']} yrs)")
# 3) HTML report — out-of-sample (test) fade with the chosen exit
M, RR = best["M"], best["RR"]
fade_sigs = []
for cs, _ in test_hi:
direction = "SHORT" if cs.sig.direction == "LONG" else "LONG"
is_long = direction == "LONG"
stop = M * cs.one_atr; tpd = RR * stop; e = cs.sig.entry
fade_sigs.append(Signal(
time=cs.sig.time, direction=direction, entry=e,
sl=e - stop if is_long else e + stop,
tp=e + tpd if is_long else e - tpd,
symbol=cs.sig.symbol, timeframe=cs.sig.timeframe,
regime="FADE"))
rep = run_backtest(signals=fade_sigs, sequential=True)
path = render(rep, filename=f"fade_hi_TEST_M{M}_RR{RR}.html")
print(f"\nHTML (out-of-sample): {path}")
+75
View File
@@ -0,0 +1,75 @@
"""
Feature-outcome analysis: do the indicator's own entry features separate
winners from losers? Uses the logged condition values (no recalculation) joined
to each signal's backtested outcome.
If a feature is predictive, win rate should trend across its buckets.
If win rate is flat across every feature, the entries have no edge to tune.
"""
import csv
from datetime import datetime
from core.connection import signal_file_path
from core.backtest import run_backtest
# 1. Outcomes per signal (independent mode = every signal evaluated, baseline exits)
rep = run_backtest(sequential=False)
outcome_by_time = {t.time.strftime("%Y-%m-%d %H:%M"): t.outcome for t in rep.trades}
# 2. Raw logged feature values
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
rows = []
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12: continue
if not r[0][:4].isdigit(): continue # skip header
d = dict(zip(COLS, r))
try:
t = datetime.strptime(d["time"], "%Y.%m.%d %H:%M").strftime("%Y-%m-%d %H:%M")
except ValueError:
continue
oc = outcome_by_time.get(t)
if oc not in ("WIN","LOSS"): continue
rows.append({
"win": 1 if oc == "WIN" else 0,
"direction": d["direction"],
"adx": float(d["adx"]),
"mom_fast": abs(float(d["mom_fast"])), # magnitude in signal direction
"mom_med": abs(float(d["mom_med"])),
"mom_slow": abs(float(d["mom_slow"])),
"vol_ratio": float(d["vol_ratio"]),
"hour": int(d["time"][11:13]),
})
n = len(rows)
base_wr = sum(r["win"] for r in rows) / n * 100
print(f"signals analysed: {n} baseline win rate: {base_wr:.1f}%\n")
def buckets(feature, k=5):
vals = sorted(r[feature] for r in rows)
cuts = [vals[int(len(vals)*i/k)] for i in range(1, k)]
def b(v):
for i, c in enumerate(cuts):
if v <= c: return i
return k-1
groups = [[] for _ in range(k)]
for r in rows:
groups[b(r[feature])].append(r["win"])
print(f"=== {feature} (quintiles, low -> high) ===")
for i, g in enumerate(groups):
if not g: continue
wr = sum(g)/len(g)*100
lo = "min" if i==0 else f"{cuts[i-1]:.3f}"
hi = "max" if i==k-1 else f"{cuts[i]:.3f}"
bar = "#" * int(wr)
print(f" Q{i+1} [{lo:>7}..{hi:>7}] n={len(g):>4} WR={wr:5.1f}% {bar}")
print()
for feat in ["adx","mom_fast","mom_med","mom_slow","vol_ratio","hour"]:
buckets(feat)
# direction split
for dval in ("LONG","SHORT"):
g = [r["win"] for r in rows if r["direction"]==dval]
if g:
print(f"direction {dval}: n={len(g)} WR={sum(g)/len(g)*100:.1f}%")
+50
View File
@@ -0,0 +1,50 @@
"""
Validate the momentum-inversion finding: does the pattern hold in BOTH the
train half and the locked test half? A real edge shows in both; noise doesn't.
"""
import csv
from datetime import datetime
from core.connection import signal_file_path
from core.backtest import run_backtest
rep = run_backtest(sequential=False)
outcome_by_time = {t.time.strftime("%Y-%m-%d %H:%M"): t.outcome for t in rep.trades}
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
rows = []
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try:
dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError:
continue
oc = outcome_by_time.get(dt.strftime("%Y-%m-%d %H:%M"))
if oc not in ("WIN","LOSS"): continue
rows.append({"dt": dt, "win": 1 if oc=="WIN" else 0,
"mom_med": abs(float(d["mom_med"])),
"mom_slow": abs(float(d["mom_slow"])),
"vol_ratio": float(d["vol_ratio"])})
rows.sort(key=lambda r: r["dt"])
split = int(len(rows)*0.7)
train, test = rows[:split], rows[split:]
def terciles(data, feat):
vals = sorted(r[feat] for r in data)
c1, c2 = vals[len(vals)//3], vals[2*len(vals)//3]
g = [[],[],[]]
for r in data:
idx = 0 if r[feat]<=c1 else (1 if r[feat]<=c2 else 2)
g[idx].append(r["win"])
return [(sum(x)/len(x)*100 if x else 0, len(x)) for x in g]
for feat in ["mom_med","mom_slow","vol_ratio"]:
tr = terciles(train, feat)
te = terciles(test, feat)
print(f"=== {feat}: win rate by tercile (low / mid / high) ===")
print(f" TRAIN: {tr[0][0]:5.1f}% / {tr[1][0]:5.1f}% / {tr[2][0]:5.1f}% (n={tr[0][1]},{tr[1][1]},{tr[2][1]})")
print(f" TEST : {te[0][0]:5.1f}% / {te[1][0]:5.1f}% / {te[2][0]:5.1f}% (n={te[0][1]},{te[1][1]},{te[2][1]})")
print()
+81
View File
@@ -0,0 +1,81 @@
"""
Higher-timeframe trend-alignment filter on top of ADX>35, honest train/test.
Rule: take a signal only if its direction agrees with the DAILY trend
(daily close vs daily SMA50), using only completed daily bars strictly BEFORE
the signal's day (no look-ahead). SMA period fixed at 50 (not tuned).
"""
import csv
from datetime import datetime
import MetaTrader5 as mt5
from core.connection import load_config, signal_file_path
from core.signals import load_signals
from core.backtest import run_backtest, report_to_dict
ADX_MIN = 35.0
SMA_PER = 50
SYMBOL = "GOLD"
cfg = load_config()
mt5.initialize(path=cfg["mt5_path"])
mt5.symbol_select(SYMBOL, True)
d1 = mt5.copy_rates_from_pos(SYMBOL, mt5.TIMEFRAME_D1, 0, 4000)
mt5.shutdown()
# daily date -> trend_up (close > SMA50), using that day's own close/SMA
ddates, dclose = [], []
for r in d1:
ddates.append(datetime.fromtimestamp(int(r["time"])).date())
dclose.append(float(r["close"]))
trend_up_by_date = {}
for i in range(len(dclose)):
if i < SMA_PER: continue
sma = sum(dclose[i-SMA_PER+1:i+1]) / SMA_PER
trend_up_by_date[ddates[i]] = dclose[i] > sma
sorted_dates = sorted(trend_up_by_date)
def htf_up_before(sig_date):
"""Daily trend from the last completed day strictly before sig_date."""
import bisect
j = bisect.bisect_left(sorted_dates, sig_date) - 1
if j < 0: return None
return trend_up_by_date[sorted_dates[j]]
# adx per signal
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
adx_by_time = {}
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try: dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError: continue
adx_by_time[dt] = float(d["adx"])
signals = sorted(load_signals(), key=lambda s: s.time)
split = int(len(signals) * 0.70)
train, test = signals[:split], signals[split:]
def aligned(s):
up = htf_up_before(s.time.date())
if up is None: return False
return (s.direction == "LONG" and up) or (s.direction == "SHORT" and not up)
def show(label, sigs):
if not sigs:
print(f"{label:<34} (none)"); return
d = report_to_dict(run_backtest(signals=sigs))
print(f"{label:<34} trades={d['trades_taken']:>4} WR={d['win_rate_pct']:>5}% "
f"PF={d['profit_factor']:>5} net={d['net_r']:>7}R maxDD={d['max_drawdown_r']:>6}R")
print(f"Daily SMA{SMA_PER} trend alignment on top of ADX>{ADX_MIN:.0f}\n")
for label, subset in [("TRAIN", train), ("TEST", test)]:
print(f"=== {label} ===")
adx = [s for s in subset if adx_by_time.get(s.time,0) > ADX_MIN]
adx_htf = [s for s in adx if aligned(s)]
show(" ADX>35 (current best)", adx)
show(" ADX>35 + daily-trend", adx_htf)
print()
+135
View File
@@ -0,0 +1,135 @@
"""
Test the momentum-inversion finding out-of-sample.
Variants, all evaluated ONLY on the locked test slice, with a fixed neutral exit
(so we isolate the entry change), realistic costs, and one-trade-at-a-time:
BASELINE : all signals, original direction
PATH A FILTER : drop high-momentum signals, keep original direction
PATH B FADE-HI: take ONLY high-momentum signals, FLIP direction (mean-revert)
PATH B FADE-ALL: take ALL signals, FLIP direction (full mean-reversion)
The high-momentum cutoff is derived from TRAIN only, then applied to TEST.
"""
import csv
from datetime import datetime
from core.connection import load_config, signal_file_path
from core.signals import load_signals
from core.optimize_exits import _build_cache, _pip_size
cfg = load_config()
ambiguous = cfg.get("ambiguous_bar", "loss")
orig_mult = cfg.get("orig_stop_multiplier", 2.5)
cost_pips = cfg.get("spread_pips", 1.0) + cfg.get("slippage_pips", 0.5)
M_FIX, RR_FIX = 3.0, 2.0 # neutral fixed exit for apples-to-apples comparison
# --- mom_med by signal time (from raw log) ---
COLS = ["time","direction","entry","sl","tp","atr_pips","regime",
"adx","mom_fast","mom_med","mom_slow","vol_ratio","vpt_dir","near_level"]
mom_by_time = {}
with open(signal_file_path(), encoding="ansi") as f:
for r in csv.reader(f):
if not r or len(r) < 12 or not r[0][:4].isdigit(): continue
d = dict(zip(COLS, r))
try:
dt = datetime.strptime(d["time"], "%Y.%m.%d %H:%M")
except ValueError:
continue
mom_by_time[dt] = abs(float(d["mom_med"]))
# --- build cache (fetch forward bars once) ---
signals = sorted(load_signals(), key=lambda s: s.time)
cache = _build_cache(signals, __import__("core.ohlcv", fromlist=["fetch_after"]).fetch_after,
orig_mult, 1000)
# attach mom_med
cache = [(cs, mom_by_time.get(cs.sig.time, 0.0)) for cs in cache]
# --- train/test split ---
split = int(len(cache) * 0.70)
train, test = cache[:split], cache[split:]
# high-momentum cutoff from TRAIN (top tercile)
train_moms = sorted(m for _, m in train)
hi_cut = train_moms[int(len(train_moms) * 2 / 3)]
print(f"high-momentum cutoff (train top tercile): mom_med > {hi_cut:.3f}")
print(f"test signals: {len(test)} | fixed exit M={M_FIX} RR={RR_FIX} | cost {cost_pips} pips\n")
pip = _pip_size(signals[0].symbol)
cost_price = cost_pips * pip
def evaluate(items, flip):
"""items: list of (cached_signal, mom_med). flip: reverse trade direction."""
busy_until = None
wins = losses = 0
gw = gl = 0.0
rs = []
for cs, _m in items:
if busy_until is not None and cs.sig.time <= busy_until:
continue
direction = cs.sig.direction
if flip:
direction = "SHORT" if direction == "LONG" else "LONG"
is_long = direction == "LONG"
stop = M_FIX * cs.one_atr
if stop <= 0:
continue
tpd = RR_FIX * stop
e = cs.sig.entry
sl = e - stop if is_long else e + stop
tp = e + tpd if is_long else e - tpd
outcome = "OPEN"; exit_time = None
for b in cs.bars:
if b["time"] <= cs.sig.time:
continue
if is_long:
hsl, htp = b["low"] <= sl, b["high"] >= tp
else:
hsl, htp = b["high"] >= sl, b["low"] <= tp
if hsl and htp:
outcome = "WIN" if ambiguous == "win" else "LOSS"; exit_time = b["time"]; break
if hsl:
outcome = "LOSS"; exit_time = b["time"]; break
if htp:
outcome = "WIN"; exit_time = b["time"]; break
cost_R = cost_price / stop
if outcome == "WIN":
r = RR_FIX - cost_R; wins += 1; gw += r
elif outcome == "LOSS":
r = -1.0 - cost_R; losses += 1; gl += abs(r)
else:
continue
rs.append(r); busy_until = exit_time
closed = wins + losses
net = sum(rs)
peak = run = mdd = 0.0
for r in rs:
run += r; peak = max(peak, run); mdd = max(mdd, peak - run)
pf = (gw / gl) if gl > 0 else float("inf")
return {
"trades": closed,
"win_rate": round(wins/closed*100,1) if closed else 0,
"profit_factor": round(pf,2),
"expectancy": round(net/closed,4) if closed else 0,
"net_r": round(net,1),
"max_dd_r": round(mdd,1),
}
variants = {
"BASELINE (all, original dir)": (test, False),
"PATH A FILTER (drop hi-mom)": ([x for x in test if x[1] <= hi_cut], False),
"PATH B FADE-HI (hi-mom, flipped)": ([x for x in test if x[1] > hi_cut], True),
"PATH B FADE-ALL (all, flipped)": (test, True),
}
print(f"{'variant':<34}{'trades':>7}{'WR%':>7}{'PF':>7}{'exp_R':>8}{'net_R':>8}{'maxDD':>7}")
print("-"*78)
for name, (items, flip) in variants.items():
r = evaluate(items, flip)
print(f"{name:<34}{r['trades']:>7}{r['win_rate']:>7}{r['profit_factor']:>7}"
f"{r['expectancy']:>8}{r['net_r']:>8}{r['max_dd_r']:>7}")