fa-quant 1.2.1__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.
- fa_quant-1.2.1.dist-info/METADATA +236 -0
- fa_quant-1.2.1.dist-info/RECORD +106 -0
- fa_quant-1.2.1.dist-info/WHEEL +4 -0
- fa_quant-1.2.1.dist-info/entry_points.txt +2 -0
- fa_quant-1.2.1.dist-info/licenses/LICENSE +21 -0
- financial_agent/__init__.py +1 -0
- financial_agent/agents/__init__.py +0 -0
- financial_agent/agents/claude/__init__.py +0 -0
- financial_agent/agents/gpt/__init__.py +0 -0
- financial_agent/agents/grok/__init__.py +0 -0
- financial_agent/dl/__init__.py +0 -0
- financial_agent/dl/models/__init__.py +0 -0
- financial_agent/dl/models/baseline_model.py +23 -0
- financial_agent/dl/preprocessing/__init__.py +0 -0
- financial_agent/dl/preprocessing/data_windowing.py +135 -0
- financial_agent/harness/__init__.py +73 -0
- financial_agent/harness/__main__.py +430 -0
- financial_agent/harness/_system/factor_scoring.py +175 -0
- financial_agent/harness/_system/settlement_ledger.py +1222 -0
- financial_agent/harness/audit.py +393 -0
- financial_agent/harness/equity_policy.toml +179 -0
- financial_agent/harness/fingerprint.py +55 -0
- financial_agent/harness/gates.py +522 -0
- financial_agent/harness/handlers.py +77 -0
- financial_agent/harness/helpers.py +47 -0
- financial_agent/harness/kernels.py +696 -0
- financial_agent/harness/ledger.py +325 -0
- financial_agent/harness/lifecycle.py +127 -0
- financial_agent/harness/manifest.py +113 -0
- financial_agent/harness/market_calendar.py +50 -0
- financial_agent/harness/mcp.py +505 -0
- financial_agent/harness/policy.py +248 -0
- financial_agent/harness/runner.py +275 -0
- financial_agent/harness/schema.py +451 -0
- financial_agent/harness/scoring.py +363 -0
- financial_agent/harness/skills.py +458 -0
- financial_agent/quant/__init__.py +1 -0
- financial_agent/quant/__main__.py +506 -0
- financial_agent/quant/backtest/__init__.py +16 -0
- financial_agent/quant/backtest/config.py +74 -0
- financial_agent/quant/backtest/costs.py +62 -0
- financial_agent/quant/backtest/engine.py +248 -0
- financial_agent/quant/backtest/metrics.py +102 -0
- financial_agent/quant/backtest/result.py +51 -0
- financial_agent/quant/data/__init__.py +21 -0
- financial_agent/quant/data/bars.py +103 -0
- financial_agent/quant/data/cache.py +168 -0
- financial_agent/quant/data/providers.py +138 -0
- financial_agent/quant/data/quality.py +104 -0
- financial_agent/quant/data/synthetic.py +67 -0
- financial_agent/quant/execution/__init__.py +24 -0
- financial_agent/quant/execution/alpaca.py +293 -0
- financial_agent/quant/execution/broker.py +98 -0
- financial_agent/quant/execution/ibkr.py +634 -0
- financial_agent/quant/execution/journal.py +139 -0
- financial_agent/quant/execution/paper.py +254 -0
- financial_agent/quant/execution/reconcile.py +35 -0
- financial_agent/quant/mcp.py +210 -0
- financial_agent/quant/portfolio.py +56 -0
- financial_agent/quant/quant_policy.toml +40 -0
- financial_agent/quant/risk/__init__.py +21 -0
- financial_agent/quant/risk/killswitch.py +61 -0
- financial_agent/quant/risk/limits.py +109 -0
- financial_agent/quant/risk/orders.py +91 -0
- financial_agent/quant/risk/pretrade.py +157 -0
- financial_agent/quant/risk/state.py +86 -0
- financial_agent/quant/runner/__init__.py +20 -0
- financial_agent/quant/runner/config.py +254 -0
- financial_agent/quant/runner/promotion.py +277 -0
- financial_agent/quant/runner/report.py +231 -0
- financial_agent/quant/runner/session.py +705 -0
- financial_agent/quant/strategies/__init__.py +19 -0
- financial_agent/quant/strategies/base.py +44 -0
- financial_agent/quant/strategies/benchmarks.py +54 -0
- financial_agent/quant/strategies/momentum.py +80 -0
- financial_agent/quant/strategies/registry.py +65 -0
- financial_agent/quant/strategies/research.py +226 -0
- financial_agent/quant/strategies/trend.py +91 -0
- financial_agent/quant/validation/__init__.py +41 -0
- financial_agent/quant/validation/bootstrap.py +39 -0
- financial_agent/quant/validation/certificate.py +189 -0
- financial_agent/quant/validation/gate.py +304 -0
- financial_agent/quant/validation/overfitting.py +148 -0
- financial_agent/quant/validation/stress.py +33 -0
- financial_agent/quant/validation/walkforward.py +157 -0
- financial_agent/rl/__init__.py +13 -0
- financial_agent/rl/agent.py +112 -0
- financial_agent/rl/environment.py +177 -0
- financial_agent/rl/trainer.py +142 -0
- financial_agent/trading/__init__.py +0 -0
- financial_agent/trading/autotrain/__init__.py +0 -0
- financial_agent/trading/autotrain/training.py +92 -0
- financial_agent/trading/backtest.py +118 -0
- financial_agent/trading/config/__init__.py +0 -0
- financial_agent/trading/config/config.py +1587 -0
- financial_agent/trading/marketdata/__init__.py +0 -0
- financial_agent/trading/marketdata/yahoodownloader.py +99 -0
- financial_agent/trading/model/__init__.py +0 -0
- financial_agent/trading/model/models.py +466 -0
- financial_agent/trading/preprocessing/__init__.py +0 -0
- financial_agent/trading/preprocessing/data.py +32 -0
- financial_agent/trading/preprocessing/preprocessors.py +162 -0
- financial_agent/trading/utils/__init__.py +0 -0
- financial_agent/trading/utils/gpu_config.py +213 -0
- financial_agent/trading/utils/main.py +52 -0
- financial_agent/trading/utils/setup_gpu_for_notebooks.py +130 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""Exclusive, fail-closed sessions; durable intents precede all submissions."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
7
|
+
import fcntl
|
|
8
|
+
from functools import cache
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import tomllib
|
|
13
|
+
from typing import Any
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
import pandas as pd # type: ignore[import-untyped]
|
|
17
|
+
|
|
18
|
+
from financial_agent.harness.market_calendar import session as calendar_session
|
|
19
|
+
from ..data import BarProvider, MarketData, check_quality
|
|
20
|
+
from ..execution import (
|
|
21
|
+
AlpacaBroker,
|
|
22
|
+
Broker,
|
|
23
|
+
BrokerError,
|
|
24
|
+
Fill,
|
|
25
|
+
IBKRBroker,
|
|
26
|
+
Journal,
|
|
27
|
+
PaperBroker,
|
|
28
|
+
)
|
|
29
|
+
from ..execution.alpaca import LIVE_URL
|
|
30
|
+
from ..risk import (
|
|
31
|
+
AccountSnapshot,
|
|
32
|
+
KillSwitch,
|
|
33
|
+
Order,
|
|
34
|
+
RiskState,
|
|
35
|
+
check_orders,
|
|
36
|
+
plan_orders,
|
|
37
|
+
update_risk_state,
|
|
38
|
+
)
|
|
39
|
+
from ..strategies import build_strategy
|
|
40
|
+
from .config import TradingConfig
|
|
41
|
+
from .promotion import authorize_live
|
|
42
|
+
from .report import SessionReport, Status, json_value, write_report
|
|
43
|
+
|
|
44
|
+
EXCHANGE_TZ = ZoneInfo("America/New_York")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SessionLocked(RuntimeError):
|
|
48
|
+
"""Another process holds the state-directory lock."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def session_lock(state_dir: Path) -> Iterator[bool]:
|
|
53
|
+
"""Kernel lock is authoritative; stale PID metadata is detected and replaced.
|
|
54
|
+
|
|
55
|
+
Never unlink the inode: concurrent processes must lock the same file.
|
|
56
|
+
"""
|
|
57
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
with (state_dir / "session.lock").open("a+") as handle:
|
|
59
|
+
try:
|
|
60
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
61
|
+
except BlockingIOError:
|
|
62
|
+
raise SessionLocked("state_dir: another session is running") from None
|
|
63
|
+
handle.seek(0)
|
|
64
|
+
previous = handle.read().strip()
|
|
65
|
+
stale = bool(previous)
|
|
66
|
+
handle.seek(0)
|
|
67
|
+
handle.truncate()
|
|
68
|
+
handle.write(str(os.getpid()))
|
|
69
|
+
handle.flush()
|
|
70
|
+
os.fsync(handle.fileno())
|
|
71
|
+
try:
|
|
72
|
+
yield stale
|
|
73
|
+
finally:
|
|
74
|
+
handle.seek(0)
|
|
75
|
+
handle.truncate()
|
|
76
|
+
handle.flush()
|
|
77
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def configured_broker(
|
|
81
|
+
config: TradingConfig, environ: Mapping[str, str], *, authorization: Any = None
|
|
82
|
+
) -> Broker:
|
|
83
|
+
"""Credentials are read only from the caller's environment."""
|
|
84
|
+
if config.broker_kind == "paper":
|
|
85
|
+
return PaperBroker(
|
|
86
|
+
config.state_dir / "paper.json",
|
|
87
|
+
costs=config.backtest.costs,
|
|
88
|
+
initial_cash=config.initial_cash,
|
|
89
|
+
)
|
|
90
|
+
if config.broker_kind == "ibkr":
|
|
91
|
+
assert config.account_id is not None
|
|
92
|
+
return IBKRBroker(
|
|
93
|
+
config.account_id,
|
|
94
|
+
base_url=config.base_url,
|
|
95
|
+
verify=config.verify,
|
|
96
|
+
conids=config.conids,
|
|
97
|
+
confirm_message_ids=config.confirm_message_ids,
|
|
98
|
+
live_authorization=authorization,
|
|
99
|
+
)
|
|
100
|
+
return AlpacaBroker(
|
|
101
|
+
environ.get("APCA_API_KEY_ID", ""),
|
|
102
|
+
environ.get("APCA_API_SECRET_KEY", ""),
|
|
103
|
+
base_url=LIVE_URL if config.mode == "live" else None,
|
|
104
|
+
live_authorization=authorization,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@cache
|
|
109
|
+
def _opening_auction_cutoff() -> time:
|
|
110
|
+
"""Packaged policy only: a run configuration cannot move the cutoff."""
|
|
111
|
+
path = Path(__file__).parents[1] / "quant_policy.toml"
|
|
112
|
+
policy = tomllib.loads(path.read_text())["execution"]
|
|
113
|
+
return time.fromisoformat(policy["opening_auction_cutoff"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _submission_closed(
|
|
117
|
+
broker: Broker, today: date, now: Callable[[], datetime]
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
"""Broker-backed opening-auction orders go out only before today's cutoff."""
|
|
120
|
+
if isinstance(broker, PaperBroker):
|
|
121
|
+
return []
|
|
122
|
+
observed = now()
|
|
123
|
+
if observed.tzinfo is None:
|
|
124
|
+
raise ValueError("now: clock must return timezone-aware datetimes")
|
|
125
|
+
local = observed.astimezone(EXCHANGE_TZ)
|
|
126
|
+
cutoff = _opening_auction_cutoff()
|
|
127
|
+
if local.date() == today and local.time() < cutoff:
|
|
128
|
+
return []
|
|
129
|
+
return [
|
|
130
|
+
f"submission window closed: observed {local:%Y-%m-%d %H:%M:%S} "
|
|
131
|
+
f"America/New_York; orders for {today} must be submitted that day "
|
|
132
|
+
f"before {cutoff:%H:%M}"
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _recover_prior_intents(broker: Broker, journal: Journal, today: date) -> None:
|
|
137
|
+
"""Observe earlier intents without placing stale orders at a later auction."""
|
|
138
|
+
resolved = {
|
|
139
|
+
e.payload["client_order_id"]
|
|
140
|
+
for e in journal.events(["submitted", "submission_abandoned"])
|
|
141
|
+
}
|
|
142
|
+
for event in journal.events(["submission_intent"]):
|
|
143
|
+
intent = event.payload
|
|
144
|
+
client_id = intent["order"]["client_order_id"]
|
|
145
|
+
if client_id in resolved or intent["session"] >= today.isoformat():
|
|
146
|
+
continue
|
|
147
|
+
ack = broker.lookup(client_id)
|
|
148
|
+
if ack is None:
|
|
149
|
+
journal.append(
|
|
150
|
+
"submission_abandoned",
|
|
151
|
+
{
|
|
152
|
+
"client_order_id": client_id,
|
|
153
|
+
"session": intent["session"],
|
|
154
|
+
"reason": "broker never observed this earlier-session intent",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
journal.append("submitted", json_value(ack))
|
|
159
|
+
resolved.add(client_id)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _sync(broker: Broker, data: MarketData, journal: Journal) -> list[Fill]:
|
|
163
|
+
"""Recover fills only for recorded intents, including interrupted settlement."""
|
|
164
|
+
submitted = {e.payload["client_order_id"] for e in journal.events(["submitted"])}
|
|
165
|
+
intents = {
|
|
166
|
+
e.payload["order"]["client_order_id"]: e.payload
|
|
167
|
+
for e in journal.events(["submission_intent"])
|
|
168
|
+
if e.payload["order"]["client_order_id"] in submitted
|
|
169
|
+
}
|
|
170
|
+
known: dict[str, float] = {}
|
|
171
|
+
for event in journal.events(["fill"]):
|
|
172
|
+
client = event.payload["client_order_id"]
|
|
173
|
+
known[client] = known.get(client, 0) + float(event.payload["qty"])
|
|
174
|
+
observed: list[Fill] = []
|
|
175
|
+
final: dict[str, str] = {}
|
|
176
|
+
if isinstance(broker, PaperBroker):
|
|
177
|
+
broker.settle(data)
|
|
178
|
+
# Persisted fills also recover a crash between settlement and journaling.
|
|
179
|
+
state = json.loads(broker.state_path.read_text())
|
|
180
|
+
observed = [
|
|
181
|
+
Fill(**f) for f in state["fills"] if f["client_order_id"] in intents
|
|
182
|
+
]
|
|
183
|
+
else:
|
|
184
|
+
terminal = {
|
|
185
|
+
e.payload["client_order_id"]: e.payload
|
|
186
|
+
for e in journal.events(["order_observed"])
|
|
187
|
+
}
|
|
188
|
+
finalized = {
|
|
189
|
+
e.payload["client_order_id"] for e in journal.events(["order_final"])
|
|
190
|
+
}
|
|
191
|
+
for client_id, intent in intents.items():
|
|
192
|
+
if client_id in finalized:
|
|
193
|
+
continue
|
|
194
|
+
auction_complete = pd.Timestamp(intent["session"]) in data.sessions
|
|
195
|
+
if auction_complete:
|
|
196
|
+
final[client_id] = (
|
|
197
|
+
"opening auction complete; unfilled remainder canceled"
|
|
198
|
+
)
|
|
199
|
+
previous = terminal.get(client_id, {})
|
|
200
|
+
recovered = known.get(client_id, 0)
|
|
201
|
+
if recovered >= intent["order"]["qty"] or (
|
|
202
|
+
previous.get("status") in ("filled", "canceled", "rejected")
|
|
203
|
+
and recovered >= previous.get("filled_qty", 0)
|
|
204
|
+
):
|
|
205
|
+
continue
|
|
206
|
+
ack = broker.lookup(client_id)
|
|
207
|
+
if ack is None:
|
|
208
|
+
if auction_complete:
|
|
209
|
+
final[client_id] = (
|
|
210
|
+
"not reported by broker after the opening auction"
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
raise BrokerError("recorded order is missing at broker")
|
|
214
|
+
journal.append("order_observed", json_value(ack))
|
|
215
|
+
if ack.filled_qty <= known.get(client_id, 0):
|
|
216
|
+
continue
|
|
217
|
+
order = intent["order"]
|
|
218
|
+
if ack.filled_avg_price is None:
|
|
219
|
+
raise BrokerError("filled order has no execution price")
|
|
220
|
+
opening = pd.Timestamp(intent["session"])
|
|
221
|
+
if opening not in data.sessions:
|
|
222
|
+
raise BrokerError("execution reference session is unavailable")
|
|
223
|
+
# Compare with the auction reference, not the previous close's gap.
|
|
224
|
+
reference = float(data.open.loc[opening, order["symbol"]])
|
|
225
|
+
prior_notional = sum(
|
|
226
|
+
e.payload["qty"] * e.payload["price"]
|
|
227
|
+
for e in journal.events(["fill"])
|
|
228
|
+
if e.payload["client_order_id"] == client_id
|
|
229
|
+
)
|
|
230
|
+
delta = ack.filled_qty - known.get(client_id, 0)
|
|
231
|
+
incremental_price = (
|
|
232
|
+
ack.filled_qty * ack.filled_avg_price - prior_notional
|
|
233
|
+
) / delta
|
|
234
|
+
observed.append(
|
|
235
|
+
Fill(
|
|
236
|
+
client_id,
|
|
237
|
+
order["symbol"],
|
|
238
|
+
order["side"],
|
|
239
|
+
ack.filled_qty,
|
|
240
|
+
incremental_price,
|
|
241
|
+
reference,
|
|
242
|
+
0.0,
|
|
243
|
+
intent["session"],
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
new: list[Fill] = []
|
|
247
|
+
for fill in observed:
|
|
248
|
+
intended = intents[fill.client_order_id]["order"]
|
|
249
|
+
if (
|
|
250
|
+
fill.symbol != intended["symbol"]
|
|
251
|
+
or fill.side != intended["side"]
|
|
252
|
+
or fill.qty > intended["qty"]
|
|
253
|
+
):
|
|
254
|
+
raise BrokerError("fill disagrees with recorded order intent")
|
|
255
|
+
delta = fill.qty - known.get(fill.client_order_id, 0)
|
|
256
|
+
if delta <= 0:
|
|
257
|
+
continue
|
|
258
|
+
# Journal incremental quantities; reconstruct cumulative fills on replay.
|
|
259
|
+
incremental = replace(fill, qty=delta)
|
|
260
|
+
journal.append("fill", {**json_value(incremental), "broker_kind": broker.name})
|
|
261
|
+
known[fill.client_order_id] = fill.qty
|
|
262
|
+
new.append(incremental)
|
|
263
|
+
# Persist finality only after incremental fills, so restart recovery cannot
|
|
264
|
+
# skip executions that were observed but not yet journaled.
|
|
265
|
+
for client_id, reason in final.items():
|
|
266
|
+
qty = known.get(client_id, 0)
|
|
267
|
+
filled = qty >= intents[client_id]["order"]["qty"]
|
|
268
|
+
journal.append(
|
|
269
|
+
"order_final",
|
|
270
|
+
{
|
|
271
|
+
"client_order_id": client_id,
|
|
272
|
+
"filled_qty": qty,
|
|
273
|
+
"status": "filled" if filled else "canceled",
|
|
274
|
+
"reason": "opening auction filled" if filled else reason,
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
return new
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _expected(journal: Journal) -> dict[str, float]:
|
|
281
|
+
positions: dict[str, float] = {}
|
|
282
|
+
for event in journal.events(["fill"]):
|
|
283
|
+
f = event.payload
|
|
284
|
+
positions[f["symbol"]] = positions.get(f["symbol"], 0) + (
|
|
285
|
+
f["qty"] if f["side"] == "buy" else -f["qty"]
|
|
286
|
+
)
|
|
287
|
+
return positions
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def run_session(
|
|
291
|
+
config: TradingConfig,
|
|
292
|
+
*,
|
|
293
|
+
today: date,
|
|
294
|
+
dry_run: bool = False,
|
|
295
|
+
now: Callable[[], datetime] | None = None,
|
|
296
|
+
provider: BarProvider | None = None,
|
|
297
|
+
broker: Broker | None = None,
|
|
298
|
+
environ: Mapping[str, str] = os.environ,
|
|
299
|
+
) -> SessionReport:
|
|
300
|
+
"""Previous-close decisions; no duplicate session submissions, even on retry.
|
|
301
|
+
|
|
302
|
+
`now` is the submission clock (default: system time); it must return aware
|
|
303
|
+
datetimes. Only broker-backed adapters are bound to the opening-auction window.
|
|
304
|
+
"""
|
|
305
|
+
clock = now or (lambda: datetime.now(timezone.utc))
|
|
306
|
+
try:
|
|
307
|
+
with session_lock(config.state_dir) as stale:
|
|
308
|
+
return _run_locked(
|
|
309
|
+
config, today, dry_run, provider, broker, environ, stale, clock
|
|
310
|
+
)
|
|
311
|
+
except SessionLocked:
|
|
312
|
+
return SessionReport(
|
|
313
|
+
"HALTED",
|
|
314
|
+
today.isoformat(),
|
|
315
|
+
["state_dir: another session is running"],
|
|
316
|
+
[],
|
|
317
|
+
[],
|
|
318
|
+
None,
|
|
319
|
+
{},
|
|
320
|
+
)
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
# A filesystem failure may prevent journaling/reporting; still fail closed.
|
|
323
|
+
return SessionReport(
|
|
324
|
+
"HALTED",
|
|
325
|
+
today.isoformat(),
|
|
326
|
+
[f"session failed ({type(exc).__name__})"],
|
|
327
|
+
[],
|
|
328
|
+
[],
|
|
329
|
+
None,
|
|
330
|
+
{},
|
|
331
|
+
dry_run=dry_run,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _run_locked(
|
|
336
|
+
config: TradingConfig,
|
|
337
|
+
today: date,
|
|
338
|
+
dry_run: bool,
|
|
339
|
+
provider: BarProvider | None,
|
|
340
|
+
broker: Broker | None,
|
|
341
|
+
environ: Mapping[str, str],
|
|
342
|
+
stale: bool,
|
|
343
|
+
now: Callable[[], datetime],
|
|
344
|
+
) -> SessionReport:
|
|
345
|
+
journal = Journal(config.state_dir / "journal.jsonl")
|
|
346
|
+
account: AccountSnapshot | None = None
|
|
347
|
+
fills: list[Fill] = []
|
|
348
|
+
orders: list[Order] = []
|
|
349
|
+
risk: dict[str, Any] = {}
|
|
350
|
+
targets: dict[str, float] = {}
|
|
351
|
+
decision: str | None = None
|
|
352
|
+
breaks_count = 0
|
|
353
|
+
verified = journal.verify().ok
|
|
354
|
+
|
|
355
|
+
def finish(status: Status, reasons: list[str]) -> SessionReport:
|
|
356
|
+
report = SessionReport(
|
|
357
|
+
status,
|
|
358
|
+
today.isoformat(),
|
|
359
|
+
reasons,
|
|
360
|
+
orders,
|
|
361
|
+
fills,
|
|
362
|
+
account,
|
|
363
|
+
risk,
|
|
364
|
+
decision,
|
|
365
|
+
targets,
|
|
366
|
+
dry_run,
|
|
367
|
+
)
|
|
368
|
+
if verified:
|
|
369
|
+
payload = report.to_dict()
|
|
370
|
+
payload.update(
|
|
371
|
+
mode=config.mode,
|
|
372
|
+
strategy=config.strategy_name,
|
|
373
|
+
params=config.strategy_params,
|
|
374
|
+
reconciliation_breaks=breaks_count,
|
|
375
|
+
broker_kind=broker.name if broker is not None else config.broker_kind,
|
|
376
|
+
)
|
|
377
|
+
journal.append("session_outcome", payload)
|
|
378
|
+
write_report(report, config.state_dir)
|
|
379
|
+
return report
|
|
380
|
+
|
|
381
|
+
if not verified:
|
|
382
|
+
return finish("HALTED", ["journal verification failed"])
|
|
383
|
+
kill = KillSwitch(config.state_dir)
|
|
384
|
+
journal.append(
|
|
385
|
+
"session_start",
|
|
386
|
+
{"session": today.isoformat(), "dry_run": dry_run, "stale_lock": stale},
|
|
387
|
+
)
|
|
388
|
+
if kill.engaged():
|
|
389
|
+
return finish("HALTED", ["kill switch engaged"])
|
|
390
|
+
try:
|
|
391
|
+
calendar = calendar_session(today)
|
|
392
|
+
journal.append("calendar", json_value(calendar))
|
|
393
|
+
if not calendar.trading_day:
|
|
394
|
+
return finish("NO_SESSION", [calendar.describe()])
|
|
395
|
+
decision = calendar_session(today - timedelta(days=1)).basis_date
|
|
396
|
+
data = config.provider(provider).fetch(
|
|
397
|
+
config.symbols, config.data_start, date.fromisoformat(decision)
|
|
398
|
+
)
|
|
399
|
+
data = data.upto(pd.Timestamp(decision))
|
|
400
|
+
quality = check_quality(
|
|
401
|
+
data, expected_last_session=date.fromisoformat(decision)
|
|
402
|
+
)
|
|
403
|
+
journal.append(
|
|
404
|
+
"data_quality",
|
|
405
|
+
{
|
|
406
|
+
"session": today.isoformat(),
|
|
407
|
+
"ok": quality.ok,
|
|
408
|
+
"issues": json_value(quality.issues),
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
if not quality.ok:
|
|
412
|
+
return finish(
|
|
413
|
+
"HALTED",
|
|
414
|
+
[
|
|
415
|
+
f"data: {i.code} {i.symbol}"
|
|
416
|
+
for i in quality.issues
|
|
417
|
+
if i.severity == "error"
|
|
418
|
+
],
|
|
419
|
+
)
|
|
420
|
+
authorization = None
|
|
421
|
+
if config.mode == "live":
|
|
422
|
+
# Required before constructing a live broker or making any live call.
|
|
423
|
+
authorization = authorize_live(config, environ=environ)
|
|
424
|
+
journal.append("live_authorized", json_value(authorization))
|
|
425
|
+
if broker is None:
|
|
426
|
+
broker = configured_broker(config, environ, authorization=authorization)
|
|
427
|
+
if broker.is_live != (config.mode == "live"):
|
|
428
|
+
return finish("HALTED", ["broker mode does not match config.mode"])
|
|
429
|
+
_recover_prior_intents(broker, journal, today)
|
|
430
|
+
fills.extend(_sync(broker, data, journal))
|
|
431
|
+
account = broker.account()
|
|
432
|
+
from ..execution import reconcile
|
|
433
|
+
|
|
434
|
+
breaks = reconcile(_expected(journal), account)
|
|
435
|
+
breaks_count = len(breaks)
|
|
436
|
+
journal.append(
|
|
437
|
+
"reconcile",
|
|
438
|
+
{
|
|
439
|
+
"session": today.isoformat(),
|
|
440
|
+
"breaks": json_value(breaks),
|
|
441
|
+
"account": json_value(account),
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
if breaks:
|
|
445
|
+
return finish("HALTED", ["reconciliation break"])
|
|
446
|
+
state_path = config.state_dir / "risk_state.json"
|
|
447
|
+
recorded_risk = journal.events(["risk_state"])
|
|
448
|
+
journal_state = (
|
|
449
|
+
RiskState(**recorded_risk[-1].payload) if recorded_risk else None
|
|
450
|
+
)
|
|
451
|
+
state = RiskState.load(state_path) if state_path.exists() else journal_state
|
|
452
|
+
if journal_state is not None and state != journal_state:
|
|
453
|
+
return finish("HALTED", ["risk state disagrees with journal"])
|
|
454
|
+
if state is None:
|
|
455
|
+
state = RiskState(account.equity, account.equity, today.isoformat())
|
|
456
|
+
state, reasons = update_risk_state(
|
|
457
|
+
state, account, config.risk, session=today.isoformat()
|
|
458
|
+
)
|
|
459
|
+
state.save(state_path)
|
|
460
|
+
dd = 1 - account.equity / state.high_water_mark
|
|
461
|
+
daily = 1 - account.equity / state.day_start_equity
|
|
462
|
+
risk.update(
|
|
463
|
+
high_water_mark=state.high_water_mark,
|
|
464
|
+
drawdown=dd,
|
|
465
|
+
daily_loss=daily,
|
|
466
|
+
drawdown_utilization=(
|
|
467
|
+
dd / config.risk.max_drawdown_pct
|
|
468
|
+
if config.risk.max_drawdown_pct
|
|
469
|
+
else None
|
|
470
|
+
),
|
|
471
|
+
daily_loss_utilization=(
|
|
472
|
+
daily / config.risk.max_daily_loss_pct
|
|
473
|
+
if config.risk.max_daily_loss_pct
|
|
474
|
+
else None
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
journal.append("risk_state", json_value(state))
|
|
478
|
+
if reasons:
|
|
479
|
+
kill.engage("; ".join(reasons), source="runner")
|
|
480
|
+
return finish("HALTED", reasons)
|
|
481
|
+
# Daily account safety runs above; hypothetical off-cadence plans cannot halt.
|
|
482
|
+
due = config.backtest.rebalance == "daily"
|
|
483
|
+
if config.backtest.rebalance == "weekly":
|
|
484
|
+
due = (
|
|
485
|
+
pd.Timestamp(decision).isocalendar()[:2]
|
|
486
|
+
!= pd.Timestamp(today).isocalendar()[:2]
|
|
487
|
+
)
|
|
488
|
+
elif config.backtest.rebalance == "monthly":
|
|
489
|
+
due = decision[:7] != today.isoformat()[:7]
|
|
490
|
+
if (
|
|
491
|
+
config.backtest.start is not None
|
|
492
|
+
and pd.Timestamp(decision) < config.backtest.start
|
|
493
|
+
):
|
|
494
|
+
due = False
|
|
495
|
+
risk["rebalance_due"] = due
|
|
496
|
+
if not due and not dry_run:
|
|
497
|
+
previous_plans = [
|
|
498
|
+
e.payload
|
|
499
|
+
for e in journal.events(["plan"])
|
|
500
|
+
if not e.payload.get("dry_run") and e.payload.get("rebalance_due", True)
|
|
501
|
+
]
|
|
502
|
+
if previous_plans:
|
|
503
|
+
targets.update(previous_plans[-1]["targets"])
|
|
504
|
+
return finish("NO_CHANGE", ["outside rebalance calendar"])
|
|
505
|
+
intents = journal.events(["submission_intent"])
|
|
506
|
+
same = [e for e in intents if e.payload["session"] == today.isoformat()]
|
|
507
|
+
submitted = {
|
|
508
|
+
e.payload["client_order_id"] for e in journal.events(["submitted"])
|
|
509
|
+
}
|
|
510
|
+
abandoned = {
|
|
511
|
+
e.payload["client_order_id"]
|
|
512
|
+
for e in journal.events(["submission_abandoned"])
|
|
513
|
+
}
|
|
514
|
+
declined = [
|
|
515
|
+
e.payload
|
|
516
|
+
for e in journal.events(["broker_question"])
|
|
517
|
+
if e.payload["session"] == today.isoformat()
|
|
518
|
+
]
|
|
519
|
+
if declined:
|
|
520
|
+
return finish("HALTED", [declined[-1]["detail"]])
|
|
521
|
+
missing = [
|
|
522
|
+
e
|
|
523
|
+
for e in same
|
|
524
|
+
if e.payload["order"]["client_order_id"] not in submitted | abandoned
|
|
525
|
+
]
|
|
526
|
+
if missing:
|
|
527
|
+
# Include already pending orders in the risk plan: their cash is unspent.
|
|
528
|
+
recovery_plan = [Order(**e.payload["order"]) for e in same]
|
|
529
|
+
checked = check_orders(
|
|
530
|
+
recovery_plan,
|
|
531
|
+
account,
|
|
532
|
+
data.close.iloc[-1],
|
|
533
|
+
config.risk,
|
|
534
|
+
kill_switch=kill,
|
|
535
|
+
)
|
|
536
|
+
orders.extend(Order(**e.payload["order"]) for e in missing)
|
|
537
|
+
journal.append(
|
|
538
|
+
"submission_recovery",
|
|
539
|
+
{
|
|
540
|
+
"session": today.isoformat(),
|
|
541
|
+
"orders": json_value(recovery_plan),
|
|
542
|
+
"halt": checked.halt,
|
|
543
|
+
"reasons": checked.reasons,
|
|
544
|
+
"dry_run": dry_run,
|
|
545
|
+
},
|
|
546
|
+
)
|
|
547
|
+
if checked.halt and due:
|
|
548
|
+
return finish("HALTED", checked.reasons)
|
|
549
|
+
if dry_run:
|
|
550
|
+
return finish(
|
|
551
|
+
"NO_CHANGE",
|
|
552
|
+
[
|
|
553
|
+
"dry run: interrupted submission preview",
|
|
554
|
+
*(["outside rebalance calendar"] if not due else []),
|
|
555
|
+
*checked.reasons,
|
|
556
|
+
],
|
|
557
|
+
)
|
|
558
|
+
for order in orders:
|
|
559
|
+
closed = _submission_closed(broker, today, now)
|
|
560
|
+
if closed:
|
|
561
|
+
return finish("HALTED", closed)
|
|
562
|
+
ack = broker.submit(order)
|
|
563
|
+
journal.append("submitted", json_value(ack))
|
|
564
|
+
if ack.status == "rejected":
|
|
565
|
+
return finish("HALTED", ["broker rejected order"])
|
|
566
|
+
return finish("TRADED", ["recovered interrupted submission"])
|
|
567
|
+
if same:
|
|
568
|
+
previous_plans = [
|
|
569
|
+
e.payload
|
|
570
|
+
for e in journal.events(["plan"])
|
|
571
|
+
if e.payload["session"] == today.isoformat()
|
|
572
|
+
]
|
|
573
|
+
if previous_plans:
|
|
574
|
+
targets.update(previous_plans[-1]["targets"])
|
|
575
|
+
return finish("NO_CHANGE", ["session already submitted"])
|
|
576
|
+
if broker.open_orders():
|
|
577
|
+
return finish("HALTED", ["unsettled orders remain"])
|
|
578
|
+
strategy = build_strategy(config.strategy_name, config.strategy_params)
|
|
579
|
+
if len(data.sessions) < strategy.warmup:
|
|
580
|
+
return finish("HALTED", ["data: insufficient strategy warmup"])
|
|
581
|
+
raw_targets = strategy.target_weights(data.upto(pd.Timestamp(decision)))
|
|
582
|
+
targets.update(config.backtest.buffered_targets(dict(raw_targets)))
|
|
583
|
+
orders.extend(
|
|
584
|
+
plan_orders(
|
|
585
|
+
raw_targets,
|
|
586
|
+
account,
|
|
587
|
+
data.close.iloc[-1],
|
|
588
|
+
session=today.isoformat(),
|
|
589
|
+
strategy=strategy.name,
|
|
590
|
+
config=config.backtest,
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
checked = check_orders(
|
|
594
|
+
orders, account, data.close.iloc[-1], config.risk, kill_switch=kill
|
|
595
|
+
)
|
|
596
|
+
turnover = sum(o.qty * float(data.close.iloc[-1][o.symbol]) for o in orders)
|
|
597
|
+
projected = dict(account.weights())
|
|
598
|
+
for order in orders:
|
|
599
|
+
projected[order.symbol] = projected.get(order.symbol, 0) + (
|
|
600
|
+
(1 if order.side == "buy" else -1)
|
|
601
|
+
* order.qty
|
|
602
|
+
* float(data.close.iloc[-1][order.symbol])
|
|
603
|
+
/ account.equity
|
|
604
|
+
)
|
|
605
|
+
risk.update(
|
|
606
|
+
turnover_utilization=(
|
|
607
|
+
turnover / account.equity / config.risk.max_turnover_pct
|
|
608
|
+
if config.risk.max_turnover_pct
|
|
609
|
+
else None
|
|
610
|
+
),
|
|
611
|
+
max_weight_utilization=(
|
|
612
|
+
max(projected.values(), default=0) / config.risk.max_weight
|
|
613
|
+
if config.risk.max_weight
|
|
614
|
+
else None
|
|
615
|
+
),
|
|
616
|
+
cash_fraction=1 - sum(projected.values()),
|
|
617
|
+
gross_utilization=(
|
|
618
|
+
sum(abs(w) for w in projected.values()) / config.risk.max_gross
|
|
619
|
+
if config.risk.max_gross
|
|
620
|
+
else None
|
|
621
|
+
),
|
|
622
|
+
order_count_utilization=(
|
|
623
|
+
len(orders) / config.risk.max_orders if config.risk.max_orders else None
|
|
624
|
+
),
|
|
625
|
+
max_order_notional_utilization=(
|
|
626
|
+
max(
|
|
627
|
+
(
|
|
628
|
+
o.qty * float(data.close.iloc[-1][o.symbol]) / account.equity
|
|
629
|
+
for o in orders
|
|
630
|
+
),
|
|
631
|
+
default=0,
|
|
632
|
+
)
|
|
633
|
+
/ config.risk.max_order_notional_pct
|
|
634
|
+
if config.risk.max_order_notional_pct
|
|
635
|
+
else None
|
|
636
|
+
),
|
|
637
|
+
)
|
|
638
|
+
journal.append(
|
|
639
|
+
"plan",
|
|
640
|
+
{
|
|
641
|
+
"session": today.isoformat(),
|
|
642
|
+
"targets": targets,
|
|
643
|
+
"orders": json_value(orders),
|
|
644
|
+
"halt": checked.halt,
|
|
645
|
+
"reasons": checked.reasons,
|
|
646
|
+
"dry_run": dry_run,
|
|
647
|
+
"rebalance_due": due,
|
|
648
|
+
},
|
|
649
|
+
)
|
|
650
|
+
if not due:
|
|
651
|
+
return finish(
|
|
652
|
+
"NO_CHANGE",
|
|
653
|
+
[
|
|
654
|
+
"outside rebalance calendar",
|
|
655
|
+
"dry run: preview only, not due",
|
|
656
|
+
*checked.reasons,
|
|
657
|
+
],
|
|
658
|
+
)
|
|
659
|
+
if checked.halt:
|
|
660
|
+
return finish("HALTED", checked.reasons)
|
|
661
|
+
if not orders:
|
|
662
|
+
return finish("NO_CHANGE", [])
|
|
663
|
+
if dry_run:
|
|
664
|
+
return finish("NO_CHANGE", ["dry run: planned orders, no submission"])
|
|
665
|
+
closed = _submission_closed(broker, today, now)
|
|
666
|
+
if closed:
|
|
667
|
+
return finish("HALTED", closed)
|
|
668
|
+
for order in orders:
|
|
669
|
+
journal.append(
|
|
670
|
+
"submission_intent",
|
|
671
|
+
{
|
|
672
|
+
"session": today.isoformat(),
|
|
673
|
+
"order": json_value(order),
|
|
674
|
+
"reference_price": float(data.close.iloc[-1][order.symbol]),
|
|
675
|
+
},
|
|
676
|
+
)
|
|
677
|
+
for order in checked.approved:
|
|
678
|
+
closed = _submission_closed(broker, today, now)
|
|
679
|
+
if closed:
|
|
680
|
+
return finish("HALTED", closed)
|
|
681
|
+
ack = broker.submit(order)
|
|
682
|
+
journal.append("submitted", json_value(ack))
|
|
683
|
+
if ack.status == "rejected":
|
|
684
|
+
return finish("HALTED", ["broker rejected order"])
|
|
685
|
+
return finish("TRADED", [])
|
|
686
|
+
except Exception as exc:
|
|
687
|
+
# Providers/transports may embed credentials in errors: expose type only.
|
|
688
|
+
from .promotion import PromotionError
|
|
689
|
+
|
|
690
|
+
from ..execution.ibkr import IBKRQuestion
|
|
691
|
+
|
|
692
|
+
if isinstance(exc, IBKRQuestion):
|
|
693
|
+
journal.append(
|
|
694
|
+
"broker_question", {"session": today.isoformat(), "detail": exc.detail}
|
|
695
|
+
)
|
|
696
|
+
reasons = (
|
|
697
|
+
[exc.detail]
|
|
698
|
+
if isinstance(exc, IBKRQuestion)
|
|
699
|
+
else (
|
|
700
|
+
exc.reasons
|
|
701
|
+
if isinstance(exc, PromotionError)
|
|
702
|
+
else [f"session failed ({type(exc).__name__})"]
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
return finish("HALTED", reasons)
|