quant-ai / backend /app /simulator.py
Ypeng12's picture
fix: watchlist persistence, live trade PnL calculation, timezone alignment, and execution log rendering
8047c26
Raw
History Blame Contribute Delete
12.2 kB
# backend/app/simulator.py
import pandas as pd
import numpy as np
import datetime
from app.config import INITIAL_CASH, FORCE_LIQUIDATION_TIME, SOFT_DRAWDOWN_LIMIT, HARD_DRAWDOWN_LIMIT, MAX_CONSECUTIVE_LOSSES, FORCE_LIQUIDATION_OPEN_FOCUS
from app.trading_engine import Portfolio
from app.strategy import evaluate_market_state
def run_backtest_sim(df, ticker, strategy_params, risk_params, is_intraday=True):
"""
Generic backtest execution engine supporting intraday (is_intraday=True) and daily (is_intraday=False) backtesting.
Outputs: regime_breakdown, drawdown_curve, Sharpe, Calmar, CAGR, Profit Factor
"""
df = df.copy()
if is_intraday:
# Precompute ORB (Opening Range Breakout) High/Low for each day
# Range is defined between 9:30 and 9:35 (first 5 minutes of regular trading hours)
df['Time'] = df.index.time
df['Date'] = df.index.date
import datetime as dt_mod
open_mask = (df['Time'] >= dt_mod.time(9, 30)) & (df['Time'] <= dt_mod.time(9, 35))
opening_df = df[open_mask]
if not opening_df.empty:
orb_high = opening_df.groupby('Date')['High'].max().to_dict()
orb_low = opening_df.groupby('Date')['Low'].min().to_dict()
df['ORB_High'] = df['Date'].map(orb_high).fillna(0.0)
df['ORB_Low'] = df['Date'].map(orb_low).fillna(0.0)
else:
df['ORB_High'] = 0.0
df['ORB_Low'] = 0.0
portfolio = Portfolio(
initial_cash=INITIAL_CASH,
slippage_rate=risk_params.get("slippage_rate", 0.0003),
commission_per_share=risk_params.get("commission_per_share", 0.005),
min_commission_per_order=risk_params.get("min_commission_per_order", 1.0)
)
# Extract risk control limits
soft_dd = risk_params.get("soft_dd", SOFT_DRAWDOWN_LIMIT)
hard_dd = risk_params.get("hard_dd", HARD_DRAWDOWN_LIMIT)
max_consecutive_losses = risk_params.get("max_consecutive_losses", MAX_CONSECUTIVE_LOSSES)
# Extract market open focus parameter
market_open_focus = strategy_params.get("market_open_focus", True)
equity_curve = []
# Record regime for each trade
trade_regime_map = {} # buy_timestamp -> regime
# Start loop from index 1 for prev_row access
for i in range(1, len(df)):
row = df.iloc[i]
prev_row = df.iloc[i-1]
timestamp = df.index[i]
time_str = timestamp.strftime("%H:%M")
close_price = float(row['Close'])
current_prices = {ticker: close_price}
shares = portfolio.get_position_shares(ticker)
avg_cost = portfolio.get_position_avg_cost(ticker)
# 1. Update account equity & peak drawdown
equity = portfolio.get_equity(current_prices)
portfolio.peak_equity = max(portfolio.peak_equity, equity)
drawdown_pct = (portfolio.peak_equity - equity) / portfolio.peak_equity if portfolio.peak_equity > 0 else 0.0
# Count consecutive losses
sell_trades = [t for t in portfolio.ledger if t['action'] == 'SELL']
consecutive_losses = 0
for t in reversed(sell_trades):
if t.get('realized_pnl', 0.0) < 0:
consecutive_losses += 1
else:
break
portfolio.consecutive_losses = consecutive_losses
# 2. Risk circuit breaker
if drawdown_pct >= hard_dd:
portfolio.risk_multiplier = 0.0
elif drawdown_pct >= soft_dd or consecutive_losses >= max_consecutive_losses:
portfolio.risk_multiplier = 0.5
else:
portfolio.risk_multiplier = 1.0
# 3. Update trailing stop peak price
if shares > 0:
portfolio.update_highest_price(ticker, close_price)
highest_price = portfolio.get_position_highest_price(ticker)
# Record equity curve
equity_curve.append({
"time": int(timestamp.timestamp()),
"value": round(equity, 2)
})
# 4. Force EOD Liquidation (Intraday only)
liq_time = FORCE_LIQUIDATION_OPEN_FOCUS if market_open_focus else FORCE_LIQUIDATION_TIME
if is_intraday and time_str == liq_time and shares > 0:
portfolio.sell(timestamp, ticker, close_price, shares)
continue
# 5. Evaluate market state and strategy
is_trading_window = True
if is_intraday:
if market_open_focus:
import datetime as dt_mod
is_trading_window = dt_mod.time(9, 35) <= timestamp.time() <= dt_mod.time(10, 15)
else:
is_trading_window = datetime.time(9, 35) <= timestamp.time() < datetime.time(15, 54)
if shares > 0:
is_trading_window = True
if is_trading_window:
current_regime = row.get('Regime', 'range_bound')
action, explanation = evaluate_market_state(
row, prev_row, shares, avg_cost, ticker, highest_price, strategy_params
)
if action == "BUY" and shares == 0:
if risk_params.get("position_sizing_mode", "atr") == "atr":
shares_to_buy = portfolio.calculate_position_size(
ticker, close_price, row['ATR'],
risk_pct=risk_params.get("risk_per_trade_pct", 0.01),
atr_multiplier=strategy_params.get("trailing_stop_atr_mult", 2.0),
max_size_pct=risk_params.get("max_position_size_pct", 0.50)
)
else:
target_allocation = equity * risk_params.get("max_position_size_pct", 0.50)
shares_to_buy = int(target_allocation / close_price)
if shares_to_buy > 0:
portfolio.buy(timestamp, ticker, close_price, shares_to_buy)
trade_regime_map[str(timestamp)] = current_regime
elif action == "SELL" and shares > 0:
portfolio.sell(timestamp, ticker, close_price, shares)
# 6. 计算回测指标
total_trades = len(portfolio.ledger)
final_equity = portfolio.get_equity({ticker: df.iloc[-1]['Close']})
net_pnl = final_equity - INITIAL_CASH
pnl_pct = (net_pnl / INITIAL_CASH) * 100
sell_trades = [t for t in portfolio.ledger if t['action'] == 'SELL']
winning_trades = [t for t in sell_trades if t.get('realized_pnl', 0.0) > 0]
losing_trades = [t for t in sell_trades if t.get('realized_pnl', 0.0) < 0]
win_rate = (len(winning_trades) / len(sell_trades)) * 100 if sell_trades else 0.0
total_commission = sum(t['commission'] for t in portfolio.ledger)
# 统计最大回撤与 drawdown curve
eq_vals = [e["value"] for e in equity_curve]
max_drawdown = 0.0
drawdown_curve = []
if eq_vals:
peaks = np.maximum.accumulate(eq_vals)
drawdowns = (peaks - eq_vals) / peaks
max_drawdown = float(np.max(drawdowns))
for idx_ec, ec in enumerate(equity_curve):
drawdown_curve.append({
"time": ec["time"],
"value": round(float(drawdowns[idx_ec]) * -100, 2) # 负百分比
})
# 7. 高级指标计算
# Profit Factor = 总盈利 / 总亏损
gross_profit = sum(t.get('realized_pnl', 0.0) for t in winning_trades)
gross_loss = abs(sum(t.get('realized_pnl', 0.0) for t in losing_trades))
profit_factor = round(gross_profit / max(gross_loss, 1e-8), 2)
# 计算回测跨度的交易日数
if len(df) >= 2:
start_date = df.index[0]
end_date = df.index[-1]
total_days = (end_date - start_date).days
total_years = total_days / 365.25 if total_days > 0 else 1.0 / 365.25
else:
total_years = 1.0 / 365.25
# CAGR = (Final/Initial)^(1/years) - 1
if final_equity > 0 and INITIAL_CASH > 0 and total_years > 0:
cagr = (final_equity / INITIAL_CASH) ** (1.0 / max(total_years, 0.01)) - 1.0
else:
cagr = 0.0
# Sharpe Ratio (基于日/bar收益序列,假设 risk-free = 0)
if len(eq_vals) > 1:
returns = np.diff(eq_vals) / np.array(eq_vals[:-1])
if np.std(returns) > 0:
# 年化 Sharpe(按交易日数年化)
bars_per_year = len(eq_vals) / max(total_years, 0.01)
sharpe = (np.mean(returns) / np.std(returns)) * np.sqrt(bars_per_year)
else:
sharpe = 0.0
else:
sharpe = 0.0
# Calmar Ratio = CAGR / Max Drawdown
calmar = round(cagr / max(max_drawdown, 1e-8), 2) if max_drawdown > 0.001 else 0.0
# 8. Regime Breakdown — 统计不同市场状态下交易表现
regime_breakdown = _compute_regime_breakdown(portfolio.ledger, trade_regime_map)
# 9. Regime 分布统计 (每个 regime 的 bar 数量占比)
regime_distribution = {}
if 'Regime' in df.columns:
regime_counts = df['Regime'].value_counts()
total_bars = len(df)
for regime_name, count in regime_counts.items():
regime_distribution[regime_name] = round(count / total_bars * 100, 1)
return {
"final_equity": round(final_equity, 2),
"net_pnl": round(net_pnl, 2),
"pnl_pct": round(pnl_pct, 2),
"total_trades": total_trades,
"round_trips": len(sell_trades),
"win_rate": round(win_rate, 2),
"commission": round(total_commission, 2),
"max_drawdown": round(max_drawdown, 4),
# 新增高级指标
"sharpe": round(sharpe, 2),
"calmar": calmar,
"cagr": round(cagr * 100, 2), # 百分比
"profit_factor": profit_factor,
"gross_profit": round(gross_profit, 2),
"gross_loss": round(gross_loss, 2),
# 数据序列
"ledger": portfolio.ledger,
"equity_curve": equity_curve,
"drawdown_curve": drawdown_curve,
# Regime 统计
"regime_breakdown": regime_breakdown,
"regime_distribution": regime_distribution,
}
def _compute_regime_breakdown(ledger, trade_regime_map):
"""
按 regime 汇总交易表现:pnl, win_rate, trade_count
"""
regime_stats = {}
# 将 BUY/SELL 配对,并查找每对交易的 regime
buy_trades = [t for t in ledger if t['action'] == 'BUY']
sell_trades = [t for t in ledger if t['action'] == 'SELL']
for i, sell in enumerate(sell_trades):
# 每个 sell 与对应的 buy 配对
if i < len(buy_trades):
buy_ts = buy_trades[i]['timestamp']
regime = trade_regime_map.get(buy_ts, 'unknown')
else:
regime = 'unknown'
pnl = sell.get('realized_pnl', 0.0)
if regime not in regime_stats:
regime_stats[regime] = {
'total_pnl': 0.0,
'trade_count': 0,
'wins': 0,
'losses': 0,
'total_commission': 0.0
}
regime_stats[regime]['total_pnl'] += pnl
regime_stats[regime]['trade_count'] += 1
regime_stats[regime]['total_commission'] += sell.get('commission', 0.0)
if pnl > 0:
regime_stats[regime]['wins'] += 1
else:
regime_stats[regime]['losses'] += 1
# 转为前端友好格式
result = []
for regime, stats in regime_stats.items():
win_rate = (stats['wins'] / stats['trade_count'] * 100) if stats['trade_count'] > 0 else 0.0
result.append({
"regime": regime,
"total_pnl": round(stats['total_pnl'], 2),
"trade_count": stats['trade_count'],
"win_rate": round(win_rate, 1),
"wins": stats['wins'],
"losses": stats['losses'],
"commission": round(stats['total_commission'], 2)
})
# 按 trade_count 降序
result.sort(key=lambda x: x['trade_count'], reverse=True)
return result