From 48202a610554b71acf1d02df56c0791deb9d5488 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:22:25 +0800 Subject: [PATCH] fix: reject nested account action fields Co-Authored-By: Codex --- src/quant_advisor_research/contracts.py | 41 +++++++++++++++++++++++++ tests/test_advisory_report.py | 23 ++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/quant_advisor_research/contracts.py b/src/quant_advisor_research/contracts.py index faec530..fa0c2e6 100644 --- a/src/quant_advisor_research/contracts.py +++ b/src/quant_advisor_research/contracts.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime as dt +import re from collections.abc import Mapping, Sequence from typing import Any @@ -38,12 +39,25 @@ class AdvisoryValidationError(ValueError): ) DISALLOWED_ACCOUNT_ACTION_KEYS = frozenset( { + "account_action", + "account_actions", "account_id", "broker", + "broker_account", + "broker_id", + "broker_order", + "broker_orders", + "order", + "orders", + "order_id", + "order_intent", + "order_intents", "order_type", "shares", + "target_quantities", "target_quantity", "target_weight", + "target_weights", "portfolio_weight", "entry_order", "exit_order", @@ -51,6 +65,28 @@ class AdvisoryValidationError(ValueError): ) +def _normalize_contract_key(value: Any) -> str: + if not isinstance(value, str): + return "" + snake_case = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value.strip()) + return re.sub(r"[^a-z0-9]+", "_", snake_case.lower()).strip("_") + + +def _find_account_action_fields(value: Any, *, path: str = "$") -> tuple[str, ...]: + findings: list[str] = [] + if isinstance(value, Mapping): + for key, item in value.items(): + normalized = _normalize_contract_key(key) + child_path = f"{path}.{key}" + if normalized in DISALLOWED_ACCOUNT_ACTION_KEYS: + findings.append(child_path) + findings.extend(_find_account_action_fields(item, path=child_path)) + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + for index, item in enumerate(value): + findings.extend(_find_account_action_fields(item, path=f"{path}[{index}]")) + return tuple(findings) + + def _require_mapping(value: Any, name: str) -> Mapping[str, Any]: if not isinstance(value, Mapping): raise AdvisoryValidationError(f"{name} must be an object") @@ -187,6 +223,11 @@ def _require_number_0_1(value: Any, name: str) -> None: def validate_advisory_report(payload: Mapping[str, Any]) -> None: + account_action_fields = _find_account_action_fields(payload) + if account_action_fields: + raise AdvisoryValidationError( + "account-action fields are forbidden: " + ", ".join(account_action_fields) + ) required = ( "schema_version", "as_of", diff --git a/tests/test_advisory_report.py b/tests/test_advisory_report.py index d34a86a..4a5c2ce 100644 --- a/tests/test_advisory_report.py +++ b/tests/test_advisory_report.py @@ -209,6 +209,29 @@ def test_contract_rejects_account_action_fields() -> None: validate_advisory_report(report) +@pytest.mark.parametrize( + "nested_action", + [ + {"account_action": {"order": {"target_weight": 0.1}}}, + {"analysis": {"broker": "alpaca"}}, + {"analysis": [{"orderIntent": {"targetWeight": 0.1}}]}, + {"analysis": {"TARGET_WEIGHT": 0.1}}, + ], +) +def test_contract_rejects_nested_account_action_fields(nested_action: dict[str, object]) -> None: + report = build_advisory_report( + as_of="2026-05-30", + cadence="weekly", + political_events_path=ROOT / "examples/political_events.example.csv", + political_watchlist_path=ROOT / "examples/political_watchlist.example.csv", + ai_signal_path=ROOT / "examples/research_signal_context.example.json", + ) + report["recommendations"][0]["nested_context"] = nested_action + + with pytest.raises(AdvisoryValidationError, match="account-action fields are forbidden"): + validate_advisory_report(report) + + def test_contract_rejects_theme_candidate_account_action_fields() -> None: report = build_advisory_report( as_of="2026-05-30",