diff --git a/backend/api/routes.py b/backend/api/routes.py index b5941ac..204829d 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field -from indicators.sessions import set_timezone + BACKEND_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if BACKEND_DIR not in sys.path: sys.path.insert(0, BACKEND_DIR) @@ -141,13 +141,29 @@ def _get_candles_for_timeframe(dataset_id, timeframe): def _build_strategy( - session, lookback, ob_age, atr_mult, use_fvg, use_ob, - proximity_pct, sweep, sweep_lookback, - min_gap_size, impulse_multiplier, require_unmitigated_fvg, - require_bos_confluence, min_ob_size, require_fvg_ob_confluence, - asian_sweep_only, day_filter, - use_break_even=False, be_trigger_rr=1.0, - use_partial_tp=False, partial_tp_rr=1.0, partial_tp_percent=50.0, + session="new_york", + lookback=5, + ob_age=50, + atr_mult=1.5, + use_fvg=True, + use_ob=True, + proximity_pct=0.3, + sweep=True, + sweep_lookback=10, + min_gap_size=0.0, + impulse_multiplier=0.0, + require_unmitigated_fvg=True, + require_bos_confluence=False, + min_ob_size=0.0, + require_fvg_ob_confluence=False, + asian_sweep_only=False, + day_filter=None, + use_break_even=False, + be_trigger_rr=1.0, + use_partial_tp=False, + partial_tp_rr=1.0, + partial_tp_percent=50.0, + timezone="est", ): return ICTStrategy( session=session, @@ -172,6 +188,7 @@ def _build_strategy( use_partial_tp=use_partial_tp, partial_tp_rr=partial_tp_rr, partial_tp_percent=partial_tp_percent, + timezone=timezone, ) @@ -189,12 +206,28 @@ def _trade_payload(trade): } -def _stats_payload(trades, rr): +def _stats_payload(trades, rr, starting_balance=10000.0): total_pnl = sum(t.pnl for t in trades) winners = [t for t in trades if t.pnl > 0] losers = [t for t in trades if t.pnl <= 0] partial_tp_trades = [t for t in trades if getattr(t, "partial_tp_taken", False)] partial_tp_realized_total = sum(float(getattr(t, "partial_tp_realized_pnl", 0.0) or 0.0) for t in partial_tp_trades) + pnls = [t.pnl for t in trades] + returns = [(p / starting_balance) for p in pnls] if starting_balance > 0 else [] + mean_return = (sum(returns) / len(returns)) if returns else 0.0 + variance = (sum((r - mean_return) ** 2 for r in returns) / len(returns)) if returns else 0.0 + std_dev = variance ** 0.5 + sharpe_ratio = ((mean_return / std_dev) * (len(returns) ** 0.5)) if std_dev > 0 else 0.0 + + equity_points = _build_equity_points(trades, starting_balance=starting_balance) + peak = equity_points[0] if equity_points else starting_balance + max_drawdown_pct = 0.0 + for value in equity_points: + if value > peak: + peak = value + drawdown_pct = ((peak - value) / peak) * 100 if peak > 0 else 0.0 + if drawdown_pct > max_drawdown_pct: + max_drawdown_pct = drawdown_pct return { "total_trades": len(trades), "winners": len(winners), @@ -208,6 +241,8 @@ def _stats_payload(trades, rr): "partial_tp_rate": (len(partial_tp_trades) / len(trades) * 100) if trades else 0, "partial_tp_realized_total": partial_tp_realized_total, "partial_tp_realized_avg": (partial_tp_realized_total / len(partial_tp_trades)) if partial_tp_trades else 0, + "sharpe_ratio": round(sharpe_ratio, 6), + "max_drawdown_pct": round(max_drawdown_pct, 6), } @@ -310,7 +345,7 @@ def _risk_metrics(trades, starting_balance=10000.0): sortino = (mean_pnl / downside_dev) * (trade_count ** 0.5) if downside_dev > 0 else 0.0 equity_points = _build_equity_points(trades, starting_balance=starting_balance) - peak = equity_points[0] + peak = equity_points[0] if equity_points else starting_balance max_drawdown_pct = 0.0 for value in equity_points: if value > peak: @@ -319,8 +354,9 @@ def _risk_metrics(trades, starting_balance=10000.0): if drawdown_pct > max_drawdown_pct: max_drawdown_pct = drawdown_pct - calmar = (net_pnl / max_drawdown_pct) if max_drawdown_pct > 0 else 0.0 - recovery = (net_pnl / max_drawdown_pct) if max_drawdown_pct > 0 else 0.0 + calmar = ((net_pnl / starting_balance) * 100 / max_drawdown_pct) if max_drawdown_pct > 0 else 0.0 + drawdown_amount = starting_balance * (max_drawdown_pct / 100) if max_drawdown_pct > 0 else 0.0 + recovery = (net_pnl / drawdown_amount) if drawdown_amount > 0 else 0.0 if trade_count < 80: trade_score = max(0.0, trade_count / 80) @@ -524,24 +560,22 @@ def get_backtest( max_consecutive_losses: int = 0, ): dataset_id = _resolve_dataset(dataset) - if "MT5" in dataset.upper(): - set_timezone("mt5") - else: - set_timezone("est") + timezone = "mt5" if "MT5" in dataset.upper() else "est" candles = _get_candles_for_timeframe(dataset_id, timeframe) strategy = _build_strategy( - session=session, lookback=lookback, ob_age=ob_age, atr_mult=atr_mult, - use_fvg=use_fvg, use_ob=use_ob, proximity_pct=proximity_pct, - sweep=sweep, sweep_lookback=sweep_lookback, - min_gap_size=min_gap_size, impulse_multiplier=impulse_multiplier, - require_unmitigated_fvg=require_unmitigated_fvg, - require_bos_confluence=require_bos_confluence, - min_ob_size=min_ob_size, require_fvg_ob_confluence=require_fvg_ob_confluence, - asian_sweep_only=asian_sweep_only, day_filter=day_filter, - use_break_even=use_break_even, be_trigger_rr=be_trigger_rr, - use_partial_tp=use_partial_tp, partial_tp_rr=partial_tp_rr, partial_tp_percent=partial_tp_percent, - ) + session=session, lookback=lookback, ob_age=ob_age, atr_mult=atr_mult, + use_fvg=use_fvg, use_ob=use_ob, proximity_pct=proximity_pct, + sweep=sweep, sweep_lookback=sweep_lookback, + min_gap_size=min_gap_size, impulse_multiplier=impulse_multiplier, + require_unmitigated_fvg=require_unmitigated_fvg, + require_bos_confluence=require_bos_confluence, + min_ob_size=min_ob_size, require_fvg_ob_confluence=require_fvg_ob_confluence, + asian_sweep_only=asian_sweep_only, day_filter=day_filter, + use_break_even=use_break_even, be_trigger_rr=be_trigger_rr, + use_partial_tp=use_partial_tp, partial_tp_rr=partial_tp_rr, partial_tp_percent=partial_tp_percent, + timezone=timezone, + ) trades = run_backtest( candles, strategy, 10000, risk_reward=rr, max_daily_loss=max_daily_loss, @@ -551,7 +585,7 @@ def get_backtest( return { "trades": [_trade_payload(t) for t in trades], "candle_times": [c.time_open.isoformat() for c in candles], - "stats": _stats_payload(trades, rr), + "stats": _stats_payload(trades, rr, starting_balance=10000.0), } @@ -585,6 +619,7 @@ def backtest_monte_carlo(req: MonteCarloRequest): @app.post("/api/optimize") def get_optimize(req: OptimizeRequest): dataset_id = _resolve_dataset(req.dataset) + timezone = "mt5" if "MT5" in dataset_id.upper() else "est" candles = _get_candles_for_timeframe(dataset_id, req.timeframe) session_list = req.sessions @@ -709,6 +744,7 @@ def get_optimize(req: OptimizeRequest): use_partial_tp=params["use_partial_tp"], partial_tp_rr=params["partial_tp_rr"], partial_tp_percent=params["partial_tp_percent"], + timezone=timezone, ) trades = run_backtest(candles, strategy, 10000, risk_reward=params["rr"]) @@ -815,6 +851,7 @@ def get_optimize_monte_carlo( ruin_drawdown_pct: float = Query(default=20.0, ge=0.0, le=100.0), ): dataset_id = _resolve_dataset(dataset) + timezone = "mt5" if "MT5" in dataset_id.upper() else "est" candles = _get_candles_for_timeframe(dataset_id, timeframe) strategy = _build_strategy( @@ -840,6 +877,7 @@ def get_optimize_monte_carlo( partial_tp_rr=partial_tp_rr, partial_tp_percent=partial_tp_percent, day_filter=None, + timezone=timezone, ) trades = run_backtest(candles, strategy, 10000, risk_reward=rr) trade_r_multiples = [getattr(t, "r_multiple", 0.0) for t in trades] @@ -948,20 +986,22 @@ def stream_backtest( max_consecutive_losses: int = 0, ): dataset_id = _resolve_dataset(dataset) + timezone = "mt5" if "MT5" in dataset_id.upper() else "est" candles = _get_candles_for_timeframe(dataset_id, timeframe) strategy = _build_strategy( - session=session, lookback=lookback, ob_age=ob_age, atr_mult=atr_mult, - use_fvg=use_fvg, use_ob=use_ob, proximity_pct=proximity_pct, - sweep=sweep, sweep_lookback=sweep_lookback, - min_gap_size=min_gap_size, impulse_multiplier=impulse_multiplier, - require_unmitigated_fvg=require_unmitigated_fvg, - require_bos_confluence=require_bos_confluence, - min_ob_size=min_ob_size, require_fvg_ob_confluence=require_fvg_ob_confluence, - asian_sweep_only=asian_sweep_only, day_filter=day_filter, - use_break_even=use_break_even, be_trigger_rr=be_trigger_rr, - use_partial_tp=use_partial_tp, partial_tp_rr=partial_tp_rr, partial_tp_percent=partial_tp_percent, - ) + session=session, lookback=lookback, ob_age=ob_age, atr_mult=atr_mult, + use_fvg=use_fvg, use_ob=use_ob, proximity_pct=proximity_pct, + sweep=sweep, sweep_lookback=sweep_lookback, + min_gap_size=min_gap_size, impulse_multiplier=impulse_multiplier, + require_unmitigated_fvg=require_unmitigated_fvg, + require_bos_confluence=require_bos_confluence, + min_ob_size=min_ob_size, require_fvg_ob_confluence=require_fvg_ob_confluence, + asian_sweep_only=asian_sweep_only, day_filter=day_filter, + use_break_even=use_break_even, be_trigger_rr=be_trigger_rr, + use_partial_tp=use_partial_tp, partial_tp_rr=partial_tp_rr, partial_tp_percent=partial_tp_percent, + timezone=timezone, + ) def _sse(data): return f"data: {json.dumps(data)}\n\n" @@ -993,7 +1033,7 @@ def stream_backtest( yield _sse({ "type": "trade", "trade": _trade_payload(trade), - "stats": _stats_payload(streamed_trades, rr), + "stats": _stats_payload(streamed_trades, rr, starting_balance=10000.0), "processed_candles": event["processed_candles"], "total_candles": event["total_candles"], }) @@ -1002,7 +1042,7 @@ def stream_backtest( yield _sse({ "type": "done", "trades": [_trade_payload(t) for t in streamed_trades], - "stats": _stats_payload(streamed_trades, rr), + "stats": _stats_payload(streamed_trades, rr, starting_balance=10000.0), "duration_ms": round(duration_ms, 1), "candle_times": [c.time_open.isoformat() for c in candles], }) diff --git a/backend/data/loader.py b/backend/data/loader.py index 5f8ea0c..5bdf60e 100644 --- a/backend/data/loader.py +++ b/backend/data/loader.py @@ -1,4 +1,3 @@ -print("File is running") import pandas as pd from data.model import Candle diff --git a/backend/data/model.py b/backend/data/model.py index fa92059..a922b1c 100644 --- a/backend/data/model.py +++ b/backend/data/model.py @@ -28,3 +28,5 @@ class Trade: exit_price: float pnl: float r_multiple: float = 0.0 + partial_tp_taken: bool = False + partial_tp_realized_pnl: float = 0.0 diff --git a/backend/engine/backtester.py b/backend/engine/backtester.py index c51694d..13ed8c3 100644 --- a/backend/engine/backtester.py +++ b/backend/engine/backtester.py @@ -30,18 +30,66 @@ def _apply_break_even_if_triggered(position, candle, strategy): position["break_even_armed"] = True +def _apply_partial_tp_if_triggered(position, candle, strategy): + if not position: + return + + if not getattr(strategy, "use_partial_tp", False): + return + + if position.get("partial_tp_taken"): + return + + trigger_rr = float(getattr(strategy, "partial_tp_rr", 1.0) or 0.0) + if trigger_rr <= 0: + return + + partial_pct = float(getattr(strategy, "partial_tp_percent", 0.0) or 0.0) + if partial_pct <= 0: + return + + is_long = position["direction"] == "long" + entry = position["entry_price"] + risk_distance = max(position.get("risk_distance", 0.0), 0.0) + if risk_distance <= 0: + return + + trigger_price = entry + (risk_distance * trigger_rr) if is_long else entry - (risk_distance * trigger_rr) + reached_trigger = candle.high >= trigger_price if is_long else candle.low <= trigger_price + if not reached_trigger: + return + + lot_size = max(position.get("lot_size", 0.0), 0.0) + if lot_size <= 0: + return + + partial_pct = min(partial_pct, 100.0) + partial_lot = lot_size * (partial_pct / 100.0) + if partial_lot <= 0: + return + + price_move = (trigger_price - entry) if is_long else (entry - trigger_price) + partial_pnl = price_move * partial_lot + + position["lot_size"] = max(lot_size - partial_lot, 0.0) + position["partial_tp_taken"] = True + position["partial_tp_realized_pnl"] = position.get("partial_tp_realized_pnl", 0.0) + partial_pnl + + def run_backtest(candles, strategy, starting_balance, risk_reward=1.0, max_daily_loss=0.0, max_consecutive_losses=0, risk_pct=1.0): trades = [] position = None consecutive_losses = 0 daily_pnl = defaultdict(float) + equity = float(starting_balance) if hasattr(strategy, "prepare"): strategy.prepare(candles) for i, candle in enumerate(candles): if position: _apply_break_even_if_triggered(position, candle, strategy) + _apply_partial_tp_if_triggered(position, candle, strategy) is_long = position["direction"] == "long" sl, tp = position["stop_loss"], position["take_profit"] @@ -53,7 +101,8 @@ def run_backtest(candles, strategy, starting_balance, risk_reward=1.0, exit_price = sl if hit_sl else tp price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price) lot_size = max(position.get("lot_size", 0.0), 0.0) - pnl = price_move * lot_size + partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0) + pnl = (price_move * lot_size) + partial_pnl risk_distance = max(position.get("risk_distance", 0.0), 1e-12) r_multiple = price_move / risk_distance @@ -65,8 +114,11 @@ def run_backtest(candles, strategy, starting_balance, risk_reward=1.0, exit_price=exit_price, pnl=pnl, r_multiple=r_multiple, + partial_tp_taken=bool(position.get("partial_tp_taken", False)), + partial_tp_realized_pnl=partial_pnl, )) position = None + equity += pnl if pnl <= 0: consecutive_losses += 1 @@ -76,6 +128,8 @@ def run_backtest(candles, strategy, starting_balance, risk_reward=1.0, daily_pnl[candle.time_open.date()] += pnl if position is None: + if equity <= 0: + continue if max_consecutive_losses > 0 and consecutive_losses >= max_consecutive_losses: continue if max_daily_loss > 0: @@ -99,7 +153,7 @@ def run_backtest(candles, strategy, starting_balance, risk_reward=1.0, ): continue - risk_amount = starting_balance * (risk_pct / 100) + risk_amount = equity * (risk_pct / 100) if risk_amount <= 0 or not math.isfinite(risk_amount): continue @@ -118,8 +172,35 @@ def run_backtest(candles, strategy, starting_balance, risk_reward=1.0, "risk_distance": sl_distance, "lot_size": lot_size, "break_even_armed": False, + "partial_tp_taken": False, + "partial_tp_realized_pnl": 0.0, } + if position and candles: + last_candle = candles[-1] + is_long = position["direction"] == "long" + exit_price = last_candle.close + price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price) + lot_size = max(position.get("lot_size", 0.0), 0.0) + partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0) + pnl = (price_move * lot_size) + partial_pnl + risk_distance = max(position.get("risk_distance", 0.0), 1e-12) + r_multiple = price_move / risk_distance + + trades.append(Trade( + enter_time=position["enter_time"], + enter_price=position["entry_price"], + direction=position["direction"], + exit_time=last_candle.time_open, + exit_price=exit_price, + pnl=pnl, + r_multiple=r_multiple, + partial_tp_taken=bool(position.get("partial_tp_taken", False)), + partial_tp_realized_pnl=partial_pnl, + )) + equity += pnl + daily_pnl[last_candle.time_open.date()] += pnl + return trades @@ -129,6 +210,7 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, consecutive_losses = 0 daily_pnl = defaultdict(float) total = len(candles) + equity = float(starting_balance) if hasattr(strategy, "prepare"): strategy.prepare(candles) @@ -143,6 +225,7 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, if position: _apply_break_even_if_triggered(position, candle, strategy) + _apply_partial_tp_if_triggered(position, candle, strategy) is_long = position["direction"] == "long" sl, tp = position["stop_loss"], position["take_profit"] @@ -154,7 +237,8 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, exit_price = sl if hit_sl else tp price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price) lot_size = max(position.get("lot_size", 0.0), 0.0) - pnl = price_move * lot_size + partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0) + pnl = (price_move * lot_size) + partial_pnl risk_distance = max(position.get("risk_distance", 0.0), 1e-12) r_multiple = price_move / risk_distance @@ -166,8 +250,11 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, exit_price=exit_price, pnl=pnl, r_multiple=r_multiple, + partial_tp_taken=bool(position.get("partial_tp_taken", False)), + partial_tp_realized_pnl=partial_pnl, ) position = None + equity += pnl if pnl <= 0: consecutive_losses += 1 @@ -179,6 +266,8 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, yield {"type": "trade", "trade": trade, "processed_candles": i, "total_candles": total} if position is None: + if equity <= 0: + continue if max_consecutive_losses > 0 and consecutive_losses >= max_consecutive_losses: continue if max_daily_loss > 0: @@ -202,7 +291,7 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, ): continue - risk_amount = starting_balance * (risk_pct / 100) + risk_amount = equity * (risk_pct / 100) if risk_amount <= 0 or not math.isfinite(risk_amount): continue @@ -221,6 +310,34 @@ def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0, "risk_distance": sl_distance, "lot_size": lot_size, "break_even_armed": False, + "partial_tp_taken": False, + "partial_tp_realized_pnl": 0.0, } + if position and candles: + last_candle = candles[-1] + is_long = position["direction"] == "long" + exit_price = last_candle.close + price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price) + lot_size = max(position.get("lot_size", 0.0), 0.0) + partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0) + pnl = (price_move * lot_size) + partial_pnl + risk_distance = max(position.get("risk_distance", 0.0), 1e-12) + r_multiple = price_move / risk_distance + + trade = Trade( + enter_time=position["enter_time"], + enter_price=position["entry_price"], + direction=position["direction"], + exit_time=last_candle.time_open, + exit_price=exit_price, + pnl=pnl, + r_multiple=r_multiple, + partial_tp_taken=bool(position.get("partial_tp_taken", False)), + partial_tp_realized_pnl=partial_pnl, + ) + equity += pnl + daily_pnl[last_candle.time_open.date()] += pnl + yield {"type": "trade", "trade": trade, "processed_candles": total, "total_candles": total} + yield {"type": "done", "total_candles": total} \ No newline at end of file diff --git a/backend/indicators/sessions.py b/backend/indicators/sessions.py index 0847593..abb0cff 100644 --- a/backend/indicators/sessions.py +++ b/backend/indicators/sessions.py @@ -22,36 +22,42 @@ SESSIONS_MT5 = { _active_sessions = SESSIONS_EST +def get_sessions_for_tz(tz="est"): + if tz and tz.lower() in ("mt5", "utc+2", "server"): + return SESSIONS_MT5 + return SESSIONS_EST + + def set_timezone(tz="est"): global _active_sessions - if tz.lower() in ("mt5", "utc+2", "server"): - _active_sessions = SESSIONS_MT5 - else: - _active_sessions = SESSIONS_EST + _active_sessions = get_sessions_for_tz(tz) -def in_session(candle_time, session_name): +def in_session(candle_time, session_name, sessions_map=None): if session_name == "all": return True - if session_name not in _active_sessions: + active = sessions_map or _active_sessions + if session_name not in active: return True t = candle_time.time() - start, end = _active_sessions[session_name] + start, end = active[session_name] if start > end: return t >= start or t < end return start <= t < end -def get_session(candle_time): - for name in _active_sessions: - if in_session(candle_time, name): +def get_session(candle_time, sessions_map=None): + active = sessions_map or _active_sessions + for name in active: + if in_session(candle_time, name, sessions_map=active): return name return "off_hours" -def filter_by_session(candles, session_name): - return [c for c in candles if in_session(c.time_open, session_name)] +def filter_by_session(candles, session_name, sessions_map=None): + active = sessions_map or _active_sessions + return [c for c in candles if in_session(c.time_open, session_name, sessions_map=active)] def in_day_filter(candle_time, allowed_days): @@ -60,8 +66,9 @@ def in_day_filter(candle_time, allowed_days): return candle_time.weekday() in allowed_days -def get_asian_range(candles): - asian = filter_by_session(candles, "asian") +def get_asian_range(candles, sessions_map=None): + active = sessions_map or _active_sessions + asian = filter_by_session(candles, "asian", sessions_map=active) if not asian: return None return { diff --git a/backend/optimize_categorical.py b/backend/optimize_categorical.py new file mode 100644 index 0000000..220cf07 --- /dev/null +++ b/backend/optimize_categorical.py @@ -0,0 +1,29 @@ +from data.loader import load_candles, resample_candles +from engine.backtester import run_backtest +from strategies.categorical_strategy import CategoricalStrategy + +candles_1m = load_candles("data/gbpjpy_jan.csv") +candles_5m = resample_candles(candles_1m, period=5) + +best_pnl = float("-inf") +best_params = None + +for lookback in [10, 15, 20, 30, 40, 50]: + for threshold in [0.2, 0.3, 0.4, 0.5, 0.7, 1.0]: + for atr_mult in [0.3, 0.4, 0.5, 0.6, 0.7]: + strategy = CategoricalStrategy( + lookback=lookback, + range_threshold=threshold, + atr_multiplier=atr_mult + ) + trades = run_backtest(candles_5m, strategy, 10000) + if len(trades) < 50: + continue + total_pnl = sum(t.pnl for t in trades) + win_rate = len([t for t in trades if t.pnl > 0]) / len(trades) * 100 + if total_pnl > best_pnl: + best_pnl = total_pnl + best_params = (lookback, threshold, atr_mult) + print(f"New best: LB={lookback}, TH={threshold}, ATR={atr_mult} -> PnL={total_pnl:.2f}, WR={win_rate:.1f}%, Trades={len(trades)}") + +print(f"\nBest: lookback={best_params[0]}, threshold={best_params[1]}, atr_mult={best_params[2]}, PnL={best_pnl:.2f}") \ No newline at end of file diff --git a/backend/strategies/ict_strategy.py b/backend/strategies/ict_strategy.py index b2c3279..044b474 100644 --- a/backend/strategies/ict_strategy.py +++ b/backend/strategies/ict_strategy.py @@ -3,7 +3,7 @@ from indicators.market_structure import find_swing_points, detect_structure from indicators.liquidity import find_liquidity_levels from indicators.fvg import find_fvgs from indicators.order_blocks import find_order_blocks -from indicators.sessions import in_session, in_day_filter, get_asian_range +from indicators.sessions import in_session, in_day_filter, get_asian_range, get_sessions_for_tz from collections import defaultdict @@ -33,6 +33,7 @@ class ICTStrategy: use_partial_tp=False, partial_tp_rr=1.0, partial_tp_percent=50.0, + timezone="est", ): self.lookback = lookback self.atr_mult = atr_mult @@ -58,6 +59,7 @@ class ICTStrategy: self.use_partial_tp = use_partial_tp self.partial_tp_rr = partial_tp_rr self.partial_tp_percent = partial_tp_percent + self.sessions_map = get_sessions_for_tz(timezone) self.swings = [] self.structure = [] @@ -88,7 +90,7 @@ class ICTStrategy: for c in candles: daily[c.time_open.date()].append(c) for date, day_candles in daily.items(): - ar = get_asian_range(day_candles) + ar = get_asian_range(day_candles, sessions_map=self.sessions_map) if ar: self.asian_ranges[date] = ar @@ -204,7 +206,7 @@ class ICTStrategy: candle = candles[index] - if not in_session(candle.time_open, self.session): + if not in_session(candle.time_open, self.session, sessions_map=self.sessions_map): self.recent_sweep = None return None diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e0c192a..8f81b99 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,7 @@ import { import { BacktestingTab } from './components/BacktestingTab'; import { OptimizerTab } from './components/OptimizerTab'; import { TradeHistory } from './components/TradeHistory'; -import { motion } from 'motion/react'; +import { motion as Motion } from 'motion/react'; import { EquityCurve } from './components/EquityCurve'; import { MetricCard } from './components/MetricCard'; @@ -115,6 +115,7 @@ export default function App() { const candleSeriesRef = useRef(null); const equitySeriesRef = useRef(null); const markersRef = useRef(null); + const abortControllerRef = useRef(null); const [mounted, setMounted] = useState(false); const [activeTab, setActiveTab] = useState('dashboard'); @@ -194,30 +195,41 @@ export default function App() { const loadData = useCallback(async () => { const shouldLoadDashboardData = ['dashboard', 'forex-stats', 'trade-history'].includes(activeTab); if (!shouldLoadDashboardData) { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } setLoading(false); return; } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + abortControllerRef.current = controller; + setLoading(true); try { const shouldLoadBacktest = showBacktest || activeTab === 'forex-stats'; const shouldFetchBacktest = shouldLoadBacktest && !hasSharedBacktest; const datasetQuery = `dataset=${encodeURIComponent(selectedDataset)}`; const fetches = [ - fetch(`http://localhost:8000/api/candles?timeframe=${timeframe}&${datasetQuery}`), - fetch(`http://localhost:8000/api/indicators?timeframe=${timeframe}&${datasetQuery}`), + fetch(`http://localhost:8000/api/candles?timeframe=${timeframe}&${datasetQuery}`, { signal: controller.signal }), + fetch(`http://localhost:8000/api/indicators?timeframe=${timeframe}&${datasetQuery}`, { signal: controller.signal }), ]; if (shouldFetchBacktest) { - fetches.push(fetch(`http://localhost:8000/api/backtest?timeframe=${timeframe}&rr=${riskReward}&lookback=${stratParams.lookback}&ob_age=${stratParams.obAge}&atr_mult=${stratParams.atrMult}&sweep=${stratParams.sweep}&sweep_lookback=${stratParams.sweepLookback}&session=${stratParams.session}&${datasetQuery}`)); + fetches.push(fetch(`http://localhost:8000/api/backtest?timeframe=${timeframe}&rr=${riskReward}&lookback=${stratParams.lookback}&ob_age=${stratParams.obAge}&atr_mult=${stratParams.atrMult}&sweep=${stratParams.sweep}&sweep_lookback=${stratParams.sweepLookback}&session=${stratParams.session}&${datasetQuery}`, { signal: controller.signal })); } const responses = await Promise.all(fetches); + if (controller.signal.aborted) return; const candleData = await responses[0].json(); const indicatorData = await responses[1].json(); const backtestPayload = shouldFetchBacktest ? await responses[2].json() : (shouldLoadBacktest ? backtestData : null); + if (controller.signal.aborted) return; const candles = candleData.candles.map((candle) => ({ time: Math.floor(new Date(candle.time).getTime() / 1000), open: candle.open, @@ -331,9 +343,12 @@ export default function App() { setBacktestData(backtestPayload); } } catch (error) { + if (error?.name === 'AbortError') return; console.error('Failed to load data:', error); } finally { - setLoading(false); + if (!controller.signal.aborted) { + setLoading(false); + } } }, [activeTab, backtestData, hasSharedBacktest, indicators, riskReward, selectedDataset, showBacktest, timeframe]); @@ -419,8 +434,18 @@ export default function App() { const backtestStats = backtestData?.stats ?? null; const equityCurve = useMemo(() => buildEquityCurve(backtestTrades), [backtestTrades]); const monthlyReturns = useMemo(() => buildMonthlyReturns(backtestTrades), [backtestTrades]); - const maxDrawdown = useMemo(() => calculateMaxDrawdown(equityCurve), [equityCurve]); - const sharpeRatio = useMemo(() => calculateSharpeRatio(backtestTrades), [backtestTrades]); + const maxDrawdown = useMemo(() => { + if (backtestStats?.max_drawdown_pct != null) { + return -Math.abs(backtestStats.max_drawdown_pct); + } + return calculateMaxDrawdown(equityCurve); + }, [backtestStats, equityCurve]); + const sharpeRatio = useMemo(() => { + if (backtestStats?.sharpe_ratio != null) { + return backtestStats.sharpe_ratio; + } + return calculateSharpeRatio(backtestTrades); + }, [backtestStats, backtestTrades]); const largestWin = useMemo(() => backtestTrades.reduce((best, t) => Math.max(best, t.pnl), 0), [backtestTrades]); const largestLoss = useMemo(() => backtestTrades.reduce((worst, t) => Math.min(worst, t.pnl), 0), [backtestTrades]); const grossProfit = backtestStats ? backtestStats.winners * backtestStats.avg_win : 0; @@ -466,7 +491,7 @@ export default function App() {
{/* Header */} - )}
- + {/* Tabs */}
@@ -521,7 +546,7 @@ export default function App() {
{/* Dashboard Tab */} - {/* Toolbar */} - +
Timeframe @@ -606,31 +631,31 @@ export default function App() {
-
+
{/* Candlestick Chart */} - +

Market Chart

Candles with structure and trade markers

- + {/* Equity Line (lightweight-charts) */} {showBacktest && ( - +

Equity Curve

Strategy balance progression

- + )} - + {/* Stats Tab */} - {/* Hero */} - +
@@ -656,10 +681,10 @@ export default function App() {

Switch CSVs here to refresh all metrics and charts.

-
+
{/* Metrics Grid */} - + {overviewMetrics.map((metric) => ( ))} - + {/* Equity Curve (recharts) */} - + - + {/* Distribution + Breakdown */} - + - - +
+ {/* Trade History Tab */} - - + - - + + {/* Backtesting Tab */} {activeTab === 'backtesting' && ( - - + )} {activeTab === 'optimizer' && ( - - + )}
diff --git a/frontend/src/components/BacktestingTab.jsx b/frontend/src/components/BacktestingTab.jsx index dae98a9..e5eb837 100644 --- a/frontend/src/components/BacktestingTab.jsx +++ b/frontend/src/components/BacktestingTab.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { motion } from 'motion/react'; +import { motion as Motion } from 'motion/react'; import { CandlestickSeries, LineSeries, @@ -618,7 +618,7 @@ export function BacktestingTab({ datasets = [], selectedDataset, onDatasetChange return (
- + {/* Header row */}
@@ -817,29 +817,29 @@ export function BacktestingTab({ datasets = [], selectedDataset, onDatasetChange
- + {/* Chart */} - +

Backtest Chart

Trade entries and exits

- + {/* Equity */} - +

Equity Curve

Balance progression

- + {/* Results */} {stats && ( - +

Results

Backtest Summary

@@ -992,8 +992,10 @@ export function BacktestingTab({ datasets = [], selectedDataset, onDatasetChange

= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>${formatMoney(partialTpRealized)}

-
+
)}
); -} \ No newline at end of file +} + + diff --git a/frontend/src/components/MetricCard.jsx b/frontend/src/components/MetricCard.jsx index d3e3b4f..00ba43b 100644 --- a/frontend/src/components/MetricCard.jsx +++ b/frontend/src/components/MetricCard.jsx @@ -1,48 +1,24 @@ -import { motion, useInView } from 'motion/react'; -import { useEffect, useRef, useState } from 'react'; +import { motion as Motion } from 'motion/react'; +import { useMemo } from 'react'; export function MetricCard({ label, value, change, isPositive, isPrimary = false, neutral = false }) { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.3 }); - const [displayValue, setDisplayValue] = useState('0'); - - useEffect(() => { - if (!isInView) return; - + const displayValue = useMemo(() => { const numericValue = parseFloat(value.replace(/[^0-9.-]/g, '')); - if (isNaN(numericValue)) { - setDisplayValue(value); - return; + if (isNaN(numericValue)) return value; + if (value.includes('$')) { + return `$${numericValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } - - const duration = 1200; - const startTime = Date.now(); - - const animate = () => { - const progress = Math.min((Date.now() - startTime) / duration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - const current = numericValue * eased; - - if (value.includes('$')) { - setDisplayValue(`$${current.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); - } else if (value.includes('%')) { - setDisplayValue(`${current.toFixed(1)}%`); - } else { - setDisplayValue(current % 1 === 0 ? Math.round(current).toString() : current.toFixed(2)); - } - - if (progress < 1) requestAnimationFrame(animate); - }; - - animate(); - }, [isInView, value]); + if (value.includes('%')) { + return `${numericValue.toFixed(1)}%`; + } + return numericValue % 1 === 0 ? Math.round(numericValue).toString() : numericValue.toFixed(2); + }, [value]); const color = neutral ? 'text-[#fafafa]' : isPositive ? 'text-[#10b981]' : 'text-[#ef4444]'; const changeLabel = typeof change === 'number' ? `${change >= 0 ? '+' : ''}${change.toFixed(1)}%` : change; return ( -
-
+
); } diff --git a/frontend/src/components/TradeDistribution.jsx b/frontend/src/components/TradeDistribution.jsx index 0a4ccdc..d7f98f0 100644 --- a/frontend/src/components/TradeDistribution.jsx +++ b/frontend/src/components/TradeDistribution.jsx @@ -21,7 +21,7 @@ function CustomTooltip({ active, payload, total }) { return null; } -export function TradeDistribution({ wins = 0, losses = 0, avgWin = 0, avgLoss = 0, largestWin = 0, largestLoss = 0 }) { +export function TradeDistribution({ wins = 0, losses = 0, avgWin = 0, avgLoss = 0 }) { const total = wins + losses; const data = [ { name: 'Wins', value: wins }, diff --git a/frontend/src/components/TradeHistory.jsx b/frontend/src/components/TradeHistory.jsx index 76a7348..4c80429 100644 --- a/frontend/src/components/TradeHistory.jsx +++ b/frontend/src/components/TradeHistory.jsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { motion } from 'motion/react'; +import { motion as Motion } from 'motion/react'; function formatCurrency(value) { const abs = Math.abs(value); @@ -104,7 +104,6 @@ export function TradeHistory({ trades = [] }) { pageSlice.map((trade, index) => { const globalIndex = (safeCurrentPage - 1) * ROWS_PER_PAGE + index; const isWin = trade.pnl > 0; - const enterDate = new Date(trade.enter_time); const exitDate = new Date(trade.exit_time); const dateStr = exitDate.toLocaleDateString('en-CA'); const timeStr = exitDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -112,7 +111,7 @@ export function TradeHistory({ trades = [] }) { const direction = trade.direction === 'long' ? 'BUY' : 'SELL'; return ( - - + ); }) )}