"""Walk-forward validation for the analysis-only dynamic allocation strategy.""" from dataclasses import dataclass from datetime import UTC, date, datetime from decimal import ROUND_HALF_UP, Decimal from uuid import uuid4 from app.model.advisor import AllocationBacktestRun from app.service.dynamic_allocation_optimizer import ( AssetClassMarketMetric, DynamicAllocationOptimizer, ) from app.service.product_metric_service import ProductMetricService _HUNDRED = Decimal("100") _ASSET_CLASSES = ("cash_management_etf", "bond_etf", "equity_etf") @dataclass(frozen=True) class BacktestPrice: trade_date: date close_price: Decimal turnover_amount: Decimal | None @dataclass(frozen=True) class AllocationBacktestResult: status: str started_on: date | None ended_on: date | None observation_count: int static_total_return_pct: Decimal | None dynamic_total_return_pct: Decimal | None static_max_drawdown_pct: Decimal | None dynamic_max_drawdown_pct: Decimal | None dynamic_rebalance_count: int liquidity_history_coverage_pct: Decimal limitations: tuple[str, ...] class AllocationBacktestService: """Runs a no-lookahead, periodic-rebalance comparison against a static baseline.""" STRATEGY_VERSION = "dynamic_allocation_walk_forward_v1" LOOKBACK_DAYS = 120 REBALANCE_INTERVAL_DAYS = 20 @classmethod def run( cls, series_by_class: dict[str, list[BacktestPrice]], strategic_weights: dict[str, int], *, return_target_lower_pct: Decimal, max_drawdown_pct: Decimal, liquidity_requirement: str, ) -> AllocationBacktestResult: missing = set(_ASSET_CLASSES) - set(series_by_class) if missing: return cls._unavailable( "data_quality_required", ("missing_quality_approved_asset_class",) ) by_date = { asset_class: {item.trade_date: item for item in rows if item.close_price > 0} for asset_class, rows in series_by_class.items() } common_dates = sorted(set.intersection(*(set(rows) for rows in by_date.values()))) if len(common_dates) <= cls.LOOKBACK_DAYS + 1: return cls._unavailable("insufficient_history", ("insufficient_common_history",)) turnover_points = [ by_date[asset_class][trade_date].turnover_amount for asset_class in _ASSET_CLASSES for trade_date in common_dates ] liquidity_coverage = cls._coverage(turnover_points) limitations = () if liquidity_coverage == _HUNDRED else ("historical_turnover_incomplete",) static_value = dynamic_value = Decimal("1") static_peak = dynamic_peak = Decimal("1") static_drawdown = dynamic_drawdown = Decimal() dynamic_weights = dict(strategic_weights) rebalances = 0 for index in range(cls.LOOKBACK_DAYS + 1, len(common_dates)): if (index - cls.LOOKBACK_DAYS - 1) % cls.REBALANCE_INTERVAL_DAYS == 0: metrics = cls._metrics( by_date, common_dates[index - cls.LOOKBACK_DAYS - 1:index], liquidity_coverage, ) dynamic_weights = DynamicAllocationOptimizer.optimize( strategic_weights, metrics, return_target_lower_pct=return_target_lower_pct, max_drawdown_pct=max_drawdown_pct, liquidity_requirement=liquidity_requirement, ).weights rebalances += 1 day_returns = { asset_class: ( by_date[asset_class][common_dates[index]].close_price / by_date[asset_class][common_dates[index - 1]].close_price - 1 ) for asset_class in _ASSET_CLASSES } static_value *= 1 + cls._weighted_return(day_returns, strategic_weights) dynamic_value *= 1 + cls._weighted_return(day_returns, dynamic_weights) static_peak = max(static_peak, static_value) dynamic_peak = max(dynamic_peak, dynamic_value) static_drawdown = min(static_drawdown, static_value / static_peak - 1) dynamic_drawdown = min(dynamic_drawdown, dynamic_value / dynamic_peak - 1) return AllocationBacktestResult( status="ready" if not limitations else "partial", started_on=common_dates[cls.LOOKBACK_DAYS], ended_on=common_dates[-1], observation_count=len(common_dates) - cls.LOOKBACK_DAYS, static_total_return_pct=cls._pct(static_value - 1), dynamic_total_return_pct=cls._pct(dynamic_value - 1), static_max_drawdown_pct=cls._pct(static_drawdown), dynamic_max_drawdown_pct=cls._pct(dynamic_drawdown), dynamic_rebalance_count=rebalances, liquidity_history_coverage_pct=liquidity_coverage, limitations=limitations, ) @classmethod def record( cls, result: AllocationBacktestResult, *, profile_risk_level: str, return_target_lower_pct: Decimal, max_drawdown_pct: Decimal, liquidity_requirement: str, ) -> AllocationBacktestRun: if result.started_on is None or result.ended_on is None: started_on = ended_on = date.today() else: started_on, ended_on = result.started_on, result.ended_on return AllocationBacktestRun( backtest_no=f"bt-{uuid4().hex[:24]}", started_on=started_on, ended_on=ended_on, profile_risk_level=profile_risk_level, return_target_lower_pct=return_target_lower_pct, max_drawdown_pct=max_drawdown_pct, liquidity_requirement=liquidity_requirement, status=result.status, observation_count=result.observation_count, static_total_return_pct=result.static_total_return_pct, dynamic_total_return_pct=result.dynamic_total_return_pct, static_max_drawdown_pct=result.static_max_drawdown_pct, dynamic_max_drawdown_pct=result.dynamic_max_drawdown_pct, dynamic_rebalance_count=result.dynamic_rebalance_count, liquidity_history_coverage_pct=result.liquidity_history_coverage_pct, limitations=list(result.limitations), strategy_version=cls.STRATEGY_VERSION, created_at=datetime.now(UTC).replace(tzinfo=None), ) @classmethod def _metrics( cls, by_date: dict[str, dict[date, BacktestPrice]], window_dates: list[date], liquidity_coverage: Decimal, ) -> list[AssetClassMarketMetric]: metrics: list[AssetClassMarketMetric] = [] for asset_class in _ASSET_CLASSES: prices = [by_date[asset_class][trade_date] for trade_date in window_dates] calculated = ProductMetricService.calculate(prices) if calculated.trailing_120d_return_pct is None or calculated.max_drawdown_pct is None: continue liquidity = calculated.average_daily_turnover_amount or Decimal() metrics.append(AssetClassMarketMetric( asset_class=asset_class, trailing_120d_return_pct=calculated.trailing_120d_return_pct, max_drawdown_pct=calculated.max_drawdown_pct, average_daily_turnover_amount=liquidity, product_count=1, liquidity_source=("historical_turnover" if liquidity_coverage == _HUNDRED else "historical_turnover_incomplete"), )) return metrics @staticmethod def _weighted_return(day_returns: dict[str, Decimal], weights: dict[str, int]) -> Decimal: return sum( (day_returns[asset_class] * Decimal(weights[asset_class]) / _HUNDRED for asset_class in _ASSET_CLASSES), Decimal(), ) @staticmethod def _coverage(values: list[Decimal | None]) -> Decimal: if not values: return Decimal() return ( Decimal(sum(value is not None and value > 0 for value in values)) / Decimal(len(values)) * _HUNDRED ).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) @staticmethod def _pct(value: Decimal) -> Decimal: return (value * _HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) @staticmethod def _unavailable(status: str, limitations: tuple[str, ...]) -> AllocationBacktestResult: return AllocationBacktestResult( status=status, started_on=None, ended_on=None, observation_count=0, static_total_return_pct=None, dynamic_total_return_pct=None, static_max_drawdown_pct=None, dynamic_max_drawdown_pct=None, dynamic_rebalance_count=0, liquidity_history_coverage_pct=Decimal(), limitations=limitations, )