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,248 @@
1
+ """Rules-as-code: the numeric policy a model must never re-derive by hand.
2
+
3
+ ``equity_policy.toml`` holds the numbers from ``rules.md``; this module turns
4
+ them into pure functions (mu prior, CI, VaR/CVaR, Kelly, score trace) and
5
+ into a governance check for self-evolution proposals, so "only the evolution
6
+ agent may change the mu table, with evidence" becomes a code path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tomllib
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Mapping
15
+
16
+ DEFAULT_POLICY_PATH = Path(__file__).with_name("equity_policy.toml")
17
+ FAMILIES = ("fund_z", "tech_z", "sent_z", "macro_z")
18
+ _MISSING = object()
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class MuBand:
23
+ min_pctl: float
24
+ mu: float
25
+ sleeve: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class KellySizing:
30
+ raw: float
31
+ fractional: float # fraction x raw, uncapped: the investability-gate input
32
+ weight: float # fractional clipped to [0, single-name cap]: the sizing input
33
+ gate: str # BLOCKED | PENALTY | OK | CAP_BINDING
34
+
35
+
36
+ class Policy:
37
+ """Read-only view over a policy document with typed helpers."""
38
+
39
+ def __init__(self, data: Mapping[str, Any], source: str = "<memory>") -> None:
40
+ self.data = data
41
+ self.source = source
42
+
43
+ @property
44
+ def version(self) -> str:
45
+ return str(self.data["policy_version"])
46
+
47
+ @property
48
+ def effective_date(self) -> str:
49
+ return str(self.data["effective_date"])
50
+
51
+ def get(self, dotted: str) -> Any:
52
+ node: Any = self.data
53
+ for part in dotted.split("."):
54
+ node = node[part]
55
+ return node
56
+
57
+ def num(self, dotted: str) -> float:
58
+ return float(self.get(dotted))
59
+
60
+ @property
61
+ def family_weights(self) -> dict[str, float]:
62
+ return {k: float(v) for k, v in self.get("score.family_weights").items()}
63
+
64
+ @property
65
+ def shadow_families(self) -> tuple[str, ...]:
66
+ """Families computed as diagnostics only (rules.md § SHADOW Diagnostic
67
+ Tooling); they never count toward Adj Score or the thresholds."""
68
+ return tuple(self.get("score.shadow_families"))
69
+
70
+ @property
71
+ def gating_families(self) -> tuple[str, ...]:
72
+ shadow = set(self.shadow_families)
73
+ return tuple(f for f in FAMILIES if f not in shadow)
74
+
75
+ @property
76
+ def mu_bands(self) -> list[MuBand]:
77
+ bands = [
78
+ MuBand(float(b["min_pctl"]), float(b["mu"]), str(b["sleeve"]))
79
+ for b in self.get("forecast.mu_bands")
80
+ ]
81
+ return sorted(bands, key=lambda b: b.min_pctl, reverse=True)
82
+
83
+ def mu_prior(self, pctl: float) -> MuBand | None:
84
+ """Calibration-table band for a percentile; ``None`` means do not rank."""
85
+ return next((b for b in self.mu_bands if pctl >= b.min_pctl), None)
86
+
87
+ def spy_mu_prior(self, regime: str) -> float:
88
+ return float(self.get("market_forecast.spy_regime_prior")[regime])
89
+
90
+ def target_price(self, entry: float, mu: float) -> float:
91
+ return entry * (1 + mu)
92
+
93
+ def ci70(self, entry: float, mu: float, sigma: float) -> tuple[float, float]:
94
+ z = self.num("forecast.ci70_z")
95
+ return entry * (1 + mu - z * sigma), entry * (1 + mu + z * sigma)
96
+
97
+ def var95(self, mu: float, sigma: float) -> float:
98
+ """Parametric one-month VaR in return space (decimal, not percent)."""
99
+ return mu - self.num("forecast.var95_z") * sigma
100
+
101
+ def cvar95(self, mu: float, sigma: float) -> float:
102
+ return mu - self.num("forecast.cvar95_z") * sigma
103
+
104
+ def kelly(self, mu: float, sigma: float) -> KellySizing:
105
+ """``mu / sigma^2`` fallback Kelly, with both the gate and sizing views.
106
+
107
+ The two views are separate fields on purpose: historical ledgers store
108
+ one number called ``kelly_025`` that some runs cap at 5% and others do
109
+ not, which makes the ``< 2%`` and cap-binding gates unreproducible.
110
+ """
111
+ return self.kelly_sizing(mu / sigma**2)
112
+
113
+ def kelly_sizing(self, raw: float) -> KellySizing:
114
+ """Gate and sizing views of a raw Kelly edge from either method."""
115
+ fractional = self.num("kelly.fraction") * raw
116
+ cap = self.num("risk.max_single_name_weight")
117
+ weight = min(max(fractional, 0.0), cap)
118
+ if fractional <= self.num("kelly.block_at_or_below"):
119
+ gate = "BLOCKED"
120
+ elif fractional < self.num("kelly.penalty_below"):
121
+ gate = "PENALTY"
122
+ elif fractional >= cap:
123
+ gate = "CAP_BINDING"
124
+ else:
125
+ gate = "OK"
126
+ return KellySizing(raw=raw, fractional=fractional, weight=weight, gate=gate)
127
+
128
+ def composite_z(self, family_z: Mapping[str, float | None]) -> float:
129
+ """Weighted family z; an UNAVAILABLE family contributes the policy default."""
130
+ fallback = self.num("score.unavailable_family_contribution")
131
+ total = 0.0
132
+ for family, weight in self.family_weights.items():
133
+ z = family_z.get(family)
134
+ total += weight * (fallback if z is None else z)
135
+ return total
136
+
137
+ def adj_score(self, composite: float, dq: float, penalties: float) -> float:
138
+ return composite * dq - penalties
139
+
140
+
141
+ def load_policy(path: Path | None = None) -> Policy:
142
+ target = path or DEFAULT_POLICY_PATH
143
+ with target.open("rb") as handle:
144
+ return Policy(tomllib.load(handle), source=str(target))
145
+
146
+
147
+ def flatten(data: Mapping[str, Any], prefix: str = "") -> dict[str, Any]:
148
+ """Dotted-key view; lists of tables are indexed (``forecast.mu_bands[0].mu``)."""
149
+ out: dict[str, Any] = {}
150
+ for key, value in data.items():
151
+ dotted = f"{prefix}.{key}" if prefix else key
152
+ if isinstance(value, Mapping):
153
+ out.update(flatten(value, dotted))
154
+ elif (
155
+ isinstance(value, list)
156
+ and value
157
+ and all(isinstance(v, Mapping) for v in value)
158
+ ):
159
+ for index, item in enumerate(value):
160
+ out.update(flatten(item, f"{dotted}[{index}]"))
161
+ else:
162
+ out[dotted] = value
163
+ return out
164
+
165
+
166
+ def _under(key: str, prefixes: list[str]) -> bool:
167
+ return any(
168
+ key == p or key.startswith(p + ".") or key.startswith(p + "[") for p in prefixes
169
+ )
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class MutationEvidence:
174
+ """What a proposal can show: canonical settlement counts + human sign-off."""
175
+
176
+ settled_n: int = 0
177
+ eff_n: int = 0
178
+ human_approved: bool = False
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class MutationVerdict:
183
+ decision: str # NO_CHANGE | ACCEPTABLE | DEFER | REJECT
184
+ changed: tuple[str, ...]
185
+ reasons: tuple[str, ...]
186
+
187
+
188
+ def check_mutation(
189
+ old: Policy, new: Policy, evidence: MutationEvidence
190
+ ) -> MutationVerdict:
191
+ """Govern a proposed policy diff with the *old* policy's limits.
192
+
193
+ ACCEPTABLE means the diff may be proposed for merge; adoption stays a
194
+ reviewed change (the harness never edits its own policy file).
195
+ """
196
+ before, after = flatten(old.data), flatten(new.data)
197
+ bookkeeping = {"policy_version", "effective_date"}
198
+ changed = tuple(
199
+ sorted(
200
+ key
201
+ for key in set(before) | set(after)
202
+ if key not in bookkeeping
203
+ and before.get(key, _MISSING) != after.get(key, _MISSING)
204
+ )
205
+ )
206
+ if not changed:
207
+ return MutationVerdict("NO_CHANGE", (), ())
208
+
209
+ rejects: list[str] = []
210
+ defers: list[str] = []
211
+ protected = list(old.get("evolution.protected"))
212
+ track_a = list(old.get("evolution.track_a"))
213
+ for key in changed:
214
+ if _under(key, protected) and not evidence.human_approved:
215
+ rejects.append(f"{key}: protected rule, requires human approval")
216
+ if new.version == old.version:
217
+ rejects.append("policy_version must change with any rule change")
218
+ if new.effective_date <= old.effective_date:
219
+ rejects.append("effective_date must move forward")
220
+
221
+ if any(_under(key, ["score.family_weights"]) for key in changed):
222
+ step = old.num("evolution.max_family_weight_step")
223
+ old_w, new_w = old.family_weights, new.family_weights
224
+ if set(old_w) != set(new_w):
225
+ rejects.append("score.family_weights: the family set cannot change")
226
+ else:
227
+ for family in FAMILIES:
228
+ delta = abs(new_w[family] - old_w[family])
229
+ if delta > step + 1e-12:
230
+ rejects.append(
231
+ f"score.family_weights.{family}: step {delta:.3f} "
232
+ f"exceeds {step:.2f}"
233
+ )
234
+ if abs(sum(new_w.values()) - 1.0) > 1e-9:
235
+ rejects.append("score.family_weights must sum to 1.0")
236
+
237
+ if any(_under(key, track_a) for key in changed):
238
+ min_n = int(old.num("evolution.track_a_min_settled_n"))
239
+ min_eff = int(old.num("evolution.track_a_min_eff_n"))
240
+ if evidence.settled_n < min_n or evidence.eff_n < min_eff:
241
+ defers.append(
242
+ f"Track A change needs settled n>={min_n} and eff_n>={min_eff} "
243
+ f"(have n={evidence.settled_n}, eff_n={evidence.eff_n}): "
244
+ "log as an observation and DEFER"
245
+ )
246
+
247
+ decision = "REJECT" if rejects else "DEFER" if defers else "ACCEPTABLE"
248
+ return MutationVerdict(decision, changed, tuple(rejects + defers))
@@ -0,0 +1,275 @@
1
+ """Run driver: the skeleton behind ``fa-harness run`` (plan §3.5, Phase 4).
2
+
3
+ The runner owns the order of a run, the (model, date) lock, and the gated
4
+ publish. Everything that does work (adapters, kernels, model calls) plugs in as
5
+ a stage handler; a stage without a handler halts the run instead of being
6
+ skipped, so a half-built pipeline can never publish.
7
+
8
+ A handler for state ``S`` runs while the lifecycle is in ``S``; the runner then
9
+ advances to the next state. ``RISK_REVIEW`` must return the run status, and the
10
+ publish gate runs before the lifecycle moves to ``PUBLISHED``. A handler sees
11
+ every earlier stage's result (``context.results``), so stages pass data forward
12
+ without a side channel.
13
+
14
+ Every run writes a journal, ``{output_dir}/.runs/{model}-{date}.json``, after each
15
+ stage: transitions, per-stage notes, data and timings, gate failures, and the
16
+ halt reason. It is the run's heartbeat while it runs and its record after.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import datetime as dt
22
+ import json
23
+ import os
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Mapping
28
+
29
+ from financial_agent.harness.gates import publish_gate
30
+ from financial_agent.harness.lifecycle import (
31
+ LifecycleError,
32
+ RunLifecycle,
33
+ RunState,
34
+ RunStatus,
35
+ )
36
+ from financial_agent.harness.policy import Policy, load_policy
37
+
38
+ # States whose handler runs before the lifecycle advances, in order.
39
+ PIPELINE = (
40
+ RunState.PRECHECK,
41
+ RunState.REFLECTION,
42
+ RunState.DATA_OK,
43
+ RunState.TECHNICALS_OK,
44
+ RunState.SCORED,
45
+ RunState.PORTFOLIO_DRAFT,
46
+ RunState.RISK_REVIEW,
47
+ )
48
+ POST_PUBLISH = RunState.EVOLUTION_REVIEW
49
+
50
+
51
+ class RunLocked(RuntimeError):
52
+ """Another run holds the (model, date) lock, or a crashed one left it."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class StageResult:
57
+ note: str = ""
58
+ status: RunStatus | None = None # required from the RISK_REVIEW handler
59
+ halt: str | None = None # a handler's way to stop the run with a reason
60
+ data: Mapping[str, Any] = field(default_factory=dict) # for later stages
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class RunContext:
65
+ model: str
66
+ date: str
67
+ package_dir: Path
68
+ policy: Policy | None = field(default=None, compare=False)
69
+ # Results of the stages that already ran, filled in by ``run``.
70
+ results: dict[RunState, StageResult] = field(default_factory=dict, compare=False)
71
+
72
+ @property
73
+ def run_id(self) -> str:
74
+ return f"{self.model}-{self.date}"
75
+
76
+
77
+ Handler = Callable[[RunContext], StageResult]
78
+
79
+
80
+ @dataclass
81
+ class RunReport:
82
+ run_id: str
83
+ lifecycle: RunLifecycle
84
+ gate_failures: list[str] = field(default_factory=list)
85
+ stages: dict[str, dict[str, Any]] = field(default_factory=dict)
86
+
87
+ @property
88
+ def published(self) -> bool:
89
+ return self.lifecycle.status not in {None, RunStatus.HALTED}
90
+
91
+ @property
92
+ def halt_reason(self) -> str | None:
93
+ last = self.lifecycle.log[-1] if self.lifecycle.log else None
94
+ if last is not None and last.target is RunState.HALTED:
95
+ return last.note
96
+ return None
97
+
98
+
99
+ class RunLock:
100
+ """``{output_dir}/.locks/{model}-{date}.lock``, created atomically.
101
+
102
+ Not reentrant and never stolen: a crashed run leaves the file, and a person
103
+ removes it after looking at the half-written package.
104
+ """
105
+
106
+ def __init__(self, output_dir: Path, run_id: str) -> None:
107
+ self.path = output_dir / ".locks" / f"{run_id}.lock"
108
+
109
+ def __enter__(self) -> "RunLock":
110
+ self.path.parent.mkdir(parents=True, exist_ok=True)
111
+ try:
112
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
113
+ except FileExistsError:
114
+ raise RunLocked(
115
+ f"{self.path} exists: a run is in progress or crashed; "
116
+ "inspect the package, then remove the lock"
117
+ ) from None
118
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
119
+ handle.write(f"pid={os.getpid()}\n")
120
+ return self
121
+
122
+ def __exit__(self, *exc: object) -> None:
123
+ self.path.unlink(missing_ok=True)
124
+
125
+
126
+ class RunJournal:
127
+ """``{output_dir}/.runs/{run_id}.json``, replaced atomically on every write."""
128
+
129
+ def __init__(self, output_dir: Path, run_id: str) -> None:
130
+ self.path = output_dir / ".runs" / f"{run_id}.json"
131
+ self.started_at = _now()
132
+
133
+ def write(self, report: RunReport, *, finished: bool = False) -> None:
134
+ lifecycle = report.lifecycle
135
+ body = {
136
+ "run_id": report.run_id,
137
+ "started_at": self.started_at,
138
+ "updated_at": _now(),
139
+ "finished": finished,
140
+ "state": lifecycle.state.value,
141
+ "status": lifecycle.status.value if lifecycle.status else None,
142
+ "transcript": lifecycle.transcript(),
143
+ "transitions": [
144
+ {
145
+ "source": t.source.value,
146
+ "target": t.target.value,
147
+ "at": t.at,
148
+ "note": t.note,
149
+ }
150
+ for t in lifecycle.log
151
+ ],
152
+ "stages": report.stages,
153
+ "gate_failures": report.gate_failures,
154
+ "halt_reason": report.halt_reason,
155
+ }
156
+ self.path.parent.mkdir(parents=True, exist_ok=True)
157
+ partial = self.path.with_suffix(".json.partial")
158
+ partial.write_text(json.dumps(body, indent=2, default=str), encoding="utf-8")
159
+ partial.replace(self.path)
160
+
161
+
162
+ def _now() -> str:
163
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
164
+
165
+
166
+ def run(
167
+ model: str,
168
+ date: str,
169
+ output_dir: Path,
170
+ handlers: Mapping[RunState, Handler],
171
+ *,
172
+ policy: Policy | None = None,
173
+ ) -> RunReport:
174
+ """Drive one run to ``EVOLUTION_REVIEW`` or ``HALTED``; never raises on a halt."""
175
+ policy = policy or load_policy()
176
+ context = RunContext(model, date, output_dir / f"{model}-{date}", policy)
177
+ report = RunReport(context.run_id, RunLifecycle(context.run_id))
178
+ with RunLock(output_dir, context.run_id):
179
+ journal = RunJournal(output_dir, context.run_id)
180
+ try:
181
+ _drive(context, report, handlers, policy, journal)
182
+ finally:
183
+ journal.write(report, finished=True)
184
+ return report
185
+
186
+
187
+ def _drive(
188
+ context: RunContext,
189
+ report: RunReport,
190
+ handlers: Mapping[RunState, Handler],
191
+ policy: Policy,
192
+ journal: RunJournal,
193
+ ) -> None:
194
+ lifecycle = report.lifecycle
195
+ status: RunStatus | None = None
196
+ for index, state in enumerate(PIPELINE):
197
+ outcome = _stage(handlers, state, context, report)
198
+ journal.write(report)
199
+ if isinstance(outcome, str):
200
+ lifecycle.halt(outcome)
201
+ return
202
+ if outcome.halt:
203
+ lifecycle.halt(outcome.halt)
204
+ return
205
+ if state is RunState.RISK_REVIEW:
206
+ if outcome.status is None or outcome.status is RunStatus.HALTED:
207
+ lifecycle.halt("RISK_REVIEW handler returned no publishable status")
208
+ return
209
+ status = outcome.status
210
+ break
211
+ lifecycle.advance(PIPELINE[index + 1], outcome.note)
212
+ assert status is not None
213
+ report.gate_failures = _gate(context, policy)
214
+ try:
215
+ lifecycle.publish(status, report.gate_failures)
216
+ except LifecycleError as exc:
217
+ lifecycle.halt(str(exc))
218
+ return
219
+ journal.write(report)
220
+ if POST_PUBLISH in handlers:
221
+ outcome = _stage(handlers, POST_PUBLISH, context, report)
222
+ note = outcome if isinstance(outcome, str) else outcome.note
223
+ else:
224
+ note = "skipped: no handler"
225
+ lifecycle.advance(POST_PUBLISH, note)
226
+
227
+
228
+ def _stage(
229
+ handlers: Mapping[RunState, Handler],
230
+ state: RunState,
231
+ context: RunContext,
232
+ report: RunReport,
233
+ ) -> StageResult | str:
234
+ """Run one stage and record it; a string result is the halt reason."""
235
+ started = time.monotonic()
236
+ outcome = _call(handlers, state, context)
237
+ entry: dict[str, Any] = {"seconds": round(time.monotonic() - started, 3)}
238
+ if isinstance(outcome, str):
239
+ entry["halt"] = outcome
240
+ else:
241
+ context.results[state] = outcome
242
+ entry.update(note=outcome.note, data=dict(outcome.data))
243
+ if outcome.halt:
244
+ entry["halt"] = outcome.halt
245
+ report.stages[state.value] = entry
246
+ return outcome
247
+
248
+
249
+ def _call(
250
+ handlers: Mapping[RunState, Handler], state: RunState, context: RunContext
251
+ ) -> StageResult | str:
252
+ """A handler's result, or the halt reason when it is missing or raises."""
253
+ handler = handlers.get(state)
254
+ if handler is None:
255
+ return f"no handler for {state.value}"
256
+ try:
257
+ return handler(context)
258
+ except Exception as exc: # a failed stage halts the run; it never publishes
259
+ return f"{state.value} handler failed: {type(exc).__name__}: {exc}"
260
+
261
+
262
+ def _gate(context: RunContext, policy: Policy) -> list[str]:
263
+ if not context.package_dir.is_dir():
264
+ return [f"package directory {context.package_dir} was never created"]
265
+ return publish_gate(context.package_dir, policy)
266
+
267
+
268
+ def default_handlers() -> Mapping[RunState, Handler]:
269
+ """Shipped handlers. Stages without one halt the run (plan Phase 2)."""
270
+ from financial_agent.harness.handlers import precheck_handler, reflection_handler
271
+
272
+ return {
273
+ RunState.PRECHECK: precheck_handler,
274
+ RunState.REFLECTION: reflection_handler,
275
+ }