"""Historical, analysis-only validation of static and dynamic allocations.""" from collections import defaultdict from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import UTC, date, datetime from decimal import Decimal from typing import Any from uuid import uuid4 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.advisor_backtest_contracts import AllocationBacktestQuery from app.core.contracts import RequestContext from app.infrastructure.db import SessionFactory from app.model.advisor_product import ( AdvisorAllocationBacktestRun, AdvisorProductPriceHistory, ) from app.model.audit import InteractionAudit from app.repository.advisor_product_repository import AdvisorProductRepository from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository from app.service.asset_allocation_service import AssetAllocationService from app.service.authorization_service import AuthorizationService from app.service.dynamic_allocation_optimizer import ( ASSET_CLASSES, AssetClassMarketMetric, DynamicAllocationOptimizer, ) from app.service.product_governance_monitor_service import SALES_INSTITUTION @dataclass(frozen=True) class BacktestObservation: trade_date: date returns_pct: dict[str, Decimal] liquidity_observed: bool @dataclass(frozen=True) class Performance: total_return_pct: Decimal max_drawdown_pct: Decimal @dataclass(frozen=True) class BacktestResult: status: str observation_count: int static: Performance | None dynamic: Performance | None dynamic_rebalance_count: int liquidity_history_coverage_pct: Decimal limitations: tuple[str, ...] class AllocationBacktestEngine: @staticmethod def run( observations: Sequence[BacktestObservation], static_weights: dict[str, int], dynamic_weights: dict[date, dict[str, int]], ) -> BacktestResult: ordered = sorted(observations, key=lambda item: item.trade_date) liquidity_count = sum(item.liquidity_observed for item in ordered) liquidity_coverage = ( Decimal(liquidity_count) / Decimal(len(ordered)) * Decimal("100") if ordered else Decimal() ) limitations: list[str] = [] if len(ordered) < 120: limitations.append("历史观察不足 120 个交易日,动态优化无法覆盖完整窗口。") if liquidity_coverage < 80: limitations.append("流动性历史字段覆盖率低于 80%,流动性结论受限。") status = "ready" if len(ordered) < 20: status = "insufficient_history" elif liquidity_coverage < 80: status = "partial" return BacktestResult( status=status, observation_count=len(ordered), static=( AllocationBacktestEngine._performance(ordered, static_weights) if ordered else None ), dynamic=( AllocationBacktestEngine._performance_by_date( ordered, dynamic_weights, static_weights ) if ordered else None ), dynamic_rebalance_count=sum( 1 for item in ordered if item.trade_date in dynamic_weights ), liquidity_history_coverage_pct=liquidity_coverage, limitations=tuple(limitations), ) @staticmethod def _performance( observations: Sequence[BacktestObservation], weights: dict[str, int] ) -> Performance: return AllocationBacktestEngine._performance_by_date(observations, {}, weights) @staticmethod def _performance_by_date( observations: Sequence[BacktestObservation], weights_by_date: dict[date, dict[str, int]], fallback: dict[str, int], ) -> Performance: value = Decimal("1") peak = value max_drawdown = Decimal() for observation in observations: weights = weights_by_date.get(observation.trade_date, fallback) daily_return = sum( Decimal(weight) / Decimal("100") * observation.returns_pct.get(asset_class, Decimal()) / Decimal("100") for asset_class, weight in weights.items() ) value *= Decimal("1") + daily_return peak = max(peak, value) if peak > 0: max_drawdown = min(max_drawdown, value / peak - Decimal("1")) return Performance( total_return_pct=((value - Decimal("1")) * Decimal("100")).quantize(Decimal("0.0001")), max_drawdown_pct=(max_drawdown * Decimal("100")).quantize(Decimal("0.0001")), ) class AllocationBacktestService: def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None: self.session_factory = session_factory async def run( self, payload: AllocationBacktestQuery, context: RequestContext, key: str | None = None ) -> dict[str, object]: await AuthorizationService.require(context, "asset-allocation:backtest", admin=True) result = await self._calculate(payload) async def operation(session: AsyncSession) -> dict[str, Any]: row = AdvisorAllocationBacktestRun( backtest_no=f"AB-{uuid4().hex[:24]}", started_on=payload.started_on, ended_on=payload.ended_on, profile_risk_level=f"C{payload.profile_risk_level}", return_target_lower_pct=Decimal(payload.return_target_lower_pct), max_drawdown_pct=Decimal(payload.max_drawdown_pct), liquidity_requirement=payload.liquidity_requirement, status=result.status, observation_count=result.observation_count, static_total_return_pct=result.static.total_return_pct if result.static else None, dynamic_total_return_pct=result.dynamic.total_return_pct if result.dynamic else None, static_max_drawdown_pct=result.static.max_drawdown_pct if result.static else None, dynamic_max_drawdown_pct=result.dynamic.max_drawdown_pct if result.dynamic else None, dynamic_rebalance_count=result.dynamic_rebalance_count, liquidity_history_coverage_pct=result.liquidity_history_coverage_pct, limitations=list(result.limitations), strategy_version="constrained_historical_multi_factor_v1", created_at=datetime.now(UTC).replace(tzinfo=None), ) session.add(row) session.add( InteractionAudit( actor_type="user", actor_id=int(context.user_id), portal=context.portal, action_type="advisor.allocation_backtest_created", detail={"backtest_no": row.backtest_no, "trace_id": context.trace_id}, created_at=datetime.now(UTC).replace(tzinfo=None), ) ) await session.flush() return {"data": self._view(row), "meta": {"trace_id": context.trace_id}} from app.service.api_transaction_service import ApiTransactionService return await ApiTransactionService().execute( context, f"advisor:allocation-backtests:{payload.started_on}:{payload.ended_on}", key, payload.model_dump(mode="json"), operation, ) async def _calculate(self, payload: AllocationBacktestQuery) -> BacktestResult: end_at = datetime.combine(payload.ended_on, datetime.max.time()) async with self.session_factory() as session: candidates = await AdvisorProductRepository(session).authoritative_tradable_products( end_at, sales_institution=SALES_INSTITUTION, limit=100 ) candidates, _ = AdvisorProductRepository.hard_suitability_filter( candidates, payload.profile_risk_level ) product_ids = tuple(item.product.id for item in candidates) repository = PortfolioAnalysisRepository(session) classifications = await repository.latest_asset_classifications( product_ids, payload.ended_on ) qualities = await repository.latest_quality(product_ids, payload.ended_on) rows = list( await session.scalars( select(AdvisorProductPriceHistory) .where( AdvisorProductPriceHistory.product_id.in_(product_ids), AdvisorProductPriceHistory.trade_date >= payload.started_on, AdvisorProductPriceHistory.trade_date <= payload.ended_on, AdvisorProductPriceHistory.price_kind == "fund_nav", ) .order_by( AdvisorProductPriceHistory.product_id, AdvisorProductPriceHistory.trade_date ) ) ) eligible = { product_id: classification.asset_class for product_id, classification in classifications.items() if classification.asset_class in ASSET_CLASSES and qualities.get(product_id) is not None and qualities[product_id].status == "accepted" } return self._calculate_from_rows(rows, eligible, payload) @staticmethod def _calculate_from_rows( rows: Sequence[AdvisorProductPriceHistory], eligible: dict[int, str], payload: AllocationBacktestQuery, ) -> BacktestResult: per_product: dict[int, list[AdvisorProductPriceHistory]] = defaultdict(list) for row in rows: if row.product_id in eligible: per_product[row.product_id].append(row) daily_returns: dict[date, dict[str, list[Decimal]]] = defaultdict(lambda: defaultdict(list)) daily_liquidity: dict[date, list[bool]] = defaultdict(list) for product_id, history in per_product.items(): for previous, current in zip(history, history[1:], strict=False): if previous.close_price <= 0: continue daily_returns[current.trade_date][eligible[product_id]].append( (current.close_price / previous.close_price - Decimal("1")) * Decimal("100") ) daily_liquidity[current.trade_date].append(current.turnover_amount is not None) observations = [ BacktestObservation( trade_date=trade_date, returns_pct={ asset_class: sum(values, Decimal()) / len(values) for asset_class, values in returns.items() }, liquidity_observed=all(daily_liquidity[trade_date]), ) for trade_date, returns in sorted(daily_returns.items()) if returns ] static = AssetAllocationService._strategic_weights( f"C{payload.profile_risk_level}", max(13, (payload.ended_on - payload.started_on).days // 30), payload.liquidity_requirement, Decimal(payload.max_drawdown_pct), ) dynamic: dict[date, dict[str, int]] = {} for index in range(119, len(observations), 20): metrics = AllocationBacktestService._rolling_metrics( observations[index - 119 : index + 1] ) optimized = DynamicAllocationOptimizer.optimize( static, metrics, return_target_lower_pct=Decimal(payload.return_target_lower_pct), max_drawdown_pct=Decimal(payload.max_drawdown_pct), liquidity_requirement=payload.liquidity_requirement, ) dynamic[observations[index].trade_date] = optimized.weights return AllocationBacktestEngine.run(observations, static, dynamic) @staticmethod def _rolling_metrics( observations: Sequence[BacktestObservation], ) -> list[AssetClassMarketMetric]: grouped: dict[str, list[Decimal]] = defaultdict(list) liquidity: dict[str, list[Decimal]] = defaultdict(list) for observation in observations: for asset_class, value in observation.returns_pct.items(): grouped[asset_class].append(value) if observation.liquidity_observed: liquidity[asset_class].append(Decimal("1")) result: list[AssetClassMarketMetric] = [] for asset_class, returns in grouped.items(): value = Decimal("1") peak = value drawdown = Decimal() for item in returns: value *= Decimal("1") + item / Decimal("100") peak = max(peak, value) drawdown = min(drawdown, value / peak - Decimal("1")) result.append( AssetClassMarketMetric( asset_class=asset_class, trailing_120d_return_pct=(value - Decimal("1")) * Decimal("100"), max_drawdown_pct=drawdown * Decimal("100"), average_daily_turnover_amount=Decimal("10000000") * (Decimal(len(liquidity[asset_class])) / Decimal(len(returns))), product_count=len(returns), ) ) return result @staticmethod def _view(row: AdvisorAllocationBacktestRun) -> dict[str, object]: return { "backtest_no": row.backtest_no, "started_on": row.started_on.isoformat(), "ended_on": row.ended_on.isoformat(), "profile_risk_level": row.profile_risk_level, "return_target_lower_pct": str(row.return_target_lower_pct), "max_drawdown_pct": str(row.max_drawdown_pct), "liquidity_requirement": row.liquidity_requirement, "status": row.status, "observation_count": row.observation_count, "static_total_return_pct": str(row.static_total_return_pct) if row.static_total_return_pct is not None else None, "dynamic_total_return_pct": str(row.dynamic_total_return_pct) if row.dynamic_total_return_pct is not None else None, "static_max_drawdown_pct": str(row.static_max_drawdown_pct) if row.static_max_drawdown_pct is not None else None, "dynamic_max_drawdown_pct": str(row.dynamic_max_drawdown_pct) if row.dynamic_max_drawdown_pct is not None else None, "dynamic_rebalance_count": row.dynamic_rebalance_count, "liquidity_history_coverage_pct": str(row.liquidity_history_coverage_pct), "limitations": row.limitations, "strategy_version": row.strategy_version, }