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,430 @@
1
+ """Harness CLI: the tool surface any agent runtime can call from a shell.
2
+
3
+ python -m financial_agent.harness skills validate
4
+ python -m financial_agent.harness skills catalog
5
+ python -m financial_agent.harness audit --output-dir agents/equity/output
6
+ python -m financial_agent.harness gate agents/equity/output/<model>-<date> [--json]
7
+ python -m financial_agent.harness replay --since 2026-07-31
8
+ python -m financial_agent.harness schema [<package>] [--json-schema]
9
+ python -m financial_agent.harness session <YYYY-MM-DD>
10
+ python -m financial_agent.harness reachability [--families tech_z macro_z]
11
+ python -m financial_agent.harness kernel <kind> --input FILE
12
+ (kind: equity, market, risk, portfolio, score)
13
+ python -m financial_agent.harness run --model <id> --date <YYYY-MM-DD>
14
+ python -m financial_agent.harness manifest agents/equity/output/<model>-<date>
15
+ python -m financial_agent.harness hash agents/equity/output/<model>-<date>
16
+ python -m financial_agent.harness clones --since 2026-07-31
17
+ python -m financial_agent.harness policy-check OLD.toml NEW.toml --settled-n 20
18
+ python -m financial_agent.harness mcp
19
+
20
+ Exit status is non-zero when a check fails, so a skill, a CI job, or a
21
+ PreToolUse hook can use the command as a gate.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from financial_agent.harness.audit import audit_output_dir
33
+ from financial_agent.harness.fingerprint import (
34
+ artifact_digests,
35
+ find_clones,
36
+ package_digest,
37
+ )
38
+ from financial_agent.harness.gates import (
39
+ PREDICTIONS_FILE,
40
+ go_reachability,
41
+ publish_gate,
42
+ replay_history,
43
+ replay_status,
44
+ )
45
+ from financial_agent.harness.kernels import (
46
+ EquityInputs,
47
+ Holding,
48
+ KernelError,
49
+ MarketInputs,
50
+ equity_record,
51
+ market_forecast_records,
52
+ portfolio_feasibility,
53
+ price_risk,
54
+ )
55
+ from financial_agent.harness.lifecycle import RunState
56
+ from financial_agent.harness.manifest import (
57
+ gate_report,
58
+ render_manifest_sections,
59
+ replay_lines,
60
+ )
61
+ from financial_agent.harness.market_calendar import session
62
+ from financial_agent.harness.policy import (
63
+ FAMILIES,
64
+ MutationEvidence,
65
+ check_mutation,
66
+ load_policy,
67
+ )
68
+ from financial_agent.harness.runner import RunLocked, default_handlers, run
69
+ from financial_agent.harness.schema import json_schema, validate_payload
70
+ from financial_agent.harness.scoring import score_request
71
+ from financial_agent.harness.skills import CHARS_PER_TOKEN, SkillRegistry
72
+
73
+ DEFAULT_SKILL_ROOTS = (
74
+ Path("skills"),
75
+ Path(".agents/skills"),
76
+ Path("agents/equity/turtle-trader"),
77
+ )
78
+
79
+
80
+ def _registry(roots: list[Path] | None) -> SkillRegistry:
81
+ candidates = roots or [r for r in DEFAULT_SKILL_ROOTS if r.exists()]
82
+ return SkillRegistry.discover(
83
+ candidates,
84
+ known_stages=[s.value for s in RunState],
85
+ known_tools=tool_names(),
86
+ )
87
+
88
+
89
+ def tool_names() -> list[str]:
90
+ """CLI subcommands: the names a skill's ``fa-tools`` may bind to."""
91
+ sub = next(
92
+ a for a in build_parser()._actions if isinstance(a, argparse._SubParsersAction)
93
+ )
94
+ return sorted(sub.choices)
95
+
96
+
97
+ def _print_json(value: Any) -> None:
98
+ print(json.dumps(value, indent=2, sort_keys=False, default=str))
99
+
100
+
101
+ def cmd_skills(args: argparse.Namespace) -> int:
102
+ registry = _registry(args.root)
103
+ if args.action == "catalog":
104
+ catalog = registry.catalog_xml()
105
+ print(catalog)
106
+ print(
107
+ f"<!-- {len(registry.skills)} skills, ~{len(catalog) // CHARS_PER_TOKEN} "
108
+ "tokens at level 1 -->"
109
+ )
110
+ return 0
111
+ failed = False
112
+ for result in registry.results:
113
+ status = "OK" if result.ok else "INVALID"
114
+ print(f"{status:8} {result.path}")
115
+ for error in result.errors:
116
+ print(f" error: {error}")
117
+ for warning in result.warnings:
118
+ print(f" warn: {warning}")
119
+ failed = failed or not result.ok
120
+ return 1 if failed else 0
121
+
122
+
123
+ def cmd_audit(args: argparse.Namespace) -> int:
124
+ report = audit_output_dir(
125
+ args.output_dir, load_policy(args.policy), as_of=args.as_of
126
+ )
127
+ print(report.to_markdown())
128
+ errors = [f for f in report.findings if f.severity == "error"]
129
+ return 1 if errors and args.strict else 0
130
+
131
+
132
+ def cmd_gate(args: argparse.Namespace) -> int:
133
+ policy = load_policy(args.policy)
134
+ if args.json:
135
+ report = gate_report(args.package, policy)
136
+ _print_json(report)
137
+ return 0 if report["pass"] else 1
138
+ failures = publish_gate(args.package, policy)
139
+ replay = replay_status(args.package, policy)
140
+ if replay is not None:
141
+ print(f"investable (recomputed): {len(replay.investable)} {replay.investable}")
142
+ print(
143
+ f"status replay: {replay.decision.status.value} "
144
+ f"({'; '.join(replay.decision.reasons)}); "
145
+ f"published: {replay.published}; Required inputs assumed grounded"
146
+ )
147
+ for line in replay_lines(replay):
148
+ print(line)
149
+ print(f"content hash: sha256:{package_digest(args.package)}")
150
+ for failure in failures:
151
+ print(f"GATE FAIL: {failure}")
152
+ print("publish gate:", "FAIL" if failures else "PASS")
153
+ return 1 if failures else 0
154
+
155
+
156
+ def cmd_replay(args: argparse.Namespace) -> int:
157
+ replays = replay_history(
158
+ args.output_dir, load_policy(args.policy), since=args.since, as_of=args.as_of
159
+ )
160
+ agree = [name for name, replay in replays if replay.agrees]
161
+ print("| package | session | replay | published | reasons |")
162
+ print("|---|---|---|---|---|")
163
+ for name, replay in replays:
164
+ if replay.agrees and not args.all:
165
+ continue
166
+ kind = replay.session.kind if replay.session else "-"
167
+ reasons = "; ".join(replay.decision.reasons).replace("|", "\\|")
168
+ print(
169
+ f"| {name} | {kind} | {replay.decision.status.value} | "
170
+ f"{replay.published} | {reasons} |"
171
+ )
172
+ print()
173
+ print(
174
+ f"{len(agree)} of {len(replays)} replayed packages agree with their "
175
+ "published status (Required inputs assumed grounded)."
176
+ )
177
+ return 1 if args.strict and len(agree) < len(replays) else 0
178
+
179
+
180
+ def cmd_schema(args: argparse.Namespace) -> int:
181
+ policy = load_policy(args.policy)
182
+ if args.json_schema or args.package is None:
183
+ _print_json(json_schema(policy))
184
+ return 0
185
+ path = args.package / PREDICTIONS_FILE
186
+ if not path.exists():
187
+ print(f"{path}: not found")
188
+ return 1
189
+ payload = json.loads(path.read_text(encoding="utf-8"))
190
+ declared = isinstance(payload, dict) and "schema_version" in payload
191
+ findings = validate_payload(payload, policy)
192
+ for finding in findings:
193
+ print(f"v1: {finding}")
194
+ label = "declares" if declared else "legacy ledger, does not declare"
195
+ print(f"{path}: {label} schema_version; {len(findings)} v1 finding(s)")
196
+ return 1 if findings else 0
197
+
198
+
199
+ def cmd_session(args: argparse.Namespace) -> int:
200
+ day = session(args.date)
201
+ print(f"{day.kind} {day.describe()}")
202
+ return 0
203
+
204
+
205
+ def cmd_reachability(args: argparse.Namespace) -> int:
206
+ reach = go_reachability(
207
+ load_policy(args.policy),
208
+ available_families=args.families,
209
+ ranked_names=args.ranked_names,
210
+ )
211
+ print(f"gating families: {', '.join(reach.gating_families) or 'none'}")
212
+ for blocker in reach.blockers:
213
+ print(f"BLOCKER: {blocker}")
214
+ print("GO", "reachable" if reach.reachable else "unreachable")
215
+ return 0 if reach.reachable else 1
216
+
217
+
218
+ def run_kernel(kind: str, request: dict[str, Any], policy: Any) -> Any:
219
+ """Shared by the CLI and the MCP server; raises ``KernelError``."""
220
+ if kind == "equity":
221
+ return [
222
+ equity_record(
223
+ request["run_date"],
224
+ request["model"],
225
+ EquityInputs.from_mapping(item),
226
+ policy,
227
+ )
228
+ for item in request["names"]
229
+ ]
230
+ if kind == "market":
231
+ return market_forecast_records(
232
+ request["run_date"],
233
+ request["model"],
234
+ request["regime"],
235
+ [MarketInputs.from_mapping(item) for item in request["etfs"]],
236
+ policy,
237
+ )
238
+ if kind == "portfolio":
239
+ return vars(
240
+ portfolio_feasibility(
241
+ [Holding.from_mapping(item) for item in request["holdings"]],
242
+ request["spy_closes"],
243
+ policy,
244
+ )
245
+ )
246
+ if kind == "score":
247
+ return score_request(request, policy)
248
+ if kind == "risk":
249
+ return vars(
250
+ price_risk(
251
+ request["closes"],
252
+ request["spy_closes"],
253
+ tlt_closes=request.get("tlt_closes"),
254
+ )
255
+ )
256
+ raise KernelError(f"unknown kernel {kind!r}")
257
+
258
+
259
+ def cmd_kernel(args: argparse.Namespace) -> int:
260
+ text = sys.stdin.read() if str(args.input) == "-" else args.input.read_text()
261
+ try:
262
+ _print_json(run_kernel(args.kind, json.loads(text), load_policy(args.policy)))
263
+ except (KernelError, KeyError, TypeError, ValueError) as exc:
264
+ print(f"KERNEL ERROR: {type(exc).__name__}: {exc}")
265
+ return 1
266
+ return 0
267
+
268
+
269
+ def cmd_mcp(args: argparse.Namespace) -> int:
270
+ from financial_agent.harness.mcp import serve
271
+
272
+ serve(sys.stdin, sys.stdout, output_dir=args.output_dir)
273
+ return 0
274
+
275
+
276
+ def cmd_run(args: argparse.Namespace) -> int:
277
+ try:
278
+ report = run(args.model, args.date, args.output_dir, default_handlers())
279
+ except RunLocked as exc:
280
+ print(f"LOCKED: {exc}")
281
+ return 2
282
+ print(report.lifecycle.transcript())
283
+ for failure in report.gate_failures:
284
+ print(f"GATE FAIL: {failure}")
285
+ if report.published:
286
+ print(f"run {report.run_id}: {report.lifecycle.status}")
287
+ return 0
288
+ print(f"run {report.run_id}: HALTED: {report.halt_reason}")
289
+ return 1
290
+
291
+
292
+ def cmd_manifest(args: argparse.Namespace) -> int:
293
+ text, failures = render_manifest_sections(args.package, load_policy(args.policy))
294
+ print(text)
295
+ return 1 if failures else 0
296
+
297
+
298
+ def cmd_hash(args: argparse.Namespace) -> int:
299
+ for name, digest in artifact_digests(args.package).items():
300
+ print(f"{digest} {name}")
301
+ print(f"content hash: sha256:{package_digest(args.package)}")
302
+ return 0
303
+
304
+
305
+ def cmd_clones(args: argparse.Namespace) -> int:
306
+ clones = find_clones(args.output_dir, since=args.since)
307
+ for (name, digest), packages in sorted(clones.items()):
308
+ print(f"{name} {digest[:12]}: {', '.join(packages)}")
309
+ print(f"{len(clones)} artifact(s) shared between packages")
310
+ return 1 if clones and args.strict else 0
311
+
312
+
313
+ def cmd_policy_check(args: argparse.Namespace) -> int:
314
+ verdict = check_mutation(
315
+ load_policy(args.old),
316
+ load_policy(args.new),
317
+ MutationEvidence(
318
+ settled_n=args.settled_n,
319
+ eff_n=args.eff_n,
320
+ human_approved=args.human_approved,
321
+ ),
322
+ )
323
+ print(f"decision: {verdict.decision}")
324
+ for key in verdict.changed:
325
+ print(f" changed: {key}")
326
+ for reason in verdict.reasons:
327
+ print(f" reason: {reason}")
328
+ return 0 if verdict.decision in {"NO_CHANGE", "ACCEPTABLE"} else 1
329
+
330
+
331
+ def build_parser() -> argparse.ArgumentParser:
332
+ parser = argparse.ArgumentParser(prog="python -m financial_agent.harness")
333
+ sub = parser.add_subparsers(dest="command", required=True)
334
+
335
+ skills = sub.add_parser("skills", help="validate skills or print the catalog")
336
+ skills.add_argument("action", choices=["validate", "catalog"])
337
+ skills.add_argument("--root", type=Path, action="append")
338
+ skills.set_defaults(func=cmd_skills)
339
+
340
+ audit = sub.add_parser("audit", help="conformance audit of prediction ledgers")
341
+ audit.add_argument("--output-dir", type=Path, default=Path("agents/equity/output"))
342
+ audit.add_argument("--as-of", help="only packages dated on or before YYYY-MM-DD")
343
+ audit.add_argument("--policy", type=Path)
344
+ audit.add_argument("--strict", action="store_true", help="exit 1 on any error")
345
+ audit.set_defaults(func=cmd_audit)
346
+
347
+ gate = sub.add_parser("gate", help="publish gate + status replay for a package")
348
+ gate.add_argument("package", type=Path)
349
+ gate.add_argument("--policy", type=Path)
350
+ gate.add_argument("--json", action="store_true", help="machine-readable report")
351
+ gate.set_defaults(func=cmd_gate)
352
+
353
+ replay = sub.add_parser("replay", help="status replay across the run history")
354
+ replay.add_argument("--output-dir", type=Path, default=Path("agents/equity/output"))
355
+ replay.add_argument("--since", help="only packages dated on or after YYYY-MM-DD")
356
+ replay.add_argument("--as-of", help="only packages dated on or before YYYY-MM-DD")
357
+ replay.add_argument("--all", action="store_true", help="list agreeing rows too")
358
+ replay.add_argument("--policy", type=Path)
359
+ replay.add_argument("--strict", action="store_true", help="exit 1 on a mismatch")
360
+ replay.set_defaults(func=cmd_replay)
361
+
362
+ schema = sub.add_parser("schema", help="validate a ledger against schema v1")
363
+ schema.add_argument("package", type=Path, nargs="?")
364
+ schema.add_argument("--json-schema", action="store_true", help="print the schema")
365
+ schema.add_argument("--policy", type=Path)
366
+ schema.set_defaults(func=cmd_schema)
367
+
368
+ day = sub.add_parser("session", help="NYSE session type for a run date")
369
+ day.add_argument("date", help="YYYY-MM-DD")
370
+ day.set_defaults(func=cmd_session)
371
+
372
+ reach = sub.add_parser("reachability", help="structural GO blockers")
373
+ reach.add_argument("--families", nargs="*", choices=FAMILIES)
374
+ reach.add_argument("--ranked-names", type=int)
375
+ reach.add_argument("--policy", type=Path)
376
+ reach.set_defaults(func=cmd_reachability)
377
+
378
+ kernel = sub.add_parser("kernel", help="compute records or risk from JSON input")
379
+ kernel.add_argument(
380
+ "kind", choices=["equity", "market", "risk", "portfolio", "score"]
381
+ )
382
+ kernel.add_argument("--input", type=Path, required=True, help="JSON file or -")
383
+ kernel.add_argument("--policy", type=Path)
384
+ kernel.set_defaults(func=cmd_kernel)
385
+
386
+ mcp = sub.add_parser("mcp", help="serve the read-only tools over MCP (stdio)")
387
+ mcp.add_argument("--output-dir", type=Path, default=Path("agents/equity/output"))
388
+ mcp.set_defaults(func=cmd_mcp)
389
+
390
+ run_parser = sub.add_parser("run", help="drive one run through the lifecycle")
391
+ run_parser.add_argument("--model", required=True)
392
+ run_parser.add_argument("--date", required=True, help="YYYY-MM-DD")
393
+ run_parser.add_argument(
394
+ "--output-dir", type=Path, default=Path("agents/equity/output")
395
+ )
396
+ run_parser.set_defaults(func=cmd_run)
397
+
398
+ manifest = sub.add_parser("manifest", help="generated manifest sections")
399
+ manifest.add_argument("package", type=Path)
400
+ manifest.add_argument("--policy", type=Path)
401
+ manifest.set_defaults(func=cmd_manifest)
402
+
403
+ digest = sub.add_parser("hash", help="per-artifact and package content hashes")
404
+ digest.add_argument("package", type=Path)
405
+ digest.set_defaults(func=cmd_hash)
406
+
407
+ clones = sub.add_parser("clones", help="artifacts shared between packages")
408
+ clones.add_argument("--output-dir", type=Path, default=Path("agents/equity/output"))
409
+ clones.add_argument("--since", help="only packages dated on or after YYYY-MM-DD")
410
+ clones.add_argument("--strict", action="store_true", help="exit 1 on any clone")
411
+ clones.set_defaults(func=cmd_clones)
412
+
413
+ check = sub.add_parser("policy-check", help="govern a proposed policy change")
414
+ check.add_argument("old", type=Path)
415
+ check.add_argument("new", type=Path)
416
+ check.add_argument("--settled-n", type=int, default=0)
417
+ check.add_argument("--eff-n", type=int, default=0)
418
+ check.add_argument("--human-approved", action="store_true")
419
+ check.set_defaults(func=cmd_policy_check)
420
+ return parser
421
+
422
+
423
+ def main(argv: list[str] | None = None) -> int:
424
+ args = build_parser().parse_args(argv)
425
+ result: int = args.func(args)
426
+ return result
427
+
428
+
429
+ if __name__ == "__main__":
430
+ sys.exit(main())
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env python3
2
+ """Shared helpers for the SHADOW diagnostic scripts.
3
+
4
+ Used by `fundamental_diagnostics.py` and `sentiment_diagnostics.py`: fetch
5
+ plumbing (SSL context, JSON fetch, ticker-list parsing) and the
6
+ cross-sectional factor-scoring pipeline that turns per-name raw signals into
7
+ a family z-score, per rules.md's Family Aggregation rule -- convert
8
+ sourceable metrics into cross-sectional z-scores after winsorizing extreme
9
+ observations at the 5th and 95th percentiles, then equal-weight the
10
+ available signal z-scores.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as dt
16
+ import json
17
+ import ssl
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ SSL_CONTEXT: ssl.SSLContext | None = None
23
+
24
+
25
+ def https_context() -> ssl.SSLContext | None:
26
+ global SSL_CONTEXT
27
+ if SSL_CONTEXT is not None:
28
+ return SSL_CONTEXT
29
+ try:
30
+ import certifi # type: ignore
31
+
32
+ SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
33
+ except Exception:
34
+ SSL_CONTEXT = ssl.create_default_context()
35
+ return SSL_CONTEXT
36
+
37
+
38
+ def fetch_json(url: str, headers: dict[str, str], timeout: int = 20) -> Any:
39
+ req = urllib.request.Request(url, headers=headers)
40
+ with urllib.request.urlopen(req, timeout=timeout, context=https_context()) as resp:
41
+ result: Any = json.loads(resp.read())
42
+ return result
43
+
44
+
45
+ def read_tickers(tickers: list[str], tickers_file: Path | None) -> list[str]:
46
+ """Merge --tickers and --tickers-file into one de-duplicated, order-
47
+ preserving, upper-cased ticker list."""
48
+ out_tickers = list(tickers)
49
+ if tickers_file:
50
+ text = tickers_file.read_text()
51
+ out_tickers.extend(
52
+ t.strip().upper() for t in text.replace(",", "\n").splitlines() if t.strip()
53
+ )
54
+ seen = set()
55
+ deduped = []
56
+ for t in out_tickers:
57
+ if t not in seen:
58
+ seen.add(t)
59
+ deduped.append(t)
60
+ return deduped
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Cross-sectional z-scoring
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def winsorize(
69
+ values: list[float], lo_pct: float = 0.05, hi_pct: float = 0.95
70
+ ) -> list[float]:
71
+ if len(values) < 2:
72
+ return values
73
+ ordered = sorted(values)
74
+ lo = ordered[max(0, int(len(ordered) * lo_pct))]
75
+ hi = ordered[min(len(ordered) - 1, int(len(ordered) * hi_pct))]
76
+ return [min(max(v, lo), hi) for v in values]
77
+
78
+
79
+ def zscore_cross_sectionally(
80
+ per_ticker_values: dict[str, dict[str, float | None]],
81
+ signal_names: tuple[str, ...],
82
+ lower_is_better: set[str],
83
+ ) -> dict[str, dict[str, float | None]]:
84
+ """z-scores each named signal across the tickers that carry it; polarity
85
+ is normalized per `lower_is_better` so every returned z-score is
86
+ "higher is better", regardless of the raw signal's own polarity.
87
+ """
88
+ z: dict[str, dict[str, float | None]] = {t: {} for t in per_ticker_values}
89
+ for signal in signal_names:
90
+ tickers = [t for t, v in per_ticker_values.items() if v.get(signal) is not None]
91
+ if len(tickers) < 2:
92
+ for t in per_ticker_values:
93
+ z[t][signal] = None
94
+ continue
95
+ raw = [per_ticker_values[t][signal] for t in tickers] # type: ignore[misc]
96
+ winsorized = winsorize(raw) # type: ignore[arg-type]
97
+ mean = sum(winsorized) / len(winsorized)
98
+ variance = sum((v - mean) ** 2 for v in winsorized) / len(winsorized)
99
+ stdev = variance**0.5
100
+ for t in per_ticker_values:
101
+ if t not in tickers:
102
+ z[t][signal] = None
103
+ continue
104
+ raw_v: float = per_ticker_values[t][signal] # type: ignore[assignment]
105
+ wv = min(max(raw_v, min(winsorized)), max(winsorized))
106
+ score = 0.0 if stdev == 0 else (wv - mean) / stdev
107
+ z[t][signal] = -score if signal in lower_is_better else score
108
+ return z
109
+
110
+
111
+ def composite_z(
112
+ z_signals: dict[str, float | None], min_signals: int = 2
113
+ ) -> tuple[float | None, int]:
114
+ """Equal-weight average of available signal z-scores, per rules.md's
115
+ "equal-weight available sourceable metric z-scores" rule. `composite` is
116
+ None when fewer than `min_signals` are available (mirrors rules.md's
117
+ "fewer than two sourceable metrics" -> family UNAVAILABLE rule)."""
118
+ available = [v for v in z_signals.values() if v is not None]
119
+ if len(available) < min_signals:
120
+ return None, len(available)
121
+ return sum(available) / len(available), len(available)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # SHADOW output envelope (shared shape between both diagnostic scripts)
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def build_shadow_output(
130
+ tickers: list[str],
131
+ per_ticker_values: dict[str, dict[str, float | None]],
132
+ per_ticker_lineage: dict[str, Any],
133
+ fetch_failures: dict[str, str],
134
+ signal_names: tuple[str, ...],
135
+ lower_is_better: set[str],
136
+ composite_key: str,
137
+ family_label: str,
138
+ plan_path: str,
139
+ ) -> dict[str, Any]:
140
+ """Assemble the standard SHADOW diagnostic manifest: z-score every
141
+ signal, compute the family composite under `composite_key`, and wrap it
142
+ all in the disclosure envelope both scripts publish. Every record and
143
+ the envelope itself carry `"gating_status": "SHADOW"` -- this output is
144
+ never wired into rules.md's Evidence Thresholds or Adj Score.
145
+ """
146
+ z_scores = zscore_cross_sectionally(
147
+ per_ticker_values, signal_names, lower_is_better
148
+ )
149
+
150
+ records = {}
151
+ for ticker in per_ticker_values:
152
+ composite, n_available = composite_z(z_scores[ticker])
153
+ records[ticker] = {
154
+ "gating_status": "SHADOW",
155
+ "raw_signals": per_ticker_values[ticker],
156
+ "z_signals": z_scores[ticker],
157
+ composite_key: composite,
158
+ "n_signals_available": n_available,
159
+ "sourceable": n_available >= 2,
160
+ "lineage": per_ticker_lineage[ticker],
161
+ }
162
+
163
+ return {
164
+ "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
165
+ "gating_status": "SHADOW",
166
+ "note": f"Diagnostic only per {plan_path} Phase 1 -- {family_label} is NOT "
167
+ "wired into the 3-of-4 evidence-threshold gate or Adj Score until a "
168
+ "future run explicitly promotes it per the Evolution Policy "
169
+ "(HUMAN_REVIEW + logged shadow run).",
170
+ "universe_sourceable_pct": (
171
+ round(100 * len(per_ticker_values) / len(tickers), 1) if tickers else 0.0
172
+ ),
173
+ "fetch_failures": fetch_failures,
174
+ "records": records,
175
+ }