Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/quant_advisor_research/contracts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import datetime as dt
import re
from collections.abc import Mapping, Sequence
from typing import Any

Expand Down Expand Up @@ -38,19 +39,54 @@ 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",
}
)


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")
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions tests/test_advisory_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading