mirror of
https://github.com/FXDavid-OffbeatForex/MBT.git
synced 2026-11-02 03:56:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
8391d68312
commit
f47ac228b1
File diff suppressed because it is too large
Load Diff
+76
-15
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+66
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)}")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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}%")
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
Reference in New Issue
Block a user