[GAP-3] feat: Wire RRBacktester into prediction service and API
- Added RRBacktester import and initialization in PredictionService - Added BacktestSummary dataclass for typed responses - Added run_backtest() method with signal generation - Added _generate_backtest_signals() helper for historical signals - Added backtester_available property - Updated POST /api/backtest endpoint to use real backtester - Added GET /api/backtest/status endpoint for availability check - Added losing_trades, avg_trade_duration, metrics_by_rr, metrics_by_amd to response The backtester now uses: - RRBacktester from src/backtesting/rr_backtester.py - MetricsCalculator from src/backtesting/metrics.py - AMD phase filtering and R:R configuration support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a22fb11968
commit
a1e606c21a
112
src/api/main.py
112
src/api/main.py
@ -25,7 +25,8 @@ from ..services.prediction_service import (
|
||||
Direction,
|
||||
AMDPhase as ServiceAMDPhase,
|
||||
VolatilityRegime as ServiceVolatilityRegime,
|
||||
HierarchicalResult
|
||||
HierarchicalResult,
|
||||
BacktestSummary
|
||||
)
|
||||
|
||||
# API Models
|
||||
@ -833,6 +834,7 @@ class BacktestResponse(BaseModel):
|
||||
"""Backtest results response"""
|
||||
total_trades: int
|
||||
winning_trades: int
|
||||
losing_trades: int
|
||||
winrate: float
|
||||
net_profit: float
|
||||
profit_factor: float
|
||||
@ -843,41 +845,105 @@ class BacktestResponse(BaseModel):
|
||||
signals_generated: int
|
||||
signals_filtered: int
|
||||
signals_traded: int
|
||||
avg_trade_duration: float = 0.0
|
||||
metrics_by_rr: Optional[Dict[str, Any]] = None
|
||||
metrics_by_amd: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@app.post("/api/backtest", response_model=BacktestResponse, tags=["Backtesting"])
|
||||
async def run_backtest(request: BacktestRequest):
|
||||
"""
|
||||
Run backtest on historical data
|
||||
Run backtest on historical data.
|
||||
|
||||
Simulates trading based on ML predictions and returns performance metrics.
|
||||
Uses RRBacktester with TP/SL simulation and AMD phase filtering.
|
||||
|
||||
Args:
|
||||
request: Backtest configuration
|
||||
request: Backtest configuration including:
|
||||
- symbol: Trading symbol
|
||||
- start_date/end_date: Date range for backtest
|
||||
- initial_capital: Starting capital
|
||||
- risk_per_trade: Risk per trade as fraction
|
||||
- rr_config: Risk/Reward config (rr_2_1 or rr_3_1)
|
||||
- min_confidence: Minimum probability to trade
|
||||
- filter_by_amd: Whether to filter by AMD phase
|
||||
"""
|
||||
if not models_state.get("backtester"):
|
||||
global prediction_service
|
||||
|
||||
if prediction_service is None:
|
||||
prediction_service = get_prediction_service()
|
||||
|
||||
if not prediction_service.backtester_available:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Backtester not loaded"
|
||||
detail="Backtester not available. Service may still be initializing."
|
||||
)
|
||||
|
||||
# TODO: Implement actual backtesting
|
||||
# backtester = models_state["backtester"]
|
||||
# result = backtester.run_backtest(price_data, signals)
|
||||
try:
|
||||
result = await prediction_service.run_backtest(
|
||||
symbol=request.symbol.upper(),
|
||||
start_date=request.start_date,
|
||||
end_date=request.end_date,
|
||||
initial_capital=request.initial_capital,
|
||||
risk_per_trade=request.risk_per_trade,
|
||||
rr_config=request.rr_config,
|
||||
min_confidence=request.min_confidence,
|
||||
filter_by_amd=request.filter_by_amd
|
||||
)
|
||||
|
||||
# Mock response
|
||||
return BacktestResponse(
|
||||
total_trades=150,
|
||||
winning_trades=82,
|
||||
winrate=0.547,
|
||||
net_profit=3250.75,
|
||||
profit_factor=1.85,
|
||||
max_drawdown=1250.50,
|
||||
max_drawdown_pct=0.125,
|
||||
sharpe_ratio=1.42,
|
||||
sortino_ratio=2.15,
|
||||
signals_generated=450,
|
||||
signals_filtered=200,
|
||||
signals_traded=150
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Backtest failed. Check logs for details."
|
||||
)
|
||||
|
||||
return BacktestResponse(
|
||||
total_trades=result.total_trades,
|
||||
winning_trades=result.winning_trades,
|
||||
losing_trades=result.losing_trades,
|
||||
winrate=result.winrate,
|
||||
net_profit=result.net_profit,
|
||||
profit_factor=result.profit_factor,
|
||||
max_drawdown=result.max_drawdown,
|
||||
max_drawdown_pct=result.max_drawdown_pct,
|
||||
sharpe_ratio=result.sharpe_ratio,
|
||||
sortino_ratio=result.sortino_ratio,
|
||||
signals_generated=result.signals_generated,
|
||||
signals_filtered=result.signals_filtered,
|
||||
signals_traded=result.signals_traded,
|
||||
avg_trade_duration=result.avg_trade_duration,
|
||||
metrics_by_rr=result.metrics_by_rr,
|
||||
metrics_by_amd=result.metrics_by_amd
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Backtest failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Backtest failed: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/backtest/status", tags=["Backtesting"])
|
||||
async def get_backtest_status():
|
||||
"""
|
||||
Get backtester availability status.
|
||||
"""
|
||||
global prediction_service
|
||||
|
||||
if prediction_service is None:
|
||||
prediction_service = get_prediction_service()
|
||||
|
||||
return {
|
||||
"available": prediction_service.backtester_available,
|
||||
"supported_symbols": ["XAUUSD", "EURUSD", "BTCUSD", "GBPUSD", "USDJPY"],
|
||||
"rr_configs": ["rr_2_1", "rr_3_1"],
|
||||
"default_config": {
|
||||
"initial_capital": 10000.0,
|
||||
"risk_per_trade": 0.02,
|
||||
"min_confidence": 0.55,
|
||||
"filter_by_amd": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Training endpoint
|
||||
|
||||
@ -41,6 +41,10 @@ from ..pipelines.hierarchical_pipeline import (
|
||||
PredictionResult as HierarchicalPredictionResult
|
||||
)
|
||||
|
||||
# Backtesting engine
|
||||
from ..backtesting.rr_backtester import RRBacktester, BacktestConfig, BacktestResult
|
||||
from ..backtesting.metrics import TradingMetrics, TradeRecord
|
||||
|
||||
|
||||
class Direction(Enum):
|
||||
LONG = "long"
|
||||
@ -118,6 +122,27 @@ class HierarchicalResult:
|
||||
trade_quality: str # 'high', 'medium', 'low', 'skip'
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestSummary:
|
||||
"""Summary of backtest results"""
|
||||
total_trades: int
|
||||
winning_trades: int
|
||||
losing_trades: int
|
||||
winrate: float
|
||||
net_profit: float
|
||||
profit_factor: float
|
||||
max_drawdown: float
|
||||
max_drawdown_pct: float
|
||||
sharpe_ratio: float
|
||||
sortino_ratio: float
|
||||
signals_generated: int
|
||||
signals_filtered: int
|
||||
signals_traded: int
|
||||
avg_trade_duration: float
|
||||
metrics_by_rr: Dict[str, Dict] = None
|
||||
metrics_by_amd: Dict[str, Dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradingSignal:
|
||||
"""Complete trading signal"""
|
||||
@ -187,6 +212,7 @@ class PredictionService:
|
||||
self._amd_detector = None
|
||||
self._attention_provider = None # Level 0 attention models
|
||||
self._hierarchical_pipeline = None # L0→L1→L2 pipeline
|
||||
self._backtester = None # Backtesting engine
|
||||
self._models_loaded = False
|
||||
|
||||
# Symbol-specific trainers (nuevos modelos por símbolo/timeframe)
|
||||
@ -258,6 +284,14 @@ class PredictionService:
|
||||
logger.warning(f"HierarchicalPipeline initialization failed: {e}")
|
||||
self._hierarchical_pipeline = None
|
||||
|
||||
# Initialize Backtesting Engine
|
||||
try:
|
||||
self._backtester = RRBacktester(BacktestConfig())
|
||||
logger.info("✅ RRBacktester initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"RRBacktester initialization failed: {e}")
|
||||
self._backtester = None
|
||||
|
||||
self._models_loaded = True
|
||||
|
||||
# Cargar modelos por símbolo si el feature flag está activo
|
||||
@ -715,6 +749,173 @@ class PredictionService:
|
||||
return None
|
||||
return self._hierarchical_pipeline.get_model_info(symbol)
|
||||
|
||||
@property
|
||||
def backtester_available(self) -> bool:
|
||||
"""Check if backtester is available."""
|
||||
return self._backtester is not None
|
||||
|
||||
async def run_backtest(
|
||||
self,
|
||||
symbol: str,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
initial_capital: float = 10000.0,
|
||||
risk_per_trade: float = 0.02,
|
||||
rr_config: str = "rr_2_1",
|
||||
min_confidence: float = 0.55,
|
||||
filter_by_amd: bool = True
|
||||
) -> Optional[BacktestSummary]:
|
||||
"""
|
||||
Run backtest on historical data.
|
||||
|
||||
Args:
|
||||
symbol: Trading symbol
|
||||
start_date: Start date for backtest
|
||||
end_date: End date for backtest
|
||||
initial_capital: Starting capital
|
||||
risk_per_trade: Risk per trade as fraction (0.02 = 2%)
|
||||
rr_config: Risk/Reward configuration (rr_2_1 or rr_3_1)
|
||||
min_confidence: Minimum confidence to trade
|
||||
filter_by_amd: Filter by AMD phase
|
||||
|
||||
Returns:
|
||||
BacktestSummary with results, or None if failed
|
||||
"""
|
||||
if not self._backtester:
|
||||
logger.warning("Backtester not available")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Configure backtester
|
||||
config = BacktestConfig(
|
||||
initial_capital=initial_capital,
|
||||
risk_per_trade=risk_per_trade,
|
||||
min_confidence=min_confidence,
|
||||
filter_by_amd=filter_by_amd
|
||||
)
|
||||
self._backtester.config = config
|
||||
|
||||
# Fetch historical data
|
||||
days_diff = (end_date - start_date).days
|
||||
periods = days_diff * 288 # Approximate 5-minute bars per day
|
||||
|
||||
df = await self.get_market_data(symbol, "5m", lookback_periods=min(periods, 10000))
|
||||
|
||||
if df.empty:
|
||||
logger.warning(f"No data available for backtest: {symbol}")
|
||||
return None
|
||||
|
||||
# Filter by date range
|
||||
df = df[(df.index >= start_date) & (df.index <= end_date)]
|
||||
|
||||
if len(df) < 100:
|
||||
logger.warning(f"Insufficient data for backtest: {len(df)} bars")
|
||||
return None
|
||||
|
||||
# Generate signals for the period
|
||||
signals = await self._generate_backtest_signals(df, symbol, rr_config)
|
||||
|
||||
# Run backtest
|
||||
result = self._backtester.run_backtest(
|
||||
price_data=df,
|
||||
signals=signals,
|
||||
rr_config={'name': rr_config, 'sl': 5.0, 'tp': 10.0 if rr_config == 'rr_2_1' else 15.0}
|
||||
)
|
||||
|
||||
# Convert to summary
|
||||
return BacktestSummary(
|
||||
total_trades=result.metrics.total_trades,
|
||||
winning_trades=result.metrics.winning_trades,
|
||||
losing_trades=result.metrics.losing_trades,
|
||||
winrate=result.metrics.winrate,
|
||||
net_profit=result.metrics.net_profit,
|
||||
profit_factor=result.metrics.profit_factor,
|
||||
max_drawdown=result.metrics.max_drawdown,
|
||||
max_drawdown_pct=result.metrics.max_drawdown_pct,
|
||||
sharpe_ratio=result.metrics.sharpe_ratio,
|
||||
sortino_ratio=result.metrics.sortino_ratio,
|
||||
signals_generated=result.signals_generated,
|
||||
signals_filtered=result.signals_filtered,
|
||||
signals_traded=result.signals_traded,
|
||||
avg_trade_duration=result.metrics.avg_trade_duration,
|
||||
metrics_by_rr={k: v.to_dict() for k, v in result.metrics_by_rr.items()},
|
||||
metrics_by_amd={k: v.to_dict() for k, v in result.metrics_by_amd.items()}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backtest failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
async def _generate_backtest_signals(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
symbol: str,
|
||||
rr_config: str
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Generate signals for backtesting from price data.
|
||||
|
||||
Uses the prediction models to generate prob_tp_first and direction.
|
||||
"""
|
||||
signals = pd.DataFrame(index=df.index)
|
||||
|
||||
# Generate signals at regular intervals (every 12 bars = 1 hour for 5m data)
|
||||
signal_interval = 12
|
||||
n = len(df)
|
||||
|
||||
signals['prob_tp_first'] = np.nan
|
||||
signals['direction'] = 'long'
|
||||
signals['horizon'] = '15m'
|
||||
signals['rr_config'] = rr_config
|
||||
signals['amd_phase'] = None
|
||||
signals['volatility_regime'] = 'medium'
|
||||
signals['confidence'] = np.nan
|
||||
|
||||
for i in range(100, n, signal_interval):
|
||||
try:
|
||||
# Get slice of data for prediction
|
||||
df_slice = df.iloc[:i+1]
|
||||
|
||||
# Generate features
|
||||
features = self.feature_engineer.create_features(df_slice)
|
||||
|
||||
# Get AMD phase
|
||||
amd_detection = self._heuristic_amd_detection(df_slice)
|
||||
|
||||
# Generate probability (use TPSL classifier if available)
|
||||
if self._tpsl_classifier:
|
||||
pred = self._tpsl_classifier.predict(features, rr_config)
|
||||
prob = pred.get("prob_tp_first", 0.5)
|
||||
confidence = pred.get("confidence", 0.5)
|
||||
else:
|
||||
# Heuristic probability based on momentum
|
||||
returns_5 = df_slice['close'].pct_change(5).iloc[-1]
|
||||
prob = 0.5 + (returns_5 * 5) # Simple momentum-based
|
||||
prob = max(0.35, min(0.65, prob))
|
||||
confidence = 0.5
|
||||
|
||||
# Determine direction based on AMD phase
|
||||
if amd_detection.phase == AMDPhase.ACCUMULATION:
|
||||
direction = 'long'
|
||||
elif amd_detection.phase == AMDPhase.DISTRIBUTION:
|
||||
direction = 'short'
|
||||
else:
|
||||
# Use momentum
|
||||
direction = 'long' if returns_5 > 0 else 'short'
|
||||
|
||||
# Set signal values
|
||||
idx = df.index[i]
|
||||
signals.loc[idx, 'prob_tp_first'] = prob
|
||||
signals.loc[idx, 'direction'] = direction
|
||||
signals.loc[idx, 'amd_phase'] = amd_detection.phase.value
|
||||
signals.loc[idx, 'confidence'] = confidence
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Signal generation failed at bar {i}: {e}")
|
||||
continue
|
||||
|
||||
return signals
|
||||
|
||||
async def generate_signal(
|
||||
self,
|
||||
symbol: str,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user