From 1a89b5c8267eb4ba03fc75408fc55f422050531d Mon Sep 17 00:00:00 2001 From: Pigbibi Date: Fri, 9 Oct 2026 18:08:30 +0800 Subject: [PATCH] refactor(qpk): prefer semantics+display over mixed annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B06/N05 follow-up after #654: keep ValueTargetExecutionAnnotations as the compat wire aggregate, but make ValueTargetExecutionSemantics + ValueTargetDisplayAnnotations the preferred construction path. - VALUE_TARGET_DISPLAY_FIELD_NAMES as presentation field source of truth - split/resolve helpers; runtime plan accepts semantics=/display= - build_value_target_execution_annotations uses split→merge (equiv) - do not delete mixed fields yet (B03/B12 consumer adoption gate) --- ...b06_execution_presentation_facade.zh-CN.md | 21 +- .../common/execution_translation.py | 21 +- .../common/presentation/__init__.py | 6 + .../common/presentation/value_target_plan.py | 189 +++++++++++++++++- .../common/strategy_contracts.py | 88 +++----- .../test_value_target_presentation_facade.py | 71 +++++++ 6 files changed, 327 insertions(+), 69 deletions(-) diff --git a/docs/b06_execution_presentation_facade.zh-CN.md b/docs/b06_execution_presentation_facade.zh-CN.md index 51ac7038..9a48bdb8 100644 --- a/docs/b06_execution_presentation_facade.zh-CN.md +++ b/docs/b06_execution_presentation_facade.zh-CN.md @@ -1,6 +1,8 @@ -# B06:value-target 执行 API 与展示布局分离(第一刀) +# B06 / N05:value-target 执行 API 与展示布局分离 -## 做了什么 +## 已做 + +### 第一刀(QPK #654) - 新增 `quant_platform_kit.common.presentation`: - `ValueTargetExecutionSemantics`(阈值/时序/数值) - `ValueTargetDisplayAnnotations`(文案) @@ -8,10 +10,17 @@ - 新增 `build_value_target_execution_runtime_plan`:**不**在签名上接收 dashboard 布局 kwargs - 保留 `build_value_target_runtime_plan` 为兼容 facade(旧 kwargs → Presentation) -## 未做(follow-up) -- 未删除 `ValueTargetExecutionAnnotations` 混合字段;未迁移平台 `decision_mapper` 调用方 -- 未把 SOXL 公式移出 `runtime_inputs.py`(属 B03/B07) -- 未改阈值/数量/时序数值行为 +### 第二刀(本 PR:annotations 展示字段归属) +- 明确 `VALUE_TARGET_DISPLAY_FIELD_NAMES` 为展示字段真源 +- 新增 `split_value_target_annotation_parts` / `resolve_value_target_execution_annotations` +- `build_value_target_execution_runtime_plan` 支持首选 `semantics=` + `display=` +- `build_value_target_execution_annotations` 内部改为 split→merge(行为等价) +- `ValueTargetExecutionAnnotations` **仍保留混合字段**作为 wire/compat aggregate(batch-upgrade-then-remove;删字段等 B12 采用证据) + +## 未做 +- 未从 `ValueTargetExecutionAnnotations` **删除**展示字段(B03:消费者迁移未齐前禁止) +- 未迁移 LongBridge / Firstrade `decision_mapper`(禁区) +- 未把 SOXL 公式移出 `runtime_inputs.py`(B03/B07) ## 验证 `pytest tests/test_strategy_contracts.py tests/test_value_target_presentation_facade.py` diff --git a/src/quant_platform_kit/common/execution_translation.py b/src/quant_platform_kit/common/execution_translation.py index aed30275..a2b04ca8 100644 --- a/src/quant_platform_kit/common/execution_translation.py +++ b/src/quant_platform_kit/common/execution_translation.py @@ -4,7 +4,12 @@ from dataclasses import dataclass from typing import Any -from .presentation.value_target_plan import ValueTargetPlanPresentation +from .presentation.value_target_plan import ( + ValueTargetDisplayAnnotations, + ValueTargetExecutionSemantics, + ValueTargetPlanPresentation, + resolve_value_target_execution_annotations, +) from .strategy_contracts import ( PositionTarget, StrategyContractValidationError, @@ -228,21 +233,29 @@ def build_value_target_execution_runtime_plan( portfolio_inputs: ValueTargetPortfolioInputs, strategy_symbols_order: str = "risk_safe_income", annotations: ValueTargetExecutionAnnotations | None = None, + semantics: ValueTargetExecutionSemantics | None = None, + display: ValueTargetDisplayAnnotations | None = None, include_sellable_quantities: bool | None = None, presentation: ValueTargetPlanPresentation | None = None, ) -> dict[str, Any]: """Build the value-target runtime plan without dashboard-layout kwargs. + Prefer ``semantics`` + optional ``display`` for new callers. ``annotations`` + remains the compatibility aggregate (mixed execution + display fields). Presentation knobs (portfolio row layout / field filters) are optional via - ``ValueTargetPlanPresentation``. Callers that only need execution semantics - should omit ``presentation`` and use the default structural layout. + ``ValueTargetPlanPresentation``. """ presentation = presentation or ValueTargetPlanPresentation() execution_plan = build_value_target_execution_plan( decision, strategy_profile=strategy_profile, ) - resolved_annotations = annotations or build_value_target_execution_annotations(decision) + resolved_annotations = resolve_value_target_execution_annotations( + decision=decision, + annotations=annotations, + semantics=semantics, + display=display, + ) portfolio_plan = build_value_target_portfolio_plan( execution_plan, market_values=portfolio_inputs.market_values, diff --git a/src/quant_platform_kit/common/presentation/__init__.py b/src/quant_platform_kit/common/presentation/__init__.py index 305c25e6..1093b9fc 100644 --- a/src/quant_platform_kit/common/presentation/__init__.py +++ b/src/quant_platform_kit/common/presentation/__init__.py @@ -6,19 +6,25 @@ """ from .value_target_plan import ( + VALUE_TARGET_DISPLAY_FIELD_NAMES, ValueTargetDisplayAnnotations, ValueTargetExecutionSemantics, ValueTargetPlanPresentation, display_annotations_from_execution_annotations, execution_semantics_from_annotations, merge_value_target_execution_annotations, + resolve_value_target_execution_annotations, + split_value_target_annotation_parts, ) __all__ = [ + "VALUE_TARGET_DISPLAY_FIELD_NAMES", "ValueTargetDisplayAnnotations", "ValueTargetExecutionSemantics", "ValueTargetPlanPresentation", "display_annotations_from_execution_annotations", "execution_semantics_from_annotations", "merge_value_target_execution_annotations", + "resolve_value_target_execution_annotations", + "split_value_target_annotation_parts", ] diff --git a/src/quant_platform_kit/common/presentation/value_target_plan.py b/src/quant_platform_kit/common/presentation/value_target_plan.py index 55e8f791..d3ff2416 100644 --- a/src/quant_platform_kit/common/presentation/value_target_plan.py +++ b/src/quant_platform_kit/common/presentation/value_target_plan.py @@ -1,19 +1,44 @@ """Split value-target execution semantics from display/layout knobs. ``ValueTargetExecutionAnnotations`` in ``strategy_contracts`` remains the -compatibility wire type (all fields). New callers should prefer: +compatibility wire aggregate (execution + display fields). New callers should +prefer: - ``ValueTargetExecutionSemantics`` for thresholds / timing / numeric gates - ``ValueTargetDisplayAnnotations`` for dashboard/signal copy - ``ValueTargetPlanPresentation`` for portfolio row layout / field selection + +Construct the wire aggregate with ``merge_value_target_execution_annotations`` +(or pass ``semantics`` / ``display`` into +``build_value_target_execution_runtime_plan``). Mixed-field removal waits on +consumer adoption evidence (B12). """ from __future__ import annotations +import math from collections.abc import Mapping from dataclasses import dataclass from typing import Any -from quant_platform_kit.common.strategy_contracts import ValueTargetExecutionAnnotations +from quant_platform_kit.common.strategy_contracts import ( + StrategyContractValidationError, + StrategyDecision, + ValueTargetExecutionAnnotations, + validate_strategy_decision, +) + +# Source of truth for which annotation keys are presentation-owned. +VALUE_TARGET_DISPLAY_FIELD_NAMES: frozenset[str] = frozenset( + { + "signal_display", + "status_display", + "dashboard_text", + "separator", + "deploy_ratio_text", + "income_ratio_text", + "income_locked_ratio_text", + } +) @dataclass(frozen=True) @@ -97,6 +122,7 @@ def merge_value_target_execution_annotations( semantics: ValueTargetExecutionSemantics, display: ValueTargetDisplayAnnotations | None = None, ) -> ValueTargetExecutionAnnotations: + """Build the compatibility wire aggregate from split types.""" display = display or ValueTargetDisplayAnnotations() return ValueTargetExecutionAnnotations( trade_threshold_value=float(semantics.trade_threshold_value), @@ -121,3 +147,162 @@ def merge_value_target_execution_annotations( current_min_trade=semantics.current_min_trade, investable_cash=semantics.investable_cash, ) + + +def _pick_annotation_str( + annotations: Mapping[str, Any], + diagnostics: Mapping[str, Any], + *keys: str, +) -> str | None: + for key in keys: + value = annotations.get(key, diagnostics.get(key)) + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def _ensure_finite_annotation_number(value: object, *, field_name: str) -> None: + if value is None: + return + if not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise StrategyContractValidationError( + f"{field_name} must be a finite number when provided" + ) + + +def _pick_annotation_float( + annotations: Mapping[str, Any], + diagnostics: Mapping[str, Any], + *keys: str, + default: float | None = None, + field_prefix: str = "execution_annotations", +) -> float | None: + for key in keys: + value = annotations.get(key, diagnostics.get(key)) + if value is None: + continue + _ensure_finite_annotation_number(value, field_name=f"{field_prefix}.{key}") + return float(value) + return default + + +def split_value_target_annotation_parts( + decision: StrategyDecision, +) -> tuple[ValueTargetExecutionSemantics, ValueTargetDisplayAnnotations]: + """Parse decision diagnostics into execution semantics + display copy.""" + validate_strategy_decision(decision) + diagnostics = dict(decision.diagnostics) + raw_annotations = diagnostics.get("execution_annotations") + annotations = dict(raw_annotations) if isinstance(raw_annotations, Mapping) else {} + + threshold_value = _pick_annotation_float( + annotations, + diagnostics, + "trade_threshold_value", + "threshold", + "threshold_value", + ) + if threshold_value is None: + raise StrategyContractValidationError( + "ValueTargetExecutionAnnotations requires trade_threshold_value " + "(or legacy threshold/threshold_value)" + ) + + signal_delay = _pick_annotation_float( + annotations, + diagnostics, + "signal_effective_after_trading_days", + ) + semantics = ValueTargetExecutionSemantics( + trade_threshold_value=threshold_value, + reserved_cash=float( + _pick_annotation_float( + annotations, + diagnostics, + "reserved_cash", + "reserved", + default=0.0, + ) + or 0.0 + ), + signal_date=_pick_annotation_str(annotations, diagnostics, "signal_date"), + effective_date=_pick_annotation_str(annotations, diagnostics, "effective_date"), + execution_timing_contract=_pick_annotation_str( + annotations, diagnostics, "execution_timing_contract" + ), + execution_calendar_source=_pick_annotation_str( + annotations, diagnostics, "execution_calendar_source" + ), + signal_effective_after_trading_days=( + int(signal_delay) if signal_delay is not None else None + ), + benchmark_symbol=_pick_annotation_str(annotations, diagnostics, "benchmark_symbol"), + benchmark_price=_pick_annotation_float( + annotations, diagnostics, "benchmark_price", "qqq_price" + ), + long_trend_value=_pick_annotation_float( + annotations, diagnostics, "long_trend_value", "ma200" + ), + exit_line=_pick_annotation_float(annotations, diagnostics, "exit_line"), + active_risk_asset=_pick_annotation_str(annotations, diagnostics, "active_risk_asset"), + current_min_trade=_pick_annotation_float( + annotations, diagnostics, "current_min_trade" + ), + investable_cash=_pick_annotation_float(annotations, diagnostics, "investable_cash"), + ) + display = ValueTargetDisplayAnnotations( + signal_display=_pick_annotation_str( + annotations, diagnostics, "signal_display", "signal_message" + ), + status_display=_pick_annotation_str( + annotations, diagnostics, "status_display", "market_status" + ), + dashboard_text=_pick_annotation_str( + annotations, diagnostics, "dashboard_text", "dashboard" + ), + separator=_pick_annotation_str(annotations, diagnostics, "separator"), + deploy_ratio_text=_pick_annotation_str( + annotations, diagnostics, "deploy_ratio_text" + ), + income_ratio_text=_pick_annotation_str( + annotations, diagnostics, "income_ratio_text" + ), + income_locked_ratio_text=_pick_annotation_str( + annotations, diagnostics, "income_locked_ratio_text" + ), + ) + return semantics, display + + +def resolve_value_target_execution_annotations( + *, + decision: StrategyDecision | None = None, + annotations: ValueTargetExecutionAnnotations | None = None, + semantics: ValueTargetExecutionSemantics | None = None, + display: ValueTargetDisplayAnnotations | None = None, +) -> ValueTargetExecutionAnnotations: + """Resolve wire annotations from preferred split types or legacy aggregate. + + Precedence: + 1. Explicit ``annotations`` (compat facade) + 2. ``semantics`` (+ optional ``display``) merged + 3. Parse ``decision`` diagnostics into split parts then merge + """ + if annotations is not None and (semantics is not None or display is not None): + raise StrategyContractValidationError( + "Pass either annotations= (compat) or semantics=/display= (preferred), not both" + ) + if annotations is not None: + return annotations + if semantics is not None: + return merge_value_target_execution_annotations(semantics, display) + if decision is None: + raise StrategyContractValidationError( + "resolve_value_target_execution_annotations requires annotations, " + "semantics, or decision" + ) + parsed_semantics, parsed_display = split_value_target_annotation_parts(decision) + return merge_value_target_execution_annotations(parsed_semantics, parsed_display) diff --git a/src/quant_platform_kit/common/strategy_contracts.py b/src/quant_platform_kit/common/strategy_contracts.py index 87ab316a..698dea25 100644 --- a/src/quant_platform_kit/common/strategy_contracts.py +++ b/src/quant_platform_kit/common/strategy_contracts.py @@ -106,24 +106,44 @@ class ValueTargetPortfolioPlan: @dataclass(frozen=True) class ValueTargetExecutionAnnotations: + """Compatibility wire aggregate for value-target runtime payloads. + + Execution-relevant fields belong on + ``quant_platform_kit.common.presentation.ValueTargetExecutionSemantics``. + Dashboard/copy fields belong on ``ValueTargetDisplayAnnotations``. + + Prefer constructing via + ``merge_value_target_execution_annotations(semantics, display)`` (or the + ``semantics`` / ``display`` kwargs on + ``build_value_target_execution_runtime_plan``). Direct construction with + mixed display kwargs remains supported until consumers finish batch upgrade + (B06/N05); field removal waits on adoption evidence (B12). + """ + trade_threshold_value: float reserved_cash: float = 0.0 + # --- presentation-owned (compat dual-write on wire) --- signal_display: str | None = None status_display: str | None = None dashboard_text: str | None = None + # --- execution semantics --- signal_date: str | None = None effective_date: str | None = None execution_timing_contract: str | None = None execution_calendar_source: str | None = None signal_effective_after_trading_days: int | None = None + # --- presentation-owned (compat) --- separator: str | None = None + # --- execution semantics --- benchmark_symbol: str | None = None benchmark_price: float | None = None long_trend_value: float | None = None exit_line: float | None = None + # --- presentation-owned (compat) --- deploy_ratio_text: str | None = None income_ratio_text: str | None = None income_locked_ratio_text: str | None = None + # --- execution semantics --- active_risk_asset: str | None = None current_min_trade: float | None = None investable_cash: float | None = None @@ -959,65 +979,19 @@ def build_value_target_plan_payload( def build_value_target_execution_annotations( decision: StrategyDecision, ) -> ValueTargetExecutionAnnotations: - validate_strategy_decision(decision) - diagnostics = dict(decision.diagnostics) - raw_annotations = diagnostics.get("execution_annotations") - annotations = dict(raw_annotations) if isinstance(raw_annotations, Mapping) else {} - - def _pick_str(*keys: str) -> str | None: - for key in keys: - value = annotations.get(key, diagnostics.get(key)) - if value is None: - continue - text = str(value).strip() - if text: - return text - return None - - def _pick_float(*keys: str, default: float | None = None) -> float | None: - for key in keys: - value = annotations.get(key, diagnostics.get(key)) - if value is None: - continue - _ensure_finite_number(value, field_name=f"execution_annotations.{key}") - return float(value) - return default - - threshold_value = _pick_float("trade_threshold_value", "threshold", "threshold_value") - if threshold_value is None: - raise StrategyContractValidationError( - "ValueTargetExecutionAnnotations requires trade_threshold_value " - "(or legacy threshold/threshold_value)" - ) - - return ValueTargetExecutionAnnotations( - trade_threshold_value=threshold_value, - reserved_cash=float(_pick_float("reserved_cash", "reserved", default=0.0) or 0.0), - signal_display=_pick_str("signal_display", "signal_message"), - status_display=_pick_str("status_display", "market_status"), - dashboard_text=_pick_str("dashboard_text", "dashboard"), - signal_date=_pick_str("signal_date"), - effective_date=_pick_str("effective_date"), - execution_timing_contract=_pick_str("execution_timing_contract"), - execution_calendar_source=_pick_str("execution_calendar_source"), - signal_effective_after_trading_days=( - int(signal_delay) - if (signal_delay := _pick_float("signal_effective_after_trading_days")) is not None - else None - ), - separator=_pick_str("separator"), - benchmark_symbol=_pick_str("benchmark_symbol"), - benchmark_price=_pick_float("benchmark_price", "qqq_price"), - long_trend_value=_pick_float("long_trend_value", "ma200"), - exit_line=_pick_float("exit_line"), - deploy_ratio_text=_pick_str("deploy_ratio_text"), - income_ratio_text=_pick_str("income_ratio_text"), - income_locked_ratio_text=_pick_str("income_locked_ratio_text"), - active_risk_asset=_pick_str("active_risk_asset"), - current_min_trade=_pick_float("current_min_trade"), - investable_cash=_pick_float("investable_cash"), + """Parse diagnostics into the compatibility wire aggregate. + + Internally splits presentation-owned fields from execution semantics, then + merges them so legacy callers keep a single annotations object. + """ + # Local import keeps presentation -> strategy_contracts one-way for types, + # while this facade uses the split helpers for construction. + from quant_platform_kit.common.presentation.value_target_plan import ( + resolve_value_target_execution_annotations, ) + return resolve_value_target_execution_annotations(decision=decision) + # Note: load_strategy_entrypoint is in common/strategies.py (v0.9.0+). # Import from quant_platform_kit.common.strategies, not strategy_contracts. diff --git a/tests/test_value_target_presentation_facade.py b/tests/test_value_target_presentation_facade.py index 65a499c0..0d8c9d7f 100644 --- a/tests/test_value_target_presentation_facade.py +++ b/tests/test_value_target_presentation_facade.py @@ -119,5 +119,76 @@ def test_semantics_display_round_trip(self) -> None: ) +class ValueTargetAnnotationSplitTests(unittest.TestCase): + def test_split_parts_then_merge_matches_compat_builder(self) -> None: + from quant_platform_kit.common.presentation import ( + VALUE_TARGET_DISPLAY_FIELD_NAMES, + resolve_value_target_execution_annotations, + split_value_target_annotation_parts, + ) + from quant_platform_kit.common.strategy_contracts import ( + build_value_target_execution_annotations, + ) + + decision = _decision() + semantics, display = split_value_target_annotation_parts(decision) + self.assertEqual(semantics.trade_threshold_value, 25.0) + self.assertEqual(display.signal_display, "risk-on") + self.assertEqual(display.dashboard_text, "line-a\nline-b") + self.assertTrue({"signal_display", "dashboard_text"} <= VALUE_TARGET_DISPLAY_FIELD_NAMES) + + via_split = resolve_value_target_execution_annotations( + semantics=semantics, + display=display, + ) + via_compat = build_value_target_execution_annotations(decision) + self.assertEqual(via_split, via_compat) + + def test_runtime_plan_prefers_semantics_display_over_mixed_annotations(self) -> None: + decision = _decision() + inputs = _inputs() + via_mixed = build_value_target_execution_runtime_plan( + decision, + strategy_profile="soxl_soxx_trend_income", + portfolio_inputs=inputs, + annotations=ValueTargetExecutionAnnotations( + trade_threshold_value=25.0, + reserved_cash=10.0, + signal_display="risk-on", + dashboard_text="line-a\nline-b", + ), + ) + via_split = build_value_target_execution_runtime_plan( + decision, + strategy_profile="soxl_soxx_trend_income", + portfolio_inputs=inputs, + semantics=ValueTargetExecutionSemantics( + trade_threshold_value=25.0, + reserved_cash=10.0, + ), + display=ValueTargetDisplayAnnotations( + signal_display="risk-on", + dashboard_text="line-a\nline-b", + ), + ) + self.assertEqual(via_mixed, via_split) + self.assertEqual(via_split["execution"]["signal_display"], "risk-on") + self.assertEqual(via_split["execution"]["trade_threshold_value"], 25.0) + + def test_rejects_annotations_with_semantics(self) -> None: + from quant_platform_kit.common.presentation import ( + resolve_value_target_execution_annotations, + ) + from quant_platform_kit.common.strategy_contracts import ( + StrategyContractValidationError, + ) + + with self.assertRaises(StrategyContractValidationError): + resolve_value_target_execution_annotations( + annotations=ValueTargetExecutionAnnotations(trade_threshold_value=1.0), + semantics=ValueTargetExecutionSemantics(trade_threshold_value=1.0), + ) + + if __name__ == "__main__": unittest.main()