Skip to content

Commit 8fe1303

Browse files
Pigbibicodex
andauthored
fix: reject nested account action fields (#62)
Co-authored-by: Codex <noreply@openai.com>
1 parent ce6aa05 commit 8fe1303

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

src/quant_advisor_research/contracts.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import datetime as dt
4+
import re
45
from collections.abc import Mapping, Sequence
56
from typing import Any
67

@@ -38,19 +39,54 @@ class AdvisoryValidationError(ValueError):
3839
)
3940
DISALLOWED_ACCOUNT_ACTION_KEYS = frozenset(
4041
{
42+
"account_action",
43+
"account_actions",
4144
"account_id",
4245
"broker",
46+
"broker_account",
47+
"broker_id",
48+
"broker_order",
49+
"broker_orders",
50+
"order",
51+
"orders",
52+
"order_id",
53+
"order_intent",
54+
"order_intents",
4355
"order_type",
4456
"shares",
57+
"target_quantities",
4558
"target_quantity",
4659
"target_weight",
60+
"target_weights",
4761
"portfolio_weight",
4862
"entry_order",
4963
"exit_order",
5064
}
5165
)
5266

5367

68+
def _normalize_contract_key(value: Any) -> str:
69+
if not isinstance(value, str):
70+
return ""
71+
snake_case = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value.strip())
72+
return re.sub(r"[^a-z0-9]+", "_", snake_case.lower()).strip("_")
73+
74+
75+
def _find_account_action_fields(value: Any, *, path: str = "$") -> tuple[str, ...]:
76+
findings: list[str] = []
77+
if isinstance(value, Mapping):
78+
for key, item in value.items():
79+
normalized = _normalize_contract_key(key)
80+
child_path = f"{path}.{key}"
81+
if normalized in DISALLOWED_ACCOUNT_ACTION_KEYS:
82+
findings.append(child_path)
83+
findings.extend(_find_account_action_fields(item, path=child_path))
84+
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
85+
for index, item in enumerate(value):
86+
findings.extend(_find_account_action_fields(item, path=f"{path}[{index}]"))
87+
return tuple(findings)
88+
89+
5490
def _require_mapping(value: Any, name: str) -> Mapping[str, Any]:
5591
if not isinstance(value, Mapping):
5692
raise AdvisoryValidationError(f"{name} must be an object")
@@ -187,6 +223,11 @@ def _require_number_0_1(value: Any, name: str) -> None:
187223

188224

189225
def validate_advisory_report(payload: Mapping[str, Any]) -> None:
226+
account_action_fields = _find_account_action_fields(payload)
227+
if account_action_fields:
228+
raise AdvisoryValidationError(
229+
"account-action fields are forbidden: " + ", ".join(account_action_fields)
230+
)
190231
required = (
191232
"schema_version",
192233
"as_of",

tests/test_advisory_report.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,29 @@ def test_contract_rejects_account_action_fields() -> None:
209209
validate_advisory_report(report)
210210

211211

212+
@pytest.mark.parametrize(
213+
"nested_action",
214+
[
215+
{"account_action": {"order": {"target_weight": 0.1}}},
216+
{"analysis": {"broker": "alpaca"}},
217+
{"analysis": [{"orderIntent": {"targetWeight": 0.1}}]},
218+
{"analysis": {"TARGET_WEIGHT": 0.1}},
219+
],
220+
)
221+
def test_contract_rejects_nested_account_action_fields(nested_action: dict[str, object]) -> None:
222+
report = build_advisory_report(
223+
as_of="2026-05-30",
224+
cadence="weekly",
225+
political_events_path=ROOT / "examples/political_events.example.csv",
226+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
227+
ai_signal_path=ROOT / "examples/research_signal_context.example.json",
228+
)
229+
report["recommendations"][0]["nested_context"] = nested_action
230+
231+
with pytest.raises(AdvisoryValidationError, match="account-action fields are forbidden"):
232+
validate_advisory_report(report)
233+
234+
212235
def test_contract_rejects_theme_candidate_account_action_fields() -> None:
213236
report = build_advisory_report(
214237
as_of="2026-05-30",

0 commit comments

Comments
 (0)