You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
496 lines
17 KiB
Python
496 lines
17 KiB
Python
"""Core domain models for the ACE-F backtester."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
class PositionStatus(str, Enum):
|
|
PLANNED = "PLANNED"
|
|
ENTERED = "ENTERED"
|
|
PARTIALLY_EXITED = "PARTIALLY_EXITED"
|
|
OPEN = "OPEN"
|
|
EXIT_PENDING = "EXIT_PENDING"
|
|
CLOSED = "CLOSED"
|
|
ARCHIVED = "ARCHIVED"
|
|
|
|
|
|
class ExitReason(str, Enum):
|
|
STOP = "STOP"
|
|
TARGET = "TARGET"
|
|
TIME = "TIME"
|
|
TRAILING = "TRAILING"
|
|
KILL_SWITCH = "KILL_SWITCH"
|
|
MISSING_BAR = "MISSING_BAR"
|
|
NO_FOLLOW_THROUGH = "NO_FOLLOW_THROUGH"
|
|
|
|
|
|
class BacktestMode(str, Enum):
|
|
RESEARCH = "research"
|
|
LIVE = "live"
|
|
|
|
|
|
class Candidate(BaseModel):
|
|
"""An eligible trade candidate derived from a Parquet snapshot row."""
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
event_id: str
|
|
symbol: str
|
|
issuer_id: str | None = None
|
|
score: float
|
|
sector: str # "UNKNOWN" if unavailable
|
|
event_type: str
|
|
event_timestamp: dt.datetime # must be timezone-aware
|
|
event_date: dt.date | None = None
|
|
filing_time_bucket: str
|
|
timing_class: str = "unknown" # "same_day", "after_close", "unknown"
|
|
reaction_date: dt.date
|
|
execution_date: dt.date # mapped from Parquet entry_date at SnapshotStore boundary
|
|
entry_price_est: float # reaction-day close price
|
|
avg_dollar_volume: float # 20-day mean(volume * close)
|
|
atr_14: float | None = None
|
|
score_bucket: str
|
|
engine_id: str = "default"
|
|
entry_timing_policy: str = "next_open"
|
|
shadow_only: bool = False
|
|
engine_max_holding_days: int | None = None
|
|
engine_risk_budget_pct: float = 1.0
|
|
engine_target_atr_multiplier: float | None = None
|
|
engine_target_1_fraction: float | None = None
|
|
engine_trailing_model: str | None = None
|
|
engine_trailing_warmup_days: int | None = None
|
|
trade_direction: str = "long" # "long" or "short"
|
|
features: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class PlannedOrder(BaseModel):
|
|
"""A sized, gated order plan for a candidate."""
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
candidate: Candidate
|
|
shares: int
|
|
entry_price_limit: float
|
|
stop_price: float
|
|
target_price: float
|
|
risk_dollars: float
|
|
event_date: dt.date | None = None
|
|
timing_class: str = "unknown"
|
|
engine_id: str = "default"
|
|
entry_timing_policy: str = "next_open"
|
|
shadow_only: bool = False
|
|
skip_reason: str | None = None # non-None means the order was rejected
|
|
|
|
|
|
class FilledTrade(BaseModel):
|
|
"""A completed (closed) trade leg."""
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
trade_id: str
|
|
position_id: str
|
|
event_id: str
|
|
symbol: str
|
|
event_date: dt.date | None = None
|
|
timing_class: str = "unknown"
|
|
engine_id: str = "default"
|
|
entry_timing_policy: str = "next_open"
|
|
shadow_only: bool = False
|
|
entry_date: dt.date
|
|
exit_date: dt.date
|
|
entry_price: float
|
|
exit_price: float
|
|
exit_reason: ExitReason
|
|
shares: int
|
|
commission: float
|
|
slippage_bps: float
|
|
gross_pnl: float
|
|
net_pnl: float
|
|
pnl_pct: float
|
|
r_multiple: float
|
|
holding_days: int
|
|
|
|
|
|
class OpenPosition(BaseModel):
|
|
"""A live open position (mutable throughout its lifetime)."""
|
|
|
|
position_id: str
|
|
plan: PlannedOrder
|
|
entry_date: dt.date
|
|
entry_price: float
|
|
entry_fill_slippage_bps: float
|
|
current_stop: float
|
|
target_price: float
|
|
peak_price: float
|
|
shares_open: int
|
|
shares_total: int
|
|
days_held: int = 0
|
|
status: PositionStatus = PositionStatus.ENTERED
|
|
partial_fills: list[FilledTrade] = Field(default_factory=list)
|
|
|
|
|
|
class DailyPortfolioState(BaseModel):
|
|
"""Immutable snapshot of portfolio state at end of a trading day."""
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
date: dt.date
|
|
equity: float
|
|
cash_available: float
|
|
gross_exposure: float
|
|
net_exposure: float
|
|
reserved_risk_budget: float
|
|
unrealized_pnl: float
|
|
realized_pnl: float
|
|
open_positions: list[str] = Field(default_factory=list) # position_ids
|
|
daily_new_risk_used: float
|
|
peak_equity: float
|
|
current_drawdown_pct: float
|
|
|
|
|
|
class MetricsBundle(BaseModel):
|
|
"""21 performance metrics for a completed backtest run."""
|
|
|
|
# Trade metrics (7)
|
|
trade_count: int = 0
|
|
win_rate: float | None = None
|
|
avg_win_pct: float | None = None
|
|
avg_loss_pct: float | None = None
|
|
profit_factor: float | None = None
|
|
expectancy_r: float | None = None
|
|
avg_r_multiple: float | None = None
|
|
|
|
# Portfolio metrics (8)
|
|
total_return_pct: float | None = None
|
|
annualized_return_pct: float | None = None
|
|
max_drawdown_pct: float | None = None
|
|
calmar_ratio: float | None = None
|
|
sharpe_ratio: float | None = None
|
|
sortino_ratio: float | None = None
|
|
avg_daily_pnl: float | None = None
|
|
avg_positions_held: float | None = None
|
|
avg_gross_exposure_pct: float | None = None
|
|
avg_net_exposure_pct: float | None = None
|
|
days_in_market_pct: float | None = None
|
|
|
|
# Stability metrics (4)
|
|
trade_skewness: float | None = None
|
|
trade_kurtosis: float | None = None
|
|
monthly_win_rate: float | None = None
|
|
equity_curve_r_squared: float | None = None
|
|
|
|
# Practicality metrics (4)
|
|
avg_holding_days: float | None = None
|
|
stop_exit_rate: float | None = None
|
|
target_exit_rate: float | None = None
|
|
no_follow_through_exit_rate: float | None = None
|
|
score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict)
|
|
|
|
# Bootstrap confidence intervals (95%)
|
|
bootstrap_cis: dict[str, tuple[float, float] | None] = Field(default_factory=dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config models (mirror JSON Schema)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class UniverseConfig(BaseModel):
|
|
min_price: float = 5.0
|
|
min_avg_dollar_volume: float = 1_000_000.0
|
|
exclude_asset_types: list[str] = Field(default_factory=list)
|
|
allowed_exchanges: list[str] | None = None
|
|
|
|
|
|
class SignalConfig(BaseModel):
|
|
score_threshold: float = 0.5
|
|
max_candidates_per_day: int = 5
|
|
execution_timing: str = "next_open"
|
|
decision_timing: str = "reaction_close"
|
|
ranking_fields: list[str] = Field(default_factory=list)
|
|
scoring_model: str = "default" # "default" or "pead"
|
|
pead_reaction_threshold: float = 0.05
|
|
pead_volume_threshold: float = 1.5
|
|
|
|
|
|
class RiskConfig(BaseModel):
|
|
per_trade_risk_pct: float = 0.01 # 1% of equity per trade
|
|
max_daily_new_risk_pct: float = 0.03 # 3% of equity per day
|
|
max_positions: int = 10
|
|
max_positions_per_sector: int = 3
|
|
max_position_value_pct: float | None = None # max fraction of equity in one position
|
|
max_adv_fraction: float | None = None # max fraction of avg daily volume
|
|
cooldown_after_loss_streak: int = 0 # consecutive losses to trigger cooldown
|
|
cooldown_days: int = 0 # days to sit out after streak
|
|
macro_regime_enabled: bool = False # block entries when SPY < SMA
|
|
macro_regime_size_scaler: float = 1.0 # size scaler when SPY < SMA (< 1.0 = scale down instead of block)
|
|
macro_sma_period: int = 20 # SMA lookback for macro regime
|
|
stop_atr_multiplier: float = 1.5 # ATR multiplier for stop distance
|
|
backtest_mode: str = "research" # "research" or "live"
|
|
kill_switch_cooldown_days: int = 20 # trading days before reset (research only)
|
|
kill_switch_log_only: bool = False # log-only mode (don't trigger, just observe)
|
|
veto_oneoff_penalty: float = 0.5 # block if oneoff_penalty >= this
|
|
veto_parse_confidence_min: float = 0.4 # block if parse_confidence < this
|
|
veto_unknown_direction: bool = True # block if event_direction == "unknown"
|
|
veto_bearish_direction: bool = True # block if event_direction == "bearish"
|
|
|
|
|
|
class ExecutionConfig(BaseModel):
|
|
entry_fill_model: str = "next_open"
|
|
exit_fill_model: str = "daily_bar_approximation"
|
|
slippage_bps_base: float = 10.0
|
|
commission_per_share: float = 0.005
|
|
same_bar_priority: str = "stop_first_conservative"
|
|
stop_model: str | None = None
|
|
target_model: str = "fixed_r" # "fixed_r" or "atr_multiple"
|
|
target_1_r: float | None = None # R-multiple for first target (fixed_r model)
|
|
target_atr_multiplier: float = 1.5 # ATR multiplier for target (atr_multiple model)
|
|
target_1_fraction: float | None = None # fraction to exit at target_1 (partial exit)
|
|
trailing_model: str | None = None
|
|
trailing_warmup_days: int = 0 # days after entry before trailing activates
|
|
max_holding_days: int = 10
|
|
no_follow_through_exit: bool = False # exit at D+1 close if close < entry price
|
|
|
|
|
|
class StrategyEngineConfig(BaseModel):
|
|
"""Specialist engine routing and execution policy."""
|
|
|
|
engine_id: str
|
|
event_types: list[str] = Field(default_factory=list)
|
|
timing_class: str = "any" # "same_day", "after_close", "any"
|
|
direction: str = "any" # "long_only", "short_only", "any"
|
|
entry_timing_policy: str = "next_open" # "next_open", "reaction_close"
|
|
max_holding_days: int | None = None
|
|
engine_risk_budget_pct: float = 1.0
|
|
target_atr_multiplier_override: float | None = None
|
|
target_1_fraction_override: float | None = None
|
|
trailing_model_override: str | None = None
|
|
trailing_warmup_days_override: int | None = None
|
|
score_threshold_override: float | None = None
|
|
pead_reaction_threshold_override: float | None = None
|
|
pead_volume_threshold_override: float | None = None
|
|
reaction_day_return_min: float | None = None
|
|
reaction_day_return_max: float | None = None
|
|
gap_size_min: float | None = None
|
|
gap_size_max: float | None = None
|
|
shadow_only: bool = False
|
|
enabled: bool = True
|
|
|
|
|
|
class EventTypeProfile(BaseModel):
|
|
"""Per-event-type overrides for scoring, risk, and exit parameters."""
|
|
enabled: bool = True
|
|
score_threshold_override: float | None = None
|
|
max_holding_days_override: int | None = None
|
|
stop_atr_multiplier_override: float | None = None
|
|
target_atr_multiplier_override: float | None = None
|
|
direction_filter: str = "any" # "bullish_only", "bearish_only", "any"
|
|
|
|
|
|
class ReportingConfig(BaseModel):
|
|
write_trade_blotter: bool = True
|
|
write_equity_curve: bool = True
|
|
write_metrics_summary: bool = True
|
|
generate_plots: bool = False
|
|
attribution_buckets: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class BacktestConfig(BaseModel):
|
|
strategy_name: str
|
|
dataset_snapshot_id: str
|
|
universe: UniverseConfig = Field(default_factory=UniverseConfig)
|
|
signal: SignalConfig = Field(default_factory=SignalConfig)
|
|
risk: RiskConfig = Field(default_factory=RiskConfig)
|
|
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
|
|
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
|
|
event_type_profiles: dict[str, EventTypeProfile] = Field(default_factory=dict)
|
|
strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list)
|
|
strategy_engine_selection_mode: str = "interleave" # "interleave" or "global_score"
|
|
|
|
def get_event_profile(self, event_type: str) -> EventTypeProfile | None:
|
|
"""Look up event-type-specific profile. Returns None if no override."""
|
|
return self.event_type_profiles.get(event_type)
|
|
|
|
def get_strategy_engines(self) -> list[StrategyEngineConfig]:
|
|
"""Enabled strategy engines in manifest order."""
|
|
return [engine for engine in self.strategy_engines if engine.enabled]
|
|
|
|
def get_active_strategy_engines(self) -> list[StrategyEngineConfig]:
|
|
"""Enabled engines that participate in the live portfolio."""
|
|
return [engine for engine in self.get_strategy_engines() if not engine.shadow_only]
|
|
|
|
def get_shadow_strategy_engines(self) -> list[StrategyEngineConfig]:
|
|
"""Enabled engines that run in paper/shadow mode only."""
|
|
return [engine for engine in self.get_strategy_engines() if engine.shadow_only]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Experiment models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SplitSpec(BaseModel):
|
|
kind: str
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ExperimentManifest(BaseModel):
|
|
experiment_name: str
|
|
dataset_snapshot_id: str
|
|
description: str | None = None
|
|
base_config: str # path to base config JSON file
|
|
overrides: dict[str, Any] = Field(default_factory=dict)
|
|
strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list)
|
|
splits: list[SplitSpec] = Field(default_factory=list)
|
|
tags: list[str] = Field(default_factory=list)
|
|
notes: str | None = None
|
|
|
|
|
|
class ExperimentResult(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
run_id: str
|
|
manifest: ExperimentManifest
|
|
resolved_config: BacktestConfig
|
|
metrics: MetricsBundle
|
|
artifact_paths: dict[str, str] = Field(default_factory=dict)
|
|
started_at: dt.datetime
|
|
finished_at: dt.datetime
|
|
total_trading_days: int
|
|
total_candidates_seen: int
|
|
total_orders_rejected: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Improvement Tracking models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SQSWeights(BaseModel):
|
|
"""Weights for Strategy Quality Score computation."""
|
|
|
|
profitability: float = 0.40
|
|
risk: float = 0.25
|
|
consistency: float = 0.20
|
|
robustness: float = 0.15
|
|
low_trade_penalty_threshold: int = 20
|
|
low_trade_penalty_factor: float = 0.5
|
|
|
|
|
|
class SQSv2Weights(BaseModel):
|
|
"""Weights for Strategy Quality Score v2 with capital efficiency."""
|
|
|
|
profitability: float = 0.35
|
|
risk: float = 0.25
|
|
consistency: float = 0.20
|
|
robustness: float = 0.10
|
|
capital_efficiency: float = 0.10
|
|
low_trade_penalty_threshold: int = 20
|
|
low_trade_penalty_factor: float = 0.5
|
|
|
|
|
|
class PromotionScoreWeights(BaseModel):
|
|
"""Weights for promotion scoring across valid/test splits."""
|
|
|
|
valid_quality: float = 0.55
|
|
test_quality: float = 0.15
|
|
floor_quality: float = 0.30
|
|
|
|
|
|
class UnifiedScoreWeights(BaseModel):
|
|
"""Weights for a stricter single ranking score across valid/test splits."""
|
|
|
|
split_profitability: float = 0.25
|
|
split_risk: float = 0.20
|
|
split_consistency: float = 0.15
|
|
split_robustness: float = 0.20
|
|
split_capital_efficiency: float = 0.20
|
|
valid_quality: float = 0.45
|
|
test_quality: float = 0.20
|
|
floor_quality: float = 0.20
|
|
gap_quality: float = 0.15
|
|
|
|
|
|
class SplitResult(BaseModel):
|
|
"""Metrics for a single backtest split (train/valid/test)."""
|
|
|
|
run_id: str
|
|
trade_count: int = 0
|
|
profit_factor: float | None = None
|
|
total_return_pct: float | None = None
|
|
win_rate: float | None = None
|
|
max_drawdown_pct: float | None = None
|
|
sharpe_ratio: float | None = None
|
|
monthly_win_rate: float | None = None
|
|
equity_curve_r_squared: float | None = None
|
|
avg_gross_exposure_pct: float | None = None
|
|
avg_net_exposure_pct: float | None = None
|
|
days_in_market_pct: float | None = None
|
|
|
|
|
|
class ConfigDelta(BaseModel):
|
|
"""Records what changed from a baseline experiment."""
|
|
|
|
base_experiment: str
|
|
changes: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class JournalEntry(BaseModel):
|
|
"""One improvement cycle entry in the journal."""
|
|
|
|
entry_id: str
|
|
timestamp: str
|
|
experiment_name: str
|
|
hypothesis: str
|
|
config_delta: ConfigDelta | None = None
|
|
results: dict[str, SplitResult] = Field(default_factory=dict) # split_name → SplitResult
|
|
sqs_score: float | None = None
|
|
sqs_breakdown: dict[str, float] = Field(default_factory=dict)
|
|
sqs_v2_score: float | None = None
|
|
sqs_v2_breakdown: dict[str, float] = Field(default_factory=dict)
|
|
promotion_score: float | None = None
|
|
promotion_breakdown: dict[str, float] = Field(default_factory=dict)
|
|
unified_score: float | None = None
|
|
unified_breakdown: dict[str, float] = Field(default_factory=dict)
|
|
verdict: str = "unknown" # better / worse / neutral / unknown
|
|
verdict_reasoning: str = ""
|
|
next_direction: str = ""
|
|
tags: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RegistryEntry(BaseModel):
|
|
"""A leaderboard row derived from a JournalEntry."""
|
|
|
|
entry_id: str
|
|
experiment_name: str
|
|
sqs_score: float
|
|
sqs_v2_score: float | None = None
|
|
promotion_score: float | None = None
|
|
unified_score: float | None = None
|
|
# test split metrics
|
|
profit_factor: float | None = None
|
|
total_return_pct: float | None = None
|
|
win_rate: float | None = None
|
|
sharpe_ratio: float | None = None
|
|
max_drawdown_pct: float | None = None
|
|
trade_count: int = 0
|
|
avg_gross_exposure_pct: float | None = None
|
|
avg_net_exposure_pct: float | None = None
|
|
days_in_market_pct: float | None = None
|
|
# valid split metrics
|
|
valid_profit_factor: float | None = None
|
|
valid_total_return_pct: float | None = None
|
|
valid_win_rate: float | None = None
|
|
valid_sharpe_ratio: float | None = None
|
|
valid_max_drawdown_pct: float | None = None
|
|
valid_trade_count: int = 0
|
|
valid_avg_gross_exposure_pct: float | None = None
|
|
valid_avg_net_exposure_pct: float | None = None
|
|
valid_days_in_market_pct: float | None = None
|
|
timestamp: str = ""
|
|
|
|
|
|
class ExperimentRegistry(BaseModel):
|
|
"""Full leaderboard data (regenerated from journal)."""
|
|
|
|
entries: list[RegistryEntry] = Field(default_factory=list)
|
|
updated_at: str = ""
|