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.
Files changed (106) hide show
  1. fa_quant-1.2.1.dist-info/METADATA +236 -0
  2. fa_quant-1.2.1.dist-info/RECORD +106 -0
  3. fa_quant-1.2.1.dist-info/WHEEL +4 -0
  4. fa_quant-1.2.1.dist-info/entry_points.txt +2 -0
  5. fa_quant-1.2.1.dist-info/licenses/LICENSE +21 -0
  6. financial_agent/__init__.py +1 -0
  7. financial_agent/agents/__init__.py +0 -0
  8. financial_agent/agents/claude/__init__.py +0 -0
  9. financial_agent/agents/gpt/__init__.py +0 -0
  10. financial_agent/agents/grok/__init__.py +0 -0
  11. financial_agent/dl/__init__.py +0 -0
  12. financial_agent/dl/models/__init__.py +0 -0
  13. financial_agent/dl/models/baseline_model.py +23 -0
  14. financial_agent/dl/preprocessing/__init__.py +0 -0
  15. financial_agent/dl/preprocessing/data_windowing.py +135 -0
  16. financial_agent/harness/__init__.py +73 -0
  17. financial_agent/harness/__main__.py +430 -0
  18. financial_agent/harness/_system/factor_scoring.py +175 -0
  19. financial_agent/harness/_system/settlement_ledger.py +1222 -0
  20. financial_agent/harness/audit.py +393 -0
  21. financial_agent/harness/equity_policy.toml +179 -0
  22. financial_agent/harness/fingerprint.py +55 -0
  23. financial_agent/harness/gates.py +522 -0
  24. financial_agent/harness/handlers.py +77 -0
  25. financial_agent/harness/helpers.py +47 -0
  26. financial_agent/harness/kernels.py +696 -0
  27. financial_agent/harness/ledger.py +325 -0
  28. financial_agent/harness/lifecycle.py +127 -0
  29. financial_agent/harness/manifest.py +113 -0
  30. financial_agent/harness/market_calendar.py +50 -0
  31. financial_agent/harness/mcp.py +505 -0
  32. financial_agent/harness/policy.py +248 -0
  33. financial_agent/harness/runner.py +275 -0
  34. financial_agent/harness/schema.py +451 -0
  35. financial_agent/harness/scoring.py +363 -0
  36. financial_agent/harness/skills.py +458 -0
  37. financial_agent/quant/__init__.py +1 -0
  38. financial_agent/quant/__main__.py +506 -0
  39. financial_agent/quant/backtest/__init__.py +16 -0
  40. financial_agent/quant/backtest/config.py +74 -0
  41. financial_agent/quant/backtest/costs.py +62 -0
  42. financial_agent/quant/backtest/engine.py +248 -0
  43. financial_agent/quant/backtest/metrics.py +102 -0
  44. financial_agent/quant/backtest/result.py +51 -0
  45. financial_agent/quant/data/__init__.py +21 -0
  46. financial_agent/quant/data/bars.py +103 -0
  47. financial_agent/quant/data/cache.py +168 -0
  48. financial_agent/quant/data/providers.py +138 -0
  49. financial_agent/quant/data/quality.py +104 -0
  50. financial_agent/quant/data/synthetic.py +67 -0
  51. financial_agent/quant/execution/__init__.py +24 -0
  52. financial_agent/quant/execution/alpaca.py +293 -0
  53. financial_agent/quant/execution/broker.py +98 -0
  54. financial_agent/quant/execution/ibkr.py +634 -0
  55. financial_agent/quant/execution/journal.py +139 -0
  56. financial_agent/quant/execution/paper.py +254 -0
  57. financial_agent/quant/execution/reconcile.py +35 -0
  58. financial_agent/quant/mcp.py +210 -0
  59. financial_agent/quant/portfolio.py +56 -0
  60. financial_agent/quant/quant_policy.toml +40 -0
  61. financial_agent/quant/risk/__init__.py +21 -0
  62. financial_agent/quant/risk/killswitch.py +61 -0
  63. financial_agent/quant/risk/limits.py +109 -0
  64. financial_agent/quant/risk/orders.py +91 -0
  65. financial_agent/quant/risk/pretrade.py +157 -0
  66. financial_agent/quant/risk/state.py +86 -0
  67. financial_agent/quant/runner/__init__.py +20 -0
  68. financial_agent/quant/runner/config.py +254 -0
  69. financial_agent/quant/runner/promotion.py +277 -0
  70. financial_agent/quant/runner/report.py +231 -0
  71. financial_agent/quant/runner/session.py +705 -0
  72. financial_agent/quant/strategies/__init__.py +19 -0
  73. financial_agent/quant/strategies/base.py +44 -0
  74. financial_agent/quant/strategies/benchmarks.py +54 -0
  75. financial_agent/quant/strategies/momentum.py +80 -0
  76. financial_agent/quant/strategies/registry.py +65 -0
  77. financial_agent/quant/strategies/research.py +226 -0
  78. financial_agent/quant/strategies/trend.py +91 -0
  79. financial_agent/quant/validation/__init__.py +41 -0
  80. financial_agent/quant/validation/bootstrap.py +39 -0
  81. financial_agent/quant/validation/certificate.py +189 -0
  82. financial_agent/quant/validation/gate.py +304 -0
  83. financial_agent/quant/validation/overfitting.py +148 -0
  84. financial_agent/quant/validation/stress.py +33 -0
  85. financial_agent/quant/validation/walkforward.py +157 -0
  86. financial_agent/rl/__init__.py +13 -0
  87. financial_agent/rl/agent.py +112 -0
  88. financial_agent/rl/environment.py +177 -0
  89. financial_agent/rl/trainer.py +142 -0
  90. financial_agent/trading/__init__.py +0 -0
  91. financial_agent/trading/autotrain/__init__.py +0 -0
  92. financial_agent/trading/autotrain/training.py +92 -0
  93. financial_agent/trading/backtest.py +118 -0
  94. financial_agent/trading/config/__init__.py +0 -0
  95. financial_agent/trading/config/config.py +1587 -0
  96. financial_agent/trading/marketdata/__init__.py +0 -0
  97. financial_agent/trading/marketdata/yahoodownloader.py +99 -0
  98. financial_agent/trading/model/__init__.py +0 -0
  99. financial_agent/trading/model/models.py +466 -0
  100. financial_agent/trading/preprocessing/__init__.py +0 -0
  101. financial_agent/trading/preprocessing/data.py +32 -0
  102. financial_agent/trading/preprocessing/preprocessors.py +162 -0
  103. financial_agent/trading/utils/__init__.py +0 -0
  104. financial_agent/trading/utils/gpu_config.py +213 -0
  105. financial_agent/trading/utils/main.py +52 -0
  106. 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)