reorganized data files and enhance backtesting structure, monte carlo sim
This commit is contained in:
+974
-61
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
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in New Issue
Block a user