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,325 @@
1
+ """Provenance by construction: the ``01_preflight.md`` Source Ledger as code.
2
+
3
+ Today the model hand-writes ~180 ledger rows per run and then re-types their
4
+ values into five other artifacts. Here every fact enters through a method
5
+ that stamps provenance, derived facts inherit UNAVAILABLE from any missing
6
+ input (``rules.md`` § Source Ledger Contract, hard rule 2), the Price
7
+ Sourcing Standard is a function rather than a paragraph, and narrative text
8
+ cites ``{{L012}}`` placeholders that the renderer fills -- so a transcription
9
+ error has nowhere to happen.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as dt
15
+ import re
16
+ from dataclasses import dataclass, field, replace
17
+ from typing import Callable, Sequence
18
+ from urllib.parse import urlparse
19
+
20
+ FRESHNESS_TAGS = (
21
+ "LIVE",
22
+ "DELAYED",
23
+ "OFFICIAL_FILING",
24
+ "HISTORICAL",
25
+ "ILLUSTRATIVE_REF",
26
+ "UNAVAILABLE",
27
+ )
28
+ # Weakest input wins when facts are combined.
29
+ _FRESHNESS_RANK = {tag: rank for rank, tag in enumerate(FRESHNESS_TAGS)}
30
+ CLAIM_TYPES = ("OBSERVED", "DERIVED", "INFERRED", "ILLUSTRATIVE", "UNAVAILABLE")
31
+ PRICE_AGREEMENT = 0.01 # rules.md § Price Sourcing Standard: two sources within 1%
32
+
33
+ Value = float | str | None
34
+ _REF_RE = re.compile(r"\{\{(L\d+[a-z]?)(?:\|(\w+))?\}\}")
35
+ # Decimals (optionally %), integer percentages, count ratios like 27/27,
36
+ # thousands-grouped integers, and bare integers of three or more digits.
37
+ _LITERAL_NUMBER_RE = re.compile(
38
+ r"(?<![\w.{/])(?:[-+]?\d[\d,]*\.\d+%?|[-+]?\d+%|\d+/\d+|\d{1,3}(?:,\d{3})+"
39
+ r"|\d{3,})(?![\w}/])"
40
+ )
41
+ _DATE_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
42
+
43
+
44
+ class LedgerError(ValueError):
45
+ """A fact without provenance, an unknown row, or a malformed citation."""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Quote:
50
+ """One observed price from one source, stamped when it was fetched."""
51
+
52
+ source: str
53
+ value: float
54
+ retrieved_at: str
55
+ from_market_data_tool: bool = False
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class LedgerRow:
60
+ row_id: str
61
+ field: str
62
+ entity: str
63
+ value: Value
64
+ unit: str
65
+ observation_date: str | None
66
+ source: str
67
+ freshness_tag: str
68
+ claim_type: str
69
+ inputs: tuple[str, ...] = ()
70
+ used_by: tuple[str, ...] = ()
71
+
72
+ @property
73
+ def available(self) -> bool:
74
+ return self.claim_type != "UNAVAILABLE"
75
+
76
+
77
+ def _host(source: str) -> str:
78
+ parsed = urlparse(source)
79
+ return (parsed.netloc or source).lower().removeprefix("www.")
80
+
81
+
82
+ @dataclass
83
+ class SourceLedger:
84
+ run_id: str
85
+ rows: dict[str, LedgerRow] = field(default_factory=dict)
86
+ _next: int = 1
87
+
88
+ # -- writing ---------------------------------------------------------
89
+
90
+ def _add(self, row: LedgerRow) -> str:
91
+ if row.freshness_tag not in _FRESHNESS_RANK:
92
+ raise LedgerError(f"freshness_tag {row.freshness_tag!r} not allowed")
93
+ if row.claim_type not in CLAIM_TYPES:
94
+ raise LedgerError(f"claim_type {row.claim_type!r} not allowed")
95
+ self.rows[row.row_id] = row
96
+ return row.row_id
97
+
98
+ def _new_id(self) -> str:
99
+ row_id = f"L{self._next:03d}"
100
+ self._next += 1
101
+ return row_id
102
+
103
+ def observe(
104
+ self,
105
+ field_name: str,
106
+ entity: str,
107
+ value: float | str,
108
+ *,
109
+ unit: str,
110
+ observation_date: str,
111
+ source: str,
112
+ freshness_tag: str,
113
+ retrieved_at: str,
114
+ ) -> str:
115
+ """A fact read directly from a cited source during this run."""
116
+ if not source or not retrieved_at:
117
+ raise LedgerError("an OBSERVED row needs a source and retrieved_at")
118
+ try:
119
+ dt.date.fromisoformat(observation_date)
120
+ except ValueError:
121
+ raise LedgerError(f"observation_date {observation_date!r}") from None
122
+ return self._add(
123
+ LedgerRow(
124
+ row_id=self._new_id(),
125
+ field=field_name,
126
+ entity=entity,
127
+ value=value,
128
+ unit=unit,
129
+ observation_date=observation_date,
130
+ source=f"{source} retrieved_at {retrieved_at}",
131
+ freshness_tag=freshness_tag,
132
+ claim_type="OBSERVED",
133
+ )
134
+ )
135
+
136
+ def unavailable(self, field_name: str, entity: str, reason: str) -> str:
137
+ return self._add(
138
+ LedgerRow(
139
+ row_id=self._new_id(),
140
+ field=field_name,
141
+ entity=entity,
142
+ value=None,
143
+ unit="",
144
+ observation_date=None,
145
+ source=reason,
146
+ freshness_tag="UNAVAILABLE",
147
+ claim_type="UNAVAILABLE",
148
+ )
149
+ )
150
+
151
+ def observe_price(
152
+ self,
153
+ entity: str,
154
+ quotes: Sequence[Quote],
155
+ *,
156
+ observation_date: str,
157
+ freshness_tag: str,
158
+ unit: str = "USD",
159
+ ) -> str:
160
+ """Price Sourcing Standard as code: a market-data tool quote, or two
161
+ independent sources agreeing within 1%; anything else is UNAVAILABLE."""
162
+ tool = [q for q in quotes if q.from_market_data_tool]
163
+ if tool:
164
+ q = tool[0]
165
+ return self.observe(
166
+ "close",
167
+ entity,
168
+ q.value,
169
+ unit=unit,
170
+ observation_date=observation_date,
171
+ source=q.source,
172
+ freshness_tag=freshness_tag,
173
+ retrieved_at=q.retrieved_at,
174
+ )
175
+ by_host: dict[str, Quote] = {}
176
+ for q in quotes:
177
+ by_host.setdefault(_host(q.source), q)
178
+ independent = list(by_host.values())
179
+ if len(independent) < 2:
180
+ return self.unavailable(
181
+ "close",
182
+ entity,
183
+ f"price grounding failed: {len(independent)} independent source(s)",
184
+ )
185
+ values = [q.value for q in independent]
186
+ spread = max(values) / min(values) - 1
187
+ if spread > PRICE_AGREEMENT:
188
+ return self.unavailable(
189
+ "close",
190
+ entity,
191
+ f"price grounding failed: sources disagree {spread:.2%}",
192
+ )
193
+ primary = independent[0]
194
+ cross = "; ".join(f"{q.source}={q.value}" for q in independent[1:])
195
+ return self.observe(
196
+ "close",
197
+ entity,
198
+ primary.value,
199
+ unit=unit,
200
+ observation_date=observation_date,
201
+ source=f"{primary.source} (cross-check {cross}, spread {spread:.2%})",
202
+ freshness_tag=freshness_tag,
203
+ retrieved_at=primary.retrieved_at,
204
+ )
205
+
206
+ def derive(
207
+ self,
208
+ field_name: str,
209
+ entity: str,
210
+ *,
211
+ formula: str,
212
+ inputs: Sequence[str],
213
+ compute: Callable[..., float],
214
+ unit: str,
215
+ ) -> str:
216
+ """A computed fact; any UNAVAILABLE input makes the result UNAVAILABLE."""
217
+ rows = [self.row(i) for i in inputs]
218
+ if not rows:
219
+ raise LedgerError("a DERIVED row must cite at least one input row")
220
+ if not all(r.available for r in rows):
221
+ missing = ", ".join(r.row_id for r in rows if not r.available)
222
+ return self._add(
223
+ LedgerRow(
224
+ row_id=self._new_id(),
225
+ field=field_name,
226
+ entity=entity,
227
+ value=None,
228
+ unit=unit,
229
+ observation_date=None,
230
+ source=f"DERIVED: {formula}; input(s) {missing} UNAVAILABLE",
231
+ freshness_tag="UNAVAILABLE",
232
+ claim_type="UNAVAILABLE",
233
+ inputs=tuple(inputs),
234
+ )
235
+ )
236
+ value = compute(*(r.value for r in rows))
237
+ dates = [r.observation_date for r in rows if r.observation_date]
238
+ weakest = max((r.freshness_tag for r in rows), key=_FRESHNESS_RANK.__getitem__)
239
+ return self._add(
240
+ LedgerRow(
241
+ row_id=self._new_id(),
242
+ field=field_name,
243
+ entity=entity,
244
+ value=value,
245
+ unit=unit,
246
+ observation_date=max(dates) if dates else None,
247
+ source=f"DERIVED: {formula}; inputs {', '.join(inputs)}",
248
+ freshness_tag=weakest,
249
+ claim_type="DERIVED",
250
+ inputs=tuple(inputs),
251
+ )
252
+ )
253
+
254
+ # -- reading ---------------------------------------------------------
255
+
256
+ def row(self, row_id: str) -> LedgerRow:
257
+ try:
258
+ return self.rows[row_id]
259
+ except KeyError:
260
+ raise LedgerError(f"unknown ledger row {row_id}") from None
261
+
262
+ def cite(self, row_id: str, artifact: str) -> Value:
263
+ """Read a value *and* record which artifact used it (``used_by``)."""
264
+ row = self.row(row_id)
265
+ if artifact not in row.used_by:
266
+ self.rows[row_id] = replace(row, used_by=row.used_by + (artifact,))
267
+ return row.value
268
+
269
+ def render(self, template: str, artifact: str) -> str:
270
+ """Fill ``{{L012}}`` / ``{{L012|pct}}`` citations from the ledger."""
271
+
272
+ def fill(match: re.Match[str]) -> str:
273
+ row_id, fmt = match.group(1), match.group(2)
274
+ value = self.cite(row_id, artifact)
275
+ if value is None:
276
+ return f"UNAVAILABLE ({row_id})"
277
+ if fmt == "pct" and isinstance(value, float):
278
+ return f"{value:+.2%} ({row_id})"
279
+ if isinstance(value, float):
280
+ return f"{value:,.2f} ({row_id})"
281
+ return f"{value} ({row_id})"
282
+
283
+ return _REF_RE.sub(fill, template)
284
+
285
+ def to_markdown(self) -> str:
286
+ """The Source Ledger table in the ``runbook.md § 01`` schema."""
287
+ header = (
288
+ "| artifact | field | ticker/entity | value | unit | observation_date "
289
+ "| source | freshness_tag | claim_type | used_by |\n"
290
+ "|---|---|---|---|---|---|---|---|---|---|"
291
+ )
292
+ lines = [header]
293
+ for row in self.rows.values():
294
+ value = "UNAVAILABLE" if row.value is None else row.value
295
+ cells = [
296
+ row.row_id,
297
+ row.field,
298
+ row.entity,
299
+ str(value),
300
+ row.unit,
301
+ row.observation_date or "UNAVAILABLE",
302
+ row.source.replace("|", "\\|"),
303
+ row.freshness_tag,
304
+ row.claim_type,
305
+ ", ".join(row.used_by),
306
+ ]
307
+ lines.append("| " + " | ".join(cells) + " |")
308
+ return "\n".join(lines)
309
+
310
+
311
+ def lint_literal_numbers(template: str, allow: Sequence[str] = ()) -> list[str]:
312
+ """Numbers typed into model-authored text instead of cited via ``{{L...}}``.
313
+
314
+ ISO dates and one- or two-digit integers ("3 of 4 families") pass;
315
+ decimals, percentages, count ratios (``27/27``), and integers of three or
316
+ more digits must come from the ledger unless listed in ``allow``.
317
+ """
318
+ allowed = set(allow)
319
+ findings: list[str] = []
320
+ for line_no, line in enumerate(template.splitlines(), start=1):
321
+ scrubbed = _DATE_RE.sub("", _REF_RE.sub("", line))
322
+ for match in _LITERAL_NUMBER_RE.finditer(scrubbed):
323
+ if match.group(0) not in allowed:
324
+ findings.append(f"line {line_no}: literal number {match.group(0)!r}")
325
+ return findings
@@ -0,0 +1,127 @@
1
+ """Run lifecycle: the ``main.md`` state machine enforced in code.
2
+
3
+ ``main.md`` says "a run is a state machine, not an essay". Today the model
4
+ narrates the transitions; here the harness owns them, so a skipped stage, an
5
+ extra revision pass, or publishing without a passing gate is an exception,
6
+ not a sentence in a manifest.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+
15
+
16
+ class RunState(str, Enum):
17
+ PRECHECK = "PRECHECK"
18
+ REFLECTION = "REFLECTION"
19
+ DATA_OK = "DATA_OK"
20
+ TECHNICALS_OK = "TECHNICALS_OK"
21
+ SCORED = "SCORED"
22
+ PORTFOLIO_DRAFT = "PORTFOLIO_DRAFT"
23
+ RISK_REVIEW = "RISK_REVIEW"
24
+ PUBLISHED = "PUBLISHED"
25
+ CLOSE_LOGGED = "CLOSE_LOGGED"
26
+ EVOLUTION_REVIEW = "EVOLUTION_REVIEW"
27
+ HALTED = "HALTED"
28
+
29
+
30
+ class RunStatus(str, Enum):
31
+ GO = "GO"
32
+ NO_TRADE = "NO_TRADE"
33
+ REVIEW_ONLY = "REVIEW_ONLY"
34
+ HALTED = "HALTED"
35
+
36
+
37
+ _FORWARD: dict[RunState, frozenset[RunState]] = {
38
+ RunState.PRECHECK: frozenset({RunState.REFLECTION}),
39
+ RunState.REFLECTION: frozenset({RunState.DATA_OK}),
40
+ RunState.DATA_OK: frozenset({RunState.TECHNICALS_OK}),
41
+ RunState.TECHNICALS_OK: frozenset({RunState.SCORED}),
42
+ RunState.SCORED: frozenset({RunState.PORTFOLIO_DRAFT}),
43
+ RunState.PORTFOLIO_DRAFT: frozenset({RunState.RISK_REVIEW}),
44
+ RunState.RISK_REVIEW: frozenset({RunState.PUBLISHED}),
45
+ RunState.PUBLISHED: frozenset({RunState.CLOSE_LOGGED, RunState.EVOLUTION_REVIEW}),
46
+ RunState.CLOSE_LOGGED: frozenset({RunState.EVOLUTION_REVIEW}),
47
+ RunState.EVOLUTION_REVIEW: frozenset(),
48
+ RunState.HALTED: frozenset(),
49
+ }
50
+ # rules.md § Intra-Loop Revision Limit: one portfolio<->risk revision pass and
51
+ # one clarification back to factor scoring.
52
+ _REVISIONS: dict[tuple[RunState, RunState], str] = {
53
+ (RunState.RISK_REVIEW, RunState.PORTFOLIO_DRAFT): "risk_revision",
54
+ (RunState.PORTFOLIO_DRAFT, RunState.SCORED): "scoring_clarification",
55
+ (RunState.RISK_REVIEW, RunState.SCORED): "scoring_clarification",
56
+ }
57
+ REVISION_BUDGET = {"risk_revision": 1, "scoring_clarification": 1}
58
+
59
+
60
+ class LifecycleError(RuntimeError):
61
+ """An illegal transition, an exhausted revision budget, or a failed gate."""
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Transition:
66
+ source: RunState
67
+ target: RunState
68
+ at: str
69
+ note: str
70
+
71
+
72
+ @dataclass
73
+ class RunLifecycle:
74
+ run_id: str
75
+ state: RunState = RunState.PRECHECK
76
+ status: RunStatus | None = None
77
+ log: list[Transition] = field(default_factory=list)
78
+ revisions_used: dict[str, int] = field(default_factory=dict)
79
+
80
+ def advance(
81
+ self, target: RunState, note: str = "", *, at: str | None = None
82
+ ) -> None:
83
+ if target is RunState.PUBLISHED:
84
+ raise LifecycleError("use publish(): PUBLISHED requires a status and gate")
85
+ self._move(target, note, at)
86
+
87
+ def halt(self, reason: str, *, at: str | None = None) -> None:
88
+ if self.state in {RunState.HALTED, RunState.EVOLUTION_REVIEW}:
89
+ raise LifecycleError(f"cannot halt from terminal state {self.state.value}")
90
+ self.status = RunStatus.HALTED
91
+ self._record(RunState.HALTED, reason, at)
92
+
93
+ def publish(
94
+ self, status: RunStatus, gate_failures: list[str], *, at: str | None = None
95
+ ) -> None:
96
+ """Publish only when every publish-gate check passed."""
97
+ if gate_failures:
98
+ raise LifecycleError("publish gate failed: " + "; ".join(gate_failures))
99
+ if status is RunStatus.HALTED:
100
+ raise LifecycleError("use halt(): HALTED is not a publication status")
101
+ self._move(RunState.PUBLISHED, f"status={status.value}", at)
102
+ self.status = status
103
+
104
+ def _move(self, target: RunState, note: str, at: str | None) -> None:
105
+ budget_key = _REVISIONS.get((self.state, target))
106
+ if budget_key is not None:
107
+ used = self.revisions_used.get(budget_key, 0)
108
+ if used >= REVISION_BUDGET[budget_key]:
109
+ raise LifecycleError(
110
+ f"{budget_key} budget exhausted ({used}); publish NO_TRADE or halt"
111
+ )
112
+ self.revisions_used[budget_key] = used + 1
113
+ elif target not in _FORWARD[self.state]:
114
+ raise LifecycleError(
115
+ f"illegal transition {self.state.value} -> {target.value}"
116
+ )
117
+ self._record(target, note, at)
118
+
119
+ def _record(self, target: RunState, note: str, at: str | None) -> None:
120
+ stamp = at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
121
+ self.log.append(Transition(self.state, target, stamp, note))
122
+ self.state = target
123
+
124
+ def transcript(self) -> str:
125
+ """The manifest's state-transition line, generated rather than typed."""
126
+ states = [RunState.PRECHECK.value] + [t.target.value for t in self.log]
127
+ return " -> ".join(states)
@@ -0,0 +1,113 @@
1
+ """Manifest sections generated from the package directory (plan §3.5 step 7).
2
+
3
+ Three manifests in the history listed artifacts that were never written. The
4
+ checklist here is read from disk after the files exist, next to the gate result,
5
+ the status replay, and the content hash, so none of it is typed by a model.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from financial_agent.harness.fingerprint import artifact_digests, package_digest
14
+ from financial_agent.harness.gates import (
15
+ ALWAYS_ARTIFACTS,
16
+ CHECKPOINT_ARTIFACTS,
17
+ PREDICTIONS_FILE,
18
+ StatusReplay,
19
+ publish_gate,
20
+ replay_status,
21
+ )
22
+ from financial_agent.harness.policy import Policy
23
+
24
+
25
+ def replay_lines(replay: StatusReplay) -> list[str]:
26
+ """Calendar, structural GO blockers, and per-threshold rejections."""
27
+ lines = []
28
+ if replay.session is not None:
29
+ lines.append(f"Session: {replay.session.describe()}.")
30
+ reach = replay.reachability
31
+ if reach is not None:
32
+ if reach.reachable:
33
+ lines.append(
34
+ f"GO reachable with {', '.join(reach.gating_families)} scoring."
35
+ )
36
+ else:
37
+ lines.append("GO unreachable: " + "; ".join(reach.blockers) + ".")
38
+ if replay.blocking:
39
+ counts = ", ".join(f"{k}: {v}" for k, v in sorted(replay.blocking.items()))
40
+ lines.append(f"Names rejected per evidence threshold: {counts}.")
41
+ return lines
42
+
43
+
44
+ def replay_dict(replay: StatusReplay) -> dict[str, Any]:
45
+ reach = replay.reachability
46
+ return {
47
+ "status": replay.decision.status.value,
48
+ "reasons": list(replay.decision.reasons),
49
+ "published": replay.published,
50
+ "agrees": replay.agrees,
51
+ "investable": replay.investable,
52
+ "session": None if replay.session is None else vars(replay.session),
53
+ "blocking": replay.blocking,
54
+ "go_reachable": None if reach is None else reach.reachable,
55
+ "go_blockers": [] if reach is None else list(reach.blockers),
56
+ }
57
+
58
+
59
+ def gate_report(package_dir: Path, policy: Policy) -> dict[str, Any]:
60
+ """The publish gate, status replay, and content hash as one JSON object."""
61
+ failures = publish_gate(package_dir, policy)
62
+ replay = replay_status(package_dir, policy)
63
+ return {
64
+ "package": package_dir.name,
65
+ "pass": not failures,
66
+ "failures": failures,
67
+ "content_hash": f"sha256:{package_digest(package_dir)}",
68
+ "replay": None if replay is None else replay_dict(replay),
69
+ }
70
+
71
+
72
+ def render_manifest_sections(
73
+ package_dir: Path, policy: Policy
74
+ ) -> tuple[str, list[str]]:
75
+ """Markdown for the manifest's generated sections, and the gate failures."""
76
+ digests = artifact_digests(package_dir)
77
+ failures = publish_gate(package_dir, policy)
78
+ lines = [
79
+ "## Artifact checklist (generated from the directory)",
80
+ "",
81
+ "| Artifact | Present | sha256 |",
82
+ "|---|---|---|",
83
+ ]
84
+ for name in (*ALWAYS_ARTIFACTS, PREDICTIONS_FILE):
85
+ digest = digests.get(name)
86
+ lines.append(
87
+ f"| `{name}` | {'yes' if digest else '**NO**'} | "
88
+ f"{f'`{digest[:12]}`' if digest else '-'} |"
89
+ )
90
+ for name in CHECKPOINT_ARTIFACTS:
91
+ if name in digests:
92
+ lines.append(f"| `{name}` (checkpoint) | yes | `{digests[name][:12]}` |")
93
+ lines += ["", f"Content hash: `sha256:{package_digest(package_dir)}`", ""]
94
+
95
+ replay = replay_status(package_dir, policy)
96
+ lines.append("## Status replay")
97
+ lines.append("")
98
+ if replay is None:
99
+ lines.append(f"No `{PREDICTIONS_FILE}`: nothing to replay.")
100
+ else:
101
+ reasons = "; ".join(replay.decision.reasons)
102
+ lines.append(
103
+ f"Replay: `{replay.decision.status.value}` ({reasons}); "
104
+ f"published: `{replay.published}`; "
105
+ f"{len(replay.investable)} investable. Required inputs assumed grounded."
106
+ )
107
+ lines += ["", *replay_lines(replay)]
108
+ lines += ["", "## Publish gate", ""]
109
+ if failures:
110
+ lines.extend(f"- FAIL: {failure}" for failure in failures)
111
+ else:
112
+ lines.append("PASS")
113
+ return "\n".join(lines), failures
@@ -0,0 +1,50 @@
1
+ """The exchange calendar for run dates: one rule set for every model.
2
+
3
+ The same market holiday has been labelled three ways in the run history, and
4
+ weekend runs publish ``REVIEW_ONLY`` under one model and ``NO_TRADE`` under
5
+ another. A session is computed here from the NYSE calendar that
6
+ ``settlement_ledger.py`` already uses for settlement timing, so status replay,
7
+ the run driver, and settlement agree on what a trading day is.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+ from dataclasses import dataclass
14
+
15
+ from financial_agent.harness.helpers import load_helper
16
+
17
+ TRADING = "TRADING"
18
+ WEEKEND = "WEEKEND"
19
+ HOLIDAY = "HOLIDAY"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Session:
24
+ date: str
25
+ kind: str # TRADING | WEEKEND | HOLIDAY
26
+ basis_date: str # the most recent trading day at or before ``date``
27
+
28
+ @property
29
+ def trading_day(self) -> bool:
30
+ return self.kind == TRADING
31
+
32
+ def describe(self) -> str:
33
+ if self.trading_day:
34
+ return f"{self.date} is a trading day"
35
+ what = "a weekend" if self.kind == WEEKEND else "a market holiday"
36
+ return f"{self.date} is {what}; last close {self.basis_date}"
37
+
38
+
39
+ def session(day: str | dt.date) -> Session:
40
+ """Classify ``day`` (``YYYY-MM-DD``) against the NYSE calendar."""
41
+ date = dt.date.fromisoformat(day) if isinstance(day, str) else day
42
+ ledger = load_helper("settlement_ledger")
43
+ if ledger.is_trading_day(date):
44
+ kind = TRADING
45
+ elif date.weekday() >= 5:
46
+ kind = WEEKEND
47
+ else:
48
+ kind = HOLIDAY
49
+ basis = ledger.most_recent_trading_day_at_or_before(date)
50
+ return Session(date.isoformat(), kind, basis.isoformat())