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,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
|
+
}
|