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.
- tradeagent/__init__.py +1 -0
- tradeagent/account_policy.py +49 -0
- tradeagent/accounting.py +127 -0
- tradeagent/broker.py +497 -0
- tradeagent/calendar.py +55 -0
- tradeagent/canary_review.py +457 -0
- tradeagent/canary_review_cli.py +95 -0
- tradeagent/cli.py +172 -0
- tradeagent/codex_bridge.py +320 -0
- tradeagent/contracts/market-data-1.7.0.json +334 -0
- tradeagent/contracts/official-1.6.2.json +3397 -0
- tradeagent/contracts/official-1.7.0.json +4704 -0
- tradeagent/contracts/official-1.7.3.json +4733 -0
- tradeagent/contracts/options-research-1.8.0.json +696 -0
- tradeagent/dashboard/__init__.py +0 -0
- tradeagent/dashboard/jobs.py +167 -0
- tradeagent/dashboard/readers.py +632 -0
- tradeagent/dashboard/server.py +501 -0
- tradeagent/dashboard/static/app.css +435 -0
- tradeagent/dashboard/static/app.js +1366 -0
- tradeagent/dashboard/static/favicon.svg +1 -0
- tradeagent/dashboard/static/index.html +35 -0
- tradeagent/demo.py +145 -0
- tradeagent/doctor.py +179 -0
- tradeagent/execution.py +123 -0
- tradeagent/execution_policy.py +685 -0
- tradeagent/friendly.py +105 -0
- tradeagent/health.py +111 -0
- tradeagent/legacy/__init__.py +1 -0
- tradeagent/legacy/autonomous.py +270 -0
- tradeagent/legacy/canary.py +292 -0
- tradeagent/legacy/cli.py +79 -0
- tradeagent/legacy/policy.py +363 -0
- tradeagent/legacy/release.py +29 -0
- tradeagent/legacy/runner.py +152 -0
- tradeagent/legacy/standalone.py +201 -0
- tradeagent/legacy/standalone_cli.py +149 -0
- tradeagent/legacy/standalone_reference.py +211 -0
- tradeagent/legacy/strategy.py +118 -0
- tradeagent/legacy/supervised_cli.py +138 -0
- tradeagent/market_mcp.py +38 -0
- tradeagent/model.py +330 -0
- tradeagent/oneshot.py +1211 -0
- tradeagent/oneshot_cli.py +404 -0
- tradeagent/opportunity_cli.py +457 -0
- tradeagent/opportunity_live.py +486 -0
- tradeagent/options.py +463 -0
- tradeagent/owner.example.toml +37 -0
- tradeagent/paths.py +24 -0
- tradeagent/prospective/__init__.py +1 -0
- tradeagent/prospective/access.py +92 -0
- tradeagent/prospective/assessor.py +339 -0
- tradeagent/prospective/collector.py +211 -0
- tradeagent/prospective/continuous.py +741 -0
- tradeagent/prospective/daily.py +722 -0
- tradeagent/prospective/daily_reporting.py +698 -0
- tradeagent/prospective/event_assessor.py +137 -0
- tradeagent/prospective/event_reporting.py +246 -0
- tradeagent/prospective/event_store.py +124 -0
- tradeagent/prospective/events.py +764 -0
- tradeagent/prospective/providers.py +252 -0
- tradeagent/prospective/sec_events.py +381 -0
- tradeagent/prospective/service.py +379 -0
- tradeagent/prospective/v5_supplement.py +197 -0
- tradeagent/research/__init__.py +1 -0
- tradeagent/research/agent_market.py +17 -0
- tradeagent/research/alpha_data.py +113 -0
- tradeagent/research/alpha_experiment.py +486 -0
- tradeagent/research/alpha_signals.py +126 -0
- tradeagent/research/attribution.py +133 -0
- tradeagent/research/bidirectional.py +454 -0
- tradeagent/research/call_put.py +603 -0
- tradeagent/research/demo.py +234 -0
- tradeagent/research/domain.py +298 -0
- tradeagent/research/evidence_cohorts.py +212 -0
- tradeagent/research/evolution.py +265 -0
- tradeagent/research/expressions.py +167 -0
- tradeagent/research/feedback.py +98 -0
- tradeagent/research/history_data.py +182 -0
- tradeagent/research/lab.py +163 -0
- tradeagent/research/learning.py +264 -0
- tradeagent/research/market.py +58 -0
- tradeagent/research/opportunities.py +492 -0
- tradeagent/research/opportunity_workflow.py +1231 -0
- tradeagent/research/option_economics.py +55 -0
- tradeagent/research/outcomes.py +148 -0
- tradeagent/research/put_shadow.py +318 -0
- tradeagent/research/replay.py +550 -0
- tradeagent/research/reporting.py +73 -0
- tradeagent/research/research_universe.py +68 -0
- tradeagent/research/selector.py +87 -0
- tradeagent/research/store.py +425 -0
- tradeagent/research/tradeplan.py +390 -0
- tradeagent/risk.py +177 -0
- tradeagent/robinhood_login.py +497 -0
- tradeagent/schema.py +234 -0
- tradeagent/simulator.py +501 -0
- tradeagent/standalone_mcp.py +578 -0
- tradeagent/state.py +310 -0
- tradeagent/strategy.py +132 -0
- tradeagent/supervised.py +759 -0
- tradeagent/vendor/__init__.py +1 -0
- tradeagent/vendor/client.py +33 -0
- tradeagent/vendor/exceptions.py +21 -0
- tradeagent/vendor/indicators.py +23 -0
- tradeagent/vendor/run_lock.py +366 -0
- tradeagent/vendor/trend.py +24 -0
- tradeagent-1.0.0.dist-info/METADATA +231 -0
- tradeagent-1.0.0.dist-info/RECORD +116 -0
- tradeagent-1.0.0.dist-info/WHEEL +5 -0
- tradeagent-1.0.0.dist-info/entry_points.txt +2 -0
- tradeagent-1.0.0.dist-info/licenses/LICENSE +202 -0
- tradeagent-1.0.0.dist-info/licenses/licenses/Oft3r-MIT.txt +21 -0
- tradeagent-1.0.0.dist-info/licenses/licenses/abiemann-MIT.txt +21 -0
- tradeagent-1.0.0.dist-info/licenses/licenses/cbangera2-MIT.txt +21 -0
- 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)
|
tradeagent/accounting.py
ADDED
|
@@ -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")
|