From 77a029f2f5e72f3c3eaf154d49966a9898844953 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:32:30 +0900 Subject: [PATCH] refactor(b12): migrate decision_mapper to QPK semantics/display facade - decision_mapper builds ValueTargetExecutionSemantics + ValueTargetDisplayAnnotations and calls build_value_target_execution_runtime_plan(semantics=, display=) - no more build_value_target_runtime_plan / mixed ValueTargetExecutionAnnotations - bump QPK pin 350dd38 -> 155d74a (same pin as Schwab/Binance) for the facade - parity: golden plans generated on main@a314852 + QPK@350dd38 (legacy path); new path reproduces them exactly, and matches QPK compat facade per case --- decision_mapper.py | 94 ++- pyproject.toml | 4 +- qsl.toml | 2 +- tests/fixtures/value_target_plan_parity.json | 773 +++++++++++++++++++ tests/test_value_target_facade_parity.py | 52 ++ tests/value_target_parity_cases.py | 318 ++++++++ uv.lock | 8 +- 7 files changed, 1195 insertions(+), 56 deletions(-) create mode 100644 tests/fixtures/value_target_plan_parity.json create mode 100644 tests/test_value_target_facade_parity.py create mode 100644 tests/value_target_parity_cases.py diff --git a/decision_mapper.py b/decision_mapper.py index c4783b7..350fdea 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -12,14 +12,17 @@ PositionTarget, StrategyContractValidationError, StrategyDecision, - ValueTargetExecutionAnnotations, - build_value_target_execution_annotations, ) from quant_platform_kit.common.execution_translation import ( - build_value_target_runtime_plan, + build_value_target_execution_runtime_plan, resolve_decision_target_mode, translate_decision_to_target_mode, ) +from quant_platform_kit.common.presentation import ( + ValueTargetDisplayAnnotations, + ValueTargetExecutionSemantics, + split_value_target_annotation_parts, +) from us_equity_strategies.catalog import resolve_canonical_profile _SAFE_HAVEN_SYMBOLS = frozenset({"BOXX", "BIL"}) @@ -158,30 +161,30 @@ def _resolve_platform_reserved_cash( def _apply_reserved_cash_policy( - annotations: ValueTargetExecutionAnnotations, + semantics: ValueTargetExecutionSemantics, *, portfolio_inputs, runtime_metadata: Mapping[str, Any] | None, -) -> ValueTargetExecutionAnnotations: +) -> ValueTargetExecutionSemantics: reserved_cash = max( - float(annotations.reserved_cash or 0.0), + float(semantics.reserved_cash or 0.0), _resolve_platform_reserved_cash( total_equity=float(portfolio_inputs.total_equity), runtime_metadata=runtime_metadata, ), ) - base_investable_cash = annotations.investable_cash + base_investable_cash = semantics.investable_cash if base_investable_cash is None: base_investable_cash = max( 0.0, - float(portfolio_inputs.liquid_cash) - float(annotations.reserved_cash or 0.0), + float(portfolio_inputs.liquid_cash) - float(semantics.reserved_cash or 0.0), ) investable_cash = min( max(0.0, float(base_investable_cash)), max(0.0, float(portfolio_inputs.liquid_cash) - reserved_cash), ) return replace( - annotations, + semantics, reserved_cash=reserved_cash, investable_cash=investable_cash, ) @@ -229,13 +232,17 @@ def _build_zero_equity_no_execute_decision( ) +# (semantics, display) pair; merged into the wire aggregate only inside QPK. +ValueTargetAnnotationParts = tuple[ValueTargetExecutionSemantics, ValueTargetDisplayAnnotations] + + def _build_weight_translation_annotations( decision: StrategyDecision, *, total_equity: float, liquid_cash: float, runtime_metadata: Mapping[str, Any] | None = None, -) -> ValueTargetExecutionAnnotations: +) -> ValueTargetAnnotationParts: diagnostics = {**dict(runtime_metadata or {}), **dict(decision.diagnostics)} execution_annotations = {} for source in ( @@ -257,12 +264,9 @@ def _build_weight_translation_annotations( or diagnostics.get("canary_status") or "" ).strip() - return ValueTargetExecutionAnnotations( + semantics = ValueTargetExecutionSemantics( trade_threshold_value=threshold_value, reserved_cash=0.0, - signal_display=signal_display, - status_display=status_display, - dashboard_text=str(execution_annotations.get("dashboard_text") or diagnostics.get("dashboard") or ""), signal_date=str(execution_annotations.get("signal_date") or diagnostics.get("signal_date") or ""), effective_date=str(execution_annotations.get("effective_date") or diagnostics.get("effective_date") or ""), execution_timing_contract=str( @@ -305,6 +309,12 @@ def _build_weight_translation_annotations( current_min_trade=threshold_value, investable_cash=max(0.0, float(liquid_cash)), ) + display = ValueTargetDisplayAnnotations( + signal_display=signal_display, + status_display=status_display, + dashboard_text=str(execution_annotations.get("dashboard_text") or diagnostics.get("dashboard") or ""), + ) + return semantics, display def _normalize_to_value_decision( @@ -313,7 +323,7 @@ def _normalize_to_value_decision( portfolio_inputs, runtime_metadata: Mapping[str, Any] | None, cash_only_execution: bool = True, -) -> tuple[StrategyDecision, ValueTargetExecutionAnnotations | None]: +) -> tuple[StrategyDecision, ValueTargetAnnotationParts | None]: target_mode = resolve_decision_target_mode(decision) no_execute = "no_execute" in set(decision.risk_flags) if target_mode == "value" and not no_execute: @@ -358,9 +368,9 @@ def _normalize_to_value_decision( return _build_hold_current_value_decision(portfolio_inputs, diagnostics=diagnostics), None -def _build_annotations(decision: StrategyDecision, *, portfolio_inputs) -> ValueTargetExecutionAnnotations: +def _build_annotations(decision: StrategyDecision, *, portfolio_inputs) -> ValueTargetAnnotationParts: try: - annotations = build_value_target_execution_annotations(decision) + semantics, display = split_value_target_annotation_parts(decision) except StrategyContractValidationError as exc: if "requires trade_threshold_value" not in str(exc): raise @@ -373,37 +383,22 @@ def _build_annotations(decision: StrategyDecision, *, portfolio_inputs) -> Value float(portfolio_inputs.total_equity) ) diagnostics["execution_annotations"] = execution_annotations - annotations = build_value_target_execution_annotations( + semantics, display = split_value_target_annotation_parts( replace(decision, diagnostics=diagnostics) ) - investable_cash = annotations.investable_cash + investable_cash = semantics.investable_cash if investable_cash is None: - investable_cash = max(0.0, float(portfolio_inputs.liquid_cash) - annotations.reserved_cash) - current_min_trade = annotations.current_min_trade + investable_cash = max(0.0, float(portfolio_inputs.liquid_cash) - semantics.reserved_cash) + current_min_trade = semantics.current_min_trade if current_min_trade is None: - current_min_trade = annotations.trade_threshold_value - return ValueTargetExecutionAnnotations( - trade_threshold_value=annotations.trade_threshold_value, - reserved_cash=annotations.reserved_cash, - signal_display=annotations.signal_display, - status_display=annotations.status_display, - dashboard_text=annotations.dashboard_text, - separator=annotations.separator, - benchmark_symbol=annotations.benchmark_symbol, - benchmark_price=annotations.benchmark_price, - long_trend_value=annotations.long_trend_value, - exit_line=annotations.exit_line, - signal_date=annotations.signal_date, - effective_date=annotations.effective_date, - execution_timing_contract=annotations.execution_timing_contract, - execution_calendar_source=annotations.execution_calendar_source, - signal_effective_after_trading_days=annotations.signal_effective_after_trading_days, - deploy_ratio_text=annotations.deploy_ratio_text, - income_ratio_text=annotations.income_ratio_text, - income_locked_ratio_text=annotations.income_locked_ratio_text, - active_risk_asset=annotations.active_risk_asset, - current_min_trade=current_min_trade, - investable_cash=investable_cash, + current_min_trade = semantics.trade_threshold_value + return ( + replace( + semantics, + current_min_trade=current_min_trade, + investable_cash=investable_cash, + ), + display, ) @@ -430,20 +425,21 @@ def map_strategy_decision_to_plan( runtime_metadata=runtime_metadata, cash_only_execution=cash_only_execution, ) - annotations = translated_annotations or _build_annotations( + semantics, display = translated_annotations or _build_annotations( normalized_decision, portfolio_inputs=portfolio_inputs, ) - annotations = _apply_reserved_cash_policy( - annotations, + semantics = _apply_reserved_cash_policy( + semantics, portfolio_inputs=portfolio_inputs, runtime_metadata=runtime_metadata, ) - plan = build_value_target_runtime_plan( + plan = build_value_target_execution_runtime_plan( normalized_decision, strategy_profile=canonical_profile, portfolio_inputs=portfolio_inputs, - annotations=annotations, + semantics=semantics, + display=display, include_sellable_quantities=True, ) metadata = getattr(snapshot, "metadata", {}) or {} diff --git a/pyproject.toml b/pyproject.toml index fcfce1d..2254ca2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "pytest", "pytz", "requests", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@350dd38ece0952beef096893a7dcf8ae3871609a", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@155d74a615ccdebf7223787939c2dc7151d75efd", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c31f28484b8489a1ecda8e595a6d2afd3bce8185", ] license = "MIT" @@ -82,5 +82,5 @@ show_missing = true [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@350dd38ece0952beef096893a7dcf8ae3871609a", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@155d74a615ccdebf7223787939c2dc7151d75efd", ] diff --git a/qsl.toml b/qsl.toml index 3ce5731..23f8278 100644 --- a/qsl.toml +++ b/qsl.toml @@ -5,7 +5,7 @@ upgrade_ring = "ring_d" allow_legacy = false [qsl.requires] -quant_platform_kit = "350dd38ece0952beef096893a7dcf8ae3871609a" +quant_platform_kit = "155d74a615ccdebf7223787939c2dc7151d75efd" us_equity_strategies = "c31f28484b8489a1ecda8e595a6d2afd3bce8185" [qsl.compat] diff --git a/tests/fixtures/value_target_plan_parity.json b/tests/fixtures/value_target_plan_parity.json new file mode 100644 index 0000000..8cfd80f --- /dev/null +++ b/tests/fixtures/value_target_plan_parity.json @@ -0,0 +1,773 @@ +{ + "no_execute_holds_current_positions": { + "allocation": { + "income_symbols": [ + "QQQI" + ], + "positions": [ + { + "role": "risk", + "symbol": "SOXL", + "target_value": 4000.0 + }, + { + "role": "safe_haven", + "symbol": "BOXX", + "target_value": 1100.0 + }, + { + "role": "income", + "symbol": "QQQI", + "target_value": 260.0 + } + ], + "risk_symbols": [ + "SOXL" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "strategy_profile": "soxl_soxx_trend_income", + "strategy_symbols": [ + "SOXL", + "BOXX", + "QQQI" + ], + "target_mode": "value", + "targets": { + "BOXX": 1100.0, + "QQQI": 260.0, + "SOXL": 4000.0 + } + }, + "execution": { + "current_min_trade": 100.0, + "investable_cash": 0.0, + "reserved_cash": 2000.0, + "separator": "===", + "signal_display": "hold", + "trade_threshold_value": 100.0 + }, + "portfolio": { + "cash_sweep_symbol": "BOXX", + "income_symbols": [ + "QQQI" + ], + "liquid_cash": 1000.0, + "market_values": { + "BOXX": 1100.0, + "QQQI": 260.0, + "SOXL": 4000.0 + }, + "portfolio_rows": [ + [ + "SOXL", + "BOXX" + ], + [ + "QQQI" + ] + ], + "quantities": { + "BOXX": 10.0, + "QQQI": 5.0, + "SOXL": 40.0 + }, + "risk_symbols": [ + "SOXL" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "sellable_quantities": { + "BOXX": 10.0, + "QQQI": 5.0, + "SOXL": 40.0 + }, + "strategy_symbols": [ + "SOXL", + "BOXX", + "QQQI" + ], + "target_values": { + "BOXX": 1100.0, + "QQQI": 260.0, + "SOXL": 4000.0 + }, + "total_equity": 6360.0 + }, + "strategy_profile": "soxl_soxx_trend_income" + }, + "value_full_annotations_soxl": { + "allocation": { + "income_symbols": [ + "QQQI" + ], + "positions": [ + { + "role": "risk", + "symbol": "SOXL", + "target_value": 5000.0 + }, + { + "role": "safe_haven", + "symbol": "BOXX", + "target_value": 2000.0 + }, + { + "role": "income", + "symbol": "QQQI", + "target_value": 800.0 + } + ], + "risk_symbols": [ + "SOXL" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "strategy_profile": "soxl_soxx_trend_income", + "strategy_symbols": [ + "SOXL", + "BOXX", + "QQQI" + ], + "target_mode": "value", + "targets": { + "BOXX": 2000.0, + "QQQI": 800.0, + "SOXL": 5000.0 + } + }, + "execution": { + "active_risk_asset": "SOXL", + "benchmark_price": 251.5, + "benchmark_symbol": "SOXX", + "blend_gate_volatility_delever_metric": 0.61, + "blend_gate_volatility_delever_threshold_mode": "rolling_percentile", + "current_min_trade": 175.0, + "dashboard_text": "dash", + "deploy_ratio_text": "60%", + "effective_date": "2026-10-09", + "execution_calendar_source": "xnys", + "execution_timing_contract": "next_session_close", + "exit_line": 230.0, + "income_locked_ratio_text": "5%", + "income_ratio_text": "20%", + "investable_cash": 1800.0, + "long_trend_value": 240.25, + "market_regime_control_reason_codes": [ + "macro:vix_crisis_level" + ], + "market_regime_control_route": "risk_reduced", + "reserved_cash": 418.0, + "separator": "---", + "signal_date": "2026-10-08", + "signal_display": "risk on", + "signal_effective_after_trading_days": "1", + "status_display": "trend up", + "trade_threshold_value": 150.0 + }, + "portfolio": { + "cash_by_currency": { + "USD": 3000.0 + }, + "cash_sweep_symbol": "BOXX", + "income_symbols": [ + "QQQI" + ], + "liquid_cash": 3000.0, + "market_values": { + "BOXX": 1100.0, + "QQQI": 260.0, + "SOXL": 4000.0 + }, + "portfolio_rows": [ + [ + "SOXL", + "BOXX" + ], + [ + "QQQI" + ] + ], + "quantities": { + "BOXX": 10.0, + "QQQI": 5.0, + "SOXL": 40.0 + }, + "risk_symbols": [ + "SOXL" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "sellable_quantities": { + "BOXX": 10.0, + "QQQI": 5.0, + "SOXL": 40.0 + }, + "strategy_symbols": [ + "SOXL", + "BOXX", + "QQQI" + ], + "target_values": { + "BOXX": 2000.0, + "QQQI": 800.0, + "SOXL": 5000.0 + }, + "total_equity": 8360.0 + }, + "strategy_profile": "soxl_soxx_trend_income" + }, + "value_investable_cash_capped_by_policy": { + "allocation": { + "income_symbols": [], + "positions": [ + { + "role": "risk", + "symbol": "TQQQ", + "target_value": 3000.0 + } + ], + "risk_symbols": [ + "TQQQ" + ], + "safe_haven_symbols": [], + "strategy_profile": "tqqq_growth_income", + "strategy_symbols": [ + "TQQQ" + ], + "target_mode": "value", + "targets": { + "TQQQ": 3000.0 + } + }, + "execution": { + "current_min_trade": 90.0, + "investable_cash": 900.0, + "reserved_cash": 300.0, + "trade_threshold_value": 100.0 + }, + "portfolio": { + "cash_sweep_symbol": null, + "income_symbols": [], + "liquid_cash": 1200.0, + "market_values": { + "TQQQ": 0.0 + }, + "portfolio_rows": [ + [ + "TQQQ" + ] + ], + "quantities": { + "TQQQ": 0.0 + }, + "risk_symbols": [ + "TQQQ" + ], + "safe_haven_symbols": [], + "sellable_quantities": { + "TQQQ": 0.0 + }, + "strategy_symbols": [ + "TQQQ" + ], + "target_values": { + "TQQQ": 3000.0 + }, + "total_equity": 1200.0 + }, + "strategy_profile": "tqqq_growth_income" + }, + "value_legacy_keys_tqqq": { + "allocation": { + "income_symbols": [], + "positions": [ + { + "role": "risk", + "symbol": "QQQM", + "target_value": 1000.0 + }, + { + "role": "risk", + "symbol": "TQQQ", + "target_value": 4000.0 + }, + { + "role": "safe_haven", + "symbol": "BOXX", + "target_value": 3000.0 + } + ], + "risk_symbols": [ + "QQQM", + "TQQQ" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "strategy_profile": "tqqq_growth_income", + "strategy_symbols": [ + "QQQM", + "TQQQ", + "BOXX" + ], + "target_mode": "value", + "targets": { + "BOXX": 3000.0, + "QQQM": 1000.0, + "TQQQ": 4000.0 + } + }, + "execution": { + "benchmark_price": 512.0, + "current_min_trade": 120.0, + "dashboard_text": "legacy dash", + "dual_drive_crisis_defense_destination": "BOXX", + "dual_drive_volatility_delever_applied": true, + "investable_cash": 2420.0, + "long_trend_value": 480.0, + "reserved_cash": 80.0, + "signal_display": "legacy signal", + "status_display": "legacy status", + "trade_threshold_value": 120.0 + }, + "portfolio": { + "cash_sweep_symbol": "BOXX", + "income_symbols": [], + "liquid_cash": 2500.0, + "market_values": { + "BOXX": 2200.0, + "QQQM": 0.0, + "TQQQ": 3500.0 + }, + "portfolio_rows": [ + [ + "QQQM", + "TQQQ", + "BOXX" + ] + ], + "quantities": { + "BOXX": 20.0, + "QQQM": 0.0, + "TQQQ": 30.0 + }, + "risk_symbols": [ + "QQQM", + "TQQQ" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "sellable_quantities": { + "BOXX": 20.0, + "QQQM": 0.0, + "TQQQ": 30.0 + }, + "strategy_symbols": [ + "QQQM", + "TQQQ", + "BOXX" + ], + "target_values": { + "BOXX": 3000.0, + "QQQM": 1000.0, + "TQQQ": 4000.0 + }, + "total_equity": 8200.0 + }, + "strategy_profile": "tqqq_growth_income" + }, + "value_without_threshold_uses_default": { + "allocation": { + "income_symbols": [], + "positions": [ + { + "role": "risk", + "symbol": "SOXL", + "target_value": 6000.0 + } + ], + "risk_symbols": [ + "SOXL" + ], + "safe_haven_symbols": [], + "strategy_profile": "soxl_soxx_trend_income", + "strategy_symbols": [ + "SOXL" + ], + "target_mode": "value", + "targets": { + "SOXL": 6000.0 + } + }, + "execution": { + "current_min_trade": 100.0, + "investable_cash": 6860.0, + "reserved_cash": 140.0, + "signal_display": "s", + "trade_threshold_value": 100.0 + }, + "portfolio": { + "cash_sweep_symbol": null, + "income_symbols": [], + "liquid_cash": 7000.0, + "market_values": { + "SOXL": 0.0 + }, + "portfolio_rows": [ + [ + "SOXL" + ] + ], + "quantities": { + "SOXL": 0.0 + }, + "risk_symbols": [ + "SOXL" + ], + "safe_haven_symbols": [], + "sellable_quantities": { + "SOXL": 0.0 + }, + "strategy_symbols": [ + "SOXL" + ], + "target_values": { + "SOXL": 6000.0 + }, + "total_equity": 7000.0 + }, + "strategy_profile": "soxl_soxx_trend_income" + }, + "weight_translation_not_cash_only": { + "allocation": { + "income_symbols": [], + "positions": [ + { + "role": "risk", + "symbol": "AAPL", + "target_value": 9000.0 + }, + { + "role": "risk", + "symbol": "NVDA", + "target_value": 6000.0 + } + ], + "risk_symbols": [ + "AAPL", + "NVDA" + ], + "safe_haven_symbols": [], + "strategy_profile": "russell_top50_leader_rotation", + "strategy_symbols": [ + "AAPL", + "NVDA" + ], + "target_mode": "value", + "targets": { + "AAPL": 9000.0, + "NVDA": 6000.0 + } + }, + "execution": { + "current_min_trade": 150.0, + "investable_cash": 500.0, + "reserved_cash": 0.0, + "signal_display": "fallback display", + "status_display": "canary", + "trade_threshold_value": 150.0 + }, + "portfolio": { + "cash_sweep_symbol": null, + "income_symbols": [], + "liquid_cash": 500.0, + "market_values": { + "AAPL": 9000.0, + "NVDA": 5700.0 + }, + "portfolio_rows": [ + [ + "AAPL", + "NVDA" + ] + ], + "quantities": { + "AAPL": 50.0, + "NVDA": 30.0 + }, + "risk_symbols": [ + "AAPL", + "NVDA" + ], + "safe_haven_symbols": [], + "sellable_quantities": { + "AAPL": 50.0, + "NVDA": 30.0 + }, + "strategy_symbols": [ + "AAPL", + "NVDA" + ], + "target_values": { + "AAPL": 9000.0, + "NVDA": 6000.0 + }, + "total_equity": 15000.0 + }, + "strategy_profile": "russell_top50_leader_rotation" + }, + "weight_translation_with_policy": { + "allocation": { + "income_symbols": [], + "positions": [ + { + "role": "risk", + "symbol": "AAPL", + "target_value": 2000.0 + }, + { + "role": "risk", + "symbol": "MSFT", + "target_value": 2000.0 + }, + { + "role": "safe_haven", + "symbol": "BOXX", + "target_value": 1000.0 + } + ], + "risk_symbols": [ + "AAPL", + "MSFT" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "strategy_profile": "russell_top50_leader_rotation", + "strategy_symbols": [ + "AAPL", + "MSFT", + "BOXX" + ], + "target_mode": "value", + "targets": { + "AAPL": 2000.0, + "BOXX": 1000.0, + "MSFT": 2000.0 + } + }, + "execution": { + "benchmark_price": 600.5, + "benchmark_symbol": "SPY", + "current_min_trade": 100.0, + "dashboard_text": "weights", + "effective_date": "2026-10-09", + "execution_calendar_source": "xnys", + "execution_timing_contract": "same_day", + "exit_line": 570.0, + "investable_cash": 2500.0, + "long_trend_value": 580.0, + "market_regime_control_enabled": true, + "reserved_cash": 1500.0, + "signal_date": "2026-10-08", + "signal_display": "risk on", + "signal_effective_after_trading_days": "0", + "status_display": "breadth ok", + "trade_threshold_value": 100.0 + }, + "portfolio": { + "cash_sweep_symbol": "BOXX", + "income_symbols": [], + "liquid_cash": 4000.0, + "market_values": { + "AAPL": 1000.0, + "BOXX": 0.0, + "MSFT": 0.0 + }, + "portfolio_rows": [ + [ + "AAPL", + "MSFT", + "BOXX" + ] + ], + "quantities": { + "AAPL": 5.0, + "BOXX": 0.0, + "MSFT": 0.0 + }, + "risk_symbols": [ + "AAPL", + "MSFT" + ], + "safe_haven_symbols": [ + "BOXX" + ], + "sellable_quantities": { + "AAPL": 5.0, + "BOXX": 0.0, + "MSFT": 0.0 + }, + "strategy_symbols": [ + "AAPL", + "MSFT", + "BOXX" + ], + "target_values": { + "AAPL": 2000.0, + "BOXX": 1000.0, + "MSFT": 2000.0 + }, + "total_equity": 5000.0 + }, + "strategy_profile": "russell_top50_leader_rotation" + }, + "weight_zero_equity_blocked_empty": { + "allocation": { + "income_symbols": [ + "QQQI" + ], + "positions": [ + { + "role": "risk", + "symbol": "AAPL", + "target_value": 0.0 + }, + { + "role": "income", + "symbol": "QQQI", + "target_value": 0.0 + } + ], + "risk_symbols": [ + "AAPL" + ], + "safe_haven_symbols": [], + "strategy_profile": "russell_top50_leader_rotation", + "strategy_symbols": [ + "AAPL", + "QQQI" + ], + "target_mode": "value", + "targets": { + "AAPL": 0.0, + "QQQI": 0.0 + } + }, + "execution": { + "current_min_trade": 100.0, + "investable_cash": 0.0, + "reserved_cash": 0.0, + "trade_threshold_value": 100.0 + }, + "portfolio": { + "cash_sweep_symbol": null, + "income_symbols": [ + "QQQI" + ], + "liquid_cash": 0.0, + "market_values": { + "AAPL": 0.0, + "QQQI": 0.0 + }, + "portfolio_rows": [ + [ + "AAPL" + ], + [ + "QQQI" + ] + ], + "quantities": { + "AAPL": 0.0, + "QQQI": 0.0 + }, + "risk_symbols": [ + "AAPL" + ], + "safe_haven_symbols": [], + "sellable_quantities": { + "AAPL": 0.0, + "QQQI": 0.0 + }, + "strategy_symbols": [ + "AAPL", + "QQQI" + ], + "target_values": { + "AAPL": 0.0, + "QQQI": 0.0 + }, + "total_equity": 0.0 + }, + "strategy_profile": "russell_top50_leader_rotation" + }, + "weight_zero_equity_blocked_with_holdings": { + "allocation": { + "income_symbols": [], + "positions": [ + { + "role": "risk", + "symbol": "AAPL", + "target_value": 0.0 + } + ], + "risk_symbols": [ + "AAPL" + ], + "safe_haven_symbols": [], + "strategy_profile": "russell_top50_leader_rotation", + "strategy_symbols": [ + "AAPL" + ], + "target_mode": "value", + "targets": { + "AAPL": 0.0 + } + }, + "execution": { + "current_min_trade": 100.0, + "investable_cash": 0.0, + "reserved_cash": 10.0, + "signal_display": "risk on", + "trade_threshold_value": 100.0 + }, + "portfolio": { + "cash_sweep_symbol": null, + "income_symbols": [], + "liquid_cash": 0.0, + "market_values": { + "AAPL": 0.0 + }, + "portfolio_rows": [ + [ + "AAPL" + ] + ], + "quantities": { + "AAPL": 1.0 + }, + "risk_symbols": [ + "AAPL" + ], + "safe_haven_symbols": [], + "sellable_quantities": { + "AAPL": 1.0 + }, + "strategy_symbols": [ + "AAPL" + ], + "target_values": { + "AAPL": 0.0 + }, + "total_equity": 0.0 + }, + "strategy_profile": "russell_top50_leader_rotation" + } +} diff --git a/tests/test_value_target_facade_parity.py b/tests/test_value_target_facade_parity.py new file mode 100644 index 0000000..dae8de8 --- /dev/null +++ b/tests/test_value_target_facade_parity.py @@ -0,0 +1,52 @@ +"""B12 parity: semantics/display facade path == legacy mixed-annotations output.""" + +from __future__ import annotations + +import json + +import pytest + +import decision_mapper +from value_target_parity_cases import GOLDEN_PATH, build_cases, canonical + +_GOLDEN = json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) +_CASES = build_cases() + + +def test_golden_covers_every_case() -> None: + assert sorted(_GOLDEN) == sorted(name for name, _ in _CASES) + + +@pytest.mark.parametrize("name,kwargs", _CASES, ids=[name for name, _ in _CASES]) +def test_plan_matches_legacy_golden(name: str, kwargs: dict) -> None: + plan = decision_mapper.map_strategy_decision_to_plan(**kwargs) + assert canonical(plan) == _GOLDEN[name] + + +@pytest.mark.parametrize("name,kwargs", _CASES, ids=[name for name, _ in _CASES]) +def test_plan_matches_qpk_compat_facade(name: str, kwargs: dict, monkeypatch) -> None: + """Route the same inputs through the retained QPK compat facade and compare.""" + from quant_platform_kit.common.execution_translation import build_value_target_runtime_plan + from quant_platform_kit.common.presentation import merge_value_target_execution_annotations + + modern = decision_mapper.map_strategy_decision_to_plan(**kwargs) + + def _legacy(decision, *, strategy_profile, portfolio_inputs, semantics, display, **rest): + return build_value_target_runtime_plan( + decision, + strategy_profile=strategy_profile, + portfolio_inputs=portfolio_inputs, + annotations=merge_value_target_execution_annotations(semantics, display), + **rest, + ) + + monkeypatch.setattr(decision_mapper, "build_value_target_execution_runtime_plan", _legacy) + legacy = decision_mapper.map_strategy_decision_to_plan(**kwargs) + assert canonical(modern) == canonical(legacy) + + +def test_production_mapper_no_longer_uses_legacy_value_target_api() -> None: + source = open(decision_mapper.__file__, encoding="utf-8").read() + assert "build_value_target_runtime_plan" not in source + assert "ValueTargetExecutionAnnotations" not in source + assert "build_value_target_execution_annotations" not in source diff --git a/tests/value_target_parity_cases.py b/tests/value_target_parity_cases.py new file mode 100644 index 0000000..e7fb41c --- /dev/null +++ b/tests/value_target_parity_cases.py @@ -0,0 +1,318 @@ +"""Frozen inputs for the B12 value-target facade parity test. + +The golden file ``tests/fixtures/value_target_plan_parity.json`` was produced by +running ``map_strategy_decision_to_plan`` on these exact inputs with the legacy +``build_value_target_runtime_plan`` + mixed ``ValueTargetExecutionAnnotations`` +path (FirstradePlatform main@a314852, QPK@350dd38). The migrated +``semantics=`` / ``display=`` path must reproduce it byte-for-byte after +canonical JSON encoding. + +Regenerate only when an intentional behavior change is reviewed: + python tests/value_target_parity_cases.py --write +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from quant_platform_kit.common.models import PortfolioSnapshot, Position +from quant_platform_kit.common.strategy_contracts import PositionTarget, StrategyDecision + +GOLDEN_PATH = Path(__file__).with_name("fixtures") / "value_target_plan_parity.json" +_AS_OF = datetime(2026, 10, 9, 20, 0, tzinfo=timezone.utc) + +_FULL_ANNOTATIONS = { + "trade_threshold_value": 150.0, + "reserved_cash": 250.0, + "signal_display": "risk on", + "status_display": "trend up", + "dashboard_text": "dash", + "separator": "---", + "signal_date": "2026-10-08", + "effective_date": "2026-10-09", + "execution_timing_contract": "next_session_close", + "execution_calendar_source": "xnys", + "signal_effective_after_trading_days": 1, + "benchmark_symbol": "SOXX", + "benchmark_price": 251.5, + "long_trend_value": 240.25, + "exit_line": 230.0, + "deploy_ratio_text": "60%", + "income_ratio_text": "20%", + "income_locked_ratio_text": "5%", + "active_risk_asset": "SOXL", + "current_min_trade": 175.0, + "investable_cash": 1800.0, +} + + +def _snapshot( + *, + total_equity: float, + buying_power: float | None, + cash_balance: float | None = None, + positions: tuple[tuple[str, float, float], ...] = (), + metadata: dict[str, Any] | None = None, +) -> PortfolioSnapshot: + return PortfolioSnapshot( + as_of=_AS_OF, + total_equity=total_equity, + buying_power=buying_power, + cash_balance=cash_balance, + positions=tuple( + Position(symbol=symbol, quantity=quantity, market_value=market_value) + for symbol, quantity, market_value in positions + ), + metadata=dict(metadata or {}), + ) + + +def build_cases() -> list[tuple[str, dict[str, Any]]]: + soxl_positions = (("SOXL", 40.0, 4000.0), ("BOXX", 10.0, 1100.0), ("QQQI", 5.0, 260.0)) + return [ + ( + "value_full_annotations_soxl", + dict( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="SOXL", target_value=5000.0), + PositionTarget(symbol="BOXX", target_value=2000.0, role="safe_haven"), + PositionTarget(symbol="QQQI", target_value=800.0, role="income"), + ), + diagnostics={ + "execution_annotations": dict(_FULL_ANNOTATIONS), + "blend_gate_volatility_delever_threshold_mode": "rolling_percentile", + "blend_gate_volatility_delever_metric": 0.61, + "market_regime_control_route": "risk_reduced", + "market_regime_control_reason_codes": ("macro:vix_crisis_level",), + }, + ), + snapshot=_snapshot( + total_equity=10000.0, + buying_power=3000.0, + positions=soxl_positions, + metadata={"cash_by_currency": {"USD": 3000.0}}, + ), + strategy_profile="soxl_soxx_trend_income", + runtime_metadata={ + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 100.0, + "reserved_cash_ratio": 0.05, + } + }, + ), + ), + ( + "value_legacy_keys_tqqq", + dict( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="TQQQ", target_value=4000.0), + PositionTarget(symbol="QQQM", target_value=1000.0), + PositionTarget(symbol="BOXX", target_value=3000.0, role="safe_haven"), + ), + diagnostics={ + "threshold": 120.0, + "reserved": 80.0, + "signal_message": "legacy signal", + "market_status": "legacy status", + "dashboard": "legacy dash", + "qqq_price": 512.0, + "ma200": 480.0, + "dual_drive_volatility_delever_applied": True, + "dual_drive_crisis_defense_destination": "BOXX", + }, + ), + snapshot=_snapshot( + total_equity=9000.0, + buying_power=2500.0, + positions=(("TQQQ", 30.0, 3500.0), ("BOXX", 20.0, 2200.0)), + ), + strategy_profile="tqqq_growth_income", + runtime_metadata=None, + ), + ), + ( + "value_without_threshold_uses_default", + dict( + decision=StrategyDecision( + positions=(PositionTarget(symbol="SOXL", target_value=6000.0),), + diagnostics={"signal_display": "s", "execution_annotations": {"reserved_cash": 50.0}}, + ), + snapshot=_snapshot(total_equity=25000.0, buying_power=7000.0), + strategy_profile="soxl_soxx_trend_income", + runtime_metadata={"firstrade_execution_policy": {"reserved_cash_ratio": 0.02}}, + ), + ), + ( + "weight_translation_with_policy", + dict( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="AAPL", target_weight=0.4), + PositionTarget(symbol="MSFT", target_weight=0.4), + PositionTarget(symbol="BOXX", target_weight=0.2, role="safe_haven"), + ), + diagnostics={ + "signal_description": "risk on", + "status_description": "breadth ok", + "benchmark_symbol": "spy", + "benchmark_price": 600.5, + "long_trend_value": 580.0, + "exit_line": 570.0, + "execution_annotations": { + "dashboard_text": "weights", + "signal_date": "2026-10-08", + "effective_date": "2026-10-09", + "execution_timing_contract": "same_day", + "execution_calendar_source": "xnys", + "signal_effective_after_trading_days": 0, + }, + }, + ), + snapshot=_snapshot( + total_equity=20000.0, + buying_power=4000.0, + positions=(("AAPL", 5.0, 1000.0),), + ), + strategy_profile="russell_top50_leader_rotation", + runtime_metadata={ + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 1500.0, + "reserved_cash_ratio": 0.03, + }, + "execution_annotations": {"signal_date": "2026-10-07"}, + "market_regime_control_enabled": True, + }, + ), + ), + ( + "weight_translation_not_cash_only", + dict( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="AAPL", target_weight=0.6), + PositionTarget(symbol="NVDA", target_weight=0.4), + ), + diagnostics={"signal_display": "fallback display", "canary_status": "canary"}, + ), + snapshot=_snapshot( + total_equity=15000.0, + buying_power=500.0, + cash_balance=-200.0, + positions=(("AAPL", 50.0, 9000.0), ("NVDA", 30.0, 5700.0)), + ), + strategy_profile="russell_top50_leader_rotation", + runtime_metadata={"firstrade_execution_policy": {"cash_only_execution": False}}, + ), + ), + ( + "weight_zero_equity_blocked_with_holdings", + dict( + decision=StrategyDecision( + positions=(PositionTarget(symbol="AAPL", target_weight=1.0),), + diagnostics={"signal_description": "risk on"}, + ), + snapshot=_snapshot( + total_equity=0.0, + buying_power=0.0, + positions=(("AAPL", 1.0, 0.0),), + ), + strategy_profile="russell_top50_leader_rotation", + runtime_metadata={"firstrade_execution_policy": {"reserved_cash_floor_usd": 10.0}}, + ), + ), + ( + "weight_zero_equity_blocked_empty", + dict( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="AAPL", target_weight=0.5), + PositionTarget(symbol="QQQI", target_weight=0.5), + ), + diagnostics={}, + ), + snapshot=_snapshot(total_equity=0.0, buying_power=0.0), + strategy_profile="russell_top50_leader_rotation", + runtime_metadata=None, + ), + ), + ( + "no_execute_holds_current_positions", + dict( + decision=StrategyDecision( + positions=(PositionTarget(symbol="SOXL", target_value=5000.0),), + risk_flags=frozenset({"no_execute"}), + diagnostics={"signal_display": "hold", "separator": "==="}, + ), + snapshot=_snapshot( + total_equity=8000.0, + buying_power=1000.0, + positions=soxl_positions, + ), + strategy_profile="soxl_soxx_trend_income", + runtime_metadata={"firstrade_execution_policy": {"reserved_cash_floor_usd": 2000.0}}, + ), + ), + ( + "value_investable_cash_capped_by_policy", + dict( + decision=StrategyDecision( + positions=(PositionTarget(symbol="TQQQ", target_value=3000.0),), + diagnostics={ + "execution_annotations": { + "trade_threshold_value": 100.0, + "reserved_cash": 10.0, + "investable_cash": 5000.0, + "current_min_trade": 90.0, + } + }, + ), + snapshot=_snapshot(total_equity=6000.0, buying_power=1200.0), + strategy_profile="tqqq_growth_income", + runtime_metadata={ + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 300.0, + "reserved_cash_ratio": 0.10, + } + }, + ), + ), + ] + + +def canonical(value: Any) -> Any: + """Canonical JSON-compatible form (tuples -> lists, sets -> sorted lists).""" + return json.loads(json.dumps(value, sort_keys=True, default=_default)) + + +def _default(value: Any) -> Any: + if isinstance(value, (set, frozenset)): + return sorted(value) + if isinstance(value, tuple): + return list(value) + return repr(value) + + +def render_all() -> dict[str, Any]: + from decision_mapper import map_strategy_decision_to_plan + + return { + name: canonical(map_strategy_decision_to_plan(**kwargs)) + for name, kwargs in build_cases() + } + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + rendered = render_all() + text = json.dumps(rendered, indent=2, sort_keys=True) + "\n" + if "--write" in sys.argv: + GOLDEN_PATH.write_text(text, encoding="utf-8") + print(f"wrote {GOLDEN_PATH}") + else: + sys.stdout.write(text) diff --git a/uv.lock b/uv.lock index 93fdb28..5aa9951 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=350dd38ece0952beef096893a7dcf8ae3871609a" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=155d74a615ccdebf7223787939c2dc7151d75efd" }] [[package]] name = "blinker" @@ -446,7 +446,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytz" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=350dd38ece0952beef096893a7dcf8ae3871609a" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=155d74a615ccdebf7223787939c2dc7151d75efd" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=c31f28484b8489a1ecda8e595a6d2afd3bce8185" }, @@ -1102,7 +1102,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "1.0.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=350dd38ece0952beef096893a7dcf8ae3871609a#350dd38ece0952beef096893a7dcf8ae3871609a" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=155d74a615ccdebf7223787939c2dc7151d75efd#155d74a615ccdebf7223787939c2dc7151d75efd" } [[package]] name = "requests"