tradeagent 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. tradeagent/__init__.py +1 -0
  2. tradeagent/account_policy.py +49 -0
  3. tradeagent/accounting.py +127 -0
  4. tradeagent/broker.py +497 -0
  5. tradeagent/calendar.py +55 -0
  6. tradeagent/canary_review.py +457 -0
  7. tradeagent/canary_review_cli.py +95 -0
  8. tradeagent/cli.py +172 -0
  9. tradeagent/codex_bridge.py +320 -0
  10. tradeagent/contracts/market-data-1.7.0.json +334 -0
  11. tradeagent/contracts/official-1.6.2.json +3397 -0
  12. tradeagent/contracts/official-1.7.0.json +4704 -0
  13. tradeagent/contracts/official-1.7.3.json +4733 -0
  14. tradeagent/contracts/options-research-1.8.0.json +696 -0
  15. tradeagent/dashboard/__init__.py +0 -0
  16. tradeagent/dashboard/jobs.py +167 -0
  17. tradeagent/dashboard/readers.py +632 -0
  18. tradeagent/dashboard/server.py +501 -0
  19. tradeagent/dashboard/static/app.css +435 -0
  20. tradeagent/dashboard/static/app.js +1366 -0
  21. tradeagent/dashboard/static/favicon.svg +1 -0
  22. tradeagent/dashboard/static/index.html +35 -0
  23. tradeagent/demo.py +145 -0
  24. tradeagent/doctor.py +179 -0
  25. tradeagent/execution.py +123 -0
  26. tradeagent/execution_policy.py +685 -0
  27. tradeagent/friendly.py +105 -0
  28. tradeagent/health.py +111 -0
  29. tradeagent/legacy/__init__.py +1 -0
  30. tradeagent/legacy/autonomous.py +270 -0
  31. tradeagent/legacy/canary.py +292 -0
  32. tradeagent/legacy/cli.py +79 -0
  33. tradeagent/legacy/policy.py +363 -0
  34. tradeagent/legacy/release.py +29 -0
  35. tradeagent/legacy/runner.py +152 -0
  36. tradeagent/legacy/standalone.py +201 -0
  37. tradeagent/legacy/standalone_cli.py +149 -0
  38. tradeagent/legacy/standalone_reference.py +211 -0
  39. tradeagent/legacy/strategy.py +118 -0
  40. tradeagent/legacy/supervised_cli.py +138 -0
  41. tradeagent/market_mcp.py +38 -0
  42. tradeagent/model.py +330 -0
  43. tradeagent/oneshot.py +1211 -0
  44. tradeagent/oneshot_cli.py +404 -0
  45. tradeagent/opportunity_cli.py +457 -0
  46. tradeagent/opportunity_live.py +486 -0
  47. tradeagent/options.py +463 -0
  48. tradeagent/owner.example.toml +37 -0
  49. tradeagent/paths.py +24 -0
  50. tradeagent/prospective/__init__.py +1 -0
  51. tradeagent/prospective/access.py +92 -0
  52. tradeagent/prospective/assessor.py +339 -0
  53. tradeagent/prospective/collector.py +211 -0
  54. tradeagent/prospective/continuous.py +741 -0
  55. tradeagent/prospective/daily.py +722 -0
  56. tradeagent/prospective/daily_reporting.py +698 -0
  57. tradeagent/prospective/event_assessor.py +137 -0
  58. tradeagent/prospective/event_reporting.py +246 -0
  59. tradeagent/prospective/event_store.py +124 -0
  60. tradeagent/prospective/events.py +764 -0
  61. tradeagent/prospective/providers.py +252 -0
  62. tradeagent/prospective/sec_events.py +381 -0
  63. tradeagent/prospective/service.py +379 -0
  64. tradeagent/prospective/v5_supplement.py +197 -0
  65. tradeagent/research/__init__.py +1 -0
  66. tradeagent/research/agent_market.py +17 -0
  67. tradeagent/research/alpha_data.py +113 -0
  68. tradeagent/research/alpha_experiment.py +486 -0
  69. tradeagent/research/alpha_signals.py +126 -0
  70. tradeagent/research/attribution.py +133 -0
  71. tradeagent/research/bidirectional.py +454 -0
  72. tradeagent/research/call_put.py +603 -0
  73. tradeagent/research/demo.py +234 -0
  74. tradeagent/research/domain.py +298 -0
  75. tradeagent/research/evidence_cohorts.py +212 -0
  76. tradeagent/research/evolution.py +265 -0
  77. tradeagent/research/expressions.py +167 -0
  78. tradeagent/research/feedback.py +98 -0
  79. tradeagent/research/history_data.py +182 -0
  80. tradeagent/research/lab.py +163 -0
  81. tradeagent/research/learning.py +264 -0
  82. tradeagent/research/market.py +58 -0
  83. tradeagent/research/opportunities.py +492 -0
  84. tradeagent/research/opportunity_workflow.py +1231 -0
  85. tradeagent/research/option_economics.py +55 -0
  86. tradeagent/research/outcomes.py +148 -0
  87. tradeagent/research/put_shadow.py +318 -0
  88. tradeagent/research/replay.py +550 -0
  89. tradeagent/research/reporting.py +73 -0
  90. tradeagent/research/research_universe.py +68 -0
  91. tradeagent/research/selector.py +87 -0
  92. tradeagent/research/store.py +425 -0
  93. tradeagent/research/tradeplan.py +390 -0
  94. tradeagent/risk.py +177 -0
  95. tradeagent/robinhood_login.py +497 -0
  96. tradeagent/schema.py +234 -0
  97. tradeagent/simulator.py +501 -0
  98. tradeagent/standalone_mcp.py +578 -0
  99. tradeagent/state.py +310 -0
  100. tradeagent/strategy.py +132 -0
  101. tradeagent/supervised.py +759 -0
  102. tradeagent/vendor/__init__.py +1 -0
  103. tradeagent/vendor/client.py +33 -0
  104. tradeagent/vendor/exceptions.py +21 -0
  105. tradeagent/vendor/indicators.py +23 -0
  106. tradeagent/vendor/run_lock.py +366 -0
  107. tradeagent/vendor/trend.py +24 -0
  108. tradeagent-1.0.0.dist-info/METADATA +231 -0
  109. tradeagent-1.0.0.dist-info/RECORD +116 -0
  110. tradeagent-1.0.0.dist-info/WHEEL +5 -0
  111. tradeagent-1.0.0.dist-info/entry_points.txt +2 -0
  112. tradeagent-1.0.0.dist-info/licenses/LICENSE +202 -0
  113. tradeagent-1.0.0.dist-info/licenses/licenses/Oft3r-MIT.txt +21 -0
  114. tradeagent-1.0.0.dist-info/licenses/licenses/abiemann-MIT.txt +21 -0
  115. tradeagent-1.0.0.dist-info/licenses/licenses/cbangera2-MIT.txt +21 -0
  116. tradeagent-1.0.0.dist-info/top_level.txt +1 -0
tradeagent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TradeAgent: prospective quantitative learning with separate durable execution."""
@@ -0,0 +1,49 @@
1
+ """Explicit caller-scoped account policy, verified against official 2026-10-05 metadata."""
2
+
3
+ from .model import Halt, dec
4
+
5
+ POLICY = "agentic-unleveraged-2026-10-05-v1"
6
+ SOURCE = "https://robinhood.com/us/en/support/articles/setting-up-an-agent/"
7
+
8
+
9
+ def eligible(account):
10
+ if (
11
+ account.get("agentic_allowed") is not True
12
+ or account.get("state") != "active"
13
+ or account.get("deactivated") is not False
14
+ or account.get("permanently_deactivated") is not False
15
+ or account.get("brokerage_account_type") != "individual"
16
+ or account.get("management_type") not in (None, "", "self_directed")
17
+ ):
18
+ raise Halt(
19
+ "account is not an active dedicated caller-accessible individual Agentic account"
20
+ )
21
+ if account.get("type") not in {"cash", "limited_margin"}:
22
+ raise Halt("ordinary margin or unknown account type is ineligible")
23
+ # Dedicated Agentic limited margin provides settlement access, never borrowing.
24
+ return POLICY
25
+
26
+
27
+ def capital(portfolio):
28
+ bp = portfolio.get("buying_power")
29
+ if (
30
+ not isinstance(bp, dict)
31
+ or portfolio.get("currency") != "USD"
32
+ or bp.get("display_currency") != "USD"
33
+ ):
34
+ raise Halt("missing authoritative USD buying power")
35
+ cash, pending, generic, unleveraged = (
36
+ dec(v)
37
+ for v in (
38
+ portfolio.get("cash"),
39
+ portfolio.get("pending_deposits"),
40
+ bp.get("buying_power"),
41
+ bp.get("unleveraged_buying_power"),
42
+ )
43
+ )
44
+ if min(cash, pending, generic, unleveraged) < 0:
45
+ raise Halt("cash or buying power deficit")
46
+ # Cash includes early pending deposits; exclude these. Generic BP can only
47
+ # reduce this ceiling, never increase it. unsettled_funds is lagging information,
48
+ # explicitly NOT authoritative for gating in the official schema.
49
+ return min(max(dec(0), cash - pending), unleveraged, generic)
@@ -0,0 +1,127 @@
1
+ """Durable NAV/cash-flow attribution. Unknown cash or asset movements latch a halt."""
2
+
3
+ import json
4
+
5
+ from .model import Halt, dec
6
+ from .state import dumps
7
+
8
+
9
+ class Accounting:
10
+ def __init__(self, state):
11
+ self.state = state
12
+ state.db.execute("""CREATE TABLE IF NOT EXISTS accounting(
13
+ account_key TEXT PRIMARY KEY, checkpoint TEXT NOT NULL, blocked INTEGER NOT NULL)""")
14
+ state.db.commit()
15
+
16
+ def observe(self, run, snapshot, day):
17
+ s, db = snapshot, self.state.db
18
+ row = db.execute(
19
+ "SELECT * FROM accounting WHERE account_key=?", (s.account_key,)
20
+ ).fetchone()
21
+ prior = json.loads(row["checkpoint"]) if row else None
22
+ if row and row["blocked"]:
23
+ raise Halt("cash-flow uncertainty is latched; human reconciliation/rebaseline required")
24
+ if s.nav <= 0:
25
+ return str(s.nav)
26
+ fills = {f["id"]: f for f in s.fills}
27
+ if len(fills) != len(s.fills):
28
+ raise Halt("duplicate accounting execution")
29
+ flows = {f["id"]: f for f in s.cashflows} if s.cashflows is not None else None
30
+ if flows is not None and len(flows) != len(s.cashflows):
31
+ raise Halt("duplicate cash-flow record")
32
+ external, traded, movements = dec(0), dec(0), {}
33
+ classifications = []
34
+ if prior:
35
+ old_fills = prior["fills"]
36
+ if any(k not in fills or fills[k] != v for k, v in old_fills.items()):
37
+ return self._block(run, s, prior, "fill history changed/incomplete")
38
+ for fid, fill in fills.items():
39
+ if fid in old_fills:
40
+ continue
41
+ if fill.get("fees_known") is False:
42
+ return self._block(
43
+ run,
44
+ s,
45
+ prior,
46
+ "option execution fees unavailable; cash attribution incomplete",
47
+ )
48
+ traded += dec(fill["cash_delta"])
49
+ sign = 1 if fill["side"] == "buy" else -1
50
+ symbol = fill["symbol"]
51
+ movements[symbol] = movements.get(symbol, dec(0)) + sign * dec(fill["quantity"])
52
+ classifications.append(
53
+ {
54
+ "kind": "trading_cash_change",
55
+ "execution_id": fid,
56
+ "cash_delta": fill["cash_delta"],
57
+ }
58
+ )
59
+ if flows is not None:
60
+ old_flows = prior.get("flows") or {}
61
+ if any(k not in flows or flows[k] != v for k, v in old_flows.items()):
62
+ return self._block(run, s, prior, "cash-flow history changed/incomplete")
63
+ for fid, flow in flows.items():
64
+ if fid not in old_flows:
65
+ if flow.get("kind") not in {"deposit", "withdrawal", "transfer"}:
66
+ return self._block(run, s, prior, "unknown cash-flow classification")
67
+ external += dec(flow["amount"])
68
+ classifications.append(flow)
69
+ if abs(s.cash - dec(prior["cash"]) - traded - external) > dec(".01"):
70
+ return self._block(
71
+ run, s, prior, "unexplained cash movement; transfer data unavailable"
72
+ )
73
+ holdings = {**s.positions, **s.options}
74
+ previous = prior["holdings"]
75
+ for symbol in set(holdings) | set(previous) | set(movements):
76
+ if dec(holdings.get(symbol, 0)) - dec(previous.get(symbol, 0)) != movements.get(
77
+ symbol, dec(0)
78
+ ):
79
+ return self._block(run, s, prior, "unexplained asset transfer/corporate action")
80
+ baseline = dec(prior["baseline"]) + external if prior and prior["day"] == day else s.nav
81
+ high = max(s.nav, dec(prior["high"]) + external) if prior else s.nav
82
+ if baseline <= 0 or high <= 0:
83
+ return self._block(run, s, prior or {}, "invalid flow-adjusted baseline")
84
+ s.high_water_nav = high
85
+ record = {
86
+ "day": day,
87
+ "baseline": str(baseline),
88
+ "high": str(high),
89
+ "nav": str(s.nav),
90
+ "cash": str(s.cash),
91
+ "holdings": {**s.positions, **s.options},
92
+ "fills": fills,
93
+ "flows": flows,
94
+ }
95
+ # Convert Decimals once so history comparisons on restart use identical shapes.
96
+ record = json.loads(dumps(record))
97
+ with db:
98
+ db.execute(
99
+ "INSERT OR REPLACE INTO accounting VALUES(?,?,0)", (s.account_key, dumps(record))
100
+ )
101
+ self.state.event(
102
+ run,
103
+ "accounting",
104
+ {
105
+ "baseline": str(baseline),
106
+ "high_water": str(high),
107
+ "external_flows": str(external),
108
+ "trading_cash_change": str(traded),
109
+ "market_pnl_change": str(s.nav - dec(prior["nav"]) - external) if prior else None,
110
+ "attribution": classifications,
111
+ "cashflows_available": flows is not None,
112
+ "meaning": "first observed positive NAV, flow-adjusted; realized P&L needs cost basis",
113
+ },
114
+ )
115
+ return str(baseline)
116
+
117
+ def _block(self, run, snapshot, prior, reason):
118
+ with self.state.db:
119
+ self.state.db.execute(
120
+ "INSERT OR REPLACE INTO accounting VALUES(?,?,1)",
121
+ (snapshot.account_key, dumps(prior)),
122
+ )
123
+ snapshot.accounting_verified = False
124
+ self.state.event(
125
+ run, "accounting_halt", {"reason": reason, "rebaseline": "requires human investigation"}
126
+ )
127
+ raise Halt(reason)
tradeagent/broker.py ADDED
@@ -0,0 +1,497 @@
1
+ """Official MCP read adapter; full pagination and explicit normalization."""
2
+
3
+ import math
4
+ from dataclasses import asdict
5
+ from datetime import datetime, timedelta, timezone
6
+ from decimal import Decimal
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .account_policy import capital, eligible
10
+ from .calendar import regular_session, session_bounds
11
+ from .model import MAX_FUTURE_SKEW_SECONDS, Halt, Snapshot, dec, digest, timestamp_fresh
12
+
13
+
14
+ def utc_time(value):
15
+ if not isinstance(value, str):
16
+ raise Halt("missing broker timestamp")
17
+ try:
18
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
19
+ if dt.tzinfo is None:
20
+ raise ValueError("naive timestamp")
21
+ result = dt.timestamp()
22
+ if not math.isfinite(result):
23
+ raise ValueError("non-finite timestamp")
24
+ return result
25
+ except (ValueError, OverflowError) as exc:
26
+ raise Halt("invalid broker timestamp") from exc
27
+
28
+
29
+ def data(response):
30
+ payload = response.get("data")
31
+ if not isinstance(payload, dict):
32
+ raise Halt("missing broker data envelope")
33
+ return payload
34
+
35
+
36
+ def rows(value, label):
37
+ # The server explicitly uses null to represent an empty collection.
38
+ if value is None:
39
+ return []
40
+ if not isinstance(value, list) or any(not isinstance(v, dict) for v in value):
41
+ raise Halt(f"malformed {label} collection")
42
+ return value
43
+
44
+
45
+ def unique(values, key):
46
+ result = {}
47
+ for value in values:
48
+ identity = value.get(key)
49
+ if not isinstance(identity, str) or not identity or identity in result:
50
+ raise Halt("missing/duplicate broker record identity")
51
+ result[identity] = value
52
+ return result
53
+
54
+
55
+ class Broker:
56
+ """Account numbers stay in memory; persisted account keys are SHA-256 digests."""
57
+
58
+ def __init__(self, bridge, config, risk, options_enabled=False):
59
+ self.bridge, self.config, self.risk = bridge, config, risk
60
+ self.account = None
61
+ self.account_scope = []
62
+ self.options_enabled = options_enabled
63
+
64
+ def accounts(self):
65
+ accounts = rows(data(self.bridge.read("get_accounts")).get("accounts"), "accounts")
66
+ unique(accounts, "account_number")
67
+ self.account_scope = [
68
+ {
69
+ "account_key": digest(a["account_number"])[:16],
70
+ "type": a.get("type"),
71
+ "brokerage_account_type": a.get("brokerage_account_type"),
72
+ "state": a.get("state"),
73
+ "accessible_to_this_agent": a.get("agentic_allowed") is True,
74
+ }
75
+ for a in accounts
76
+ ]
77
+ choices = [a for a in accounts if a.get("agentic_allowed") is True]
78
+ selector = self.config.account_selector
79
+ if selector:
80
+ choices = [a for a in choices if digest(a["account_number"]) == selector]
81
+ if len(choices) != 1:
82
+ raise Halt(
83
+ "select one eligible account (exactly one) with broker.account_number; visible accounts are ambiguous or selected account is inaccessible"
84
+ )
85
+ selected = choices[0]
86
+ if (
87
+ selected.get("state") != "active"
88
+ or selected.get("deactivated") is not False
89
+ or selected.get("permanently_deactivated") is not False
90
+ ):
91
+ raise Halt("agent account inactive or uncertain")
92
+ if self.account and self.account["account_number"] != selected["account_number"]:
93
+ raise Halt("agent account scope changed during run")
94
+ self.account = selected
95
+ return self.account_scope
96
+
97
+ def paged(self, tool, collection):
98
+ output, cursors, cursor = [], set(), None
99
+ for _ in range(100):
100
+ args = {"account_number": self.account["account_number"]}
101
+ if cursor:
102
+ args["cursor"] = cursor
103
+ payload = data(self.bridge.read(tool, args))
104
+ if collection not in payload:
105
+ raise Halt("missing paginated broker collection")
106
+ output.extend(rows(payload[collection], collection))
107
+ cursor = payload.get("next", "")
108
+ if not cursor:
109
+ return output
110
+ if not isinstance(cursor, str) or cursor in cursors:
111
+ raise Halt("invalid/repeated broker pagination cursor")
112
+ cursors.add(cursor)
113
+ raise Halt("broker pagination exceeded safety bound; state incomplete")
114
+
115
+ def snapshot(self, now=None, clock=None):
116
+ observed = (
117
+ clock()
118
+ if clock is not None
119
+ else datetime.now(timezone.utc).timestamp()
120
+ if now is None
121
+ else now
122
+ )
123
+ self.accounts()
124
+ p = data(
125
+ self.bridge.read("get_portfolio", {"account_number": self.account["account_number"]})
126
+ )
127
+ if p.get("currency") != "USD" or p.get("buying_power", {}).get("display_currency") != "USD":
128
+ raise Halt("unsupported/missing portfolio currency or buying power")
129
+ unsupported = (
130
+ "options_value",
131
+ "futures_value",
132
+ "event_contracts_value",
133
+ "crypto_value",
134
+ "mutual_funds_value",
135
+ "fixed_income_value",
136
+ "pending_deposits",
137
+ )
138
+ other_values = {
139
+ k: dec(p.get(k))
140
+ for k in unsupported
141
+ if k != "pending_deposits" and (k != "options_value" or not self.options_enabled)
142
+ }
143
+ if any(value < 0 for value in other_values.values()):
144
+ raise Halt("negative or uncertain unrelated asset valuation")
145
+ if self.config.mode != "LIVE" and any(
146
+ dec(p.get(k)) != 0
147
+ for k in unsupported
148
+ if k != "options_value" or not self.options_enabled
149
+ ):
150
+ raise Halt("unsupported holdings or unsettled deposit in account")
151
+ if self.config.mode != "LIVE":
152
+ other_values = {}
153
+ pos_rows = self.paged("get_equity_positions", "positions")
154
+ pos = unique(pos_rows, "symbol")
155
+ positions, available = {}, {}
156
+ for symbol, row in pos.items():
157
+ qty, sellable = dec(row.get("quantity")), dec(row.get("shares_available_for_sells"))
158
+ if (
159
+ qty < 0
160
+ or sellable < 0
161
+ or sellable > qty
162
+ or row.get("type") not in {"long", "empty"}
163
+ ):
164
+ raise Halt("invalid, short, boxed or uncertain position")
165
+ if dec(row.get("shares_pending_from_options_events")) != 0:
166
+ raise Halt("pending option-related shares require operator reconciliation")
167
+ if qty:
168
+ positions[symbol], available[symbol] = qty, sellable
169
+ raw_orders = self.paged("get_equity_orders", "orders")
170
+ orders_by_id = unique(raw_orders, "id")
171
+ orders, fills, ledger, turnover = [], set(), [], Decimal(0)
172
+ day = datetime.fromtimestamp(observed, ZoneInfo("America/New_York")).date()
173
+ for row in orders_by_id.values():
174
+ from .options import normalize_order
175
+
176
+ order = normalize_order(row)
177
+ if not isinstance(order["state"], str):
178
+ raise Halt("missing order lifecycle state")
179
+ executed = Decimal(0)
180
+ for fill in rows(row.get("executions"), "executions"):
181
+ fid = fill.get("id")
182
+ if not isinstance(fid, str) or fid in fills:
183
+ raise Halt("missing/duplicate fill identity")
184
+ fills.add(fid)
185
+ qty, price = dec(fill.get("quantity")), dec(fill.get("price"))
186
+ if qty <= 0 or price <= 0:
187
+ raise Halt("invalid fill")
188
+ executed += qty
189
+ ts = utc_time(fill.get("timestamp"))
190
+ fill_receipt = (
191
+ clock()
192
+ if clock is not None
193
+ else datetime.now(timezone.utc).timestamp()
194
+ if now is None
195
+ else now
196
+ )
197
+ if ts > fill_receipt:
198
+ raise Halt("future fill timestamp")
199
+ ledger.append(
200
+ {
201
+ "id": fid,
202
+ "symbol": row.get("symbol"),
203
+ "quantity": str(qty),
204
+ "side": row.get("side"),
205
+ "price": str(price),
206
+ "cash_delta": str(
207
+ (1 if row.get("side") == "sell" else -1) * qty * price
208
+ - dec(fill.get("fees", "0"))
209
+ ),
210
+ "timestamp": ts,
211
+ }
212
+ )
213
+ if datetime.fromtimestamp(ts, ZoneInfo("America/New_York")).date() == day:
214
+ turnover += qty * price
215
+ if executed != dec(row.get("cumulative_quantity")):
216
+ raise Halt("execution details do not reconcile cumulative fills")
217
+ # Production reconciliation needs the same fee/execution detail that
218
+ # the simulator already exposes. Never discard it after normalization.
219
+ order["executions"] = rows(row.get("executions"), "executions")
220
+ order["fees"] = row.get("fees")
221
+ if order["executions"]:
222
+ fees = dec(order["fees"])
223
+ execution_fees = [dec(fill.get("fees")) for fill in order["executions"]]
224
+ if (
225
+ fees < 0
226
+ or any(fee < 0 for fee in execution_fees)
227
+ or sum(execution_fees) != fees
228
+ ):
229
+ raise Halt("equity execution fees do not reconcile order fees")
230
+ orders.append(order)
231
+ symbols = sorted(set(self.config.allowed_symbols) | set(positions))
232
+ if len(symbols) > 100:
233
+ raise Halt("position universe exceeds supported snapshot bound")
234
+ quotes = {}
235
+ for offset in range(0, len(symbols), 20):
236
+ response = data(
237
+ self.bridge.read("get_equity_quotes", {"symbols": symbols[offset : offset + 20]})
238
+ )
239
+ for result in rows(response.get("results"), "quotes"):
240
+ q = result.get("quote")
241
+ if not isinstance(q, dict) or q.get("symbol") in quotes:
242
+ raise Halt("missing/duplicate quote")
243
+ quotes[q["symbol"]] = q
244
+ if set(quotes) != set(symbols):
245
+ raise Halt("incomplete quote coverage")
246
+ prices, quote_times, bids, asks, bid_times, ask_times = {}, {}, {}, {}, {}, {}
247
+ for symbol, q in quotes.items():
248
+ if q.get("state") != "active" or q.get("has_traded") is not True:
249
+ raise Halt("inactive/untraded instrument")
250
+ candidates = [
251
+ (utc_time(q.get("venue_last_trade_time")), dec(q.get("last_trade_price")))
252
+ ]
253
+ if q.get("last_non_reg_trade_price") is not None:
254
+ candidates.append(
255
+ (
256
+ utc_time(q.get("venue_last_non_reg_trade_time")),
257
+ dec(q["last_non_reg_trade_price"]),
258
+ )
259
+ )
260
+ quote_times[symbol], prices[symbol] = max(candidates, key=lambda v: v[0])
261
+ bids[symbol], asks[symbol] = dec(q.get("bid_price")), dec(q.get("ask_price"))
262
+ if bids[symbol] <= 0 or asks[symbol] < bids[symbol]:
263
+ raise Halt("invalid/crossed equity quote")
264
+ bid_times[symbol], ask_times[symbol] = (
265
+ utc_time(q.get("venue_bid_time")),
266
+ utc_time(q.get("venue_ask_time")),
267
+ )
268
+ all_trade_rows = []
269
+ for offset in range(0, len(self.config.allowed_symbols), 10):
270
+ tradability = data(
271
+ self.bridge.read(
272
+ "get_equity_tradability",
273
+ {
274
+ "account_number": self.account["account_number"],
275
+ "symbols": self.config.allowed_symbols[offset : offset + 10],
276
+ },
277
+ )
278
+ )
279
+ all_trade_rows.extend(rows(tradability.get("results"), "tradability"))
280
+ trade_rows = unique(all_trade_rows, "symbol")
281
+ if set(trade_rows) != set(self.config.allowed_symbols):
282
+ raise Halt("incomplete tradability coverage")
283
+ tradable = {
284
+ sym: row.get("tradeable") is True
285
+ and row.get("state") == "active"
286
+ and not row.get("internal_halt_reason")
287
+ and not row.get("internal_halt_sessions")
288
+ for sym, row in trade_rows.items()
289
+ }
290
+ policy = eligible(self.account)
291
+ snapshot = Snapshot(
292
+ account_key=digest(self.account["account_number"])[:16],
293
+ asof=observed,
294
+ nav=dec(p.get("total_value")),
295
+ cash=dec(p.get("cash")),
296
+ buying_power=capital(p),
297
+ positions=positions,
298
+ available=available,
299
+ prices=prices,
300
+ quote_times=quote_times,
301
+ asks=asks,
302
+ bids=bids,
303
+ orders=sorted(orders, key=lambda o: o["id"]),
304
+ daily_turnover=turnover,
305
+ account_type=self.account.get("type", "unknown"),
306
+ tradable=tradable,
307
+ fractional_tradable={
308
+ s: r.get("fractional_tradability") == "tradable" for s, r in trade_rows.items()
309
+ },
310
+ countries={s: r.get("country", "") for s, r in trade_rows.items()},
311
+ other_asset_values={k: v for k, v in other_values.items() if v},
312
+ bid_times=bid_times,
313
+ ask_times=ask_times,
314
+ regular_session=False,
315
+ agentic_eligible=True,
316
+ account_policy=policy,
317
+ option_level=self.account.get(
318
+ "user_option_level", self.account.get("option_level", "")
319
+ ),
320
+ fills=ledger,
321
+ )
322
+ books = {}
323
+ for offset in range(0, len(self.config.allowed_symbols), 4):
324
+ response = data(
325
+ self.bridge.read(
326
+ "get_equity_price_book",
327
+ {"symbols": self.config.allowed_symbols[offset : offset + 4]},
328
+ )
329
+ )
330
+ if response.get("errors"):
331
+ raise Halt("equity depth unavailable")
332
+ for book in rows(response.get("books"), "books"):
333
+ symbol = book.get("symbol")
334
+ if symbol in books:
335
+ raise Halt("duplicate equity book")
336
+ books[symbol] = book
337
+ if set(books) != set(self.config.allowed_symbols):
338
+ raise Halt("equity depth coverage incomplete")
339
+ validated = (
340
+ clock()
341
+ if clock is not None
342
+ else datetime.now(timezone.utc).timestamp()
343
+ if now is None
344
+ else now
345
+ )
346
+ for symbol, book in books.items():
347
+ bid_rows, ask_rows = rows(book.get("bids"), "bids"), rows(book.get("asks"), "asks")
348
+ if not bid_rows or not ask_rows:
349
+ raise Halt("empty equity book")
350
+ best_bid = max(dec(b["price"]) for b in bid_rows)
351
+ best_ask = min(dec(a["price"]) for a in ask_rows)
352
+ # Quotes and L2 are independently timestamped observations. Do not
353
+ # demand price equality across different instants. Execution prices
354
+ # and executable size must come from the SAME coherent book.
355
+ completed = validated
356
+ book_time = utc_time(book.get("updated_at"))
357
+ if best_bid <= 0 or best_ask < best_bid:
358
+ raise Halt("invalid/crossed equity book")
359
+ for stamp in (snapshot.bid_times[symbol], snapshot.ask_times[symbol], book_time):
360
+ if not timestamp_fresh(
361
+ stamp,
362
+ completed,
363
+ self.risk.max_data_age_seconds,
364
+ future_skew=MAX_FUTURE_SKEW_SECONDS,
365
+ ):
366
+ raise Halt("stale/future quote or equity book")
367
+ snapshot.bids[symbol], snapshot.asks[symbol] = best_bid, best_ask
368
+ snapshot.bid_times[symbol] = snapshot.ask_times[symbol] = book_time
369
+ if any(
370
+ type(row.get("quantity")) is not int or row["quantity"] < 0
371
+ for row in bid_rows + ask_rows
372
+ ):
373
+ raise Halt("invalid equity depth size")
374
+ snapshot.liquidity[symbol] = {
375
+ "asof": utc_time(book.get("updated_at")),
376
+ "bid_size": sum(b["quantity"] for b in bid_rows if dec(b["price"]) == best_bid),
377
+ "ask_size": sum(a["quantity"] for a in ask_rows if dec(a["price"]) == best_ask),
378
+ }
379
+ if self.options_enabled:
380
+ from .options import OptionsReader
381
+
382
+ OptionsReader(self).enrich(snapshot)
383
+ option_value = sum(
384
+ q * snapshot.option_quotes[k].mark * snapshot.option_quotes[k].contract.multiplier
385
+ for k, q in snapshot.options.items()
386
+ )
387
+ if abs(dec(p.get("options_value")) - option_value) > max(
388
+ dec(".01"), abs(snapshot.nav) * dec(".01")
389
+ ):
390
+ raise Halt("option/portfolio valuation mismatch")
391
+ if abs(dec(p.get("equity_value")) - sum(positions[s] * prices[s] for s in positions)) > max(
392
+ dec("0.01"), abs(snapshot.nav) * dec("0.01")
393
+ ):
394
+ raise Halt("equity positions do not reconcile portfolio equity value")
395
+ # A market update produced while these reads were in flight is not future
396
+ # data. Keep account asof at request start; validate returned market data
397
+ # at completion, including the existing bounded provider clock allowance.
398
+ snapshot.regular_session = regular_session(validated) and all(
399
+ timestamp_fresh(
400
+ utc_time(quotes[s]["venue_last_trade_time"]),
401
+ validated,
402
+ self.risk.max_data_age_seconds,
403
+ future_skew=MAX_FUTURE_SKEW_SECONDS,
404
+ )
405
+ for s in self.config.allowed_symbols
406
+ )
407
+ return snapshot
408
+
409
+ def histories(self, now=None):
410
+ now = (
411
+ datetime.now(timezone.utc) if now is None else datetime.fromtimestamp(now, timezone.utc)
412
+ )
413
+ response = data(
414
+ self.bridge.read(
415
+ "get_equity_historicals",
416
+ {
417
+ "symbols": self.config.allowed_symbols,
418
+ "start_time": (now - timedelta(days=730)).isoformat(),
419
+ "end_time": now.isoformat(),
420
+ "interval": "day",
421
+ "bounds": "regular",
422
+ "adjustment_type": "split",
423
+ },
424
+ )
425
+ )
426
+ if response.get("not_found"):
427
+ raise Halt("historical symbol unresolved")
428
+ by_symbol = unique(rows(response.get("results"), "historicals"), "symbol")
429
+ if set(by_symbol) != set(self.config.allowed_symbols):
430
+ raise Halt("incomplete historical universe")
431
+ histories = {}
432
+ local = now.astimezone(ZoneInfo("America/New_York"))
433
+ for symbol, result in by_symbol.items():
434
+ if result.get("interval") != "day" or result.get("bounds") != "regular":
435
+ raise Halt("unexpected historical interval/bounds")
436
+ bars, previous = [], -1
437
+ for bar in rows(result.get("bars"), "bars"):
438
+ ts = utc_time(bar.get("begins_at"))
439
+ if ts <= previous or ts > now.timestamp():
440
+ raise Halt("out-of-order, duplicate or future historical bar")
441
+ previous = ts
442
+ if bar.get("interpolated") is True:
443
+ continue
444
+ bar_day = datetime.fromtimestamp(ts, ZoneInfo("America/New_York")).date()
445
+ utc_day = datetime.fromtimestamp(ts, timezone.utc).date()
446
+ bounds = session_bounds(local.date())
447
+ if local.date() in {bar_day, utc_day} and (
448
+ bounds is None or now.timestamp() < bounds[1]
449
+ ):
450
+ # Native daily bars can use a midnight-UTC date label, which
451
+ # maps to yesterday in New York. Conservatively exclude
452
+ # either representation of today's unfinished daily candle.
453
+ continue # Never calculate with an incomplete current daily bar.
454
+ if bar.get("session") not in {"reg", ""}:
455
+ raise Halt("unexpected historical session")
456
+ opening, high, low, closing = (
457
+ dec(bar.get(k + "_price")) for k in ("open", "high", "low", "close")
458
+ )
459
+ if low <= 0 or not low <= min(opening, closing) <= max(opening, closing) <= high:
460
+ raise Halt("invalid historical OHLC")
461
+ bars.append({"begins_at": bar["begins_at"], "close_price": str(closing)})
462
+ if (
463
+ len(bars) < 205
464
+ or not 0
465
+ <= now.timestamp() - utc_time(bars[-1]["begins_at"])
466
+ <= self.risk.max_history_age_seconds
467
+ ):
468
+ raise Halt("insufficient/stale completed historical bars")
469
+ histories[symbol] = bars
470
+ return histories
471
+
472
+ def reconcile(self, initial):
473
+ final = self.snapshot()
474
+ fields = (
475
+ "account_key",
476
+ "account_type",
477
+ "positions",
478
+ "available",
479
+ "cash",
480
+ "buying_power",
481
+ "orders",
482
+ )
483
+ before, after = asdict(initial), asdict(final)
484
+ if any(before[k] != after[k] for k in fields):
485
+ raise Halt(
486
+ "account state changed during shadow cycle; operator reconciliation required"
487
+ )
488
+ return final
489
+
490
+ def review(self, _):
491
+ raise Halt("real broker review is disabled by the read-only connection")
492
+
493
+ def submit(self, *_):
494
+ raise Halt("real order submission is disabled by the read-only connection")
495
+
496
+ def cancel(self, *_):
497
+ raise Halt("real order cancellation is disabled by the read-only connection")