1741 lines
76 KiB
Python
1741 lines
76 KiB
Python
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from src.repositories.backtest_repository import BacktestRepository
|
|
from src.services.backtest.replay import ReplaySwing, iter_historical_replay
|
|
from src.services.ict_mainline import (
|
|
build_doctrine_backtest_cohort,
|
|
build_liquidity_backtest_features,
|
|
build_pd_array_backtest_features,
|
|
build_time_session_backtest_features,
|
|
evaluate_liquidity_kernel_status,
|
|
evaluate_pd_array_reaction_status,
|
|
evaluate_time_session_kernel_status,
|
|
extract_setup_playbook_id,
|
|
)
|
|
|
|
|
|
SAME_CANDLE_PRIORITY = "stop_first"
|
|
BACKTEST_SUBJECT_SIGNAL = "signal"
|
|
BACKTEST_SUBJECT_PLAN_REPLAY = "plan_replay"
|
|
BACKTEST_MODE_ENTRY_THEN_OUTCOME = "entry_then_outcome"
|
|
BACKTEST_MODE_REPLAY_NO_LOOKAHEAD = "replay_no_lookahead"
|
|
SUPPORTED_BACKTEST_SUBJECT_TYPES = {
|
|
BACKTEST_SUBJECT_SIGNAL,
|
|
BACKTEST_SUBJECT_PLAN_REPLAY,
|
|
}
|
|
SUPPORTED_BACKTEST_MODES = {
|
|
BACKTEST_MODE_ENTRY_THEN_OUTCOME,
|
|
BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
|
|
}
|
|
|
|
BACKTEST_LIFECYCLE_OPEN = "open"
|
|
BACKTEST_LIFECYCLE_REJECTED = "rejected"
|
|
BACKTEST_LIFECYCLE_AWAITING_CONFIRMATION = "awaiting_confirmation"
|
|
BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY = "invalidated_before_entry"
|
|
BACKTEST_LIFECYCLE_MISSED = "missed"
|
|
BACKTEST_LIFECYCLE_STOPPED_OUT = "stopped_out"
|
|
BACKTEST_LIFECYCLE_TP1_REACHED = "tp1_reached"
|
|
BACKTEST_LIFECYCLE_TP2_CLOSED = "tp2_closed"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestOutcome:
|
|
signal_id: Optional[int]
|
|
outcome: str
|
|
mfe: float
|
|
mae: float
|
|
tp1_hit: bool
|
|
tp2_hit: bool
|
|
invalidated_before_entry: bool
|
|
error_tags: list[str]
|
|
meta: dict
|
|
trading_plan_id: Optional[int] = None
|
|
candidate_id: Optional[int] = None
|
|
candidate_model_code: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestSignal:
|
|
signal_id: Optional[int]
|
|
side: str
|
|
entry_low: float
|
|
entry_high: float
|
|
stop_loss: float
|
|
tp1: float
|
|
tp2: float
|
|
rr_tp1: float = 0.0
|
|
rr_tp2: float = 0.0
|
|
target_plan: Optional[dict] = None
|
|
management_rules: Optional[list[dict]] = None
|
|
trading_plan_id: Optional[int] = None
|
|
candidate_id: Optional[int] = None
|
|
candidate_model_code: Optional[str] = None
|
|
candidate_appeared_ts: Optional[object] = None
|
|
candidate_confirmed_ts: Optional[object] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestCostConfig:
|
|
fee_rate: float = 0.0
|
|
slippage_rate: float = 0.0
|
|
fixed_cost_r: float = 0.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestReplayConfig:
|
|
left_bars: int = 2
|
|
right_bars: int = 2
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestQualityGate:
|
|
min_total_signals: int = 100
|
|
min_executed_signals: int = 30
|
|
min_expectancy_r: float = 0.0
|
|
min_profit_factor_r: float = 1.2
|
|
max_drawdown_r: float = 10.0
|
|
max_consecutive_losses: int = 5
|
|
|
|
|
|
class BacktestService:
|
|
def __init__(self, repository: Optional[BacktestRepository] = None) -> None:
|
|
self.repository = repository or BacktestRepository()
|
|
|
|
def run_backtest(
|
|
self,
|
|
instrument_id: int,
|
|
limit: int = 100,
|
|
cost_config: Optional[BacktestCostConfig] = None,
|
|
quality_gate: Optional[BacktestQualityGate] = None,
|
|
subject_type: str = BACKTEST_SUBJECT_SIGNAL,
|
|
trading_plan_id: Optional[int] = None,
|
|
candidate_model_code: Optional[str] = None,
|
|
mode: str = BACKTEST_MODE_ENTRY_THEN_OUTCOME,
|
|
replay_config: Optional[BacktestReplayConfig] = None,
|
|
timeframe_set: Optional[dict] = None,
|
|
session_codes: Optional[list[str]] = None,
|
|
run_metadata: Optional[dict] = None,
|
|
) -> tuple[int, list[BacktestOutcome]]:
|
|
cost_config = cost_config or BacktestCostConfig()
|
|
quality_gate = quality_gate or BacktestQualityGate()
|
|
replay_config = replay_config or BacktestReplayConfig()
|
|
timeframe_set = normalize_timeframe_set(timeframe_set)
|
|
if subject_type not in SUPPORTED_BACKTEST_SUBJECT_TYPES:
|
|
raise ValueError(f"invalid_backtest_subject_type:{subject_type}")
|
|
if mode not in SUPPORTED_BACKTEST_MODES:
|
|
raise ValueError(f"invalid_backtest_mode:{mode}")
|
|
active_trading_plan = None
|
|
if subject_type == BACKTEST_SUBJECT_PLAN_REPLAY and trading_plan_id is None:
|
|
active_trading_plan = self.repository.fetch_active_trading_plan(instrument_id=instrument_id)
|
|
trading_plan_id = active_trading_plan.get('id') if active_trading_plan else None
|
|
run_metadata = dict(run_metadata or {})
|
|
setup_playbook_id = extract_setup_playbook_id(run_metadata)
|
|
if not setup_playbook_id and active_trading_plan:
|
|
setup_playbook_id = extract_setup_playbook_id(active_trading_plan)
|
|
if setup_playbook_id:
|
|
run_metadata['setup_playbook_id'] = setup_playbook_id
|
|
liquidity_backtest_features = build_liquidity_backtest_features(
|
|
{
|
|
'subject_type': subject_type,
|
|
'model_code': 'SWEEP_MSS_FVG',
|
|
'candidate_model_code': candidate_model_code,
|
|
'timeframe_set': timeframe_set,
|
|
'run_metadata': run_metadata,
|
|
'active_trading_plan': active_trading_plan,
|
|
}
|
|
)
|
|
pd_array_backtest_features = build_pd_array_backtest_features(
|
|
{
|
|
'subject_type': subject_type,
|
|
'model_code': 'SWEEP_MSS_FVG',
|
|
'candidate_model_code': candidate_model_code,
|
|
'timeframe_set': timeframe_set,
|
|
'run_metadata': run_metadata,
|
|
'active_trading_plan': active_trading_plan,
|
|
}
|
|
)
|
|
time_session_backtest_features = build_time_session_backtest_features(
|
|
{
|
|
'subject_type': subject_type,
|
|
'model_code': 'SWEEP_MSS_FVG',
|
|
'candidate_model_code': candidate_model_code,
|
|
'timeframe_set': timeframe_set,
|
|
'session_codes': list(session_codes or []),
|
|
'run_metadata': run_metadata,
|
|
'active_trading_plan': active_trading_plan,
|
|
}
|
|
)
|
|
doctrine_backtest_cohort = build_doctrine_backtest_cohort(
|
|
{
|
|
'subject_type': subject_type,
|
|
'model_code': 'SWEEP_MSS_FVG',
|
|
'candidate_model_code': candidate_model_code,
|
|
'timeframe_set': timeframe_set,
|
|
'run_metadata': run_metadata,
|
|
'liquidity_backtest_features': liquidity_backtest_features,
|
|
'pd_array_backtest_features': pd_array_backtest_features,
|
|
'time_session_backtest_features': time_session_backtest_features,
|
|
}
|
|
)
|
|
run_id = self.repository.insert_backtest_run(
|
|
model_code='SWEEP_MSS_FVG',
|
|
instrument_id=instrument_id,
|
|
timeframe_set=timeframe_set,
|
|
subject_type=subject_type,
|
|
trading_plan_id=trading_plan_id,
|
|
candidate_model_code=candidate_model_code,
|
|
config={
|
|
'limit': limit,
|
|
'subject_type': subject_type,
|
|
'mode': mode,
|
|
'trading_plan_id': trading_plan_id,
|
|
'candidate_model_code': candidate_model_code,
|
|
'same_candle_priority': 'stop_first',
|
|
'session_filter': 'mark_outside',
|
|
'cost_model': cost_config.__dict__,
|
|
'quality_gate': quality_gate.__dict__,
|
|
'replay': replay_config.__dict__ if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD else None,
|
|
'session_codes': list(session_codes or []),
|
|
'setup_playbook_id': setup_playbook_id,
|
|
'liquidity_backtest_features': liquidity_backtest_features,
|
|
'liquidity_kernel_status': evaluate_liquidity_kernel_status(liquidity_backtest_features),
|
|
'pd_array_backtest_features': pd_array_backtest_features,
|
|
'pd_array_reaction_status': evaluate_pd_array_reaction_status(pd_array_backtest_features),
|
|
'time_session_backtest_features': time_session_backtest_features,
|
|
'time_session_kernel_status': evaluate_time_session_kernel_status(time_session_backtest_features),
|
|
'doctrine_backtest_cohort': doctrine_backtest_cohort,
|
|
'run_metadata': run_metadata,
|
|
},
|
|
)
|
|
if subject_type == BACKTEST_SUBJECT_PLAN_REPLAY:
|
|
outcomes = self._run_plan_replay(
|
|
run_id=run_id,
|
|
instrument_id=instrument_id,
|
|
limit=limit,
|
|
trading_plan_id=trading_plan_id,
|
|
candidate_model_code=candidate_model_code,
|
|
cost_config=cost_config,
|
|
mode=mode,
|
|
replay_config=replay_config,
|
|
timeframe_set=timeframe_set,
|
|
)
|
|
summary = summarize_backtest_outcomes(outcomes, subject_type=subject_type)
|
|
summary['quality_gate'] = evaluate_backtest_quality(summary, gate=quality_gate, sample_basis='executed')
|
|
outcome_setup_playbook_id = setup_playbook_id or _first_outcome_meta_value(outcomes, 'setup_playbook_id')
|
|
outcome_liquidity_features = _first_outcome_meta_value(outcomes, 'liquidity_backtest_features') or liquidity_backtest_features
|
|
outcome_pd_array_features = _first_outcome_meta_value(outcomes, 'pd_array_backtest_features') or pd_array_backtest_features
|
|
outcome_time_session_features = _first_outcome_meta_value(outcomes, 'time_session_backtest_features') or time_session_backtest_features
|
|
summary['liquidity_backtest_features'] = dict(outcome_liquidity_features or {})
|
|
summary['liquidity_kernel_status'] = evaluate_liquidity_kernel_status(summary['liquidity_backtest_features'])
|
|
summary['pd_array_backtest_features'] = dict(outcome_pd_array_features or {})
|
|
summary['pd_array_reaction_status'] = evaluate_pd_array_reaction_status(summary['pd_array_backtest_features'])
|
|
summary['time_session_backtest_features'] = dict(outcome_time_session_features or {})
|
|
summary['time_session_kernel_status'] = evaluate_time_session_kernel_status(summary['time_session_backtest_features'])
|
|
summary['doctrine_backtest_cohort'] = build_doctrine_backtest_cohort(
|
|
{
|
|
'subject_type': subject_type,
|
|
'model_code': 'SWEEP_MSS_FVG',
|
|
'candidate_model_code': candidate_model_code,
|
|
'timeframe_set': timeframe_set,
|
|
'run_metadata': {**run_metadata, 'setup_playbook_id': outcome_setup_playbook_id},
|
|
'summary': summary,
|
|
'liquidity_backtest_features': summary['liquidity_backtest_features'],
|
|
'pd_array_backtest_features': summary['pd_array_backtest_features'],
|
|
'time_session_backtest_features': summary['time_session_backtest_features'],
|
|
},
|
|
quality_gate=summary['quality_gate'],
|
|
)
|
|
self.repository.update_backtest_run_summary(run_id, summary)
|
|
return run_id, outcomes
|
|
|
|
signals = self.repository.fetch_candidate_signals(
|
|
instrument_id=instrument_id,
|
|
limit=limit,
|
|
timeframe_set=timeframe_set,
|
|
session_codes=session_codes,
|
|
)
|
|
outcomes: list[BacktestOutcome] = []
|
|
for signal in signals:
|
|
sessions = self.repository.fetch_sessions_covering_window(
|
|
instrument_id=instrument_id,
|
|
start_ts=signal['created_ts'],
|
|
end_ts=signal['created_ts'],
|
|
)
|
|
future = self.repository.fetch_future_candles(
|
|
instrument_id=instrument_id,
|
|
timeframe=str(signal.get('trigger_tf') or timeframe_set['trigger_tf']),
|
|
start_ts=signal['created_ts'],
|
|
end_ts=signal['expires_ts'],
|
|
limit=500,
|
|
)
|
|
if not future:
|
|
target_plan = extract_target_plan_from_signal_row(signal)
|
|
management_rules = extract_management_rules_from_signal_row(signal)
|
|
session_codes = resolve_signal_session_codes(signal=signal, sessions=sessions)
|
|
signal_liquidity_features = build_liquidity_backtest_features(
|
|
{
|
|
'signal': signal,
|
|
'target_plan': target_plan,
|
|
'run_metadata': run_metadata,
|
|
}
|
|
)
|
|
signal_pd_array_features = build_pd_array_backtest_features(
|
|
{
|
|
'signal': signal,
|
|
'target_plan': target_plan,
|
|
'run_metadata': run_metadata,
|
|
}
|
|
)
|
|
signal_time_session_features = build_time_session_backtest_features(
|
|
{
|
|
'signal': signal,
|
|
'target_plan': target_plan,
|
|
'run_metadata': run_metadata,
|
|
'session_codes': session_codes,
|
|
'session_filter': 'outside_window' if len(sessions) == 0 else 'covered',
|
|
}
|
|
)
|
|
error_tags = ['no_future_candles']
|
|
if len(sessions) == 0:
|
|
error_tags.append('outside_session_window')
|
|
record = BacktestOutcome(
|
|
signal_id=signal['id'],
|
|
outcome='missed',
|
|
mfe=0.0,
|
|
mae=0.0,
|
|
tp1_hit=False,
|
|
tp2_hit=False,
|
|
invalidated_before_entry=True,
|
|
error_tags=_dedupe_text_list(error_tags),
|
|
meta={
|
|
'entry_triggered': False,
|
|
'same_candle_priority': SAME_CANDLE_PRIORITY,
|
|
'same_candle_ambiguous': False,
|
|
'sessions_found': len(sessions),
|
|
'session_codes': session_codes,
|
|
'session_filter': 'outside_window' if len(sessions) == 0 else 'covered',
|
|
'signal_side': signal['side'],
|
|
'signal_symbol': signal.get('symbol'),
|
|
'signal_timeframe_set': {
|
|
'bias_tf': signal.get('bias_tf') or timeframe_set['bias_tf'],
|
|
'setup_tf': signal.get('setup_tf') or timeframe_set['setup_tf'],
|
|
'trigger_tf': signal.get('trigger_tf') or timeframe_set['trigger_tf'],
|
|
},
|
|
'signal_timeframe_bucket': build_timeframe_bucket(
|
|
bias_tf=signal.get('bias_tf') or timeframe_set['bias_tf'],
|
|
setup_tf=signal.get('setup_tf') or timeframe_set['setup_tf'],
|
|
trigger_tf=signal.get('trigger_tf') or timeframe_set['trigger_tf'],
|
|
),
|
|
'signal_created_ts': signal['created_ts'],
|
|
'rr_tp1': float(signal.get('rr_tp1') or 0.0),
|
|
'rr_tp2': float(signal.get('rr_tp2') or 0.0),
|
|
'target_plan': target_plan,
|
|
'management_rules': management_rules,
|
|
'liquidity_backtest_features': signal_liquidity_features,
|
|
'liquidity_kernel_status': evaluate_liquidity_kernel_status(signal_liquidity_features),
|
|
'pd_array_backtest_features': signal_pd_array_features,
|
|
'pd_array_reaction_status': evaluate_pd_array_reaction_status(signal_pd_array_features),
|
|
'time_session_backtest_features': signal_time_session_features,
|
|
'time_session_kernel_status': evaluate_time_session_kernel_status(signal_time_session_features),
|
|
'cost_r': 0.0,
|
|
'cost_model': cost_config.__dict__,
|
|
'backtest_mode': mode,
|
|
'r_multiple': 0.0,
|
|
'weekday': _extract_weekday(signal['created_ts']),
|
|
'replay_left_bars': replay_config.left_bars if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD else None,
|
|
'replay_right_bars': replay_config.right_bars if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD else None,
|
|
},
|
|
)
|
|
|
|
outcomes.append(record)
|
|
self.repository.insert_backtest_result(
|
|
backtest_run_id=run_id,
|
|
signal_id=record.signal_id,
|
|
trading_plan_id=record.trading_plan_id,
|
|
candidate_id=record.candidate_id,
|
|
candidate_model_code=record.candidate_model_code,
|
|
outcome=record.outcome,
|
|
mfe=record.mfe,
|
|
mae=record.mae,
|
|
tp1_hit=record.tp1_hit,
|
|
tp2_hit=record.tp2_hit,
|
|
invalidated_before_entry=record.invalidated_before_entry,
|
|
error_tags=record.error_tags,
|
|
meta=record.meta,
|
|
)
|
|
continue
|
|
|
|
session_filtered = len(sessions) == 0
|
|
session_codes = resolve_signal_session_codes(signal=signal, sessions=sessions)
|
|
record = evaluate_signal_outcome(
|
|
BacktestSignal(
|
|
signal_id=signal['id'],
|
|
side=signal['side'],
|
|
entry_low=float(signal['entry_low'] or 0),
|
|
entry_high=float(signal['entry_high'] or 0),
|
|
stop_loss=float(signal['stop_loss'] or 0),
|
|
tp1=float(signal['tp1'] or 0),
|
|
tp2=float(signal['tp2'] or 0),
|
|
rr_tp1=float(signal.get('rr_tp1') or 0),
|
|
rr_tp2=float(signal.get('rr_tp2') or 0),
|
|
target_plan=extract_target_plan_from_signal_row(signal),
|
|
management_rules=extract_management_rules_from_signal_row(signal),
|
|
trading_plan_id=signal.get('trading_plan_id'),
|
|
candidate_id=signal.get('candidate_id'),
|
|
candidate_model_code=signal.get('candidate_model_code'),
|
|
),
|
|
future,
|
|
cost_config=cost_config,
|
|
mode=mode,
|
|
replay_config=replay_config,
|
|
)
|
|
if session_filtered:
|
|
record.error_tags.append('outside_session_window')
|
|
record.meta.update(
|
|
{
|
|
'sessions_found': len(sessions),
|
|
'session_codes': session_codes,
|
|
'session_filter': 'outside_window' if session_filtered else 'covered',
|
|
'signal_side': signal['side'],
|
|
'signal_symbol': signal.get('symbol'),
|
|
'signal_timeframe_set': {
|
|
'bias_tf': signal.get('bias_tf') or timeframe_set['bias_tf'],
|
|
'setup_tf': signal.get('setup_tf') or timeframe_set['setup_tf'],
|
|
'trigger_tf': signal.get('trigger_tf') or timeframe_set['trigger_tf'],
|
|
},
|
|
'signal_timeframe_bucket': build_timeframe_bucket(
|
|
bias_tf=signal.get('bias_tf') or timeframe_set['bias_tf'],
|
|
setup_tf=signal.get('setup_tf') or timeframe_set['setup_tf'],
|
|
trigger_tf=signal.get('trigger_tf') or timeframe_set['trigger_tf'],
|
|
),
|
|
'signal_created_ts': signal['created_ts'],
|
|
'rr_tp1': float(signal.get('rr_tp1') or 0.0),
|
|
'rr_tp2': float(signal.get('rr_tp2') or 0.0),
|
|
'target_plan': extract_target_plan_from_signal_row(signal),
|
|
'management_rules': extract_management_rules_from_signal_row(signal),
|
|
'liquidity_backtest_features': build_liquidity_backtest_features(
|
|
{
|
|
'signal': signal,
|
|
'target_plan': extract_target_plan_from_signal_row(signal),
|
|
'run_metadata': run_metadata,
|
|
}
|
|
),
|
|
'pd_array_backtest_features': build_pd_array_backtest_features(
|
|
{
|
|
'signal': signal,
|
|
'target_plan': extract_target_plan_from_signal_row(signal),
|
|
'run_metadata': run_metadata,
|
|
}
|
|
),
|
|
'time_session_backtest_features': build_time_session_backtest_features(
|
|
{
|
|
'signal': signal,
|
|
'target_plan': extract_target_plan_from_signal_row(signal),
|
|
'run_metadata': run_metadata,
|
|
'session_codes': session_codes,
|
|
'session_filter': 'outside_window' if session_filtered else 'covered',
|
|
}
|
|
),
|
|
'weekday': _extract_weekday(signal['created_ts']),
|
|
}
|
|
)
|
|
record.meta['liquidity_kernel_status'] = evaluate_liquidity_kernel_status(record.meta['liquidity_backtest_features'])
|
|
record.meta['pd_array_reaction_status'] = evaluate_pd_array_reaction_status(record.meta['pd_array_backtest_features'])
|
|
record.meta['time_session_kernel_status'] = evaluate_time_session_kernel_status(record.meta['time_session_backtest_features'])
|
|
outcomes.append(record)
|
|
self.repository.insert_backtest_result(
|
|
backtest_run_id=run_id,
|
|
signal_id=record.signal_id,
|
|
trading_plan_id=record.trading_plan_id,
|
|
candidate_id=record.candidate_id,
|
|
candidate_model_code=record.candidate_model_code,
|
|
outcome=record.outcome,
|
|
mfe=record.mfe,
|
|
mae=record.mae,
|
|
tp1_hit=record.tp1_hit,
|
|
tp2_hit=record.tp2_hit,
|
|
invalidated_before_entry=record.invalidated_before_entry,
|
|
error_tags=record.error_tags,
|
|
meta=record.meta,
|
|
)
|
|
summary = summarize_backtest_outcomes(outcomes, subject_type=subject_type)
|
|
summary['quality_gate'] = evaluate_backtest_quality(summary, gate=quality_gate)
|
|
outcome_liquidity_features = _first_outcome_meta_value(outcomes, 'liquidity_backtest_features') or liquidity_backtest_features
|
|
outcome_pd_array_features = _first_outcome_meta_value(outcomes, 'pd_array_backtest_features') or pd_array_backtest_features
|
|
outcome_time_session_features = _first_outcome_meta_value(outcomes, 'time_session_backtest_features') or time_session_backtest_features
|
|
summary['liquidity_backtest_features'] = dict(outcome_liquidity_features or {})
|
|
summary['liquidity_kernel_status'] = evaluate_liquidity_kernel_status(summary['liquidity_backtest_features'])
|
|
summary['pd_array_backtest_features'] = dict(outcome_pd_array_features or {})
|
|
summary['pd_array_reaction_status'] = evaluate_pd_array_reaction_status(summary['pd_array_backtest_features'])
|
|
summary['time_session_backtest_features'] = dict(outcome_time_session_features or {})
|
|
summary['time_session_kernel_status'] = evaluate_time_session_kernel_status(summary['time_session_backtest_features'])
|
|
summary['doctrine_backtest_cohort'] = build_doctrine_backtest_cohort(
|
|
{
|
|
'subject_type': subject_type,
|
|
'model_code': 'SWEEP_MSS_FVG',
|
|
'candidate_model_code': candidate_model_code,
|
|
'timeframe_set': timeframe_set,
|
|
'run_metadata': run_metadata,
|
|
'summary': summary,
|
|
'liquidity_backtest_features': summary['liquidity_backtest_features'],
|
|
'pd_array_backtest_features': summary['pd_array_backtest_features'],
|
|
'time_session_backtest_features': summary['time_session_backtest_features'],
|
|
},
|
|
quality_gate=summary['quality_gate'],
|
|
)
|
|
self.repository.update_backtest_run_summary(run_id, summary)
|
|
return run_id, outcomes
|
|
|
|
def _run_plan_replay(
|
|
self,
|
|
*,
|
|
run_id: int,
|
|
instrument_id: int,
|
|
limit: int,
|
|
trading_plan_id: Optional[int],
|
|
candidate_model_code: Optional[str],
|
|
cost_config: BacktestCostConfig,
|
|
mode: str,
|
|
replay_config: BacktestReplayConfig,
|
|
timeframe_set: dict,
|
|
) -> list[BacktestOutcome]:
|
|
candidate_rows = self.repository.fetch_plan_replay_candidate_rows(
|
|
instrument_id=instrument_id,
|
|
limit=limit,
|
|
timeframe_set=timeframe_set,
|
|
trading_plan_id=trading_plan_id,
|
|
candidate_model_code=candidate_model_code,
|
|
)
|
|
replay_units = build_plan_replay_units(candidate_rows)
|
|
outcomes: list[BacktestOutcome] = []
|
|
for unit in replay_units:
|
|
record = self._replay_candidate_unit(
|
|
unit=unit,
|
|
instrument_id=instrument_id,
|
|
cost_config=cost_config,
|
|
mode=mode,
|
|
replay_config=replay_config,
|
|
timeframe_set=timeframe_set,
|
|
)
|
|
outcomes.append(record)
|
|
self.repository.insert_backtest_result(
|
|
backtest_run_id=run_id,
|
|
signal_id=record.signal_id,
|
|
trading_plan_id=record.trading_plan_id,
|
|
candidate_id=record.candidate_id,
|
|
candidate_model_code=record.candidate_model_code,
|
|
outcome=record.outcome,
|
|
mfe=record.mfe,
|
|
mae=record.mae,
|
|
tp1_hit=record.tp1_hit,
|
|
tp2_hit=record.tp2_hit,
|
|
invalidated_before_entry=record.invalidated_before_entry,
|
|
error_tags=record.error_tags,
|
|
meta=record.meta,
|
|
)
|
|
return outcomes
|
|
|
|
def _replay_candidate_unit(
|
|
self,
|
|
*,
|
|
unit: dict,
|
|
instrument_id: int,
|
|
cost_config: BacktestCostConfig,
|
|
mode: str,
|
|
replay_config: BacktestReplayConfig,
|
|
timeframe_set: dict,
|
|
) -> BacktestOutcome:
|
|
session_reference_ts = unit['confirmed_ts'] or unit['appeared_ts']
|
|
sessions = self.repository.fetch_sessions_covering_window(
|
|
instrument_id=instrument_id,
|
|
start_ts=session_reference_ts,
|
|
end_ts=session_reference_ts,
|
|
)
|
|
if unit['confirmed_ts'] is None:
|
|
failure_reasons = list(unit['reason_codes'] or unit['missing_confirmations'] or ['candidate_not_confirmed'])
|
|
return BacktestOutcome(
|
|
signal_id=None,
|
|
trading_plan_id=unit['trading_plan_id'],
|
|
candidate_id=unit['candidate_id'],
|
|
candidate_model_code=unit['candidate_model_code'],
|
|
outcome='missed',
|
|
mfe=0.0,
|
|
mae=0.0,
|
|
tp1_hit=False,
|
|
tp2_hit=False,
|
|
invalidated_before_entry=True,
|
|
error_tags=['candidate_not_confirmed'],
|
|
meta=build_plan_replay_meta(
|
|
unit=unit,
|
|
sessions=sessions,
|
|
entry_triggered=False,
|
|
failure_reasons=failure_reasons,
|
|
timeframe_set=timeframe_set,
|
|
model_compliant=False,
|
|
mode=mode,
|
|
),
|
|
)
|
|
|
|
future = self.repository.fetch_future_candles(
|
|
instrument_id=instrument_id,
|
|
timeframe=str(unit.get('trigger_timeframe') or timeframe_set['trigger_tf']),
|
|
start_ts=unit['confirmed_ts'],
|
|
end_ts=unit['expires_ts'],
|
|
limit=500,
|
|
)
|
|
if not future:
|
|
failure_reasons = ['no_future_candles']
|
|
return BacktestOutcome(
|
|
signal_id=None,
|
|
trading_plan_id=unit['trading_plan_id'],
|
|
candidate_id=unit['candidate_id'],
|
|
candidate_model_code=unit['candidate_model_code'],
|
|
outcome='missed',
|
|
mfe=0.0,
|
|
mae=0.0,
|
|
tp1_hit=False,
|
|
tp2_hit=False,
|
|
invalidated_before_entry=True,
|
|
error_tags=failure_reasons,
|
|
meta=build_plan_replay_meta(
|
|
unit=unit,
|
|
sessions=sessions,
|
|
entry_triggered=False,
|
|
failure_reasons=failure_reasons,
|
|
timeframe_set=timeframe_set,
|
|
model_compliant=True,
|
|
mode=mode,
|
|
),
|
|
)
|
|
|
|
signal = BacktestSignal(
|
|
signal_id=None,
|
|
side=unit['side'],
|
|
entry_low=float(unit['entry_low'] or 0.0),
|
|
entry_high=float(unit['entry_high'] or 0.0),
|
|
stop_loss=float(unit['stop_loss'] or 0.0),
|
|
tp1=float(unit['tp1'] or 0.0),
|
|
tp2=float(unit['tp2'] or 0.0),
|
|
rr_tp1=float(unit['rr_tp1'] or 0.0),
|
|
rr_tp2=float(unit['rr_tp2'] or 0.0),
|
|
target_plan=dict(unit['target_plan'] or {}),
|
|
management_rules=list(unit['management_rules'] or []),
|
|
trading_plan_id=unit['trading_plan_id'],
|
|
candidate_id=unit['candidate_id'],
|
|
candidate_model_code=unit['candidate_model_code'],
|
|
candidate_appeared_ts=unit['appeared_ts'],
|
|
candidate_confirmed_ts=unit['confirmed_ts'],
|
|
)
|
|
record = evaluate_signal_outcome(
|
|
signal=signal,
|
|
future_candles=future,
|
|
cost_config=cost_config,
|
|
mode=mode,
|
|
replay_config=replay_config,
|
|
)
|
|
invalidated_before_entry = bool(
|
|
(not record.meta.get('entry_triggered') and unit.get('invalidated_ts'))
|
|
or record.invalidated_before_entry
|
|
)
|
|
failure_reasons = list(unit['reason_codes'] or [])
|
|
if record.error_tags:
|
|
failure_reasons.extend(record.error_tags)
|
|
meta = {
|
|
**record.meta,
|
|
**build_plan_replay_meta(
|
|
unit=unit,
|
|
sessions=sessions,
|
|
entry_triggered=bool(record.meta.get('entry_triggered')),
|
|
failure_reasons=_dedupe_text_list(failure_reasons),
|
|
timeframe_set=timeframe_set,
|
|
model_compliant=True,
|
|
mode=mode,
|
|
),
|
|
}
|
|
candidate_entry_ts = record.meta.get('entry_ts') or unit.get('entry_ts')
|
|
if candidate_entry_ts is not None:
|
|
meta['candidate_entry_ts'] = candidate_entry_ts
|
|
meta['draw_target_hit'] = bool(record.tp2_hit)
|
|
return BacktestOutcome(
|
|
signal_id=None,
|
|
trading_plan_id=unit['trading_plan_id'],
|
|
candidate_id=unit['candidate_id'],
|
|
candidate_model_code=unit['candidate_model_code'],
|
|
outcome=record.outcome,
|
|
mfe=record.mfe,
|
|
mae=record.mae,
|
|
tp1_hit=record.tp1_hit,
|
|
tp2_hit=record.tp2_hit,
|
|
invalidated_before_entry=invalidated_before_entry,
|
|
error_tags=_dedupe_text_list(record.error_tags),
|
|
meta=meta,
|
|
)
|
|
|
|
|
|
def evaluate_signal_outcome(
|
|
signal: BacktestSignal,
|
|
future_candles: list[dict],
|
|
cost_config: Optional[BacktestCostConfig] = None,
|
|
mode: str = BACKTEST_MODE_ENTRY_THEN_OUTCOME,
|
|
replay_config: Optional[BacktestReplayConfig] = None,
|
|
) -> BacktestOutcome:
|
|
if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD:
|
|
return evaluate_signal_outcome_with_replay(
|
|
signal=signal,
|
|
future_candles=future_candles,
|
|
cost_config=cost_config,
|
|
replay_config=replay_config,
|
|
)
|
|
cost_config = cost_config or BacktestCostConfig()
|
|
steps = [
|
|
_BacktestEvaluationStep(
|
|
index=index,
|
|
candle=candle,
|
|
ts=_extract_candle_ts(candle),
|
|
known_candles_count=index + 1,
|
|
confirmed_swings_available=0,
|
|
last_confirmed_swing=None,
|
|
)
|
|
for index, candle in enumerate(future_candles)
|
|
]
|
|
return _evaluate_signal_outcome_steps(
|
|
signal=signal,
|
|
steps=steps,
|
|
cost_config=cost_config,
|
|
mode=BACKTEST_MODE_ENTRY_THEN_OUTCOME,
|
|
replay_config=None,
|
|
)
|
|
|
|
|
|
def evaluate_signal_outcome_with_replay(
|
|
signal: BacktestSignal,
|
|
future_candles: list[dict],
|
|
cost_config: Optional[BacktestCostConfig] = None,
|
|
replay_config: Optional[BacktestReplayConfig] = None,
|
|
) -> BacktestOutcome:
|
|
cost_config = cost_config or BacktestCostConfig()
|
|
replay_config = replay_config or BacktestReplayConfig()
|
|
try:
|
|
steps = _build_replay_evaluation_steps(future_candles=future_candles, replay_config=replay_config)
|
|
except ValueError as exc:
|
|
return BacktestOutcome(
|
|
signal_id=signal.signal_id,
|
|
outcome='missed',
|
|
mfe=0.0,
|
|
mae=0.0,
|
|
tp1_hit=False,
|
|
tp2_hit=False,
|
|
invalidated_before_entry=True,
|
|
error_tags=['invalid_replay_candles'],
|
|
meta={
|
|
'entry_triggered': False,
|
|
'same_candle_priority': SAME_CANDLE_PRIORITY,
|
|
'same_candle_ambiguous': False,
|
|
'signal_side': signal.side,
|
|
'rr_tp1': signal.rr_tp1,
|
|
'rr_tp2': signal.rr_tp2,
|
|
'mfe_r': 0.0,
|
|
'mae_r': 0.0,
|
|
'cost_r': 0.0,
|
|
'cost_model': cost_config.__dict__,
|
|
'backtest_mode': BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
|
|
'r_multiple': 0.0,
|
|
'replay_error': str(exc),
|
|
'replay_frame_count': 0,
|
|
'replay_entry_frame_index': None,
|
|
'replay_exit_frame_index': None,
|
|
'replay_known_candles_at_entry': 0,
|
|
'replay_confirmed_swing_count_at_entry': 0,
|
|
'replay_last_confirmed_swing_at_entry': None,
|
|
'replay_confirmed_swing_count_total': 0,
|
|
'replay_left_bars': replay_config.left_bars,
|
|
'replay_right_bars': replay_config.right_bars,
|
|
},
|
|
)
|
|
return _evaluate_signal_outcome_steps(
|
|
signal=signal,
|
|
steps=steps,
|
|
cost_config=cost_config,
|
|
mode=BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
|
|
replay_config=replay_config,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _BacktestEvaluationStep:
|
|
index: int
|
|
candle: dict
|
|
ts: object
|
|
known_candles_count: int
|
|
confirmed_swings_available: int
|
|
last_confirmed_swing: Optional[dict]
|
|
|
|
|
|
def _build_replay_evaluation_steps(
|
|
future_candles: list[dict],
|
|
replay_config: BacktestReplayConfig,
|
|
) -> list[_BacktestEvaluationStep]:
|
|
steps: list[_BacktestEvaluationStep] = []
|
|
confirmed_swings: list[ReplaySwing] = []
|
|
for frame in iter_historical_replay(
|
|
future_candles,
|
|
left_bars=replay_config.left_bars,
|
|
right_bars=replay_config.right_bars,
|
|
):
|
|
confirmed_swings.extend(frame.new_swings)
|
|
steps.append(
|
|
_BacktestEvaluationStep(
|
|
index=frame.index,
|
|
candle=frame.candle,
|
|
ts=_extract_candle_ts(frame.candle),
|
|
known_candles_count=len(frame.known_candles),
|
|
confirmed_swings_available=len(confirmed_swings),
|
|
last_confirmed_swing=_serialize_replay_swing(confirmed_swings[-1]) if confirmed_swings else None,
|
|
)
|
|
)
|
|
return steps
|
|
|
|
|
|
def _evaluate_signal_outcome_steps(
|
|
signal: BacktestSignal,
|
|
steps: list[_BacktestEvaluationStep],
|
|
cost_config: BacktestCostConfig,
|
|
mode: str,
|
|
replay_config: Optional[BacktestReplayConfig],
|
|
) -> BacktestOutcome:
|
|
entry_mid = (signal.entry_low + signal.entry_high) / 2
|
|
management_rules = list(signal.management_rules or [])
|
|
tp1_partial_fraction = resolve_tp1_partial_fraction(management_rules)
|
|
move_stop_enabled = has_management_rule(management_rules, "move_stop")
|
|
entry_triggered = False
|
|
tp1_hit = False
|
|
tp2_hit = False
|
|
stopped_out = False
|
|
stopped_after_tp1 = False
|
|
same_candle_ambiguous = False
|
|
mfe = 0.0
|
|
mae = 0.0
|
|
entry_step: Optional[_BacktestEvaluationStep] = None
|
|
exit_step: Optional[_BacktestEvaluationStep] = None
|
|
active_stop_loss = signal.stop_loss
|
|
|
|
for step in steps:
|
|
candle = step.candle
|
|
high = float(candle['high'])
|
|
low = float(candle['low'])
|
|
entry_now = low <= signal.entry_high and high >= signal.entry_low
|
|
entry_triggered_this_candle = False
|
|
|
|
if not entry_triggered:
|
|
if entry_now:
|
|
entry_triggered = True
|
|
entry_triggered_this_candle = True
|
|
entry_step = step
|
|
else:
|
|
continue
|
|
|
|
if signal.side == 'buy':
|
|
mfe = max(mfe, high - entry_mid)
|
|
mae = max(mae, entry_mid - low)
|
|
stop_hit = bool(active_stop_loss and low <= active_stop_loss)
|
|
tp1_now = bool(signal.tp1 and high >= signal.tp1)
|
|
tp2_now = bool(signal.tp2 and high >= signal.tp2)
|
|
else:
|
|
mfe = max(mfe, entry_mid - low)
|
|
mae = max(mae, high - entry_mid)
|
|
stop_hit = bool(active_stop_loss and high >= active_stop_loss)
|
|
tp1_now = bool(signal.tp1 and low <= signal.tp1)
|
|
tp2_now = bool(signal.tp2 and low <= signal.tp2)
|
|
|
|
if entry_triggered_this_candle and (stop_hit or tp1_now or tp2_now):
|
|
same_candle_ambiguous = True
|
|
if stop_hit and (tp1_now or tp2_now):
|
|
same_candle_ambiguous = True
|
|
|
|
if stop_hit:
|
|
stopped_out = True
|
|
stopped_after_tp1 = tp1_hit
|
|
exit_step = step
|
|
break
|
|
if tp1_now:
|
|
tp1_hit = True
|
|
if move_stop_enabled:
|
|
active_stop_loss = entry_mid
|
|
if tp2_now:
|
|
tp2_hit = True
|
|
exit_step = step
|
|
break
|
|
|
|
error_tags: list[str] = []
|
|
if same_candle_ambiguous:
|
|
error_tags.append('same_candle_entry_exit_ambiguous')
|
|
|
|
if not entry_triggered:
|
|
outcome = 'missed'
|
|
error_tags.append('entry_not_triggered')
|
|
elif tp2_hit:
|
|
outcome = 'win'
|
|
elif stopped_out and tp1_hit and move_stop_enabled:
|
|
outcome = 'breakeven'
|
|
elif tp1_hit and not stopped_out:
|
|
outcome = 'breakeven'
|
|
elif stopped_out:
|
|
outcome = 'loss'
|
|
error_tags.append('stopped_out')
|
|
else:
|
|
outcome = 'missed'
|
|
error_tags.append('no_target_hit')
|
|
risk_distance = abs(entry_mid - signal.stop_loss)
|
|
mfe_r = mfe / risk_distance if risk_distance else 0.0
|
|
mae_r = mae / risk_distance if risk_distance else 0.0
|
|
cost_r = estimate_trade_cost_r(
|
|
signal=signal,
|
|
outcome=outcome,
|
|
entry_price=entry_mid,
|
|
entry_triggered=entry_triggered,
|
|
cost_config=cost_config,
|
|
)
|
|
r_multiple = compute_outcome_r_multiple(
|
|
outcome=outcome,
|
|
rr_tp1=signal.rr_tp1,
|
|
rr_tp2=signal.rr_tp2,
|
|
cost_r=cost_r,
|
|
tp1_hit=tp1_hit,
|
|
tp2_hit=tp2_hit,
|
|
tp1_partial_fraction=tp1_partial_fraction,
|
|
stopped_after_tp1=stopped_after_tp1,
|
|
move_stop_applied=move_stop_enabled,
|
|
)
|
|
remaining_position_fraction = max(0.0, 1.0 - tp1_partial_fraction) if tp1_hit else 1.0
|
|
management_applied = {
|
|
"tp1_partial": tp1_partial_fraction > 0 and tp1_hit,
|
|
"tp1_partial_fraction": tp1_partial_fraction if tp1_hit else 0.0,
|
|
"move_stop": move_stop_enabled and tp1_hit,
|
|
"move_stop_to": "breakeven" if move_stop_enabled and tp1_hit else None,
|
|
"remaining_position_fraction": remaining_position_fraction,
|
|
"remaining_position_closed": bool(tp2_hit or (stopped_after_tp1 and move_stop_enabled)),
|
|
"tp1_realized_r": (tp1_partial_fraction * signal.rr_tp1) if tp1_hit else 0.0,
|
|
"active_stop_loss_after_tp1": entry_mid if move_stop_enabled and tp1_hit else signal.stop_loss,
|
|
}
|
|
|
|
meta = {
|
|
'entry_triggered': entry_triggered,
|
|
'entry_ts': _normalize_meta_ts(entry_step.ts) if entry_step else None,
|
|
'exit_ts': _normalize_meta_ts(exit_step.ts) if exit_step else None,
|
|
'same_candle_priority': SAME_CANDLE_PRIORITY,
|
|
'same_candle_ambiguous': same_candle_ambiguous,
|
|
'signal_side': signal.side,
|
|
'rr_tp1': signal.rr_tp1,
|
|
'rr_tp2': signal.rr_tp2,
|
|
'target_plan': dict(signal.target_plan or {}),
|
|
'management_rules': list(signal.management_rules or []),
|
|
'mfe_r': mfe_r,
|
|
'mae_r': mae_r,
|
|
'cost_r': cost_r,
|
|
'cost_model': cost_config.__dict__,
|
|
'backtest_mode': mode,
|
|
'r_multiple': r_multiple,
|
|
'management_applied': management_applied,
|
|
}
|
|
if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD:
|
|
meta.update(
|
|
{
|
|
'replay_frame_count': len(steps),
|
|
'replay_entry_frame_index': entry_step.index if entry_step else None,
|
|
'replay_exit_frame_index': exit_step.index if exit_step else None,
|
|
'replay_entry_ts': _normalize_meta_ts(entry_step.ts) if entry_step else None,
|
|
'replay_exit_ts': _normalize_meta_ts(exit_step.ts) if exit_step else None,
|
|
'replay_known_candles_at_entry': entry_step.known_candles_count if entry_step else 0,
|
|
'replay_confirmed_swing_count_at_entry': entry_step.confirmed_swings_available if entry_step else 0,
|
|
'replay_last_confirmed_swing_at_entry': entry_step.last_confirmed_swing if entry_step else None,
|
|
'replay_confirmed_swing_count_total': steps[-1].confirmed_swings_available if steps else 0,
|
|
'replay_left_bars': replay_config.left_bars if replay_config else None,
|
|
'replay_right_bars': replay_config.right_bars if replay_config else None,
|
|
}
|
|
)
|
|
|
|
return BacktestOutcome(
|
|
signal_id=signal.signal_id,
|
|
outcome=outcome,
|
|
mfe=mfe,
|
|
mae=mae,
|
|
tp1_hit=tp1_hit,
|
|
tp2_hit=tp2_hit,
|
|
invalidated_before_entry=False,
|
|
error_tags=error_tags,
|
|
meta=meta,
|
|
)
|
|
|
|
|
|
def _serialize_replay_swing(swing: ReplaySwing) -> dict:
|
|
return {
|
|
'kind': swing.kind,
|
|
'price': swing.price,
|
|
'ts': _normalize_meta_ts(swing.ts),
|
|
'source_index': swing.source_index,
|
|
'confirmed_at_index': swing.confirmed_at_index,
|
|
'left_bars': swing.left_bars,
|
|
'right_bars': swing.right_bars,
|
|
}
|
|
|
|
|
|
def _extract_candle_ts(candle: dict):
|
|
return candle.get('ts_open') or candle.get('ts_close')
|
|
|
|
|
|
def _normalize_meta_ts(value):
|
|
if hasattr(value, 'isoformat'):
|
|
return value.isoformat()
|
|
return value
|
|
|
|
|
|
def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]:
|
|
grouped: dict[tuple, list[dict]] = {}
|
|
for row in candidate_rows:
|
|
key = (
|
|
row.get('trading_plan_id'),
|
|
row.get('candidate_model_code'),
|
|
row.get('side'),
|
|
row.get('setup_timeframe'),
|
|
row.get('trigger_timeframe'),
|
|
)
|
|
grouped.setdefault(key, []).append(row)
|
|
|
|
units: list[dict] = []
|
|
for rows in grouped.values():
|
|
ordered = sorted(rows, key=lambda item: (item.get('created_ts'), item.get('candidate_id') or 0))
|
|
first = ordered[0]
|
|
latest = ordered[-1]
|
|
confirmed_row = next((row for row in ordered if candidate_row_is_executable(row)), None)
|
|
blocked_row = next(
|
|
(
|
|
row
|
|
for row in ordered
|
|
if confirmed_row is not None
|
|
and row.get('created_ts') > confirmed_row.get('created_ts')
|
|
and candidate_row_is_blocked(row)
|
|
),
|
|
None,
|
|
)
|
|
entry_payload = dict((confirmed_row or latest).get('entry') or {})
|
|
target_plan = dict(entry_payload.get('target_plan') or {})
|
|
management_rules = list((confirmed_row or latest).get('management_rules') or target_plan.get('management_rules') or [])
|
|
setup_playbook_id = (
|
|
extract_setup_playbook_id(first)
|
|
or extract_setup_playbook_id(latest)
|
|
or extract_setup_playbook_id(confirmed_row or {})
|
|
)
|
|
doctrine_backtest_cohort = build_doctrine_backtest_cohort(
|
|
{
|
|
'setup_playbook_id': setup_playbook_id,
|
|
'candidate_model_code': first.get('candidate_model_code'),
|
|
'timeframe_set': {
|
|
'setup_tf': first.get('setup_timeframe'),
|
|
'trigger_tf': first.get('trigger_timeframe'),
|
|
},
|
|
}
|
|
)
|
|
liquidity_backtest_features = build_liquidity_backtest_features(
|
|
{
|
|
'first_candidate': first,
|
|
'latest_candidate': latest,
|
|
'confirmed_candidate': confirmed_row or {},
|
|
'entry_payload': entry_payload,
|
|
'target_plan': target_plan,
|
|
'setup_playbook_id': setup_playbook_id,
|
|
}
|
|
)
|
|
liquidity_kernel_status = evaluate_liquidity_kernel_status(liquidity_backtest_features)
|
|
pd_array_backtest_features = build_pd_array_backtest_features(
|
|
{
|
|
'first_candidate': first,
|
|
'latest_candidate': latest,
|
|
'confirmed_candidate': confirmed_row or {},
|
|
'entry_payload': entry_payload,
|
|
'target_plan': target_plan,
|
|
'setup_playbook_id': setup_playbook_id,
|
|
}
|
|
)
|
|
pd_array_reaction_status = evaluate_pd_array_reaction_status(pd_array_backtest_features)
|
|
time_session_backtest_features = build_time_session_backtest_features(
|
|
{
|
|
'first_candidate': first,
|
|
'latest_candidate': latest,
|
|
'confirmed_candidate': confirmed_row or {},
|
|
'entry_payload': entry_payload,
|
|
'target_plan': target_plan,
|
|
'setup_playbook_id': setup_playbook_id,
|
|
'session_code': first.get('session_code'),
|
|
'allowed_sessions': list(first.get('allowed_sessions') or []),
|
|
}
|
|
)
|
|
time_session_kernel_status = evaluate_time_session_kernel_status(time_session_backtest_features)
|
|
doctrine_backtest_cohort = build_doctrine_backtest_cohort(
|
|
{
|
|
'setup_playbook_id': setup_playbook_id,
|
|
'candidate_model_code': first.get('candidate_model_code'),
|
|
'timeframe_set': {
|
|
'setup_tf': first.get('setup_timeframe'),
|
|
'trigger_tf': first.get('trigger_timeframe'),
|
|
},
|
|
'liquidity_backtest_features': liquidity_backtest_features,
|
|
'pd_array_backtest_features': pd_array_backtest_features,
|
|
'time_session_backtest_features': time_session_backtest_features,
|
|
}
|
|
)
|
|
units.append(
|
|
{
|
|
'trading_plan_id': first.get('trading_plan_id'),
|
|
'candidate_id': (confirmed_row or first).get('candidate_id'),
|
|
'setup_playbook_id': setup_playbook_id,
|
|
'doctrine_backtest_cohort': doctrine_backtest_cohort,
|
|
'liquidity_backtest_features': liquidity_backtest_features,
|
|
'liquidity_kernel_status': liquidity_kernel_status,
|
|
'pd_array_backtest_features': pd_array_backtest_features,
|
|
'pd_array_reaction_status': pd_array_reaction_status,
|
|
'time_session_backtest_features': time_session_backtest_features,
|
|
'time_session_kernel_status': time_session_kernel_status,
|
|
'candidate_model_code': first.get('candidate_model_code'),
|
|
'side': first.get('side'),
|
|
'session_code': first.get('session_code'),
|
|
'candidate_state': latest.get('candidate_state'),
|
|
'quality_score': latest.get('quality_score'),
|
|
'ai_explanation': latest.get('ai_explanation'),
|
|
'setup_timeframe': first.get('setup_timeframe'),
|
|
'trigger_timeframe': first.get('trigger_timeframe'),
|
|
'appeared_ts': first.get('created_ts'),
|
|
'confirmed_ts': confirmed_row.get('created_ts') if confirmed_row else None,
|
|
'invalidated_ts': blocked_row.get('created_ts') if blocked_row else None,
|
|
'expires_ts': (confirmed_row or latest).get('expiry_ts') or (confirmed_row or latest).get('expires_ts') or latest.get('created_ts'),
|
|
'entry_low': (confirmed_row or latest).get('entry_low'),
|
|
'entry_high': (confirmed_row or latest).get('entry_high'),
|
|
'stop_loss': (confirmed_row or latest).get('stop_loss'),
|
|
'tp1': (confirmed_row or latest).get('tp1'),
|
|
'tp2': (confirmed_row or latest).get('tp2'),
|
|
'rr_tp1': derive_rr_from_entry(
|
|
side=first.get('side'),
|
|
entry_low=(confirmed_row or latest).get('entry_low'),
|
|
entry_high=(confirmed_row or latest).get('entry_high'),
|
|
stop_loss=(confirmed_row or latest).get('stop_loss'),
|
|
target_price=(confirmed_row or latest).get('tp1'),
|
|
),
|
|
'rr_tp2': derive_rr_from_entry(
|
|
side=first.get('side'),
|
|
entry_low=(confirmed_row or latest).get('entry_low'),
|
|
entry_high=(confirmed_row or latest).get('entry_high'),
|
|
stop_loss=(confirmed_row or latest).get('stop_loss'),
|
|
target_price=(confirmed_row or latest).get('tp2'),
|
|
),
|
|
'target_plan': target_plan,
|
|
'management_rules': management_rules,
|
|
'missing_confirmations': list(latest.get('missing_confirmations') or []),
|
|
'reason_codes': list(((latest.get('confirmation_state') or {}).get('reason_codes')) or []),
|
|
'candidate_status': latest.get('candidate_status'),
|
|
'allowed_sessions': list(first.get('allowed_sessions') or []),
|
|
'primary_draw': dict(first.get('primary_draw') or {}),
|
|
'plan_activated_ts': first.get('activated_ts'),
|
|
'plan_created_ts': first.get('trading_plan_created_ts'),
|
|
}
|
|
)
|
|
return units
|
|
|
|
|
|
def _first_outcome_meta_value(outcomes: list[BacktestOutcome], key: str):
|
|
for outcome in outcomes:
|
|
value = dict(outcome.meta or {}).get(key)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def candidate_row_is_executable(row: dict) -> bool:
|
|
confirmation_state = dict(row.get('confirmation_state') or {})
|
|
return bool(confirmation_state.get('executable')) or row.get('candidate_status') == 'executable'
|
|
|
|
|
|
def candidate_row_is_blocked(row: dict) -> bool:
|
|
return row.get('candidate_status') == 'blocked'
|
|
|
|
|
|
def derive_rr_from_entry(side, entry_low, entry_high, stop_loss, target_price) -> float:
|
|
values = [_float_from_meta(entry_low), _float_from_meta(entry_high), _float_from_meta(stop_loss), _float_from_meta(target_price)]
|
|
if any(value is None for value in values):
|
|
return 0.0
|
|
entry_price = (_float_from_meta(entry_low) + _float_from_meta(entry_high)) / 2
|
|
risk_distance = abs(entry_price - _float_from_meta(stop_loss))
|
|
if risk_distance <= 0:
|
|
return 0.0
|
|
return abs(_float_from_meta(target_price) - entry_price) / risk_distance
|
|
|
|
|
|
def build_plan_replay_meta(
|
|
*,
|
|
unit: dict,
|
|
sessions: list[dict],
|
|
entry_triggered: bool,
|
|
failure_reasons: list[str],
|
|
timeframe_set: dict,
|
|
model_compliant: bool,
|
|
mode: str,
|
|
) -> dict:
|
|
session_codes = [row.get('session_code') for row in sessions if row.get('session_code')] or ['OFF_HOURS']
|
|
time_session_backtest_features = build_time_session_backtest_features(
|
|
{
|
|
'unit': unit,
|
|
'time_session_backtest_features': dict(unit.get('time_session_backtest_features') or {}),
|
|
'session_codes': session_codes,
|
|
'session_filter': 'outside_window' if len(sessions) == 0 else 'covered',
|
|
}
|
|
)
|
|
time_session_kernel_status = evaluate_time_session_kernel_status(time_session_backtest_features)
|
|
return {
|
|
'subject_type': BACKTEST_SUBJECT_PLAN_REPLAY,
|
|
'trading_plan_id': unit.get('trading_plan_id'),
|
|
'setup_playbook_id': unit.get('setup_playbook_id'),
|
|
'doctrine_backtest_cohort': dict(unit.get('doctrine_backtest_cohort') or {}),
|
|
'doctrine_cohort_key': (unit.get('doctrine_backtest_cohort') or {}).get('cohort_key'),
|
|
'doctrine_completeness': (unit.get('doctrine_backtest_cohort') or {}).get('doctrine_completeness'),
|
|
'liquidity_backtest_features': dict(unit.get('liquidity_backtest_features') or {}),
|
|
'liquidity_kernel_status': dict(unit.get('liquidity_kernel_status') or {}),
|
|
'pd_array_backtest_features': dict(unit.get('pd_array_backtest_features') or {}),
|
|
'pd_array_reaction_status': dict(unit.get('pd_array_reaction_status') or {}),
|
|
'time_session_backtest_features': time_session_backtest_features,
|
|
'time_session_kernel_status': time_session_kernel_status,
|
|
'plan_activated_ts': _normalize_meta_ts(unit.get('plan_activated_ts')),
|
|
'plan_created_ts': _normalize_meta_ts(unit.get('plan_created_ts')),
|
|
'candidate_id': unit.get('candidate_id'),
|
|
'candidate_model_code': unit.get('candidate_model_code'),
|
|
'candidate_appeared_ts': _normalize_meta_ts(unit.get('appeared_ts')),
|
|
'candidate_confirmed_ts': _normalize_meta_ts(unit.get('confirmed_ts')),
|
|
'candidate_invalidated_ts': _normalize_meta_ts(unit.get('invalidated_ts')),
|
|
'candidate_entry_ts': _normalize_meta_ts(unit.get('entry_ts')),
|
|
'candidate_status': unit.get('candidate_status'),
|
|
'candidate_confirmation_state': 'confirmed' if unit.get('confirmed_ts') else 'awaiting_confirmation',
|
|
'candidate_confirmed': bool(unit.get('confirmed_ts')),
|
|
'model_compliant': model_compliant,
|
|
'failure_reasons': failure_reasons,
|
|
'target_plan': dict(unit.get('target_plan') or {}),
|
|
'management_rules': list(unit.get('management_rules') or []),
|
|
'draw_target_hit': False,
|
|
'sessions_found': len(sessions),
|
|
'session_codes': session_codes,
|
|
'session_filter': 'outside_window' if len(sessions) == 0 else 'covered',
|
|
'signal_side': unit.get('side'),
|
|
'signal_symbol': f"plan:{unit.get('trading_plan_id')}",
|
|
'signal_timeframe_set': {
|
|
'bias_tf': timeframe_set.get('bias_tf'),
|
|
'setup_tf': unit.get('setup_timeframe') or timeframe_set.get('setup_tf'),
|
|
'trigger_tf': unit.get('trigger_timeframe') or timeframe_set.get('trigger_tf'),
|
|
},
|
|
'signal_timeframe_bucket': build_timeframe_bucket(
|
|
bias_tf=str(timeframe_set.get('bias_tf') or '1m'),
|
|
setup_tf=str(unit.get('setup_timeframe') or timeframe_set.get('setup_tf') or '1m'),
|
|
trigger_tf=str(unit.get('trigger_timeframe') or timeframe_set.get('trigger_tf') or '1m'),
|
|
),
|
|
'signal_created_ts': _normalize_meta_ts(unit.get('confirmed_ts') or unit.get('appeared_ts')),
|
|
'backtest_mode': mode,
|
|
'entry_triggered': entry_triggered,
|
|
'weekday': _extract_weekday(unit.get('confirmed_ts') or unit.get('appeared_ts')),
|
|
}
|
|
|
|
|
|
def resolve_signal_session_codes(*, signal: dict, sessions: list[dict]) -> list[str]:
|
|
session_codes = [row.get('session_code') for row in sessions if row.get('session_code')]
|
|
if session_codes:
|
|
return session_codes
|
|
signal_session = str(signal.get('session_code') or '').strip()
|
|
return [signal_session] if signal_session else []
|
|
|
|
|
|
def _dedupe_text_list(values: list[str]) -> list[str]:
|
|
seen = set()
|
|
result = []
|
|
for value in values:
|
|
text = str(value or '').strip()
|
|
if not text or text in seen:
|
|
continue
|
|
seen.add(text)
|
|
result.append(text)
|
|
return result
|
|
|
|
|
|
def _float_from_meta(value) -> Optional[float]:
|
|
if value is None:
|
|
return None
|
|
return float(value)
|
|
|
|
|
|
def estimate_trade_cost_r(
|
|
signal: BacktestSignal,
|
|
outcome: str,
|
|
entry_price: float,
|
|
entry_triggered: bool,
|
|
cost_config: BacktestCostConfig,
|
|
) -> float:
|
|
if not entry_triggered:
|
|
return 0.0
|
|
if outcome not in {'win', 'loss', 'breakeven'}:
|
|
return 0.0
|
|
|
|
risk_distance = abs(entry_price - signal.stop_loss)
|
|
if risk_distance <= 0:
|
|
return 0.0
|
|
|
|
exit_price = estimate_exit_price(signal=signal, outcome=outcome, entry_price=entry_price)
|
|
rate_cost = (abs(entry_price) + abs(exit_price)) * (cost_config.fee_rate + cost_config.slippage_rate)
|
|
return cost_config.fixed_cost_r + (rate_cost / risk_distance)
|
|
|
|
|
|
def estimate_exit_price(signal: BacktestSignal, outcome: str, entry_price: float) -> float:
|
|
if outcome == 'win' and signal.tp2:
|
|
return signal.tp2
|
|
if outcome == 'loss' and signal.stop_loss:
|
|
return signal.stop_loss
|
|
return entry_price
|
|
|
|
|
|
def compute_outcome_r_multiple(
|
|
outcome: str,
|
|
rr_tp1: float = 0.0,
|
|
rr_tp2: float = 0.0,
|
|
cost_r: float = 0.0,
|
|
tp1_hit: bool = False,
|
|
tp2_hit: bool = False,
|
|
tp1_partial_fraction: float = 0.0,
|
|
stopped_after_tp1: bool = False,
|
|
move_stop_applied: bool = False,
|
|
) -> float:
|
|
"""Convert the current simplified outcome labels into conservative R multiples."""
|
|
if outcome == 'win':
|
|
if tp1_hit and tp1_partial_fraction > 0:
|
|
gross_r = (tp1_partial_fraction * rr_tp1) + ((1.0 - tp1_partial_fraction) * rr_tp2)
|
|
else:
|
|
gross_r = rr_tp2
|
|
elif outcome == 'loss':
|
|
if tp1_hit and tp1_partial_fraction > 0:
|
|
remaining_loss_r = 0.0 if move_stop_applied and stopped_after_tp1 else -(1.0 - tp1_partial_fraction)
|
|
gross_r = (tp1_partial_fraction * rr_tp1) + remaining_loss_r
|
|
else:
|
|
gross_r = -1.0
|
|
elif outcome == 'breakeven':
|
|
if tp1_hit and tp1_partial_fraction > 0:
|
|
gross_r = tp1_partial_fraction * rr_tp1
|
|
else:
|
|
gross_r = 0.0
|
|
else:
|
|
gross_r = 0.0
|
|
|
|
if outcome in {'win', 'loss', 'breakeven'}:
|
|
return gross_r - cost_r
|
|
return gross_r
|
|
|
|
|
|
def has_management_rule(management_rules: list[dict], code: str) -> bool:
|
|
return any(str(item.get("code") or "") == code for item in management_rules)
|
|
|
|
|
|
def resolve_tp1_partial_fraction(management_rules: list[dict]) -> float:
|
|
for item in management_rules:
|
|
if str(item.get("code") or "") != "tp1_partial":
|
|
continue
|
|
try:
|
|
fraction = float(item.get("fraction") or 0.0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
return min(max(fraction, 0.0), 1.0)
|
|
return 0.0
|
|
|
|
|
|
def classify_backtest_lifecycle_state(
|
|
*,
|
|
outcome: Optional[str],
|
|
tp1_hit: bool,
|
|
tp2_hit: bool,
|
|
invalidated_before_entry: bool,
|
|
) -> Optional[str]:
|
|
if invalidated_before_entry:
|
|
return BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY
|
|
if outcome == 'loss':
|
|
return BACKTEST_LIFECYCLE_STOPPED_OUT
|
|
if tp2_hit or outcome == 'win':
|
|
return BACKTEST_LIFECYCLE_TP2_CLOSED
|
|
if tp1_hit or outcome == 'breakeven':
|
|
return BACKTEST_LIFECYCLE_TP1_REACHED
|
|
if outcome == 'missed':
|
|
return BACKTEST_LIFECYCLE_MISSED
|
|
return None
|
|
|
|
|
|
def classify_backtest_lifecycle_state_from_result(backtest_result: Optional[dict]) -> Optional[str]:
|
|
if backtest_result is None:
|
|
return None
|
|
return classify_backtest_lifecycle_state(
|
|
outcome=backtest_result.get('outcome'),
|
|
tp1_hit=bool(backtest_result.get('tp1_hit')),
|
|
tp2_hit=bool(backtest_result.get('tp2_hit')),
|
|
invalidated_before_entry=bool(backtest_result.get('invalidated_before_entry')),
|
|
)
|
|
|
|
|
|
def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: str = BACKTEST_SUBJECT_SIGNAL) -> dict:
|
|
total = len(outcomes)
|
|
grouped_by_outcome: dict[str, int] = {}
|
|
grouped_by_lifecycle_state: dict[str, int] = {}
|
|
grouped_by_side: dict[str, dict] = {}
|
|
grouped_by_session: dict[str, dict] = {}
|
|
grouped_by_weekday: dict[str, dict] = {}
|
|
grouped_by_symbol: dict[str, dict] = {}
|
|
grouped_by_timeframe: dict[str, dict] = {}
|
|
grouped_by_doctrine_cohort: dict[str, dict] = {}
|
|
grouped_by_liquidity_target_side: dict[str, dict] = {}
|
|
grouped_by_pd_array_type: dict[str, dict] = {}
|
|
grouped_by_time_session_code: dict[str, dict] = {}
|
|
r_multiples: list[float] = []
|
|
cost_values: list[float] = []
|
|
mfe_r_values: list[float] = []
|
|
mae_r_values: list[float] = []
|
|
executed_r_multiples: list[float] = []
|
|
executed_signals = 0
|
|
confirmed_candidates = 0
|
|
compliant_candidates = 0
|
|
draw_target_hits = 0
|
|
failure_reason_counts: dict[str, int] = {}
|
|
liquidity_kernel_reason_counts: dict[str, int] = {}
|
|
liquidity_kernel_status_counts: dict[str, int] = {}
|
|
pd_array_reaction_reason_counts: dict[str, int] = {}
|
|
pd_array_reaction_status_counts: dict[str, int] = {}
|
|
time_session_kernel_reason_counts: dict[str, int] = {}
|
|
time_session_kernel_status_counts: dict[str, int] = {}
|
|
plan_total_r: dict[int, float] = {}
|
|
plan_seen: set[int] = set()
|
|
|
|
for outcome in outcomes:
|
|
grouped_by_outcome[outcome.outcome] = grouped_by_outcome.get(outcome.outcome, 0) + 1
|
|
lifecycle_state = classify_backtest_lifecycle_state(
|
|
outcome=outcome.outcome,
|
|
tp1_hit=outcome.tp1_hit,
|
|
tp2_hit=outcome.tp2_hit,
|
|
invalidated_before_entry=outcome.invalidated_before_entry,
|
|
)
|
|
if lifecycle_state is not None:
|
|
grouped_by_lifecycle_state[lifecycle_state] = grouped_by_lifecycle_state.get(lifecycle_state, 0) + 1
|
|
if is_executed_outcome(outcome):
|
|
executed_signals += 1
|
|
executed_r_multiples.append(float(outcome.meta.get('r_multiple') or 0.0))
|
|
if outcome.meta.get('candidate_confirmed'):
|
|
confirmed_candidates += 1
|
|
if outcome.meta.get('model_compliant'):
|
|
compliant_candidates += 1
|
|
if outcome.meta.get('draw_target_hit'):
|
|
draw_target_hits += 1
|
|
for reason in list(outcome.meta.get('failure_reasons') or []) + list(outcome.error_tags or []):
|
|
failure_reason_counts[reason] = failure_reason_counts.get(reason, 0) + 1
|
|
trading_plan_id = getattr(outcome, 'trading_plan_id', None)
|
|
if trading_plan_id is not None:
|
|
plan_seen.add(int(trading_plan_id))
|
|
plan_total_r[int(trading_plan_id)] = plan_total_r.get(int(trading_plan_id), 0.0) + float(outcome.meta.get('r_multiple') or 0.0)
|
|
|
|
side = str(outcome.meta.get('signal_side') or 'unknown')
|
|
session_codes = outcome.meta.get('session_codes') or ['OFF_HOURS']
|
|
weekday = str(outcome.meta.get('weekday') or 'unknown')
|
|
symbol = _extract_symbol_from_outcome(outcome)
|
|
timeframe_bucket = str(outcome.meta.get('signal_timeframe_bucket') or 'unknown')
|
|
doctrine_cohort_key = str(outcome.meta.get('doctrine_cohort_key') or 'missing_playbook::incomplete')
|
|
liquidity_features = dict(outcome.meta.get('liquidity_backtest_features') or {})
|
|
liquidity_target_side = str(liquidity_features.get('liquidity_target_side') or 'missing_liquidity_target')
|
|
liquidity_status = dict(outcome.meta.get('liquidity_kernel_status') or evaluate_liquidity_kernel_status(liquidity_features))
|
|
liquidity_status_key = str(liquidity_status.get('status') or 'unknown')
|
|
liquidity_kernel_status_counts[liquidity_status_key] = liquidity_kernel_status_counts.get(liquidity_status_key, 0) + 1
|
|
for reason in list(liquidity_status.get('reasons') or []):
|
|
liquidity_kernel_reason_counts[str(reason)] = liquidity_kernel_reason_counts.get(str(reason), 0) + 1
|
|
pd_array_features = dict(outcome.meta.get('pd_array_backtest_features') or {})
|
|
pd_array_type = str(pd_array_features.get('pd_array_type') or 'missing_pd_array')
|
|
pd_array_status = dict(outcome.meta.get('pd_array_reaction_status') or evaluate_pd_array_reaction_status(pd_array_features))
|
|
pd_array_status_key = str(pd_array_status.get('status') or 'unknown')
|
|
pd_array_reaction_status_counts[pd_array_status_key] = pd_array_reaction_status_counts.get(pd_array_status_key, 0) + 1
|
|
for reason in list(pd_array_status.get('reasons') or []):
|
|
pd_array_reaction_reason_counts[str(reason)] = pd_array_reaction_reason_counts.get(str(reason), 0) + 1
|
|
time_session_features = dict(outcome.meta.get('time_session_backtest_features') or {})
|
|
time_session_code = str(time_session_features.get('session_code') or (session_codes[0] if session_codes else '') or 'missing_session_window')
|
|
time_session_status = dict(outcome.meta.get('time_session_kernel_status') or evaluate_time_session_kernel_status(time_session_features))
|
|
time_session_status_key = str(time_session_status.get('status') or 'unknown')
|
|
time_session_kernel_status_counts[time_session_status_key] = time_session_kernel_status_counts.get(time_session_status_key, 0) + 1
|
|
for reason in list(time_session_status.get('reasons') or []):
|
|
time_session_kernel_reason_counts[str(reason)] = time_session_kernel_reason_counts.get(str(reason), 0) + 1
|
|
r_multiples.append(float(outcome.meta.get('r_multiple') or 0.0))
|
|
cost_values.append(float(outcome.meta.get('cost_r') or 0.0))
|
|
mfe_r_values.append(float(outcome.meta.get('mfe_r') or 0.0))
|
|
mae_r_values.append(float(outcome.meta.get('mae_r') or 0.0))
|
|
|
|
_accumulate_bucket(grouped_by_side, side, outcome)
|
|
_accumulate_bucket(grouped_by_weekday, weekday, outcome)
|
|
_accumulate_bucket(grouped_by_symbol, symbol, outcome)
|
|
_accumulate_bucket(grouped_by_timeframe, timeframe_bucket, outcome)
|
|
_accumulate_bucket(grouped_by_doctrine_cohort, doctrine_cohort_key, outcome)
|
|
_accumulate_bucket(grouped_by_liquidity_target_side, liquidity_target_side, outcome)
|
|
_accumulate_bucket(grouped_by_pd_array_type, pd_array_type, outcome)
|
|
_accumulate_bucket(grouped_by_time_session_code, time_session_code, outcome)
|
|
for session_code in session_codes:
|
|
_accumulate_bucket(grouped_by_session, str(session_code), outcome)
|
|
|
|
plan_count = len(plan_seen)
|
|
plan_wins = sum(1 for total_r in plan_total_r.values() if total_r > 0.0)
|
|
candidate_count = total
|
|
result = {
|
|
'subject_type': subject_type,
|
|
'total_signals': total,
|
|
'candidate_count': candidate_count,
|
|
'outcomes': grouped_by_outcome,
|
|
'lifecycle_states': grouped_by_lifecycle_state,
|
|
'win_rate': _ratio(grouped_by_outcome.get('win', 0), total),
|
|
'loss_rate': _ratio(grouped_by_outcome.get('loss', 0), total),
|
|
'breakeven_rate': _ratio(grouped_by_outcome.get('breakeven', 0), total),
|
|
'miss_rate': _ratio(grouped_by_outcome.get('missed', 0), total),
|
|
'executed_signals': executed_signals,
|
|
'executed_rate': _ratio(executed_signals, total),
|
|
'executed_expectancy_r': _average(executed_r_multiples),
|
|
'executed_profit_factor_r': _profit_factor(executed_r_multiples),
|
|
'candidate_confirmation_rate': _ratio(confirmed_candidates, candidate_count),
|
|
'confirmed_candidates': confirmed_candidates,
|
|
'model_compliant_candidates': compliant_candidates,
|
|
'model_compliance_rate': _ratio(compliant_candidates, candidate_count),
|
|
'draw_target_hit_rate': _ratio(draw_target_hits, executed_signals),
|
|
'draw_target_hits': draw_target_hits,
|
|
'plan_count': plan_count,
|
|
'plan_wins': plan_wins,
|
|
'plan_win_rate': _ratio(plan_wins, plan_count),
|
|
'failure_reasons': failure_reason_counts,
|
|
'liquidity_kernel_status_counts': liquidity_kernel_status_counts,
|
|
'liquidity_kernel_reason_counts': liquidity_kernel_reason_counts,
|
|
'pd_array_reaction_status_counts': pd_array_reaction_status_counts,
|
|
'pd_array_reaction_reason_counts': pd_array_reaction_reason_counts,
|
|
'time_session_kernel_status_counts': time_session_kernel_status_counts,
|
|
'time_session_kernel_reason_counts': time_session_kernel_reason_counts,
|
|
'avg_mfe': _average([outcome.mfe for outcome in outcomes]),
|
|
'avg_mae': _average([outcome.mae for outcome in outcomes]),
|
|
'avg_mfe_r': _average(mfe_r_values),
|
|
'avg_mae_r': _average(mae_r_values),
|
|
'total_r': sum(r_multiples),
|
|
'expectancy_r': _average(r_multiples),
|
|
'profit_factor_r': _profit_factor(r_multiples),
|
|
'max_drawdown_r': compute_max_drawdown_r(r_multiples),
|
|
'max_consecutive_losses': compute_max_consecutive_losses(outcomes),
|
|
'cumulative_r_curve': build_cumulative_r_curve(r_multiples),
|
|
'total_cost_r': sum(cost_values),
|
|
'avg_cost_r': _average(cost_values),
|
|
'by_side': grouped_by_side,
|
|
'by_session': grouped_by_session,
|
|
'by_weekday': grouped_by_weekday,
|
|
'by_symbol': grouped_by_symbol,
|
|
'by_timeframe': grouped_by_timeframe,
|
|
'by_doctrine_cohort': grouped_by_doctrine_cohort,
|
|
'by_liquidity_target_side': grouped_by_liquidity_target_side,
|
|
'by_pd_array_type': grouped_by_pd_array_type,
|
|
'by_time_session_code': grouped_by_time_session_code,
|
|
}
|
|
result['session_performance'] = result['by_session']
|
|
return result
|
|
|
|
|
|
def normalize_timeframe_set(timeframe_set: Optional[dict]) -> dict:
|
|
timeframe_set = dict(timeframe_set or {})
|
|
bias_tf = str(timeframe_set.get('bias_tf') or '1m')
|
|
setup_tf = str(timeframe_set.get('setup_tf') or bias_tf)
|
|
trigger_tf = str(timeframe_set.get('trigger_tf') or setup_tf)
|
|
return {
|
|
'bias_tf': bias_tf,
|
|
'setup_tf': setup_tf,
|
|
'trigger_tf': trigger_tf,
|
|
}
|
|
|
|
|
|
def extract_target_plan_from_signal_row(signal: dict) -> dict:
|
|
return dict(signal.get('target_plan') or {})
|
|
|
|
|
|
def extract_management_rules_from_signal_row(signal: dict) -> list[dict]:
|
|
target_plan = dict(signal.get('target_plan') or {})
|
|
return list(target_plan.get('management_rules') or [])
|
|
|
|
|
|
def build_timeframe_bucket(*, bias_tf: str, setup_tf: str, trigger_tf: str) -> str:
|
|
return f"{bias_tf}/{setup_tf}/{trigger_tf}"
|
|
|
|
|
|
def evaluate_backtest_quality(
|
|
summary: dict,
|
|
gate: Optional[BacktestQualityGate] = None,
|
|
sample_basis: str = "total_plus_executed",
|
|
) -> dict[str, object]:
|
|
gate = gate or BacktestQualityGate()
|
|
reasons: list[str] = []
|
|
|
|
total_signals = int(summary.get('total_signals') or 0)
|
|
expectancy_r = float(summary.get('expectancy_r') or 0.0)
|
|
profit_factor_r = summary.get('profit_factor_r')
|
|
max_drawdown_r = float(summary.get('max_drawdown_r') or 0.0)
|
|
max_consecutive_losses = int(summary.get('max_consecutive_losses') or 0)
|
|
executed_signals = int(summary.get('executed_signals') or 0)
|
|
|
|
if sample_basis != "executed" and total_signals < gate.min_total_signals:
|
|
reasons.append('sample_size_too_small')
|
|
if executed_signals < gate.min_executed_signals:
|
|
reasons.append('executed_sample_too_small')
|
|
if expectancy_r <= gate.min_expectancy_r:
|
|
reasons.append('expectancy_below_threshold')
|
|
if profit_factor_r is None or float(profit_factor_r) < gate.min_profit_factor_r:
|
|
reasons.append('profit_factor_below_threshold')
|
|
if max_drawdown_r > gate.max_drawdown_r:
|
|
reasons.append('drawdown_above_threshold')
|
|
if max_consecutive_losses > gate.max_consecutive_losses:
|
|
reasons.append('consecutive_losses_above_threshold')
|
|
|
|
return {
|
|
'passed': len(reasons) == 0,
|
|
'reasons': reasons,
|
|
'gate': gate.__dict__,
|
|
'sample_basis': sample_basis,
|
|
'metrics': {
|
|
'total_signals': total_signals,
|
|
'executed_signals': executed_signals,
|
|
'expectancy_r': expectancy_r,
|
|
'profit_factor_r': profit_factor_r,
|
|
'max_drawdown_r': max_drawdown_r,
|
|
'max_consecutive_losses': max_consecutive_losses,
|
|
},
|
|
}
|
|
|
|
|
|
def _accumulate_bucket(container: dict, key: str, outcome: BacktestOutcome) -> None:
|
|
bucket = container.setdefault(
|
|
key,
|
|
{
|
|
'count': 0,
|
|
'wins': 0,
|
|
'losses': 0,
|
|
'breakevens': 0,
|
|
'misses': 0,
|
|
'executed': 0,
|
|
'executed_total_r': 0.0,
|
|
'avg_mfe_sum': 0.0,
|
|
'avg_mae_sum': 0.0,
|
|
'avg_mfe_r_sum': 0.0,
|
|
'avg_mae_r_sum': 0.0,
|
|
'current_loss_streak': 0,
|
|
'max_consecutive_losses': 0,
|
|
},
|
|
)
|
|
bucket['count'] += 1
|
|
if is_executed_outcome(outcome):
|
|
bucket['executed'] += 1
|
|
if outcome.outcome == 'win':
|
|
bucket['wins'] += 1
|
|
bucket['current_loss_streak'] = 0
|
|
elif outcome.outcome == 'loss':
|
|
bucket['losses'] += 1
|
|
bucket['current_loss_streak'] += 1
|
|
elif outcome.outcome == 'breakeven':
|
|
bucket['breakevens'] += 1
|
|
bucket['current_loss_streak'] = 0
|
|
else:
|
|
bucket['misses'] += 1
|
|
bucket['current_loss_streak'] = 0
|
|
bucket['max_consecutive_losses'] = max(bucket['max_consecutive_losses'], bucket['current_loss_streak'])
|
|
bucket['avg_mfe_sum'] += outcome.mfe
|
|
bucket['avg_mae_sum'] += outcome.mae
|
|
bucket['avg_mfe_r_sum'] += float(outcome.meta.get('mfe_r') or 0.0)
|
|
bucket['avg_mae_r_sum'] += float(outcome.meta.get('mae_r') or 0.0)
|
|
r_multiple = float(outcome.meta.get('r_multiple') or 0.0)
|
|
bucket['total_r'] = bucket.get('total_r', 0.0) + r_multiple
|
|
if is_executed_outcome(outcome):
|
|
bucket['executed_total_r'] += r_multiple
|
|
cost_r = float(outcome.meta.get('cost_r') or 0.0)
|
|
bucket['total_cost_r'] = bucket.get('total_cost_r', 0.0) + cost_r
|
|
bucket['win_rate'] = _ratio(bucket['wins'], bucket['count'])
|
|
bucket['executed_rate'] = _ratio(bucket['executed'], bucket['count'])
|
|
bucket['executed_expectancy_r'] = bucket['executed_total_r'] / bucket['executed'] if bucket['executed'] else 0.0
|
|
bucket['avg_mfe'] = bucket['avg_mfe_sum'] / bucket['count'] if bucket['count'] else 0.0
|
|
bucket['avg_mae'] = bucket['avg_mae_sum'] / bucket['count'] if bucket['count'] else 0.0
|
|
bucket['avg_mfe_r'] = bucket['avg_mfe_r_sum'] / bucket['count'] if bucket['count'] else 0.0
|
|
bucket['avg_mae_r'] = bucket['avg_mae_r_sum'] / bucket['count'] if bucket['count'] else 0.0
|
|
bucket['expectancy_r'] = bucket['total_r'] / bucket['count'] if bucket['count'] else 0.0
|
|
bucket['avg_cost_r'] = bucket['total_cost_r'] / bucket['count'] if bucket['count'] else 0.0
|
|
curve = bucket.setdefault('cumulative_r_curve', [])
|
|
curve.append(bucket['total_r'])
|
|
bucket['max_drawdown_r'] = compute_drawdown_from_curve(curve)
|
|
|
|
|
|
def is_executed_outcome(outcome: BacktestOutcome) -> bool:
|
|
return outcome.outcome in {'win', 'loss', 'breakeven'} or bool(outcome.meta.get('entry_triggered'))
|
|
|
|
|
|
def _extract_symbol_from_outcome(outcome: BacktestOutcome) -> str:
|
|
symbol = outcome.meta.get('signal_symbol')
|
|
if symbol:
|
|
return str(symbol)
|
|
instrument_id = outcome.meta.get('instrument_id')
|
|
if instrument_id is not None:
|
|
return f"instrument:{instrument_id}"
|
|
return 'UNKNOWN'
|
|
|
|
|
|
def _ratio(numerator: int, denominator: int) -> float:
|
|
return numerator / denominator if denominator else 0.0
|
|
|
|
|
|
def _average(values: list[float]) -> float:
|
|
return sum(values) / len(values) if values else 0.0
|
|
|
|
|
|
def build_cumulative_r_curve(values: list[float]) -> list[float]:
|
|
curve: list[float] = []
|
|
running = 0.0
|
|
for value in values:
|
|
running += value
|
|
curve.append(running)
|
|
return curve
|
|
|
|
|
|
def compute_max_drawdown_r(values: list[float]) -> float:
|
|
return compute_drawdown_from_curve(build_cumulative_r_curve(values))
|
|
|
|
|
|
def compute_max_consecutive_losses(outcomes: list[BacktestOutcome]) -> int:
|
|
current = 0
|
|
longest = 0
|
|
for outcome in outcomes:
|
|
if outcome.outcome == 'loss':
|
|
current += 1
|
|
longest = max(longest, current)
|
|
else:
|
|
current = 0
|
|
return longest
|
|
|
|
|
|
def compute_drawdown_from_curve(curve: list[float]) -> float:
|
|
peak = 0.0
|
|
max_drawdown = 0.0
|
|
for value in curve:
|
|
peak = max(peak, value)
|
|
max_drawdown = max(max_drawdown, peak - value)
|
|
return max_drawdown
|
|
|
|
|
|
def _profit_factor(values: list[float]) -> Optional[float]:
|
|
gross_profit = sum(value for value in values if value > 0)
|
|
gross_loss = abs(sum(value for value in values if value < 0))
|
|
if gross_loss == 0:
|
|
return None if gross_profit == 0 else gross_profit
|
|
return gross_profit / gross_loss
|
|
|
|
|
|
def _extract_weekday(ts) -> str:
|
|
if isinstance(ts, datetime):
|
|
return ts.strftime('%A')
|
|
if isinstance(ts, str):
|
|
normalized = ts.replace('Z', '+00:00')
|
|
return datetime.fromisoformat(normalized).strftime('%A')
|
|
return 'unknown'
|