reorganized data files and enhance backtesting structure, monte carlo sim

This commit is contained in:
moen0
2026-04-13 01:30:34 +02:00
parent 373e589297
commit 7d53f8589a
26 changed files with 65383 additions and 766 deletions
+974 -61
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Can't render this file because it is too large.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@ from data.model import Candle
def load_candles(filepath: str) -> list[Candle]:
df = pd.read_csv(filepath, sep=";", header=None, names=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], format="%Y%m%d %H%M%S")
df = df.sort_values("timestamp", kind="mergesort").drop_duplicates(subset=["timestamp"], keep="last")
candles = []
for _, row in df.iterrows():
+14 -2
View File
@@ -1,20 +1,32 @@
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Candle:
time_open: datetime
open: float
high: float
low: float
close: datetime
close: float
volume: float
@dataclass
class Signal:
direction: str
stop_loss: float
entry_price: float
@dataclass
class Trade:
enter_time: datetime
enter_price: float
direction: str
exit_time:datetime
exit_time: datetime
exit_price: float
pnl: float
r_multiple: float = 0.0
partial_tp_taken: bool = False
partial_tp_realized_pnl: float = 0.0
+265 -64
View File
@@ -1,51 +1,222 @@
from data.model import Candle, Trade
from strategies.base import SimpleStrategy
from data.model import Trade
from collections import defaultdict
import math
def run_backtest(
candles: list[Candle],
strategy,
starting_balance: float = 10000.0,
risk_reward: float = 1.0
) -> list[Trade]:
"""
Runs a backtest on a list of candles using the provided strategy.
Returns a list of closed Trades.
"""
def _apply_break_even_if_triggered(position, candle, strategy):
if not position:
return
if not getattr(strategy, "use_break_even", False):
return
if position.get("break_even_armed"):
return
trigger_rr = float(getattr(strategy, "be_trigger_rr", 1.0) or 0.0)
if trigger_rr <= 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 reached_trigger:
position["stop_loss"] = entry
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_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
close_fraction = min(max(partial_pct / 100.0, 0.0), 1.0)
remaining_fraction = max(position.get("remaining_fraction", 1.0), 0.0)
if remaining_fraction <= 0:
position["partial_taken"] = True
return
close_fraction = min(close_fraction, remaining_fraction)
if close_fraction <= 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)
price_move = (trigger_price - entry) if is_long else (entry - trigger_price)
realized_piece = price_move * lot_size * close_fraction
position["realized_pnl"] = position.get("realized_pnl", 0.0) + realized_piece
position["remaining_fraction"] = max(0.0, remaining_fraction - close_fraction)
position["partial_taken"] = True
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
# One-time preparation (e.g. pre-compute indicators)
consecutive_losses = 0
daily_pnl = defaultdict(float)
if hasattr(strategy, "prepare"):
strategy.prepare(candles)
for i, candle in enumerate(candles):
# === 1. Check if we have an open position (SL/TP hit) ===
if position is not None:
hit_sl = False
hit_tp = False
exit_price = None
if position:
_apply_partial_tp_if_triggered(position, candle, strategy)
_apply_break_even_if_triggered(position, candle, strategy)
if position["direction"] == "long":
if candle.low <= position["stop_loss"]:
hit_sl = True
exit_price = position["stop_loss"]
elif candle.high >= position["take_profit"]:
hit_tp = True
exit_price = position["take_profit"]
else: # short
if candle.high >= position["stop_loss"]:
hit_sl = True
exit_price = position["stop_loss"]
elif candle.low <= position["take_profit"]:
hit_tp = True
exit_price = position["take_profit"]
is_long = position["direction"] == "long"
sl, tp = position["stop_loss"], position["take_profit"]
hit_sl = candle.low <= sl if is_long else candle.high >= sl
hit_tp = candle.high >= tp if is_long else candle.low <= tp
if hit_sl or hit_tp:
# Calculate PnL
if position["direction"] == "long":
pnl = exit_price - position["entry_price"]
else: # short
pnl = position["entry_price"] - exit_price
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)
remaining_fraction = max(position.get("remaining_fraction", 1.0), 0.0)
remaining_pnl = price_move * lot_size * remaining_fraction
pnl = position.get("realized_pnl", 0.0) + remaining_pnl
initial_risk = max(position.get("initial_risk_amount", 0.0), 1e-12)
r_multiple = pnl / initial_risk
trades.append(Trade(
enter_time=position["enter_time"],
enter_price=position["entry_price"],
direction=position["direction"],
exit_time=candle.time_open,
exit_price=exit_price,
pnl=pnl,
r_multiple=r_multiple,
partial_tp_taken=bool(position.get("partial_taken", False)),
partial_tp_realized_pnl=float(position.get("realized_pnl", 0.0) or 0.0),
))
position = None
if pnl <= 0:
consecutive_losses += 1
else:
consecutive_losses = 0
daily_pnl[candle.time_open.date()] += pnl
if position is None:
if max_consecutive_losses > 0 and consecutive_losses >= max_consecutive_losses:
continue
if max_daily_loss > 0:
loss_limit = starting_balance * (max_daily_loss / 100)
if daily_pnl[candle.time_open.date()] <= -loss_limit:
continue
signal = strategy.check_signal(candles, i)
if signal is not None:
is_long = signal.direction == "BUY"
entry = signal.entry_price
sl = signal.stop_loss
sl_distance = abs(entry - sl)
if (
sl_distance <= 0
or not math.isfinite(sl_distance)
or not math.isfinite(entry)
or not math.isfinite(sl)
or risk_pct <= 0
):
continue
risk_amount = starting_balance * (risk_pct / 100)
if risk_amount <= 0 or not math.isfinite(risk_amount):
continue
lot_size = risk_amount / sl_distance
if lot_size <= 0 or not math.isfinite(lot_size):
continue
tp = entry + (sl_distance * risk_reward) if is_long else entry - (sl_distance * risk_reward)
position = {
"direction": "long" if is_long else "short",
"entry_price": entry,
"enter_time": candle.time_open,
"stop_loss": sl,
"take_profit": tp,
"risk_distance": sl_distance,
"lot_size": lot_size,
"break_even_armed": False,
"partial_taken": False,
"remaining_fraction": 1.0,
"realized_pnl": 0.0,
"initial_risk_amount": risk_amount,
}
return trades
def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0,
max_daily_loss=0.0, max_consecutive_losses=0, risk_pct=1.0):
position = None
consecutive_losses = 0
daily_pnl = defaultdict(float)
total = len(candles)
if hasattr(strategy, "prepare"):
strategy.prepare(candles)
yield {"type": "start", "total_candles": total}
progress_interval = max(1, total // 50)
for i, candle in enumerate(candles):
if i % progress_interval == 0:
yield {"type": "progress", "processed_candles": i, "total_candles": total}
if position:
_apply_partial_tp_if_triggered(position, candle, strategy)
_apply_break_even_if_triggered(position, candle, strategy)
is_long = position["direction"] == "long"
sl, tp = position["stop_loss"], position["take_profit"]
hit_sl = candle.low <= sl if is_long else candle.high >= sl
hit_tp = candle.high >= tp if is_long else candle.low <= tp
if hit_sl or hit_tp:
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)
remaining_fraction = max(position.get("remaining_fraction", 1.0), 0.0)
remaining_pnl = price_move * lot_size * remaining_fraction
pnl = position.get("realized_pnl", 0.0) + remaining_pnl
initial_risk = max(position.get("initial_risk_amount", 0.0), 1e-12)
r_multiple = pnl / initial_risk
trade = Trade(
enter_time=position["enter_time"],
@@ -53,39 +224,69 @@ def run_backtest(
direction=position["direction"],
exit_time=candle.time_open,
exit_price=exit_price,
pnl=pnl
pnl=pnl,
r_multiple=r_multiple,
partial_tp_taken=bool(position.get("partial_taken", False)),
partial_tp_realized_pnl=float(position.get("realized_pnl", 0.0) or 0.0),
)
trades.append(trade)
position = None
# === 2. Look for new entry signal only if flat ===
if pnl <= 0:
consecutive_losses += 1
else:
consecutive_losses = 0
daily_pnl[candle.time_open.date()] += pnl
yield {"type": "trade", "trade": trade, "processed_candles": i, "total_candles": total}
if position is None:
signal = strategy.check_signal(candles, i) # Fixed: pass index instead of slicing
if max_consecutive_losses > 0 and consecutive_losses >= max_consecutive_losses:
continue
if max_daily_loss > 0:
loss_limit = starting_balance * (max_daily_loss / 100)
if daily_pnl[candle.time_open.date()] <= -loss_limit:
continue
if signal == "BUY":
atr = candle.high - candle.low
mult = getattr(strategy, "atr_mult", 0.5)
bracket = atr * mult
signal = strategy.check_signal(candles, i)
if signal is not None:
is_long = signal.direction == "BUY"
entry = signal.entry_price
sl = signal.stop_loss
sl_distance = abs(entry - sl)
if (
sl_distance <= 0
or not math.isfinite(sl_distance)
or not math.isfinite(entry)
or not math.isfinite(sl)
or risk_pct <= 0
):
continue
risk_amount = starting_balance * (risk_pct / 100)
if risk_amount <= 0 or not math.isfinite(risk_amount):
continue
lot_size = risk_amount / sl_distance
if lot_size <= 0 or not math.isfinite(lot_size):
continue
tp = entry + (sl_distance * risk_reward) if is_long else entry - (sl_distance * risk_reward)
position = {
"direction": "long",
"entry_price": candle.close,
"direction": "long" if is_long else "short",
"entry_price": entry,
"enter_time": candle.time_open,
"stop_loss": candle.close - bracket,
"take_profit": candle.close + (bracket * risk_reward),
"stop_loss": sl,
"take_profit": tp,
"risk_distance": sl_distance,
"lot_size": lot_size,
"break_even_armed": False,
"partial_taken": False,
"remaining_fraction": 1.0,
"realized_pnl": 0.0,
"initial_risk_amount": risk_amount,
}
elif signal == "SELL":
atr = candle.high - candle.low
mult = getattr(strategy, "atr_mult", 0.5)
bracket = atr * mult
position = {
"direction": "short",
"entry_price": candle.close,
"enter_time": candle.time_open,
"stop_loss": candle.close + bracket,
"take_profit": candle.close - (bracket * risk_reward),
}
return trades
yield {"type": "done", "total_candles": total}
+154
View File
@@ -0,0 +1,154 @@
import random
def _calculate_percentile(data, percentile):
if not data: return 0.0
sorted_data = sorted(data)
index = (len(sorted_data) - 1) * (percentile / 100.0)
lower = int(index)
upper = lower + 1
if upper >= len(sorted_data): return sorted_data[-1]
return sorted_data[lower] + (index - lower) * (sorted_data[upper] - sorted_data[lower])
def _run_metrics(pnls, starting_balance):
if not pnls:
return {"net_pnl": 0.0, "win_rate": 0.0, "profit_factor": 0.0, "max_drawdown_pct": 0.0}
winners = [p for p in pnls if p > 0]
losers = [p for p in pnls if p < 0]
net_pnl = sum(pnls)
trade_count = len(pnls)
win_rate = (len(winners) / trade_count) * 100 if trade_count > 0 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)
equity = starting_balance
peak = starting_balance
max_drawdown_pct = 0.0
for pnl in pnls:
equity += pnl
if equity > peak:
peak = equity
drawdown_pct = ((peak - equity) / peak) * 100 if peak > 0 else 0.0
if drawdown_pct > max_drawdown_pct:
max_drawdown_pct = drawdown_pct
return {
"net_pnl": net_pnl,
"win_rate": win_rate,
"profit_factor": profit_factor,
"max_drawdown_pct": max_drawdown_pct,
}
def run_monte_carlo(
trade_r_multiples,
runs=1000,
starting_balance=10000.0,
risk_per_trade_pct=1.0,
sampling_method="bootstrap",
missed_trade_pct=5.0,
pnl_variation_pct=10.0,
price_noise_pct=0.0,
slippage_per_trade=0.0,
spread_per_trade=0.0,
per_trade_cost=None,
ruin_drawdown_pct=20.0,
seed=None,
):
if not trade_r_multiples:
return {"summary": {"runs": 0}, "distribution": [], "sample_runs": []}
run_count = max(1, int(runs))
risk_pct = max(0.0, float(risk_per_trade_pct)) / 100.0
pnl_var = max(0.0, float(pnl_variation_pct)) / 100.0
price_var = max(0.0, float(price_noise_pct)) / 100.0
miss_pct = max(0.0, float(missed_trade_pct)) / 100.0
ruin_threshold = max(0.0, float(ruin_drawdown_pct))
fixed_cost = float(per_trade_cost) if per_trade_cost is not None else (max(0.0, float(slippage_per_trade)) + max(0.0, float(spread_per_trade)))
effective_var = max(pnl_var, price_var)
rng = random.Random(seed)
run_results = []
base_trades = list(trade_r_multiples)
trade_count = len(base_trades)
for run_idx in range(run_count):
# 1. Generate the Trade Sequence
if sampling_method == "bootstrap":
path = [rng.choice(base_trades) for _ in range(trade_count)]
elif sampling_method == "shuffle":
path = base_trades[:]
rng.shuffle(path)
else:
path = base_trades[:]
adjusted_pnls = []
equity = float(starting_balance)
peak = float(starting_balance)
ruin_hit = False
# 2. Execute the trades sequentially
for base_r in path:
# Execution Risk: Did the broker drop our connection?
if rng.random() < miss_pct:
continue
r_multiple = float(base_r)
# Add volatility noise to the outcome (Slippage)
if effective_var > 0:
r_multiple *= rng.uniform(1 - effective_var, 1 + effective_var)
# Calculate PnL in dollars based on CURRENT equity (Compounding)
pnl = (equity * risk_pct * r_multiple) - fixed_cost
adjusted_pnls.append(pnl)
equity += pnl
# 3. Live Drawdown & Ruin Check (Prevents Zombie Trading)
if equity > peak:
peak = equity
current_dd = ((peak - equity) / peak) * 100 if peak > 0 else 0.0
if current_dd >= ruin_threshold or equity <= 0:
ruin_hit = True
break # Account blown or max DD hit. STOP trading.
# Calculate metrics for the surviving trades
metrics = _run_metrics(adjusted_pnls, starting_balance)
metrics["run"] = run_idx + 1
metrics["ruin"] = ruin_hit
metrics["trades_taken"] = len(adjusted_pnls)
run_results.append(metrics)
# --- Aggregate Statistics ---
pnls = [r["net_pnl"] for r in run_results]
dds = [r["max_drawdown_pct"] for r in run_results]
profitable_runs = sum(1 for p in pnls if p > 0)
ruin_count = sum(1 for r in run_results if r["ruin"])
summary = {
"runs": run_count,
"avg_pnl": round(sum(pnls) / run_count, 2),
"worst_case_pnl_5th_pct": round(_calculate_percentile(pnls, 5), 2), # 95% Confidence you make at least this much
"profitable_run_pct": round((profitable_runs / run_count) * 100, 2),
"avg_max_drawdown_pct": round(sum(dds) / run_count, 2),
"worst_case_dd_95th_pct": round(_calculate_percentile(dds, 95), 2), # 95% Confidence your DD won't exceed this
"worst_max_drawdown_pct": round(max(dds), 2),
"avg_win_rate": round(sum(r["win_rate"] for r in run_results) / run_count, 2),
"avg_profit_factor": round(sum(r["profit_factor"] for r in run_results) / run_count, 2),
"probability_of_ruin": round((ruin_count / run_count) * 100, 2),
}
return {
"summary": summary,
"distribution": run_results,
"sample_runs": run_results,
}
+54 -16
View File
@@ -1,27 +1,65 @@
def find_fvgs(candles):
def find_fvgs(candles, min_gap_size=0.0, impulse_multiplier=0.0):
"""
Find Fair Value Gaps in candle data.
Args:
candles: list of Candle objects
min_gap_size: minimum gap size in price units to filter noise (0 = no filter)
impulse_multiplier: minimum body-to-avg ratio for the middle candle (0 = no filter)
"""
fvgs = []
avg_body = 0
if impulse_multiplier > 0 and len(candles) > 20:
bodies = [abs(c.close - c.open) for c in candles[:20]]
avg_body = sum(bodies) / len(bodies)
for i in range(2, len(candles)):
c1 = candles[i - 2]
c2 = candles[i - 1]
c3 = candles[i]
# Bullish
# Impulse check on middle candle
if impulse_multiplier > 0 and avg_body > 0:
middle_body = abs(c2.close - c2.open)
if middle_body < avg_body * impulse_multiplier:
continue
# Update rolling average
avg_body = (avg_body * 19 + middle_body) / 20
# Bullish FVG
if c1.high < c3.low:
fvgs.append({
"index": i - 1,
"type": "bullish",
"top": c3.low,
"bottom": c1.high
})
gap_size = c3.low - c1.high
if gap_size >= min_gap_size:
fvgs.append({
"index": i - 1,
"type": "bullish",
"top": c3.low,
"bottom": c1.high,
"mitigated": False,
})
# bearish
# Bearish FVG
elif c1.low > c3.high:
fvgs.append({
"index": i - 1,
"type": "bearish",
"top": c1.low,
"bottom": c3.high
})
gap_size = c1.low - c3.high
if gap_size >= min_gap_size:
fvgs.append({
"index": i - 1,
"type": "bearish",
"top": c1.low,
"bottom": c3.high,
"mitigated": False,
})
return fvgs
# Mark mitigated FVGs
for fvg in fvgs:
if fvg["mitigated"]:
continue
if fvg["type"] == "bullish":
if c3.low <= fvg["bottom"]:
fvg["mitigated"] = True
elif fvg["type"] == "bearish":
if c3.high >= fvg["top"]:
fvg["mitigated"] = True
return fvgs
+4 -4
View File
@@ -19,7 +19,7 @@ def find_liquidity_levels(swings, tolerance=0.015, max_distance=100):
"price": avg_price,
"type": "equal_highs",
"count": len(cluster),
"indexes": [s["index"] for s in cluster]
"indexes": [s["index"] for s in cluster],
})
used.add(i)
@@ -30,7 +30,7 @@ def find_liquidity_levels(swings, tolerance=0.015, max_distance=100):
cluster = [l1]
for j, l2 in enumerate(lows):
if j != i and j not in used:
if abs(h1["price"] - h2["price"]) <= tolerance and abs(h1["index"] - h2["index"]) <= max_distance:
if abs(l1["price"] - l2["price"]) <= tolerance and abs(l1["index"] - l2["index"]) <= max_distance:
cluster.append(l2)
used.add(j)
if len(cluster) >= 2:
@@ -39,8 +39,8 @@ def find_liquidity_levels(swings, tolerance=0.015, max_distance=100):
"price": avg_price,
"type": "equal_lows",
"count": len(cluster),
"indexes": [s["index"] for s in cluster]
"indexes": [s["index"] for s in cluster],
})
used.add(i)
return levels
return levels
+27 -16
View File
@@ -1,31 +1,42 @@
def find_order_blocks(candles, structure, min_impulse=0.10):
def find_order_blocks(candles, structure, min_impulse=0.10, min_ob_size=0.0):
"""
Find Order Blocks based on structure breaks.
Args:
candles: list of Candle objects
structure: list of structure points from detect_structure
min_impulse: legacy param (unused, kept for compat)
min_ob_size: minimum OB size in price units (0 = no filter)
"""
obs = []
for point in structure:
if point["label"] == "HH":
# Bullish break of structure, look back for last bearish candle
idx = point["index"]
for j in range(idx - 1, max(idx - 20, 0), -1):
if candles[j].close < candles[j].open:
obs.append({
"index": j,
"type": "bullish",
"top": candles[j].open,
"bottom": candles[j].close
})
size = candles[j].open - candles[j].close
if size >= min_ob_size:
obs.append({
"index": j,
"type": "bullish",
"top": candles[j].open,
"bottom": candles[j].close,
})
break
elif point["label"] == "LL":
# Bearish break of structure, look back for last bullish candle
idx = point["index"]
for j in range(idx - 1, max(idx - 20, 0), -1):
if candles[j].close > candles[j].open:
obs.append({
"index": j,
"type": "bearish",
"top": candles[j].close,
"bottom": candles[j].open
})
size = candles[j].close - candles[j].open
if size >= min_ob_size:
obs.append({
"index": j,
"type": "bearish",
"top": candles[j].close,
"bottom": candles[j].open,
})
break
return obs
return obs
+21 -2
View File
@@ -5,24 +5,43 @@ SESSIONS_EST = {
"london": (time(2, 0), time(5, 0)),
"new_york": (time(7, 0), time(10, 0)),
"london_close": (time(10, 0), time(12, 0)),
"london_ny_overlap": (time(8, 0), time(10, 0)),
}
def in_session(candle_time, session_name):
if session_name == "all":
return True
if session_name not in SESSIONS_EST:
return True
t = candle_time.time()
start, end = SESSIONS_EST[session_name]
if start > end: # crosses midnight
if start > end:
return t >= start or t < end
return start <= t < end
def get_session(candle_time):
for name in SESSIONS_EST:
if name == "all":
continue
if in_session(candle_time, name):
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 in_day_filter(candle_time, allowed_days):
if not allowed_days:
return True
return candle_time.weekday() in allowed_days
def get_asian_range(candles):
asian = filter_by_session(candles, "asian")
if not asian:
@@ -31,4 +50,4 @@ def get_asian_range(candles):
"high": max(c.high for c in asian),
"low": min(c.low for c in asian),
"mid": (max(c.high for c in asian) + min(c.low for c in asian)) / 2,
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ 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/data.csv")
candles_1m = load_candles("data/gbpjpy_jan.csv")
candles_5m = resample_candles(candles_1m, period=5)
best_pnl = float("-inf")
-4
View File
@@ -45,14 +45,10 @@ for params in tqdm(param_combos, desc="Optimizing ICT Strategy", unit="backtest"
sweep_lookback=params["sweep_lb"],
)
# Accurate timing
t0 = time.perf_counter()
trades = run_backtest(candles_5m, strategy, 10000)
elapsed = time.perf_counter() - t0
# Optional: print every backtest (can be noisy, comment out if you want cleaner output)
# print(f"Backtest took {elapsed:.4f}s | Trades: {len(trades)}")
if len(trades) < 5:
continue
+15 -19
View File
@@ -3,42 +3,38 @@ from engine.backtester import run_backtest
from strategies.ict_strategy import ICTStrategy
import time
# Load data
candles_1m = load_candles("data/data1.csv")
candles_1m = load_candles("data/2023gj.csv")
candles_5m = resample_candles(candles_1m, period=5)
print("Testing different Risk-Reward ratios with optimized ICTStrategy...\n")
# Best params from optimization (you can tweak session/lookback etc. if you want)
strategy = ICTStrategy(
session="new_york", # Best was New York
session="london",
lookback=7,
ob_max_age=20, # Best was 20
ob_max_age=50,
atr_mult=2.5,
use_liquidity_sweep=False, # Best was False
use_liquidity_sweep=True,
sweep_lookback=5,
)
total_start = time.perf_counter()
for rr in [1.0, 1.5, 2.0, 2.5, 3.0]:
t0 = time.perf_counter()
trades = run_backtest(candles_5m, strategy, 10000, risk_reward=rr)
elapsed = time.perf_counter() - t0
if not trades:
print(f"RR={rr}: No trades")
print(f"RR={rr}: No trades ({elapsed:.2f}s)")
continue
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]
wr = len(winners) / len(trades) * 100 if trades else 0
wr = len(winners) / len(trades) * 100
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
profit_factor = abs(sum(t.pnl for t in winners) / sum(t.pnl for t in losers)) if losers else float('inf')
print(f"RR={rr:4.1f} | Trades={len(trades):4d} | WR={wr:5.1f}% | "
f"PnL={total_pnl:8.2f} | AvgWin={avg_win:6.3f} | AvgLoss={avg_loss:6.3f} | "
f"PF={profit_factor:5.2f} | Time={elapsed:.3f}s")
print(
f"RR={rr}: Trades={len(trades)}, WR={wr:.1f}%, PnL={total_pnl:.2f}, "
f"AvgW={avg_win:.3f}, AvgL={avg_loss:.3f}, Time={elapsed:.2f}s"
)
total_elapsed = time.perf_counter() - total_start
print(f"Total run time: {total_elapsed:.2f}s")
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+585 -393
View File
File diff suppressed because it is too large Load Diff
+942
View File
@@ -0,0 +1,942 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import {
CandlestickSeries,
LineSeries,
createChart,
createSeriesMarkers,
} from 'lightweight-charts';
const CHART_THEME = {
layout: { background: { color: '#0a0a0a' }, textColor: '#737373', fontFamily: 'Inter, system-ui, sans-serif', fontSize: 11 },
grid: { vertLines: { color: '#1a1a1a' }, horzLines: { color: '#1a1a1a' } },
crosshair: {
vertLine: { color: 'rgba(250, 250, 250, 0.15)', labelBackgroundColor: '#262626' },
horzLine: { color: 'rgba(250, 250, 250, 0.15)', labelBackgroundColor: '#262626' },
},
rightPriceScale: { borderColor: '#262626', textColor: '#737373' },
timeScale: { borderColor: '#262626', timeVisible: true, secondsVisible: false },
};
const TIMEFRAMES = [
{ label: '1m', value: 1 },
{ label: '3m', value: 3 },
{ label: '5m', value: 5 },
{ label: '15m', value: 15 },
{ label: '30m', value: 30 },
{ label: '1H', value: 60 },
];
const RR_OPTIONS = [1, 1.5, 2, 2.5, 3];
const SESSIONS = ['london', 'new_york', 'asian', 'london_close', 'london_ny_overlap', 'all'];
const DAYS = [
{ label: 'Mon', value: 0 },
{ label: 'Tue', value: 1 },
{ label: 'Wed', value: 2 },
{ label: 'Thu', value: 3 },
{ label: 'Fri', value: 4 },
];
const STARTING_BALANCE = 10000;
const PRESETS_KEY = 'nq_backtest_presets';
const RESULT_HISTORY_KEY = 'nq_backtest_recent_results';
function formatMoney(v) {
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function calculateProfitFactor(trades) {
const winners = trades.filter((trade) => trade.pnl > 0).reduce((sum, trade) => sum + trade.pnl, 0);
const losers = Math.abs(trades.filter((trade) => trade.pnl < 0).reduce((sum, trade) => sum + trade.pnl, 0));
if (losers <= 0) return winners > 0 ? 999 : 0;
return Number((winners / losers).toFixed(2));
}
function calculateMaxDrawdown(trades, startingBalance = STARTING_BALANCE) {
if (!trades.length) return 0;
let equity = startingBalance;
let peak = startingBalance;
let maxDrawdown = 0;
trades
.slice()
.sort((a, b) => new Date(a.exit_time) - new Date(b.exit_time))
.forEach((trade) => {
equity += trade.pnl;
if (equity > peak) peak = equity;
const drawdown = ((peak - equity) / peak) * 100;
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
});
return Number(maxDrawdown.toFixed(2));
}
function loadPresets() {
try {
return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}');
} catch { return {}; }
}
function savePresets(presets) {
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
}
function loadRecentResults() {
try {
const parsed = JSON.parse(localStorage.getItem(RESULT_HISTORY_KEY) || '[]');
return Array.isArray(parsed) ? parsed.slice(0, 5) : [];
} catch {
return [];
}
}
function saveRecentResults(results) {
localStorage.setItem(RESULT_HISTORY_KEY, JSON.stringify(results.slice(0, 5)));
}
function NumberInput({ label, value, onChange, min, max, step = 1 }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-[11px] text-[#525252] font-mono uppercase tracking-widest">{label}</label>
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full border border-[#262626] bg-black text-[#fafafa] font-mono text-[13px] px-3 py-2 outline-none focus:border-[#404040] transition-colors"
/>
</div>
);
}
function ToggleInput({ label, value, onChange, color = '#10b981' }) {
return (
<button
className={`flex items-center gap-2 px-3 py-2 text-[12px] font-mono border transition-colors ${
value ? 'border-[#404040] text-[#fafafa]' : 'border-[#262626] text-[#525252]'
}`}
onClick={() => onChange(!value)}
>
<span className="w-1.5 h-1.5" style={{ background: value ? color : '#525252' }} />
{label}
</button>
);
}
function SectionHeader({ title, subtitle }) {
return (
<div className="mb-4 mt-2">
<h3 className="text-[14px] font-semibold tracking-tight">{title}</h3>
{subtitle && <p className="text-[11px] text-[#525252] font-mono mt-0.5">{subtitle}</p>}
</div>
);
}
export function BacktestingTab({ datasets = [], selectedDataset, onDatasetChange, onBacktestComplete }) {
const chartContainerRef = useRef(null);
const equityChartRef = useRef(null);
const chartRef = useRef(null);
const equityChartObjRef = useRef(null);
const candleSeriesRef = useRef(null);
const equitySeriesRef = useRef(null);
const markersRef = useRef(null);
const progressIntervalRef = useRef(null);
const progressResetTimeoutRef = useRef(null);
const [chartsReady, setChartsReady] = useState(false);
const [loading, setLoading] = useState(false);
const [results, setResults] = useState(null);
const [autoRun, setAutoRun] = useState(false);
const [progressPct, setProgressPct] = useState(0);
const [mcLoading, setMcLoading] = useState(false);
const [mcErrorMessage, setMcErrorMessage] = useState('');
const [mcResult, setMcResult] = useState(null);
const [mcRuns, setMcRuns] = useState(500);
const [mcVariationPct, setMcVariationPct] = useState(15);
const [mcPriceNoisePct, setMcPriceNoisePct] = useState(0);
const [mcSlippage, setMcSlippage] = useState(0);
const [mcSpread, setMcSpread] = useState(0);
const [mcRuinDrawdownPct, setMcRuinDrawdownPct] = useState(20);
const [mcShuffleTrades, setMcShuffleTrades] = useState(true);
const [timeframe, setTimeframe] = useState(1);
const [riskReward, setRiskReward] = useState(2.5);
const [lookback, setLookback] = useState(7);
const [atrMult, setAtrMult] = useState(2.5);
const [session, setSession] = useState('london');
const [useFvg, setUseFvg] = useState(true);
const [useOb, setUseOb] = useState(true);
const [useLiquiditySweep, setUseLiquiditySweep] = useState(true);
const [obMaxAge, setObMaxAge] = useState(50);
const [proximityPct, setProximityPct] = useState(0.5);
const [sweepLookback, setSweepLookback] = useState(5);
const [minGapSize, setMinGapSize] = useState(0.0);
const [impulseMultiplier, setImpulseMultiplier] = useState(0.0);
const [requireUnmitigatedFvg, setRequireUnmitigatedFvg] = useState(true);
const [requireBosConfluence, setRequireBosConfluence] = useState(false);
const [minObSize, setMinObSize] = useState(0.0);
const [requireFvgObConfluence, setRequireFvgObConfluence] = useState(false);
const [asianSweepOnly, setAsianSweepOnly] = useState(false);
const [useBreakEven, setUseBreakEven] = useState(false);
const [beTriggerRr, setBeTriggerRr] = useState(1.0);
const [usePartialTp, setUsePartialTp] = useState(false);
const [partialTpRr, setPartialTpRr] = useState(1.0);
const [partialTpPercent, setPartialTpPercent] = useState(50);
const [dayFilter, setDayFilter] = useState([0, 1, 2, 3, 4]);
const [maxDailyLoss, setMaxDailyLoss] = useState(0.0);
const [maxConsecutiveLosses, setMaxConsecutiveLosses] = useState(0);
// Presets
const [presets, setPresets] = useState(loadPresets);
const [presetName, setPresetName] = useState('');
const [showPresets, setShowPresets] = useState(false);
const [recentResults, setRecentResults] = useState(loadRecentResults);
const getSettings = () => ({
timeframe, riskReward, lookback, atrMult, session,
useFvg, useOb, useLiquiditySweep, obMaxAge, proximityPct, sweepLookback,
minGapSize, impulseMultiplier, requireUnmitigatedFvg, requireBosConfluence,
minObSize, requireFvgObConfluence, asianSweepOnly, dayFilter,
useBreakEven, beTriggerRr,
usePartialTp, partialTpRr, partialTpPercent,
maxDailyLoss, maxConsecutiveLosses,
});
const applySettings = (s) => {
if (s.timeframe !== undefined) setTimeframe(s.timeframe);
if (s.riskReward !== undefined) setRiskReward(s.riskReward);
if (s.lookback !== undefined) setLookback(s.lookback);
if (s.atrMult !== undefined) setAtrMult(s.atrMult);
if (s.session !== undefined) setSession(s.session);
if (s.useFvg !== undefined) setUseFvg(s.useFvg);
if (s.useOb !== undefined) setUseOb(s.useOb);
if (s.useLiquiditySweep !== undefined) setUseLiquiditySweep(s.useLiquiditySweep);
if (s.obMaxAge !== undefined) setObMaxAge(s.obMaxAge);
if (s.proximityPct !== undefined) setProximityPct(s.proximityPct);
if (s.sweepLookback !== undefined) setSweepLookback(s.sweepLookback);
if (s.minGapSize !== undefined) setMinGapSize(s.minGapSize);
if (s.impulseMultiplier !== undefined) setImpulseMultiplier(s.impulseMultiplier);
if (s.requireUnmitigatedFvg !== undefined) setRequireUnmitigatedFvg(s.requireUnmitigatedFvg);
if (s.requireBosConfluence !== undefined) setRequireBosConfluence(s.requireBosConfluence);
if (s.minObSize !== undefined) setMinObSize(s.minObSize);
if (s.requireFvgObConfluence !== undefined) setRequireFvgObConfluence(s.requireFvgObConfluence);
if (s.asianSweepOnly !== undefined) setAsianSweepOnly(s.asianSweepOnly);
if (s.useBreakEven !== undefined) setUseBreakEven(s.useBreakEven);
if (s.beTriggerRr !== undefined) setBeTriggerRr(s.beTriggerRr);
if (s.usePartialTp !== undefined) setUsePartialTp(s.usePartialTp);
if (s.partialTpRr !== undefined) setPartialTpRr(s.partialTpRr);
if (s.partialTpPercent !== undefined) setPartialTpPercent(s.partialTpPercent);
if (s.dayFilter !== undefined) setDayFilter(s.dayFilter);
if (s.maxDailyLoss !== undefined) setMaxDailyLoss(s.maxDailyLoss);
if (s.maxConsecutiveLosses !== undefined) setMaxConsecutiveLosses(s.maxConsecutiveLosses);
};
const handleSavePreset = () => {
const name = presetName.trim();
if (!name) return;
const updated = { ...presets, [name]: getSettings() };
setPresets(updated);
savePresets(updated);
setPresetName('');
};
const handleLoadPreset = (name) => {
const preset = presets[name];
if (preset) applySettings(preset);
setShowPresets(false);
};
const handleDeletePreset = (name) => {
const updated = { ...presets };
delete updated[name];
setPresets(updated);
savePresets(updated);
};
const toggleDay = (day) => {
setDayFilter((prev) => {
if (prev.includes(day)) {
const next = prev.filter((d) => d !== day);
return next.length ? next : prev;
}
return [...prev, day].sort();
});
};
useEffect(() => {
if (!chartContainerRef.current) return;
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 420,
...CHART_THEME,
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#10b981',
downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#10b981',
wickDownColor: '#ef4444',
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
if (equityChartRef.current) {
const eqChart = createChart(equityChartRef.current, {
width: equityChartRef.current.clientWidth,
height: 160,
...CHART_THEME,
layout: { ...CHART_THEME.layout, fontSize: 10 },
});
equityChartObjRef.current = eqChart;
equitySeriesRef.current = eqChart.addSeries(LineSeries, {
color: '#10b981',
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
});
}
setChartsReady(true);
const handleResize = () => {
if (chartContainerRef.current) chart.applyOptions({ width: chartContainerRef.current.clientWidth });
if (equityChartRef.current && equityChartObjRef.current) equityChartObjRef.current.applyOptions({ width: equityChartRef.current.clientWidth });
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
equityChartObjRef.current?.remove();
};
}, []);
const runBacktest = useCallback(async () => {
if (!chartsReady) return;
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
if (progressResetTimeoutRef.current) clearTimeout(progressResetTimeoutRef.current);
setProgressPct(8);
progressIntervalRef.current = setInterval(() => {
setProgressPct((prev) => (prev < 92 ? prev + 3 : prev));
}, 140);
setLoading(true);
try {
const params = new URLSearchParams({
timeframe: timeframe.toString(),
rr: riskReward.toString(),
lookback: lookback.toString(),
atr_mult: atrMult.toString(),
session,
sweep: useLiquiditySweep.toString(),
sweep_lookback: sweepLookback.toString(),
ob_age: obMaxAge.toString(),
dataset: selectedDataset,
use_fvg: useFvg.toString(),
use_ob: useOb.toString(),
proximity_pct: proximityPct.toString(),
min_gap_size: minGapSize.toString(),
impulse_multiplier: impulseMultiplier.toString(),
require_unmitigated_fvg: requireUnmitigatedFvg.toString(),
require_bos_confluence: requireBosConfluence.toString(),
min_ob_size: minObSize.toString(),
require_fvg_ob_confluence: requireFvgObConfluence.toString(),
asian_sweep_only: asianSweepOnly.toString(),
use_break_even: useBreakEven.toString(),
be_trigger_rr: beTriggerRr.toString(),
use_partial_tp: usePartialTp.toString(),
partial_tp_rr: partialTpRr.toString(),
partial_tp_percent: partialTpPercent.toString(),
day_filter: dayFilter.join(','),
max_daily_loss: maxDailyLoss.toString(),
max_consecutive_losses: maxConsecutiveLosses.toString(),
});
const [candleRes, backtestRes] = await Promise.all([
fetch(`http://localhost:8000/api/candles?timeframe=${timeframe}&dataset=${encodeURIComponent(selectedDataset)}`),
fetch(`http://localhost:8000/api/backtest?${params}`),
]);
const candleData = await candleRes.json();
const backtestData = await backtestRes.json();
const candles = candleData.candles.map((c) => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
candleSeriesRef.current?.setData(candles);
const markers = [];
if (backtestData.trades) {
backtestData.trades.forEach((trade) => {
const isWin = trade.pnl > 0;
markers.push({
time: Math.floor(new Date(trade.enter_time).getTime() / 1000),
position: trade.direction === 'long' ? 'belowBar' : 'aboveBar',
color: isWin ? '#10b981' : '#ef4444',
shape: trade.direction === 'long' ? 'arrowUp' : 'arrowDown',
text: trade.direction === 'long' ? 'BUY' : 'SELL',
});
markers.push({
time: Math.floor(new Date(trade.exit_time).getTime() / 1000),
position: 'inBar',
color: isWin ? '#10b981' : '#ef4444',
shape: 'circle',
text: isWin ? `+${trade.pnl.toFixed(2)}` : trade.pnl.toFixed(2),
});
});
}
markers.sort((a, b) => a.time - b.time);
markersRef.current?.setMarkers([]);
markersRef.current = createSeriesMarkers(candleSeriesRef.current, markers);
chartRef.current?.timeScale().fitContent();
if (backtestData.trades?.length) {
let equity = STARTING_BALANCE;
const sortedTrades = backtestData.trades.slice().sort((a, b) => new Date(a.exit_time) - new Date(b.exit_time));
const eqData = sortedTrades.map((t) => {
equity += t.pnl;
return {
time: Math.floor(new Date(t.exit_time).getTime() / 1000),
value: Number(equity.toFixed(2)),
};
});
equitySeriesRef.current?.setData(eqData);
equityChartObjRef.current?.timeScale().fitContent();
} else {
equitySeriesRef.current?.setData([]);
}
setResults(backtestData);
onBacktestComplete?.(backtestData);
if (backtestData?.stats) {
const snapshot = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
runAt: new Date().toISOString(),
dataset: selectedDataset,
timeframe,
riskReward,
totalPnl: Number(backtestData.stats.total_pnl ?? 0),
winRate: Number(backtestData.stats.win_rate ?? 0),
totalTrades: Number(backtestData.stats.total_trades ?? 0),
};
setRecentResults((prev) => {
const next = [snapshot, ...prev].slice(0, 5);
saveRecentResults(next);
return next;
});
}
} catch (err) {
console.error('Backtest failed:', err);
} finally {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
progressIntervalRef.current = null;
}
setProgressPct(100);
setLoading(false);
progressResetTimeoutRef.current = setTimeout(() => {
setProgressPct(0);
}, 500);
}
}, [
chartsReady, timeframe, riskReward, lookback, atrMult, session,
useFvg, useOb, useLiquiditySweep, sweepLookback, obMaxAge,
selectedDataset, proximityPct, minGapSize, impulseMultiplier,
requireUnmitigatedFvg, requireBosConfluence, minObSize,
requireFvgObConfluence, asianSweepOnly, dayFilter,
useBreakEven, beTriggerRr,
usePartialTp, partialTpRr, partialTpPercent,
maxDailyLoss, maxConsecutiveLosses, onBacktestComplete,
]);
const runMonteCarlo = useCallback(async () => {
if (!results?.trades?.length) {
setMcErrorMessage('Run a backtest first so Monte Carlo has trades to simulate.');
return;
}
const trades = results.trades;
const totalTradesLocal = trades.length;
const totalPnlLocal = trades.reduce((sum, trade) => sum + Number(trade.pnl ?? 0), 0);
const winRateLocal = totalTradesLocal ? (trades.filter((trade) => trade.pnl > 0).length / totalTradesLocal) * 100 : 0;
setMcLoading(true);
setMcErrorMessage('');
setMcResult(null);
try {
const response = await fetch('http://localhost:8000/api/backtest/monte-carlo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trade_r_multiples: results.trades.map((trade) => Number(trade.r_multiple ?? 0)),
runs: mcRuns,
starting_balance: STARTING_BALANCE,
risk_per_trade_pct: 1,
sampling_method: mcShuffleTrades ? 'shuffle' : 'bootstrap',
missed_trade_pct: 0,
pnl_variation_pct: mcVariationPct,
price_noise_pct: mcPriceNoisePct,
slippage_per_trade: mcSlippage,
spread_per_trade: mcSpread,
ruin_drawdown_pct: mcRuinDrawdownPct,
base_trade_count: totalTradesLocal,
base_net_pnl: totalPnlLocal,
base_win_rate: winRateLocal,
base_profit_factor: calculateProfitFactor(results.trades),
base_max_drawdown_pct: calculateMaxDrawdown(results.trades, STARTING_BALANCE),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.detail ?? 'Monte Carlo run failed');
}
setMcResult(data);
} catch (err) {
setMcErrorMessage(err instanceof Error ? err.message : 'Monte Carlo run failed');
} finally {
setMcLoading(false);
}
}, [
results,
mcRuns,
mcVariationPct,
mcPriceNoisePct,
mcSlippage,
mcSpread,
mcRuinDrawdownPct,
mcShuffleTrades,
]);
useEffect(() => {
if (chartsReady && autoRun) runBacktest();
}, [runBacktest, chartsReady, autoRun]);
useEffect(() => {
let f2 = 0;
const f1 = requestAnimationFrame(() => {
f2 = requestAnimationFrame(() => {
if (chartRef.current && chartContainerRef.current) {
chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth });
chartRef.current.timeScale().fitContent();
}
if (equityChartObjRef.current && equityChartRef.current) {
equityChartObjRef.current.applyOptions({ width: equityChartRef.current.clientWidth });
equityChartObjRef.current.timeScale().fitContent();
}
});
});
return () => { cancelAnimationFrame(f1); cancelAnimationFrame(f2); };
}, []);
useEffect(() => () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
if (progressResetTimeoutRef.current) clearTimeout(progressResetTimeoutRef.current);
}, []);
const stats = results?.stats;
const totalTrades = stats?.total_trades ?? 0;
const winRate = stats?.win_rate ?? 0;
const totalPnl = stats?.total_pnl ?? 0;
const partialTpTrades = stats?.partial_tp_trades ?? 0;
const partialTpRate = stats?.partial_tp_rate ?? 0;
const partialTpRealized = stats?.partial_tp_realized_total ?? 0;
const presetNames = Object.keys(presets);
const mcRunsData = mcResult?.sample_runs ?? mcResult?.distribution ?? [];
const itemVariants = {
hidden: { opacity: 0, y: 18 },
visible: { opacity: 1, y: 0, transition: { duration: 0.52, ease: [0.22, 1, 0.36, 1] } },
};
return (
<div className="space-y-6">
<motion.div variants={itemVariants} className="border border-[#262626] bg-[#0a0a0a] p-6">
{/* Header row */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
<div>
<h2 className="text-[20px] font-semibold tracking-tight mb-1">Backtesting Engine</h2>
<p className="text-[13px] text-[#525252] font-mono">Adjust parameters and run strategy</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<ToggleInput label="Auto Run" value={autoRun} onChange={setAutoRun} color="#fafafa" />
{loading && <div className="w-2 h-2 bg-[#10b981] animate-pulse" />}
<button
onClick={() => setShowPresets((p) => !p)}
className="px-4 py-2 text-[13px] font-mono border border-[#262626] text-[#737373] hover:text-[#fafafa] hover:border-[#404040] transition-colors"
>
Presets
</button>
<button
onClick={runBacktest}
disabled={loading}
className="px-5 py-2 text-[13px] font-semibold font-mono bg-[#fafafa] text-black hover:bg-[#e5e5e5] transition-colors disabled:opacity-40"
>
{loading ? 'Running...' : 'Run Backtest'}
</button>
<button
onClick={runMonteCarlo}
disabled={loading || mcLoading || !results?.trades?.length}
className="px-5 py-2 text-[13px] font-semibold font-mono border border-[#6366f1] text-[#6366f1] hover:bg-[#6366f1] hover:text-white transition-colors disabled:opacity-40"
>
{mcLoading ? 'Running MC...' : 'Monte Carlo'}
</button>
</div>
</div>
<div className="mb-6">
<div className="flex items-center justify-between text-[11px] font-mono text-[#737373] mb-1">
<span>{loading ? 'Running backtest...' : 'Backtest progress'}</span>
<span>{Math.round(progressPct)}%</span>
</div>
<div className="h-2 border border-[#1f1f1f] bg-black/50 overflow-hidden">
<div
className={`h-full transition-all duration-150 ${loading ? 'bg-[#10b981]' : 'bg-[#404040]'}`}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* Presets panel */}
{showPresets && (
<div className="mb-6 p-5 border border-[#262626] bg-black">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold tracking-tight">Presets</h3>
<button
onClick={() => setShowPresets(false)}
className="text-[#525252] hover:text-[#fafafa] text-[18px] leading-none transition-colors"
>
x
</button>
</div>
{/* Save */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Preset name..."
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSavePreset()}
className="flex-1 border border-[#262626] bg-[#0a0a0a] text-[#fafafa] font-mono text-[13px] px-3 py-2 outline-none focus:border-[#404040] transition-colors"
/>
<button
onClick={handleSavePreset}
disabled={!presetName.trim()}
className="px-4 py-2 text-[13px] font-mono bg-[#fafafa] text-black hover:bg-[#e5e5e5] transition-colors disabled:opacity-30"
>
Save Current
</button>
</div>
{/* List */}
{presetNames.length === 0 ? (
<p className="text-[13px] text-[#525252] font-mono">No saved presets yet.</p>
) : (
<div className="space-y-2">
{presetNames.map((name) => (
<div key={name} className="flex items-center justify-between p-3 border border-[#1a1a1a] hover:border-[#262626] transition-colors">
<span className="text-[13px] font-mono text-[#fafafa]">{name}</span>
<div className="flex gap-2">
<button
onClick={() => handleLoadPreset(name)}
className="px-3 py-1 text-[12px] font-mono border border-[#262626] text-[#737373] hover:text-[#fafafa] hover:border-[#404040] transition-colors"
>
Load
</button>
<button
onClick={() => handleDeletePreset(name)}
className="px-3 py-1 text-[12px] font-mono border border-[#262626] text-[#ef4444] hover:border-[#ef4444] transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Core Strategy */}
<SectionHeader title="Core Strategy" />
<div className="flex flex-wrap gap-6 items-end mb-6">
<div className="flex flex-col gap-1.5">
<span className="text-[11px] text-[#525252] font-mono uppercase tracking-widest">Timeframe</span>
<div className="flex border border-[#262626]">
{TIMEFRAMES.map((tf) => (
<button key={tf.value} className={`px-3 py-1.5 text-[12px] font-mono transition-colors ${timeframe === tf.value ? 'bg-[#fafafa] text-black' : 'text-[#737373] hover:text-[#fafafa]'}`} onClick={() => setTimeframe(tf.value)}>{tf.label}</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[11px] text-[#525252] font-mono uppercase tracking-widest">Risk : Reward</span>
<div className="flex border border-[#262626]">
{RR_OPTIONS.map((rr) => (
<button key={rr} className={`px-3 py-1.5 text-[12px] font-mono transition-colors ${riskReward === rr ? 'bg-[#fafafa] text-black' : 'text-[#737373] hover:text-[#fafafa]'}`} onClick={() => setRiskReward(rr)}>1:{rr}</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[11px] text-[#525252] font-mono uppercase tracking-widest">Session</span>
<div className="flex flex-wrap border border-[#262626]">
{SESSIONS.map((s) => (
<button key={s} className={`px-3 py-1.5 text-[12px] font-mono transition-colors capitalize ${session === s ? 'bg-[#fafafa] text-black' : 'text-[#737373] hover:text-[#fafafa]'}`} onClick={() => setSession(s)}>{s.replace(/_/g, ' ')}</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[11px] text-[#525252] font-mono uppercase tracking-widest">Dataset</span>
<select className="border border-[#262626] bg-black text-[#fafafa] font-mono text-[13px] px-3 py-2 outline-none focus:border-[#404040] transition-colors" value={selectedDataset} onChange={(e) => onDatasetChange(e.target.value)}>
{datasets.map((d) => (<option key={d.id} value={d.id}>{d.label}</option>))}
</select>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
<NumberInput label="Lookback" value={lookback} onChange={setLookback} min={1} max={50} />
<NumberInput label="ATR Multiplier" value={atrMult} onChange={setAtrMult} min={0.1} max={10} step={0.1} />
<NumberInput label="OB Max Age" value={obMaxAge} onChange={setObMaxAge} min={1} max={200} />
<NumberInput label="Proximity %" value={proximityPct} onChange={setProximityPct} min={0.01} max={5} step={0.01} />
<NumberInput label="Sweep Lookback" value={sweepLookback} onChange={setSweepLookback} min={1} max={50} />
</div>
<div className="border-t border-[#1a1a1a] my-6" />
<SectionHeader title="FVG Settings" subtitle="Fair Value Gap quality filters" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
<NumberInput label="Min Gap Size" value={minGapSize} onChange={setMinGapSize} min={0} max={0.01} step={0.0001} />
<NumberInput label="Impulse Multiplier" value={impulseMultiplier} onChange={setImpulseMultiplier} min={0} max={5} step={0.1} />
</div>
<div className="flex flex-wrap gap-3 mb-6">
<ToggleInput label="Fair Value Gaps" value={useFvg} onChange={setUseFvg} />
<ToggleInput label="Require Unmitigated" value={requireUnmitigatedFvg} onChange={setRequireUnmitigatedFvg} />
<ToggleInput label="Require BOS Confluence" value={requireBosConfluence} onChange={setRequireBosConfluence} />
</div>
<div className="border-t border-[#1a1a1a] my-6" />
<SectionHeader title="Order Block Settings" subtitle="Order block quality filters" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
<NumberInput label="Min OB Size" value={minObSize} onChange={setMinObSize} min={0} max={0.01} step={0.0001} />
</div>
<div className="flex flex-wrap gap-3 mb-6">
<ToggleInput label="Order Blocks" value={useOb} onChange={setUseOb} />
<ToggleInput label="Require FVG + OB Confluence" value={requireFvgObConfluence} onChange={setRequireFvgObConfluence} />
</div>
<div className="border-t border-[#1a1a1a] my-6" />
<SectionHeader title="Liquidity Settings" subtitle="Sweep and liquidity filters" />
<div className="flex flex-wrap gap-3 mb-6">
<ToggleInput label="Liquidity Sweep" value={useLiquiditySweep} onChange={setUseLiquiditySweep} />
<ToggleInput label="Asian Range Sweep Only" value={asianSweepOnly} onChange={setAsianSweepOnly} />
</div>
<div className="border-t border-[#1a1a1a] my-6" />
<SectionHeader title="Day Filter" subtitle="Select which days to trade" />
<div className="flex gap-2 mb-6">
{DAYS.map((d) => (
<button key={d.value} className={`px-4 py-2 text-[12px] font-mono border transition-colors ${dayFilter.includes(d.value) ? 'bg-[#fafafa] text-black border-[#fafafa]' : 'border-[#262626] text-[#525252] hover:text-[#fafafa]'}`} onClick={() => toggleDay(d.value)}>{d.label}</button>
))}
</div>
<div className="border-t border-[#1a1a1a] my-6" />
<SectionHeader title="Risk Management" subtitle="Daily loss limits and streak protection" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<NumberInput label="Max Daily Loss %" value={maxDailyLoss} onChange={setMaxDailyLoss} min={0} max={10} step={0.5} />
<NumberInput label="Max Consecutive Losses" value={maxConsecutiveLosses} onChange={setMaxConsecutiveLosses} min={0} max={20} step={1} />
<NumberInput label="BE Trigger (RR)" value={beTriggerRr} onChange={setBeTriggerRr} min={0.1} max={10} step={0.1} />
<NumberInput label="Partial TP RR" value={partialTpRr} onChange={setPartialTpRr} min={0.1} max={10} step={0.1} />
<NumberInput label="Partial TP %" value={partialTpPercent} onChange={setPartialTpPercent} min={1} max={100} step={1} />
</div>
<div className="flex flex-wrap gap-3 mt-4">
<ToggleInput label="Use Break-Even" value={useBreakEven} onChange={setUseBreakEven} />
<ToggleInput label="Use Partial TP" value={usePartialTp} onChange={setUsePartialTp} />
</div>
</motion.div>
{/* Chart */}
<motion.div variants={itemVariants} className="border border-[#262626] bg-[#0a0a0a] p-6">
<div className="mb-4">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-1">Backtest Chart</p>
<h2 className="text-[20px] font-semibold tracking-tight">Trade entries and exits</h2>
</div>
<div ref={chartContainerRef} className="h-[420px] border border-[#1a1a1a] overflow-hidden" />
</motion.div>
{/* Equity */}
<motion.div variants={itemVariants} className="border border-[#262626] bg-[#0a0a0a] p-6">
<div className="mb-4">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-1">Equity Curve</p>
<h2 className="text-[20px] font-semibold tracking-tight">Balance progression</h2>
</div>
<div ref={equityChartRef} className="h-[160px] border border-[#1a1a1a] overflow-hidden" />
</motion.div>
{/* Results */}
{stats && (
<motion.div variants={itemVariants} className="border border-[#262626] bg-[#0a0a0a] p-6">
<div className="mb-6">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-1">Results</p>
<h2 className="text-[20px] font-semibold tracking-tight">Backtest Summary</h2>
<p className="text-[12px] text-[#737373] font-mono mt-2">CSV: {selectedDataset}</p>
</div>
<div className="mb-6 border border-[#1a1a1a] bg-black/30 p-4">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-3">Last 5 Runs</p>
{recentResults.length === 0 ? (
<p className="text-[12px] text-[#737373] font-mono">No previous runs saved yet.</p>
) : (
<div className="space-y-2">
{recentResults.map((run) => (
<div key={run.id} className="grid grid-cols-2 md:grid-cols-6 gap-2 text-[12px] font-mono border border-[#1a1a1a] bg-black/40 px-3 py-2">
<span className="text-[#a3a3a3]">{new Date(run.runAt).toLocaleString()}</span>
<span className="text-[#fafafa]">{run.dataset}</span>
<span className="text-[#a3a3a3]">{run.timeframe}m</span>
<span className={run.totalPnl >= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}>${formatMoney(run.totalPnl)}</span>
<span className="text-[#60a5fa]">{run.winRate.toFixed(1)}%</span>
<span className="text-[#fafafa]">{run.totalTrades} trades</span>
</div>
))}
</div>
)}
</div>
<div className="border-t border-[#1a1a1a] my-6" />
<div className="space-y-4 mb-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-1">Monte Carlo</p>
<h3 className="text-[16px] font-semibold tracking-tight">Stress test the current trade set</h3>
</div>
<button
onClick={runMonteCarlo}
disabled={mcLoading || !results?.trades?.length}
className="px-4 py-2 text-[13px] font-semibold font-mono bg-[#6366f1] text-white hover:bg-[#4f46e5] transition-colors disabled:opacity-40"
>
{mcLoading ? 'Running Monte Carlo...' : 'Run Monte Carlo'}
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<NumberInput label="Runs" value={mcRuns} onChange={setMcRuns} min={1} step={1} />
<NumberInput label="PnL Var %" value={mcVariationPct} onChange={setMcVariationPct} min={0} step={1} />
<NumberInput label="Price Noise %" value={mcPriceNoisePct} onChange={setMcPriceNoisePct} min={0} step={1} />
<NumberInput label="Slippage" value={mcSlippage} onChange={setMcSlippage} min={0} step={0.01} />
<NumberInput label="Spread" value={mcSpread} onChange={setMcSpread} min={0} step={0.01} />
<NumberInput label="Ruin DD %" value={mcRuinDrawdownPct} onChange={setMcRuinDrawdownPct} min={0} step={1} />
</div>
<label className="inline-flex items-center gap-2 text-[12px] font-mono text-[#a3a3a3]">
<input
type="checkbox"
checked={mcShuffleTrades}
onChange={(e) => setMcShuffleTrades(e.target.checked)}
className="h-4 w-4"
/>
Shuffle trade order each simulation run
</label>
{mcErrorMessage && (
<div className="border border-[#7f1d1d] bg-[#1b0a0a] text-[#fca5a5] px-4 py-3 text-[12px] font-mono">
{mcErrorMessage}
</div>
)}
{mcResult && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="p-3 border border-[#1a1a1a] bg-black/40"><p className="text-[10px] text-[#737373] font-mono uppercase">Avg PnL</p><p className="text-[16px] font-semibold">{Number(mcResult.summary?.avg_pnl ?? 0).toFixed(2)}</p></div>
<div className="p-3 border border-[#1a1a1a] bg-black/40"><p className="text-[10px] text-[#737373] font-mono uppercase">Profitable %</p><p className="text-[16px] font-semibold">{Number(mcResult.summary?.profitable_run_pct ?? 0).toFixed(2)}%</p></div>
<div className="p-3 border border-[#1a1a1a] bg-black/40"><p className="text-[10px] text-[#737373] font-mono uppercase">Worst DD %</p><p className="text-[16px] font-semibold">{Number(mcResult.summary?.worst_max_drawdown_pct ?? 0).toFixed(2)}%</p></div>
<div className="p-3 border border-[#1a1a1a] bg-black/40"><p className="text-[10px] text-[#737373] font-mono uppercase">Avg WR</p><p className="text-[16px] font-semibold">{Number(mcResult.summary?.avg_win_rate ?? 0).toFixed(2)}%</p></div>
<div className="p-3 border border-[#1a1a1a] bg-black/40"><p className="text-[10px] text-[#737373] font-mono uppercase">Avg PF</p><p className="text-[16px] font-semibold">{Number(mcResult.summary?.avg_profit_factor ?? 0).toFixed(2)}</p></div>
<div className="p-3 border border-[#1a1a1a] bg-black/40"><p className="text-[10px] text-[#737373] font-mono uppercase">Ruin %</p><p className="text-[16px] font-semibold">{Number(mcResult.summary?.probability_of_ruin ?? 0).toFixed(2)}%</p></div>
</div>
<div className="border border-[#1a1a1a] bg-black/40 overflow-auto">
<div className="grid grid-cols-[60px_100px_120px_100px_100px_80px] gap-3 px-4 py-3 text-[10px] font-mono uppercase tracking-widest text-[#737373] border-b border-[#1a1a1a]">
<span>Run</span>
<span>Net PnL</span>
<span>Max DD %</span>
<span>Win Rate</span>
<span>PF</span>
<span>Ruin</span>
</div>
{mcRunsData.map((run) => (
<div key={run.run} className="grid grid-cols-[60px_100px_120px_100px_100px_80px] gap-3 px-4 py-2 text-[12px] font-mono border-b border-[#111111]">
<span>{run.run}</span>
<span className={run.net_pnl >= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}>{Number(run.net_pnl).toFixed(2)}</span>
<span>{Number(run.max_drawdown_pct).toFixed(2)}</span>
<span>{Number(run.win_rate).toFixed(2)}%</span>
<span>{Number(run.profit_factor).toFixed(2)}</span>
<span className={run.ruin ? 'text-[#ef4444]' : 'text-[#737373]'}>{run.ruin ? 'Yes' : 'No'}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-10 gap-4">
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Net P/L</p>
<p className={`text-[24px] font-semibold ${totalPnl >= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>${formatMoney(totalPnl)}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Win Rate</p>
<p className={`text-[24px] font-semibold ${winRate > 50 ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>{winRate.toFixed(1)}%</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Total Trades</p>
<p className="text-[24px] font-semibold text-[#fafafa]">{totalTrades}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Winners</p>
<p className="text-[24px] font-semibold text-[#10b981]">{stats.winners}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Losers</p>
<p className="text-[24px] font-semibold text-[#ef4444]">{stats.losers}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Avg Win</p>
<p className="text-[24px] font-semibold text-[#10b981]">${formatMoney(stats.avg_win)}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Avg Loss</p>
<p className="text-[24px] font-semibold text-[#ef4444]">${formatMoney(Math.abs(stats.avg_loss))}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Partials</p>
<p className="text-[24px] font-semibold text-[#fafafa]">{partialTpTrades}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Partial Rate</p>
<p className="text-[24px] font-semibold text-[#60a5fa]">{partialTpRate.toFixed(1)}%</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[11px] text-[#525252] font-mono uppercase tracking-widest mb-2">Partial P/L</p>
<p className={`text-[24px] font-semibold ${partialTpRealized >= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>${formatMoney(partialTpRealized)}</p>
</div>
</div>
</motion.div>
)}
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
function CustomTooltip({ active, payload }) {
if (active && payload && payload.length) {
return (
<div className="bg-[#0a0a0a] border border-[#262626] px-4 py-3 shadow-xl">
<p className="text-[13px] text-[#737373] mb-1">{payload[0].payload.date}</p>
<p className="text-[18px] font-semibold text-[#10b981]">
${payload[0].value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
);
}
return null;
}
export function EquityCurve({ data = [], startingBalance = 10000 }) {
if (!data.length) {
return (
<div className="border border-[#262626] bg-[#0a0a0a] p-8">
<h2 className="text-[20px] font-semibold tracking-tight mb-1">Equity Curve</h2>
<p className="text-[13px] text-[#737373] mb-6">Account balance progression</p>
<div className="text-[#525252] text-sm py-12 text-center border border-dashed border-[#262626]">
No trade data available yet.
</div>
</div>
);
}
const lastEquity = data[data.length - 1]?.equity ?? startingBalance;
const changePct = ((lastEquity - startingBalance) / startingBalance) * 100;
return (
<div className="border border-[#262626] bg-[#0a0a0a] p-8">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-[20px] font-semibold tracking-tight mb-1">Equity Curve</h2>
<p className="text-[13px] text-[#737373]">Account balance progression</p>
</div>
<div className="flex gap-6 text-[13px] text-[#737373] font-mono">
<span>Start ${startingBalance.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
<span>End ${lastEquity.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
<span className={changePct >= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}>
{changePct >= 0 ? '+' : ''}{changePct.toFixed(1)}%
</span>
</div>
</div>
<ResponsiveContainer width="100%" height={360}>
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="100%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" vertical={false} />
<XAxis
dataKey="date"
stroke="#525252"
tick={{ fill: '#737373', fontSize: 12 }}
axisLine={{ stroke: '#262626' }}
tickLine={false}
/>
<YAxis
stroke="#525252"
tick={{ fill: '#737373', fontSize: 12 }}
axisLine={{ stroke: '#262626' }}
tickLine={false}
tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ stroke: '#404040', strokeWidth: 1 }} />
<Line
type="monotone"
dataKey="equity"
stroke="#10b981"
strokeWidth={2.5}
dot={false}
activeDot={{ r: 6, fill: '#10b981', stroke: '#000', strokeWidth: 2 }}
fill="url(#equityGradient)"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { motion, useInView } from 'motion/react';
import { useEffect, useRef, useState } 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 numericValue = parseFloat(value.replace(/[^0-9.-]/g, ''));
if (isNaN(numericValue)) {
setDisplayValue(value);
return;
}
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]);
const color = neutral ? 'text-[#fafafa]' : isPositive ? 'text-[#10b981]' : 'text-[#ef4444]';
const changeLabel = typeof change === 'number' ? `${change >= 0 ? '+' : ''}${change.toFixed(1)}%` : change;
return (
<motion.div
ref={ref}
className={isPrimary ? 'col-span-2 md:col-span-1' : ''}
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<div className="relative p-6 border border-[#262626] bg-[#0a0a0a] hover:border-[#404040] transition-colors h-full">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[13px] text-[#737373] uppercase tracking-wider font-medium">
{label}
</span>
{!neutral && (
<span className={color}>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{isPositive ? (
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
) : (
<polyline points="22 17 13.5 8.5 8.5 13.5 2 7" />
)}
</svg>
</span>
)}
</div>
<div className={`text-[32px] font-semibold tracking-tight ${color}`}>
{displayValue}
</div>
{changeLabel && (
<div className="flex items-center gap-1.5 text-[13px]">
<span className={color}>{changeLabel}</span>
<span className="text-[#525252]">vs. initial</span>
</div>
)}
</div>
</div>
</motion.div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
CartesianGrid,
} from 'recharts';
function formatCurrency(value) {
return value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function CustomTooltip({ active, payload }) {
if (active && payload && payload.length) {
const value = payload[0].value;
return (
<div className="bg-[#0a0a0a] border border-[#262626] px-4 py-3 shadow-xl">
<p className="text-[13px] text-[#737373] mb-1">{payload[0].payload.month}</p>
<p className={`text-[18px] font-semibold ${value >= 0 ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>
{value >= 0 ? '+' : ''}{value.toFixed(1)}%
</p>
</div>
);
}
return null;
}
export function PerformanceBreakdown({ monthlyReturns = [], largestWin = 0, largestLoss = 0, maxDrawdown = 0, sharpeRatio = 0 }) {
const chartData = monthlyReturns.map((item) => ({
month: item.month,
returnPct: item.returnPct,
}));
const sorted = [...monthlyReturns];
const bestMonth = sorted.reduce((best, item) => (item.returnPct > best.returnPct ? item : best), sorted[0] ?? { month: '-', returnPct: 0 });
const worstMonth = sorted.reduce((worst, item) => (item.returnPct < worst.returnPct ? item : worst), sorted[0] ?? { month: '-', returnPct: 0 });
return (
<div className="border border-[#262626] bg-[#0a0a0a] p-8">
<div className="mb-6">
<h2 className="text-[20px] font-semibold tracking-tight mb-1">Performance Breakdown</h2>
<p className="text-[13px] text-[#737373]">Monthly returns</p>
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" vertical={false} />
<XAxis
dataKey="month"
stroke="#525252"
tick={{ fill: '#737373', fontSize: 12 }}
axisLine={{ stroke: '#262626' }}
tickLine={false}
/>
<YAxis
stroke="#525252"
tick={{ fill: '#737373', fontSize: 12 }}
axisLine={{ stroke: '#262626' }}
tickLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#1a1a1a' }} />
<Bar dataKey="returnPct" radius={0}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.returnPct >= 0 ? '#10b981' : '#ef4444'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="mt-8 pt-6 border-t border-[#1a1a1a] grid grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[13px] text-[#737373] mb-2">Max Drawdown</p>
<p className="text-[24px] font-semibold text-[#ef4444]">{maxDrawdown.toFixed(1)}%</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[13px] text-[#737373] mb-2">Sharpe Ratio</p>
<p className={`text-[24px] font-semibold ${sharpeRatio >= 1 ? 'text-[#10b981]' : 'text-[#f59e0b]'}`}>
{sharpeRatio.toFixed(2)}
</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[13px] text-[#737373] mb-2">Best Month</p>
<p className="text-[16px] font-semibold text-[#10b981]">
{bestMonth.month} ({bestMonth.returnPct > 0 ? '+' : ''}{bestMonth.returnPct.toFixed(1)}%)
</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[13px] text-[#737373] mb-2">Worst Month</p>
<p className="text-[16px] font-semibold text-[#ef4444]">
{worstMonth.month} ({worstMonth.returnPct > 0 ? '+' : ''}{worstMonth.returnPct.toFixed(1)}%)
</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[13px] text-[#737373] mb-2">Largest Win</p>
<p className="text-[24px] font-semibold text-[#10b981]">${formatCurrency(largestWin)}</p>
</div>
<div className="p-4 border border-[#1a1a1a] bg-black/40">
<p className="text-[13px] text-[#737373] mb-2">Largest Loss</p>
<p className="text-[24px] font-semibold text-[#ef4444]">${formatCurrency(largestLoss)}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
function formatCurrency(value) {
return value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function CustomTooltip({ active, payload, total }) {
if (active && payload && payload.length) {
return (
<div className="bg-[#0a0a0a] border border-[#262626] px-4 py-3 shadow-xl">
<p className="text-[13px] text-[#fafafa] font-semibold mb-1">{payload[0].name}</p>
<p className="text-[15px] text-[#737373]">
{payload[0].value} trades ({((payload[0].value / total) * 100).toFixed(1)}%)
</p>
</div>
);
}
return null;
}
export function TradeDistribution({ wins = 0, losses = 0, avgWin = 0, avgLoss = 0, largestWin = 0, largestLoss = 0 }) {
const total = wins + losses;
const data = [
{ name: 'Wins', value: wins },
{ name: 'Losses', value: losses },
];
const COLORS = ['#10b981', '#ef4444'];
return (
<div className="border border-[#262626] bg-[#0a0a0a] p-8">
<div className="mb-6">
<h2 className="text-[20px] font-semibold tracking-tight mb-1">Trade Distribution</h2>
<p className="text-[13px] text-[#737373]">Win/Loss breakdown</p>
</div>
<div className="flex flex-col lg:flex-row items-center gap-8">
<div className="w-full lg:w-1/2">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} stroke="none" />
))}
</Pie>
<Tooltip content={<CustomTooltip total={total} />} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="w-full lg:w-1/2 space-y-4">
<div className="flex items-center justify-between p-4 border border-[#1a1a1a] bg-black/40">
<div>
<p className="text-[13px] text-[#737373] mb-1">Winning Trades</p>
<p className="text-[24px] font-semibold text-[#10b981]">{wins}</p>
</div>
<div className="text-right">
<p className="text-[13px] text-[#737373] mb-1">Avg. Win</p>
<p className="text-[16px] font-semibold text-[#10b981]">${formatCurrency(avgWin)}</p>
</div>
</div>
<div className="flex items-center justify-between p-4 border border-[#1a1a1a] bg-black/40">
<div>
<p className="text-[13px] text-[#737373] mb-1">Losing Trades</p>
<p className="text-[24px] font-semibold text-[#ef4444]">{losses}</p>
</div>
<div className="text-right">
<p className="text-[13px] text-[#737373] mb-1">Avg. Loss</p>
<p className="text-[16px] font-semibold text-[#ef4444]">${formatCurrency(avgLoss)}</p>
</div>
</div>
</div>
</div>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useState, useMemo } from 'react';
import { motion } from 'motion/react';
function formatCurrency(value) {
const abs = Math.abs(value);
const formatted = abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return value >= 0 ? `+$${formatted}` : `$-${formatted}`;
}
const ROWS_PER_PAGE = 10;
export function TradeHistory({ trades = [] }) {
const [page, setPage] = useState(1);
const [pairFilter, setPairFilter] = useState('all');
const [timeFilter, setTimeFilter] = useState('all');
const pairs = useMemo(() => {
const set = new Set(trades.map((t) => t.pair ?? 'GBP/JPY'));
return ['all', ...Array.from(set).sort()];
}, [trades]);
const filtered = useMemo(() => {
let result = trades.slice().sort((a, b) => new Date(b.exit_time) - new Date(a.exit_time));
if (pairFilter !== 'all') {
result = result.filter((t) => (t.pair ?? 'GBP/JPY') === pairFilter);
}
if (timeFilter !== 'all') {
const now = new Date();
const cutoff = new Date(now);
if (timeFilter === '7d') cutoff.setDate(now.getDate() - 7);
else if (timeFilter === '30d') cutoff.setDate(now.getDate() - 30);
else if (timeFilter === '90d') cutoff.setDate(now.getDate() - 90);
result = result.filter((t) => new Date(t.exit_time) >= cutoff);
}
return result;
}, [trades, pairFilter, timeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / ROWS_PER_PAGE));
const safeCurrentPage = Math.min(page, totalPages);
const pageSlice = filtered.slice((safeCurrentPage - 1) * ROWS_PER_PAGE, safeCurrentPage * ROWS_PER_PAGE);
const getPageNumbers = () => {
const pages = [];
const start = Math.max(1, safeCurrentPage - 2);
const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) pages.push(i);
return pages;
};
return (
<div className="border border-[#262626] bg-[#0a0a0a]">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4 p-6 border-b border-[#262626]">
<div>
<h2 className="text-[20px] font-semibold tracking-tight mb-1">Trade History</h2>
<p className="text-[13px] text-[#525252] font-mono">{filtered.length} trades</p>
</div>
<div className="flex gap-3">
<select
className="border border-[#262626] bg-black text-[#fafafa] font-mono text-[13px] px-3 py-2 outline-none focus:border-[#404040] transition-colors"
value={pairFilter}
onChange={(e) => { setPairFilter(e.target.value); setPage(1); }}
>
{pairs.map((p) => (
<option key={p} value={p}>{p === 'all' ? 'All Pairs' : p}</option>
))}
</select>
<select
className="border border-[#262626] bg-black text-[#fafafa] font-mono text-[13px] px-3 py-2 outline-none focus:border-[#404040] transition-colors"
value={timeFilter}
onChange={(e) => { setTimeFilter(e.target.value); setPage(1); }}
>
<option value="all">All Time</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-[#262626]">
{['ID', 'Date', 'Time', 'Pair', 'Type', 'Entry', 'Exit', 'P/L', 'Status'].map((h) => (
<th key={h} className="px-6 py-4 text-[11px] text-[#525252] font-mono uppercase tracking-widest font-medium">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{pageSlice.length === 0 ? (
<tr>
<td colSpan={9} className="px-6 py-12 text-center text-[#525252] font-mono text-sm">
No trades found.
</td>
</tr>
) : (
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' });
const pair = trade.pair ?? 'GBP/JPY';
const direction = trade.direction === 'long' ? 'BUY' : 'SELL';
return (
<motion.tr
key={`${trade.enter_time}-${index}`}
className="border-b border-[#1a1a1a] hover:bg-[#111111] transition-colors"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.03 }}
>
<td className="px-6 py-4 text-[13px] text-[#525252] font-mono">#{globalIndex + 1}</td>
<td className="px-6 py-4 text-[14px] font-mono">{dateStr}</td>
<td className="px-6 py-4 text-[14px] text-[#737373] font-mono">{timeStr}</td>
<td className="px-6 py-4 text-[14px] font-semibold">{pair}</td>
<td className="px-6 py-4">
<span className={`inline-block px-3 py-1 text-[12px] font-semibold font-mono ${
direction === 'BUY'
? 'bg-[#10b981] text-black'
: 'bg-[#ef4444] text-black'
}`}>
{direction}
</span>
</td>
<td className="px-6 py-4 text-[14px] font-mono text-[#737373]">{trade.enter_price.toFixed(5)}</td>
<td className="px-6 py-4 text-[14px] font-mono text-[#737373]">{trade.exit_price.toFixed(5)}</td>
<td className={`px-6 py-4 text-[14px] font-semibold font-mono ${isWin ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>
{formatCurrency(trade.pnl)}
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 border border-[#262626] text-[12px] font-mono text-[#737373]">
Closed
</span>
</td>
</motion.tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-[#262626]">
<p className="text-[13px] text-[#525252] font-mono">
Showing {(safeCurrentPage - 1) * ROWS_PER_PAGE + 1}{Math.min(safeCurrentPage * ROWS_PER_PAGE, filtered.length)} of {filtered.length} trades
</p>
<div className="flex gap-1">
<button
className="px-3 py-1.5 text-[13px] font-mono border border-[#262626] text-[#737373] hover:text-[#fafafa] hover:border-[#404040] transition-colors disabled:opacity-30 disabled:pointer-events-none"
disabled={safeCurrentPage <= 1}
onClick={() => setPage(safeCurrentPage - 1)}
>
Previous
</button>
{getPageNumbers().map((p) => (
<button
key={p}
className={`px-3 py-1.5 text-[13px] font-mono border transition-colors ${
p === safeCurrentPage
? 'bg-[#fafafa] text-black border-[#fafafa]'
: 'border-[#262626] text-[#737373] hover:text-[#fafafa] hover:border-[#404040]'
}`}
onClick={() => setPage(p)}
>
{p}
</button>
))}
<button
className="px-3 py-1.5 text-[13px] font-mono border border-[#262626] text-[#737373] hover:text-[#fafafa] hover:border-[#404040] transition-colors disabled:opacity-30 disabled:pointer-events-none"
disabled={safeCurrentPage >= totalPages}
onClick={() => setPage(safeCurrentPage + 1)}
>
Next
</button>
</div>
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
@import "tailwindcss";
:root {
--background: #000000;
--foreground: #fafafa;
--card: #0a0a0a;
--card-foreground: #fafafa;
--muted: #737373;
--border: #262626;
--border-hover: #404040;
--success: #10b981;
--loss: #ef4444;
--accent: #fafafa;
--subtle: #1a1a1a;
--input-bg: #0a0a0a;
}
@layer base {
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)