|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import datetime as dt |
| 4 | +import re |
4 | 5 | from collections.abc import Mapping, Sequence |
5 | 6 | from typing import Any |
6 | 7 |
|
@@ -38,19 +39,54 @@ class AdvisoryValidationError(ValueError): |
38 | 39 | ) |
39 | 40 | DISALLOWED_ACCOUNT_ACTION_KEYS = frozenset( |
40 | 41 | { |
| 42 | + "account_action", |
| 43 | + "account_actions", |
41 | 44 | "account_id", |
42 | 45 | "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", |
43 | 55 | "order_type", |
44 | 56 | "shares", |
| 57 | + "target_quantities", |
45 | 58 | "target_quantity", |
46 | 59 | "target_weight", |
| 60 | + "target_weights", |
47 | 61 | "portfolio_weight", |
48 | 62 | "entry_order", |
49 | 63 | "exit_order", |
50 | 64 | } |
51 | 65 | ) |
52 | 66 |
|
53 | 67 |
|
| 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 | + |
54 | 90 | def _require_mapping(value: Any, name: str) -> Mapping[str, Any]: |
55 | 91 | if not isinstance(value, Mapping): |
56 | 92 | raise AdvisoryValidationError(f"{name} must be an object") |
@@ -187,6 +223,11 @@ def _require_number_0_1(value: Any, name: str) -> None: |
187 | 223 |
|
188 | 224 |
|
189 | 225 | 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 | + ) |
190 | 231 | required = ( |
191 | 232 | "schema_version", |
192 | 233 | "as_of", |
|
0 commit comments