Files

1061 lines
40 KiB
Python
Raw Permalink Normal View History

import os
import sys
import json
import time
import random
from functools import lru_cache
from itertools import islice, product
from typing import Literal, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import StreamingResponse
2026-04-10 19:54:56 +02:00
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
2026-05-11 19:14:32 +02:00
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)
2026-04-10 19:54:56 +02:00
from data.loader import load_candles, resample_candles
from engine.backtester import run_backtest, run_backtest_stream
from engine.monte_carlo import run_monte_carlo
2026-04-10 19:54:56 +02:00
from indicators.fvg import find_fvgs
from indicators.liquidity import find_liquidity_levels
from indicators.market_structure import detect_structure, find_swing_points
2026-04-10 19:54:56 +02:00
from indicators.order_blocks import find_order_blocks
from strategies.ict_strategy import ICTStrategy
DEFAULT_DATASET = "data.csv"
DATA_DIR = os.path.join(BACKEND_DIR, "data")
2026-04-10 19:54:56 +02:00
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
2026-04-10 19:54:56 +02:00
allow_methods=["*"],
allow_headers=["*"],
)
def _list_csv_datasets():
if not os.path.isdir(DATA_DIR):
return []
return sorted([name for name in os.listdir(DATA_DIR) if name.endswith(".csv")])
def _resolve_dataset(dataset):
csvs = _list_csv_datasets()
if not csvs:
raise HTTPException(status_code=500, detail="No CSV datasets found in backend/data")
requested = dataset or DEFAULT_DATASET
if requested in csvs:
return requested
if requested == DEFAULT_DATASET:
return csvs[0]
raise HTTPException(status_code=400, detail=f"Dataset '{requested}' not found")
def _parse_day_filter(day_filter):
"""Parse comma separated day ints '0,1,2,3,4' into list. None or empty = all days."""
if not day_filter:
return None
try:
days = [int(d.strip()) for d in day_filter.split(",") if d.strip()]
valid = [d for d in days if 0 <= d <= 4]
return valid if valid else None
except ValueError:
return None
def _parse_int_list(value, fallback):
if not value:
return fallback
try:
parsed = [int(part.strip()) for part in value.split(",") if part.strip()]
except ValueError:
return fallback
return parsed or fallback
def _parse_float_list(value, fallback):
if not value:
return fallback
try:
parsed = [float(part.strip()) for part in value.split(",") if part.strip()]
except ValueError:
return fallback
return parsed or fallback
def _parse_bool_list(value, fallback):
if not value:
return fallback
parsed = []
for part in value.split(","):
token = part.strip().lower()
if token in {"true", "1", "yes", "y"}:
parsed.append(True)
elif token in {"false", "0", "no", "n"}:
parsed.append(False)
return parsed or fallback
def _parse_str_list(value, fallback):
if not value:
return fallback
parsed = [part.strip() for part in value.split(",") if part.strip()]
return parsed or fallback
def _dataset_path(dataset_id):
return os.path.join(DATA_DIR, dataset_id)
def _dataset_mtime(dataset_id):
path = _dataset_path(dataset_id)
try:
return os.path.getmtime(path)
except OSError as exc:
raise HTTPException(status_code=400, detail=f"Dataset '{dataset_id}' not found") from exc
@lru_cache(maxsize=32)
def _load_candles_cached(dataset_id, dataset_mtime):
candles = load_candles(f"data/{dataset_id}")
return tuple(candles)
@lru_cache(maxsize=128)
def _resample_candles_cached(dataset_id, timeframe, dataset_mtime):
candles_1m = _load_candles_cached(dataset_id, dataset_mtime)
2026-04-10 19:54:56 +02:00
candles = resample_candles(candles_1m, period=timeframe)
return tuple(candles)
def _get_candles_for_timeframe(dataset_id, timeframe):
return _resample_candles_cached(dataset_id, int(timeframe), _dataset_mtime(dataset_id))
def _build_strategy(
2026-05-11 19:14:32 +02:00
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,
lookback=lookback,
ob_max_age=ob_age,
atr_mult=atr_mult,
use_fvg=use_fvg,
use_ob=use_ob,
proximity_pct=proximity_pct,
use_liquidity_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=_parse_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,
2026-05-11 19:14:32 +02:00
timezone=timezone,
)
def _trade_payload(trade):
return {
"enter_time": trade.enter_time.isoformat(),
"exit_time": trade.exit_time.isoformat(),
"enter_price": trade.enter_price,
"exit_price": trade.exit_price,
"direction": trade.direction,
"pnl": trade.pnl,
"r_multiple": getattr(trade, "r_multiple", 0.0),
"partial_tp_taken": getattr(trade, "partial_tp_taken", False),
"partial_tp_realized_pnl": getattr(trade, "partial_tp_realized_pnl", 0.0),
}
2026-05-11 19:14:32 +02:00
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)
2026-05-11 19:14:32 +02:00
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),
"losers": len(losers),
"win_rate": len(winners) / len(trades) * 100 if trades else 0,
"total_pnl": total_pnl,
"avg_win": sum(t.pnl for t in winners) / len(winners) if winners else 0,
"avg_loss": sum(t.pnl for t in losers) / len(losers) if losers else 0,
"risk_reward": rr,
"partial_tp_trades": len(partial_tp_trades),
"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,
2026-05-11 19:14:32 +02:00
"sharpe_ratio": round(sharpe_ratio, 6),
"max_drawdown_pct": round(max_drawdown_pct, 6),
}
def _build_monte_carlo_payload(trade_r_multiples, *, runs, starting_balance, risk_per_trade_pct, sampling_method,
missed_trade_pct, pnl_variation_pct, price_noise_pct, slippage_per_trade,
spread_per_trade, ruin_drawdown_pct, seed=None, base=None):
monte_carlo = run_monte_carlo(
trade_r_multiples=trade_r_multiples,
runs=runs,
starting_balance=starting_balance,
risk_per_trade_pct=risk_per_trade_pct,
sampling_method=sampling_method,
missed_trade_pct=missed_trade_pct,
pnl_variation_pct=pnl_variation_pct,
price_noise_pct=price_noise_pct,
slippage_per_trade=slippage_per_trade,
spread_per_trade=spread_per_trade,
ruin_drawdown_pct=ruin_drawdown_pct,
seed=seed,
)
summary = dict(monte_carlo.get("summary", {}))
distribution = monte_carlo.get("distribution", [])
sample_runs = monte_carlo.get("sample_runs", distribution)
return {
**monte_carlo,
"summary": summary,
"distribution": distribution,
"sample_runs": sample_runs,
"base": base or None,
}
class MonteCarloRequest(BaseModel):
trade_r_multiples: list[float] = Field(default_factory=list, min_length=1)
runs: int = Field(default=500, ge=1, le=20000)
starting_balance: float = Field(default=10000.0, gt=0)
risk_per_trade_pct: float = Field(default=1.0, ge=0.0, le=100.0)
sampling_method: Literal["bootstrap", "shuffle"] = "bootstrap"
missed_trade_pct: float = Field(default=5.0, ge=0.0, le=100.0)
pnl_variation_pct: float = Field(default=15.0, ge=0.0, le=100.0)
price_noise_pct: float = Field(default=0.0, ge=0.0, le=100.0)
slippage_per_trade: float = Field(default=0.0, ge=0.0)
spread_per_trade: float = Field(default=0.0, ge=0.0)
ruin_drawdown_pct: float = Field(default=20.0, ge=0.0, le=100.0)
seed: Optional[int] = None
base_trade_count: Optional[int] = None
base_net_pnl: Optional[float] = None
base_win_rate: Optional[float] = None
base_profit_factor: Optional[float] = None
base_max_drawdown_pct: Optional[float] = None
def _build_equity_points(trades, starting_balance=10000.0):
equity = starting_balance
points = [starting_balance]
for trade in sorted(trades, key=lambda t: t.exit_time):
equity += trade.pnl
points.append(equity)
return points
def _risk_metrics(trades, starting_balance=10000.0):
if not trades:
return {
"net_pnl": 0.0,
"win_rate": 0.0,
"profit_factor": 0.0,
"sharpe_ratio": 0.0,
"sortino_ratio": 0.0,
"max_drawdown_pct": 0.0,
"calmar_ratio": 0.0,
"recovery_ratio": 0.0,
"trade_count": 0,
"trade_score": 0.0,
"pf_x_wr": 0.0,
"custom_fitness": 0.0,
}
pnls = [t.pnl for t in trades]
winners = [p for p in pnls if p > 0]
losers = [p for p in pnls if p < 0]
trade_count = len(trades)
net_pnl = sum(pnls)
win_rate = (len(winners) / trade_count) * 100 if trade_count else 0.0
gross_profit = sum(winners)
gross_loss = abs(sum(losers))
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else (999.0 if gross_profit > 0 else 0.0)
mean_pnl = net_pnl / trade_count if trade_count else 0.0
variance = sum((p - mean_pnl) ** 2 for p in pnls) / trade_count if trade_count else 0.0
std_dev = variance ** 0.5
sharpe = (mean_pnl / std_dev) * (trade_count ** 0.5) if std_dev > 0 else 0.0
downside = [min(0.0, p - mean_pnl) for p in pnls]
downside_variance = (sum(d * d for d in downside) / trade_count) if trade_count else 0.0
downside_dev = downside_variance ** 0.5
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)
2026-05-11 19:14:32 +02:00
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
2026-05-11 19:14:32 +02:00
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)
elif trade_count > 400:
trade_score = max(0.0, 400 / trade_count)
else:
trade_score = 1.0
pf_x_wr = profit_factor * (win_rate / 100)
custom_fitness = pf_x_wr * trade_score
return {
"net_pnl": round(net_pnl, 6),
"win_rate": round(win_rate, 4),
"profit_factor": round(profit_factor, 6),
"sharpe_ratio": round(sharpe, 6),
"sortino_ratio": round(sortino, 6),
"max_drawdown_pct": round(max_drawdown_pct, 6),
"calmar_ratio": round(calmar, 6),
"recovery_ratio": round(recovery, 6),
"trade_count": trade_count,
"trade_score": round(trade_score, 6),
"pf_x_wr": round(pf_x_wr, 6),
"custom_fitness": round(custom_fitness, 6),
}
def _dominates(a, b):
maximize_keys = [
"net_pnl",
"sharpe_ratio",
"sortino_ratio",
"profit_factor",
"calmar_ratio",
"recovery_ratio",
"win_rate",
"trade_score",
"pf_x_wr",
"custom_fitness",
]
no_worse = all(a.get(key, 0) >= b.get(key, 0) for key in maximize_keys) and a.get("max_drawdown_pct", 0) <= b.get("max_drawdown_pct", 0)
strictly_better = any(a.get(key, 0) > b.get(key, 0) for key in maximize_keys) or a.get("max_drawdown_pct", 0) < b.get("max_drawdown_pct", 0)
return no_worse and strictly_better
def _pareto_front(items):
front = []
for candidate in items:
dominated = False
for other in items:
if other is candidate:
continue
if _dominates(other, candidate):
dominated = True
break
if not dominated:
front.append(candidate)
return front
class OptimizeRequest(BaseModel):
timeframe: int = Field(default=5, ge=1)
rr: float = Field(default=2.5, gt=0)
rr_values: list[float] = Field(default_factory=lambda: [2.5], min_length=1)
dataset: str = DEFAULT_DATASET
min_trades: int = Field(default=5, ge=1)
max_trades: int = Field(default=1000, ge=1)
max_combinations: int = Field(default=1000, ge=1, le=50000)
combo_sampling_mode: Literal["first", "random"] = "random"
combo_sampling_seed: Optional[int] = None
top_n: int = Field(default=10, ge=1)
sessions: list[str] = Field(default_factory=lambda: ["london", "new_york"], min_length=1)
lookback_values: list[int] = Field(default_factory=lambda: [3, 5, 7, 10], min_length=1)
ob_age_values: list[int] = Field(default_factory=lambda: [20, 50, 80], min_length=1)
atr_values: list[float] = Field(default_factory=lambda: [1.0, 1.5, 2.0, 2.5], min_length=1)
2026-04-10 19:54:56 +02:00
min_ob_size_values: list[float] = Field(default_factory=lambda: [0.00030, 0.00040, 0.00050, 0.00060, 0.00080], min_length=1)
min_gap_size_values: list[float] = Field(default_factory=lambda: [0.00015, 0.00020, 0.00025, 0.00030, 0.00035], min_length=1)
impulse_multiplier_values: list[float] = Field(default_factory=lambda: [1.15, 1.25, 1.35, 1.45, 1.60], min_length=1)
require_unmitigated_fvg_modes: list[bool] = Field(default_factory=lambda: [True, False], min_length=1)
require_fvg_ob_confluence_modes: list[bool] = Field(default_factory=lambda: [True, False], min_length=1)
require_bos_confluence_modes: list[bool] = Field(default_factory=lambda: [True, False], min_length=1)
sweep_modes: list[bool] = Field(default_factory=lambda: [True, False], min_length=1)
sweep_lb_values: list[int] = Field(default_factory=lambda: [5, 10, 15], min_length=1)
asian_sweep_only_modes: list[bool] = Field(default_factory=lambda: [True, False], min_length=1)
use_break_even_modes: list[bool] = Field(default_factory=lambda: [False, True], min_length=1)
be_trigger_rr_values: list[float] = Field(default_factory=lambda: [1.0, 1.5, 2.0], min_length=1)
use_partial_tp_modes: list[bool] = Field(default_factory=lambda: [False, True], min_length=1)
partial_tp_rr_values: list[float] = Field(default_factory=lambda: [1.0, 1.5, 2.0], min_length=1)
partial_tp_percent_values: list[float] = Field(default_factory=lambda: [50.0], min_length=1)
rank_objective: Literal[
"custom_fitness",
"net_pnl",
"sharpe_ratio",
"sortino_ratio",
"profit_factor",
"calmar_ratio",
"recovery_ratio",
"win_rate",
"pf_x_wr",
"trade_score",
"trade_count",
"max_drawdown_pct",
] = "custom_fitness"
@app.get("/api/datasets")
def get_datasets():
csvs = _list_csv_datasets()
default_id = DEFAULT_DATASET if DEFAULT_DATASET in csvs else (csvs[0] if csvs else "")
datasets = [
{
"id": csv,
"label": csv.replace(".csv", "").replace("_", " ").title(),
"default": csv == default_id,
}
for csv in csvs
]
return {"datasets": datasets}
@app.get("/api/candles")
def get_candles(timeframe: int = 5, dataset: str = DEFAULT_DATASET):
dataset_id = _resolve_dataset(dataset)
candles = _get_candles_for_timeframe(dataset_id, timeframe)
2026-04-10 19:54:56 +02:00
return {
"candles": [
{
"time": c.time_open.isoformat(),
"open": c.open,
"high": c.high,
"low": c.low,
"close": c.close,
2026-04-10 19:54:56 +02:00
}
for c in candles
]
}
2026-04-10 19:54:56 +02:00
@app.get("/api/indicators")
def get_indicators(timeframe: int = 5, dataset: str = DEFAULT_DATASET):
dataset_id = _resolve_dataset(dataset)
candles = _get_candles_for_timeframe(dataset_id, timeframe)
2026-04-10 19:54:56 +02:00
swings = find_swing_points(candles)
structure = detect_structure(swings)
levels = find_liquidity_levels(swings)
fvgs = find_fvgs(candles)
obs = find_order_blocks(candles, structure)
2026-04-10 20:11:56 +02:00
candle_times = [c.time_open.isoformat() for c in candles]
2026-04-10 19:54:56 +02:00
return {
2026-04-10 20:11:56 +02:00
"candle_times": candle_times,
2026-04-10 19:54:56 +02:00
"swings": swings,
"structure": structure,
"liquidity": levels,
"fvgs": fvgs,
"order_blocks": obs,
2026-04-11 17:23:26 +02:00
}
2026-04-11 17:23:26 +02:00
@app.get("/api/backtest")
def get_backtest(
timeframe: int = 5,
rr: float = 2.5,
lookback: int = 7,
ob_age: int = 50,
atr_mult: float = 2.5,
sweep: bool = True,
sweep_lookback: int = 5,
session: str = "london",
dataset: str = DEFAULT_DATASET,
use_fvg: bool = True,
use_ob: bool = True,
proximity_pct: float = 0.5,
# FVG Quality
min_gap_size: float = 0.0,
impulse_multiplier: float = 0.0,
require_unmitigated_fvg: bool = True,
require_bos_confluence: bool = False,
# Order Block
min_ob_size: float = 0.0,
require_fvg_ob_confluence: bool = False,
# Liquidity
asian_sweep_only: bool = False,
# Break-even
use_break_even: bool = False,
be_trigger_rr: float = 1.0,
# Partial TP
use_partial_tp: bool = False,
partial_tp_rr: float = 1.0,
partial_tp_percent: float = 50.0,
# Time
day_filter: Optional[str] = None,
# Risk Management
max_daily_loss: float = 0.0,
max_consecutive_losses: int = 0,
):
dataset_id = _resolve_dataset(dataset)
2026-05-11 19:14:32 +02:00
timezone = "mt5" if "MT5" in dataset.upper() else "est"
candles = _get_candles_for_timeframe(dataset_id, timeframe)
2026-04-11 17:23:26 +02:00
strategy = _build_strategy(
2026-05-11 19:14:32 +02:00
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,
max_consecutive_losses=max_consecutive_losses,
2026-04-11 17:23:26 +02:00
)
return {
"trades": [_trade_payload(t) for t in trades],
"candle_times": [c.time_open.isoformat() for c in candles],
2026-05-11 19:14:32 +02:00
"stats": _stats_payload(trades, rr, starting_balance=10000.0),
}
2026-04-11 17:23:26 +02:00
@app.post("/api/backtest/monte-carlo")
def backtest_monte_carlo(req: MonteCarloRequest):
base = {
"trade_count": req.base_trade_count if req.base_trade_count is not None else len(req.trade_r_multiples),
"net_pnl": req.base_net_pnl if req.base_net_pnl is not None else 0.0,
"win_rate": req.base_win_rate if req.base_win_rate is not None else 0.0,
"profit_factor": req.base_profit_factor if req.base_profit_factor is not None else 0.0,
"max_drawdown_pct": req.base_max_drawdown_pct if req.base_max_drawdown_pct is not None else 0.0,
}
2026-04-11 17:23:26 +02:00
return _build_monte_carlo_payload(
req.trade_r_multiples,
runs=req.runs,
starting_balance=req.starting_balance,
risk_per_trade_pct=req.risk_per_trade_pct,
sampling_method=req.sampling_method,
missed_trade_pct=req.missed_trade_pct,
pnl_variation_pct=req.pnl_variation_pct,
price_noise_pct=req.price_noise_pct,
slippage_per_trade=req.slippage_per_trade,
spread_per_trade=req.spread_per_trade,
ruin_drawdown_pct=req.ruin_drawdown_pct,
seed=req.seed,
base=base,
)
@app.post("/api/optimize")
def get_optimize(req: OptimizeRequest):
dataset_id = _resolve_dataset(req.dataset)
2026-05-11 19:14:32 +02:00
timezone = "mt5" if "MT5" in dataset_id.upper() else "est"
candles = _get_candles_for_timeframe(dataset_id, req.timeframe)
session_list = req.sessions
lookback_list = req.lookback_values
ob_age_list = req.ob_age_values
atr_list = req.atr_values
rr_list = [float(value) for value in req.rr_values if float(value) > 0]
if not rr_list:
rr_list = [req.rr]
min_ob_size_list = req.min_ob_size_values
min_gap_size_list = req.min_gap_size_values
impulse_multiplier_list = req.impulse_multiplier_values
require_unmitigated_fvg_list = req.require_unmitigated_fvg_modes
require_fvg_ob_confluence_list = req.require_fvg_ob_confluence_modes
require_bos_confluence_list = req.require_bos_confluence_modes
sweep_list = req.sweep_modes
sweep_lb_list = req.sweep_lb_values
asian_sweep_only_list = req.asian_sweep_only_modes
use_break_even_list = req.use_break_even_modes
be_trigger_rr_list = [float(value) for value in req.be_trigger_rr_values if float(value) > 0]
if not be_trigger_rr_list:
be_trigger_rr_list = [1.0]
use_partial_tp_list = req.use_partial_tp_modes
partial_tp_rr_list = [float(value) for value in req.partial_tp_rr_values if float(value) > 0]
if not partial_tp_rr_list:
partial_tp_rr_list = [1.0]
partial_tp_percent_list = [float(value) for value in req.partial_tp_percent_values if 0 < float(value) <= 100]
if not partial_tp_percent_list:
partial_tp_percent_list = [50.0]
ranking_key = req.rank_objective
def _rank_results(items):
if ranking_key == "max_drawdown_pct":
return sorted(items, key=lambda x: x.get(ranking_key, 0))
return sorted(items, key=lambda x: x.get(ranking_key, 0), reverse=True)
sample_mode = req.combo_sampling_mode
rng = random.Random(req.combo_sampling_seed) if sample_mode == "random" else None
combos = []
base_grid = {
"session": session_list,
"rr": rr_list,
"lookback": lookback_list,
"ob_age": ob_age_list,
"atr_mult": atr_list,
"min_ob_size": min_ob_size_list,
"min_gap_size": min_gap_size_list,
"impulse_multiplier": impulse_multiplier_list,
"require_unmitigated_fvg": require_unmitigated_fvg_list,
"require_fvg_ob_confluence": require_fvg_ob_confluence_list,
"require_bos_confluence": require_bos_confluence_list,
"sweep": sweep_list,
"asian_sweep_only": asian_sweep_only_list,
"use_break_even": use_break_even_list,
"be_trigger_rr": be_trigger_rr_list,
"use_partial_tp": use_partial_tp_list,
"partial_tp_rr": partial_tp_rr_list,
"partial_tp_percent": partial_tp_percent_list,
}
base_keys = tuple(base_grid.keys())
def _candidate_iter():
for values in product(*(base_grid[key] for key in base_keys)):
base_params = dict(zip(base_keys, values))
for sweep_lb in (sweep_lb_list if base_params["sweep"] else [0]):
yield {
**base_params,
"sweep_lookback": sweep_lb,
}
base_count_without_sweep = 1
for key in base_keys:
if key == "sweep":
continue
base_count_without_sweep *= len(base_grid[key])
true_sweep_count = sum(1 for enabled in sweep_list if enabled)
false_sweep_count = sum(1 for enabled in sweep_list if not enabled)
sweep_factor = (true_sweep_count * len(sweep_lb_list)) + false_sweep_count
total_generated_combinations = base_count_without_sweep * sweep_factor
if sample_mode == "first":
combos = list(islice(_candidate_iter(), req.max_combinations))
else:
for generated_idx, candidate in enumerate(_candidate_iter(), start=1):
if len(combos) < req.max_combinations:
if len(combos) < req.max_combinations:
combos.append(candidate)
else:
replace_index = rng.randint(0, generated_idx - 1)
if replace_index < req.max_combinations:
combos[replace_index] = candidate
executed_combinations = len(combos)
capped_by_max_combinations = total_generated_combinations > executed_combinations
def event_stream():
started = time.perf_counter()
results = []
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'processed': 0, 'total_combinations': executed_combinations, 'generated_combinations': total_generated_combinations, 'executed_combinations': executed_combinations, 'max_combinations': req.max_combinations, 'capped_by_max_combinations': capped_by_max_combinations, 'combo_sampling_mode': sample_mode, 'combo_sampling_seed': req.combo_sampling_seed, 'valid_results': 0, 'top_results': []})}\n\n"
for i, params in enumerate(combos):
strategy = ICTStrategy(
session=params["session"],
lookback=params["lookback"],
ob_max_age=params["ob_age"],
atr_mult=params["atr_mult"],
use_liquidity_sweep=params["sweep"],
sweep_lookback=params["sweep_lookback"],
min_gap_size=params["min_gap_size"],
impulse_multiplier=params["impulse_multiplier"],
require_unmitigated_fvg=params["require_unmitigated_fvg"],
require_bos_confluence=params["require_bos_confluence"],
min_ob_size=params["min_ob_size"],
require_fvg_ob_confluence=params["require_fvg_ob_confluence"],
asian_sweep_only=params["asian_sweep_only"],
use_break_even=params["use_break_even"],
be_trigger_rr=params["be_trigger_rr"],
use_partial_tp=params["use_partial_tp"],
partial_tp_rr=params["partial_tp_rr"],
partial_tp_percent=params["partial_tp_percent"],
2026-05-11 19:14:32 +02:00
timezone=timezone,
)
trades = run_backtest(candles, strategy, 10000, risk_reward=params["rr"])
if req.min_trades <= len(trades) <= req.max_trades:
metrics = _risk_metrics(trades, starting_balance=10000.0)
results.append({
"params": params,
"risk_reward": params["rr"],
"pnl": round(metrics["net_pnl"], 4),
"trades": metrics["trade_count"],
"win_rate": round(metrics["win_rate"], 2),
**metrics,
})
if (i + 1) % 4 == 0 or i == len(combos) - 1:
ranked = _rank_results(results)
pareto = _pareto_front(results)
progress = ((i + 1) / max(1, len(combos))) * 100
payload = {
"type": "progress",
"progress": progress,
"processed": i + 1,
"total_combinations": executed_combinations,
"generated_combinations": total_generated_combinations,
"executed_combinations": executed_combinations,
"max_combinations": req.max_combinations,
"capped_by_max_combinations": capped_by_max_combinations,
"combo_sampling_mode": sample_mode,
"combo_sampling_seed": req.combo_sampling_seed,
"valid_results": len(ranked),
"rank_objective": ranking_key,
"top_results": ranked[:max(1, req.top_n)],
"pareto_front": sorted(pareto, key=lambda x: x.get("custom_fitness", 0), reverse=True)[:max(1, req.top_n)],
}
yield f"data: {json.dumps(payload)}\n\n"
total_elapsed = time.perf_counter() - started
ranked = _rank_results(results)
pareto = _pareto_front(results)
best = ranked[0] if ranked else None
safe_elapsed = total_elapsed if total_elapsed > 0 else 0.0001
payload = {
"type": "finished",
"dataset": dataset_id,
"timeframe": req.timeframe,
"risk_reward_values": rr_list,
"min_trades": req.min_trades,
"max_trades": req.max_trades,
"rank_objective": ranking_key,
"all_results": ranked,
"top_results": ranked[:max(1, req.top_n)],
"pareto_front": sorted(pareto, key=lambda x: x.get("custom_fitness", 0), reverse=True),
"best": best,
"best_result": best,
"elapsed_seconds": round(total_elapsed, 3),
"combos_per_second": round(len(combos) / safe_elapsed, 2),
"total_combinations": executed_combinations,
"generated_combinations": total_generated_combinations,
"executed_combinations": executed_combinations,
"max_combinations": req.max_combinations,
"capped_by_max_combinations": capped_by_max_combinations,
"combo_sampling_mode": sample_mode,
"combo_sampling_seed": req.combo_sampling_seed,
"valid_results": len(ranked),
2026-04-11 17:23:26 +02:00
}
yield f"data: {json.dumps(payload)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@app.get("/api/optimize/monte-carlo")
def get_optimize_monte_carlo(
timeframe: int = 5,
rr: float = 2.5,
dataset: str = DEFAULT_DATASET,
session: str = "london",
lookback: int = 7,
ob_age: int = 50,
atr_mult: float = 2.5,
min_ob_size: float = 0.0005,
min_gap_size: float = 0.00025,
impulse_multiplier: float = 1.35,
require_unmitigated_fvg: bool = True,
require_fvg_ob_confluence: bool = False,
require_bos_confluence: bool = False,
sweep: bool = True,
sweep_lookback: int = 5,
asian_sweep_only: bool = False,
use_break_even: bool = False,
be_trigger_rr: float = 1.0,
use_partial_tp: bool = False,
partial_tp_rr: float = 1.0,
partial_tp_percent: float = 50.0,
runs: int = Query(default=500, ge=1, le=20000),
shuffle_trades: bool = True,
pnl_variation_pct: float = Query(default=15.0, ge=0.0, le=100.0),
price_noise_pct: float = Query(default=0.0, ge=0.0, le=100.0),
slippage_per_trade: float = Query(default=0.0, ge=0.0),
spread_per_trade: float = Query(default=0.0, ge=0.0),
ruin_drawdown_pct: float = Query(default=20.0, ge=0.0, le=100.0),
):
dataset_id = _resolve_dataset(dataset)
2026-05-11 19:14:32 +02:00
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=True,
use_ob=True,
proximity_pct=0.5,
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,
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,
day_filter=None,
2026-05-11 19:14:32 +02:00
timezone=timezone,
)
trades = run_backtest(candles, strategy, 10000, risk_reward=rr)
trade_r_multiples = [getattr(t, "r_multiple", 0.0) for t in trades]
base_metrics = _risk_metrics(trades, starting_balance=10000.0)
return {
"dataset": dataset_id,
"timeframe": timeframe,
"risk_reward": rr,
"best_params": {
"session": session,
"lookback": lookback,
"ob_age": ob_age,
"atr_mult": atr_mult,
"min_ob_size": min_ob_size,
"min_gap_size": min_gap_size,
"impulse_multiplier": impulse_multiplier,
"require_unmitigated_fvg": require_unmitigated_fvg,
"require_fvg_ob_confluence": require_fvg_ob_confluence,
"require_bos_confluence": require_bos_confluence,
"sweep": sweep,
"sweep_lookback": sweep_lookback,
"asian_sweep_only": asian_sweep_only,
"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,
},
"base": {
"trade_count": len(trades),
"net_pnl": base_metrics["net_pnl"],
"win_rate": base_metrics["win_rate"],
"profit_factor": base_metrics["profit_factor"],
"max_drawdown_pct": base_metrics["max_drawdown_pct"],
},
"config": {
"runs": runs,
"risk_per_trade_pct": 1.0,
"shuffle_trades": shuffle_trades,
"pnl_variation_pct": pnl_variation_pct,
"price_noise_pct": price_noise_pct,
"slippage_per_trade": slippage_per_trade,
"spread_per_trade": spread_per_trade,
"ruin_drawdown_pct": ruin_drawdown_pct,
},
**_build_monte_carlo_payload(
trade_r_multiples,
runs=runs,
starting_balance=10000.0,
risk_per_trade_pct=1.0,
sampling_method="shuffle" if shuffle_trades else "bootstrap",
missed_trade_pct=0.0,
pnl_variation_pct=pnl_variation_pct,
price_noise_pct=price_noise_pct,
slippage_per_trade=slippage_per_trade,
spread_per_trade=spread_per_trade,
ruin_drawdown_pct=ruin_drawdown_pct,
seed=None,
base={
"trade_count": len(trades),
"net_pnl": base_metrics["net_pnl"],
"win_rate": base_metrics["win_rate"],
"profit_factor": base_metrics["profit_factor"],
"max_drawdown_pct": base_metrics["max_drawdown_pct"],
},
),
}
@app.get("/api/backtest/stream")
def stream_backtest(
timeframe: int = 5,
rr: float = 2.5,
lookback: int = 7,
ob_age: int = 50,
atr_mult: float = 2.5,
sweep: bool = True,
sweep_lookback: int = 5,
session: str = "london",
dataset: str = DEFAULT_DATASET,
use_fvg: bool = True,
use_ob: bool = True,
proximity_pct: float = 0.5,
2026-04-13 20:37:21 +02:00
min_gap_size: float = 0.0,
impulse_multiplier: float = 0.0,
require_unmitigated_fvg: bool = True,
require_bos_confluence: bool = False,
2026-04-13 20:37:21 +02:00
min_ob_size: float = 0.0,
require_fvg_ob_confluence: bool = False,
# Liquidity
asian_sweep_only: bool = False,
# Break-even
use_break_even: bool = False,
be_trigger_rr: float = 1.0,
# Partial TP
use_partial_tp: bool = False,
partial_tp_rr: float = 1.0,
partial_tp_percent: float = 50.0,
# Time
day_filter: Optional[str] = None,
# Risk Management
max_daily_loss: float = 0.0,
max_consecutive_losses: int = 0,
):
dataset_id = _resolve_dataset(dataset)
2026-05-11 19:14:32 +02:00
timezone = "mt5" if "MT5" in dataset_id.upper() else "est"
candles = _get_candles_for_timeframe(dataset_id, timeframe)
strategy = _build_strategy(
2026-05-11 19:14:32 +02:00
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"
def event_stream():
started = time.perf_counter()
streamed_trades = []
try:
for event in run_backtest_stream(
candles, strategy, 10000, risk_reward=rr,
max_daily_loss=max_daily_loss,
max_consecutive_losses=max_consecutive_losses,
):
kind = event["type"]
if kind == "start":
yield _sse({"type": "start", "total_candles": event["total_candles"]})
elif kind == "progress":
total = max(1, event["total_candles"])
progress_pct = int((event["processed_candles"] / total) * 100)
yield _sse({
"type": "progress",
"processed_candles": event["processed_candles"],
"total_candles": event["total_candles"],
"progress_pct": progress_pct,
})
elif kind == "trade":
trade = event["trade"]
streamed_trades.append(trade)
yield _sse({
"type": "trade",
"trade": _trade_payload(trade),
2026-05-11 19:14:32 +02:00
"stats": _stats_payload(streamed_trades, rr, starting_balance=10000.0),
"processed_candles": event["processed_candles"],
"total_candles": event["total_candles"],
})
elif kind == "done":
duration_ms = (time.perf_counter() - started) * 1000
yield _sse({
"type": "done",
"trades": [_trade_payload(t) for t in streamed_trades],
2026-05-11 19:14:32 +02:00
"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],
})
except Exception as exc:
yield _sse({"type": "error", "message": str(exc)})
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)