s-ttp commited on
Commit
da2ea0f
·
verified ·
1 Parent(s): e1bc5dd

ML models: walk-forward-judged panel rank, plain-language ML disclosure in report and progress

Browse files
analyst.py CHANGED
@@ -63,7 +63,7 @@ SEAT_RE = re.compile(r"^\s+(?:\w+ )?\((Neutral|Bear|Bull|PM|fallback)\)\s+(?:[-+
63
  # The framework's inputs for this company, shown while the storytellers work ("under the hood"). Only these
64
  # fields cross, each type-checked and bounded; values, ratings and storyteller views never do.
65
  FACT_FIELDS = {"industry": str, "country": str, "beta": float, "beta_basis": str, "erp": float, "erp_month": str,
66
- "crp": float, "stage": str, "segments": list, "segment_build": bool, "consensus": bool}
67
 
68
 
69
  def _facts(payload: str) -> dict:
 
63
  # The framework's inputs for this company, shown while the storytellers work ("under the hood"). Only these
64
  # fields cross, each type-checked and bounded; values, ratings and storyteller views never do.
65
  FACT_FIELDS = {"industry": str, "country": str, "beta": float, "beta_basis": str, "erp": float, "erp_month": str,
66
+ "crp": float, "stage": str, "segments": list, "segment_build": bool, "consensus": bool, "ml_rank": str}
67
 
68
 
69
  def _facts(payload: str) -> dict:
engine/brief.py CHANGED
@@ -42,7 +42,9 @@ def _quant_block(s):
42
  else f"You may describe it only as '{pm.get('allowed_claim_strength')}'.")]
43
  lines.append(f"Cross-sectional panel rank: {pr.get('state')} ({pr.get('basis')}). "
44
  + ("Not usable as evidence: its figures are withheld." if not pr.get("persona_visibility")
45
- else "Usable only as weak evidence."))
 
 
46
  return "\n".join(lines)
47
 
48
 
 
42
  else f"You may describe it only as '{pm.get('allowed_claim_strength')}'.")]
43
  lines.append(f"Cross-sectional panel rank: {pr.get('state')} ({pr.get('basis')}). "
44
  + ("Not usable as evidence: its figures are withheld." if not pr.get("persona_visibility")
45
+ else f"Usable only as {pr.get('allowed_claim_strength')}: a machine-learning model ranks the stock above "
46
+ "[[ML_RANK_SHARE]] of the [[ML_RANK_UNIVERSE]] largest US companies (decile [[ML_RANK_DECILE]]) for "
47
+ "12-month return relative to them. It is a ranking, not a price target."))
48
  return "\n".join(lines)
49
 
50
 
engine/catalog.py CHANGED
@@ -131,6 +131,11 @@ CLAIMS = [
131
  ("forecast", "consensus")),
132
  ClaimDef("LIFECYCLE_FORWARD_GROWTH", "valuation.lifecycle.forward_revenue_growth", PCT_SIGNED, "forward revenue growth used for staging",
133
  ("trailing",)),
 
 
 
 
 
134
  ClaimDef("DMD_INDUSTRY", "valuation.damodaran_inputs.industry", TEXT, "industry (Damodaran classification)"),
135
  ClaimDef("DMD_UNLEVERED_BETA", "valuation.damodaran_inputs.unlevered_beta", RATIO, "industry unlevered beta (cash-corrected)"),
136
  ClaimDef("DMD_BETA", "valuation.damodaran_inputs.beta", RATIO, "bottom-up beta (re-levered at the company's D/E)"),
 
131
  ("forecast", "consensus")),
132
  ClaimDef("LIFECYCLE_FORWARD_GROWTH", "valuation.lifecycle.forward_revenue_growth", PCT_SIGNED, "forward revenue growth used for staging",
133
  ("trailing",)),
134
+ ClaimDef("ML_RANK_SHARE", "quant.panel_rank.figures.rank_share", PCT, "share of ranked companies below this one"),
135
+ ClaimDef("ML_RANK_DECILE", "quant.panel_rank.figures.decile", COUNT, "machine-learning rank decile"),
136
+ ClaimDef("ML_RANK_UNIVERSE", "quant.panel_rank.figures.n_universe", COUNT, "companies ranked"),
137
+ ClaimDef("ML_RANK_IC", "quant.panel_rank.reliability.rank_ic", RATIO, "walk-forward rank correlation"),
138
+ ClaimDef("ML_RANK_POSITIVE", "quant.panel_rank.reliability.ic_positive_share", PCT, "share of test months the rank was right-ordered"),
139
  ClaimDef("DMD_INDUSTRY", "valuation.damodaran_inputs.industry", TEXT, "industry (Damodaran classification)"),
140
  ClaimDef("DMD_UNLEVERED_BETA", "valuation.damodaran_inputs.unlevered_beta", RATIO, "industry unlevered beta (cash-corrected)"),
141
  ClaimDef("DMD_BETA", "valuation.damodaran_inputs.beta", RATIO, "bottom-up beta (re-levered at the company's D/E)"),
engine/policy.py CHANGED
@@ -400,6 +400,28 @@ def panel_reliability(meta=None):
400
  if ic is None or sharpe is None:
401
  return reliability_record("UNAVAILABLE", basis="the panel snapshot carries no walk-forward validation metadata",
402
  extra={"model": "cross-sectional panel-ML rank"})
403
- state = "VALIDATED" if ic >= 0.10 and sharpe >= 0.8 and n >= 24 else ("LIMITED" if ic >= 0.02 and n >= 12 else "UNRELIABLE")
404
- return reliability_record(state, basis=f"walk-forward cross-sectional rank-IC {ic:.2f}, decile Sharpe {sharpe:.1f} over {n} periods",
405
- extra={"model": "cross-sectional panel-ML rank", "rank_ic": ic, "decile_sharpe": sharpe, "n_periods": n})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  if ic is None or sharpe is None:
401
  return reliability_record("UNAVAILABLE", basis="the panel snapshot carries no walk-forward validation metadata",
402
  extra={"model": "cross-sectional panel-ML rank"})
403
+ # 12-month windows of consecutive monthly cross-sections overlap: significance is judged on the
404
+ # non-overlapping count, and the latest test block must not have failed (today's regime is what a
405
+ # live analysis relies on; every approach went negative in 2024-25 in the prediction backtest)
406
+ t, n_ind = _f(m.get("ic_t")), _f(m.get("n_independent")) or n / 12.0
407
+ folds = m.get("folds") or []
408
+ recent = _f((folds[-1] if folds else {}).get("rank_ic"))
409
+ recent_ok = recent is not None and recent >= 0
410
+ if ic >= 0.10 and sharpe >= 0.8 and (t or 0) >= 2.0 and n_ind >= 8 and recent is not None and recent > 0:
411
+ state = "VALIDATED"
412
+ elif ic >= 0.02 and (t or 0) >= 1.0 and n_ind >= 5 and recent_ok:
413
+ state = "LIMITED"
414
+ else:
415
+ state = "UNRELIABLE"
416
+ why = ("" if state != "UNRELIABLE" else
417
+ "; the latest test block was negative" if (recent is not None and recent < 0) else
418
+ "; too weak or too few independent periods")
419
+ basis = (f"walk-forward rank correlation {ic:+.3f} (t {t if t is not None else float('nan'):.1f} over {n_ind:.1f} independent years), "
420
+ f"positive in {(_f(m.get('ic_positive_share')) or 0) * 100:.0f}% of months, decile Sharpe {sharpe:.2f}"
421
+ + (f", latest test block {(folds[-1] or {}).get('test')} {recent:+.3f}" if recent is not None else "") + why)
422
+ return reliability_record(state, basis=basis,
423
+ extra={"model": "cross-sectional panel-ML rank", "rank_ic": ic, "decile_sharpe": sharpe, "n_periods": n,
424
+ "ic_t": t, "n_independent": n_ind, "recent_block_ic": recent,
425
+ "recent_block": (folds[-1] if folds else {}).get("test"),
426
+ "ic_positive_share": _f(m.get("ic_positive_share")),
427
+ "failed_recently": bool(recent is not None and recent < 0)})
engine/predict_yearly.py CHANGED
@@ -7582,7 +7582,8 @@ Examples:
7582
  "stage": _CRUCIBLE_STORY_STAGE,
7583
  "segments": [g.get("name") for g in (_sbp.get("segments") or [])] if _sbp.get("filed") else [],
7584
  "segment_build": bool(_sbp.get("segment_build_required")),
7585
- "consensus": bool(_eec.get("current_year_revenue"))}
 
7586
  print("@@progress story-start " + json.dumps(_facts, default=str), flush=True)
7587
  story_val_result = _sd.story_value(_ctx, _story_call, _story_call2, _story_call3, verbose=args.verbose)
7588
  if not story_val_result:
 
7582
  "stage": _CRUCIBLE_STORY_STAGE,
7583
  "segments": [g.get("name") for g in (_sbp.get("segments") or [])] if _sbp.get("filed") else [],
7584
  "segment_build": bool(_sbp.get("segment_build_required")),
7585
+ "consensus": bool(_eec.get("current_year_revenue")),
7586
+ "ml_rank": (_panel_state(ticker)[0] or {}).get("state")}
7587
  print("@@progress story-start " + json.dumps(_facts, default=str), flush=True)
7588
  story_val_result = _sd.story_value(_ctx, _story_call, _story_call2, _story_call3, verbose=args.verbose)
7589
  if not story_val_result:
engine/report_model.py CHANGED
@@ -412,6 +412,7 @@ def build_user_report(s, cat, prose, limitations):
412
  exc = [x.get("name") for x in (s.blend.get("excluded_sources") or ()) if x.get("name")]
413
  if exc:
414
  B.append(_para("blend", r.t("Carrying no weight in this analysis: " + ", ".join(exc) + ".")))
 
415
  B.append(_para("blend", r.t("The headline is the share-weighted sum of these views, a price return that excludes dividends: "),
416
  r.c("HEADLINE_RETURN"), r.t(".")))
417
 
@@ -435,6 +436,29 @@ def build_user_report(s, cat, prose, limitations):
435
  return UserReport(s.identity.ticker, s.identity.get("company_name"), s.identity.get("as_of"), s.version, B, claims, limitations)
436
 
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  # ═══════════════════════════════════════════════════════════════════════════════════════
439
  # serialisation: both formats walk the same blocks
440
  # ═══════════════════════════════════════════════════════════════════════════════════════
 
412
  exc = [x.get("name") for x in (s.blend.get("excluded_sources") or ()) if x.get("name")]
413
  if exc:
414
  B.append(_para("blend", r.t("Carrying no weight in this analysis: " + ", ".join(exc) + ".")))
415
+ B += _ml_paragraphs(s, r, cat)
416
  B.append(_para("blend", r.t("The headline is the share-weighted sum of these views, a price return that excludes dividends: "),
417
  r.c("HEADLINE_RETURN"), r.t(".")))
418
 
 
436
  return UserReport(s.identity.ticker, s.identity.get("company_name"), s.identity.get("as_of"), s.version, B, claims, limitations)
437
 
438
 
439
+ def _ml_paragraphs(s, r, cat):
440
+ """Machine learning, in plain words. Two models are checked on every run; each counts only as far
441
+ as its out-of-sample record allows. A model that is not admitted is described without its figures."""
442
+ pm = (s.quant.get("price_model") or {}).get("reliability") or {}
443
+ pr = (s.quant.get("panel_rank") or {}).get("reliability") or {}
444
+ out = []
445
+ if pr.get("persona_visibility") and "ML_RANK_SHARE" in cat:
446
+ out.append(_para("blend", r.t("Machine learning: a model trained on twelve years of data ranks the stock above "), r.c("ML_RANK_SHARE"),
447
+ r.t(" of the "), r.c("ML_RANK_UNIVERSE"), r.t(" largest US companies (decile "), r.c("ML_RANK_DECILE"),
448
+ r.t(") for 12-month return relative to them. In walk-forward tests its rank correlation was "), r.c("ML_RANK_IC"),
449
+ r.t(", right-ordered in "), r.c("ML_RANK_POSITIVE"),
450
+ r.t(" of months. The analysts may cite it as weak evidence; it does not enter the headline return.")))
451
+ elif pr.get("state") == "UNRELIABLE":
452
+ why = ("it failed in the most recent test period" if pr.get("failed_recently") else "its out-of-sample record is too weak")
453
+ out.append(_para("blend", r.t("Machine learning: a model that ranks each stock against the largest US companies was checked "
454
+ f"and not used, because {why}.")))
455
+ if pm.get("state") in ("UNRELIABLE", "UNAVAILABLE"):
456
+ out.append(_para("blend", r.t("A single-stock machine-learning price model was also checked and not used: "
457
+ + ("its out-of-sample direction calls were no better than chance."
458
+ if pm.get("state") == "UNRELIABLE" else "there was not enough history to test it."))))
459
+ return out
460
+
461
+
462
  # ═══════════════════════════════════════════════════════════════════════════════════════
463
  # serialisation: both formats walk the same blocks
464
  # ═══════════════════════════════════════════════════════════════════════════════════════
engine/state_builder.py CHANGED
@@ -401,8 +401,10 @@ def build_forecast_stage(state, *, pred_fund, fair_val, story, engine_run, engin
401
  "rank_ic": _f((ml or {}).get("backtest_rank_ic")),
402
  "mae": _f((ml or {}).get("backtest_mae")),
403
  "n_oos_eff": _f((ml or {}).get("n_oos_effective"))}}
 
 
404
  s.quant.panel_rank = {"reliability": dict(panel_rel or {}),
405
- "figures": (dict(panel_row) if (panel_row and (panel_rel or {}).get("persona_visibility")) else None),
406
  "diagnostics_only": dict(panel_row) if panel_row else None}
407
 
408
  # macro, technical, sentiment
 
401
  "rank_ic": _f((ml or {}).get("backtest_rank_ic")),
402
  "mae": _f((ml or {}).get("backtest_mae")),
403
  "n_oos_eff": _f((ml or {}).get("n_oos_effective"))}}
404
+ _pfig = (dict(panel_row, rank_share=(_f(panel_row.get("xs_rank_pct")) or 0.0) / 100.0)
405
+ if (panel_row and (panel_rel or {}).get("persona_visibility")) else None)
406
  s.quant.panel_rank = {"reliability": dict(panel_rel or {}),
407
+ "figures": _pfig,
408
  "diagnostics_only": dict(panel_row) if panel_row else None}
409
 
410
  # macro, technical, sentiment
engine/tests/test_pipeline.py CHANGED
@@ -161,7 +161,44 @@ def test_persona_brief_carries_tokens_not_quant_figures():
161
 
162
  def test_panel_reliability_requires_metadata():
163
  assert pol.panel_reliability(None)["state"] == "UNAVAILABLE"
164
- assert pol.panel_reliability({"rank_ic": 0.05, "decile_sharpe": 0.6, "n_periods": 30})["state"] == "LIMITED"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
 
167
  # ── storytellers ─────────────────────────────────────────────────────────────────────
 
161
 
162
  def test_panel_reliability_requires_metadata():
163
  assert pol.panel_reliability(None)["state"] == "UNAVAILABLE"
164
+ # a record without a significance test or fold history cannot be admitted
165
+ assert pol.panel_reliability({"rank_ic": 0.05, "decile_sharpe": 0.6, "n_periods": 30})["state"] == "UNRELIABLE"
166
+
167
+
168
+ def test_panel_reliability_judges_the_walk_forward_record():
169
+ """The shipped model's real record (2026-09-23 build): pooled rank IC +0.038, t 1.4 over 10.5 independent
170
+ years, but the latest block (2024-2025) was negative, so it is not admitted for a live analysis."""
171
+ real = {"rank_ic": 0.0383, "ic_t": 1.39, "ic_positive_share": 0.698, "decile_sharpe": 0.48, "n_periods": 126,
172
+ "n_independent": 10.5, "folds": [{"test": "2016-2017", "rank_ic": 0.1007}, {"test": "2022-2023", "rank_ic": 0.0393},
173
+ {"test": "2024-2025", "rank_ic": -0.0555}]}
174
+ r = pol.panel_reliability(real)
175
+ assert r["state"] == "UNRELIABLE" and r["failed_recently"] and "latest test block 2024-2025 -0.056" in r["basis"]
176
+ ok = dict(real, folds=real["folds"][:-1] + [{"test": "2024-2025", "rank_ic": 0.012}])
177
+ assert pol.panel_reliability(ok)["state"] == "LIMITED"
178
+ strong = dict(ok, rank_ic=0.12, ic_t=2.4, decile_sharpe=0.9)
179
+ assert pol.panel_reliability(strong)["state"] == "VALIDATED"
180
+ assert pol.panel_reliability(dict(ok, ic_t=0.7))["state"] == "UNRELIABLE" # not significant
181
+
182
+
183
+ def test_machine_learning_is_described_in_plain_words(good):
184
+ """Admitted: the rank and its record as audited claims. Not admitted: one sentence, no figures."""
185
+ import policy as pol2
186
+ d = F.all_data()
187
+ run, payload, failed = F.run_pipeline(data=d)
188
+ md = payload["markdown"]
189
+ assert failed is None and "single-stock machine-learning price model was also checked and not used" in md
190
+ rel = pol2.panel_reliability({"rank_ic": 0.05, "ic_t": 1.6, "ic_positive_share": 0.7, "decile_sharpe": 0.6, "n_periods": 120,
191
+ "n_independent": 10, "folds": [{"test": "2024-2025", "rank_ic": 0.02}]})
192
+ row = {"decile": 8, "xs_rank_pct": 74.0, "pred_excess_12m": 0.03, "as_of": "2026-09-18", "n_universe": 936}
193
+ run, payload, failed = F.run_pipeline(data=d, forecast_over={"panel_rel": rel, "panel_row": row})
194
+ md = payload["markdown"]
195
+ assert failed is None, failed
196
+ assert "ranks the stock above 74.0% of the 936 largest US companies (decile 8)" in md and "rank correlation was 0.05" in md
197
+ bad = pol2.panel_reliability({"rank_ic": 0.04, "ic_t": 1.4, "decile_sharpe": 0.5, "n_periods": 126, "n_independent": 10.5,
198
+ "folds": [{"test": "2024-2025", "rank_ic": -0.05}]})
199
+ run, payload, failed = F.run_pipeline(data=d, forecast_over={"panel_rel": bad, "panel_row": row})
200
+ assert failed is None and "checked and not used, because it failed in the most recent test period" in payload["markdown"]
201
+ assert "74.0%" not in payload["markdown"] # an excluded model's figures never appear
202
 
203
 
204
  # ── storytellers ─────────────────────────────────────────────────────────────────────
static/app.js CHANGED
@@ -74,6 +74,8 @@ function valuationHood(f) {
74
  ? `${f.segments.join(", ")} have different economics, so each storyteller must value them one by one.`
75
  : `${f.segments.join(", ")} are valued together; their economics are close enough.`]);
76
  if (f.consensus) it.push(["Near term", "The first two years lean on analysts' revenue estimates; after that, growth fades toward the economy's."]);
 
 
77
  it.push(["Storytellers", "Three different AI models are each writing a story at the same time: market size, share, margins and reinvestment."]);
78
  it.push(["Cash flows", "Each story becomes ten years of cash flow, discounted at today's rate for five years and then at a rate drifting toward a mature company's."]);
79
  it.push(["The value", "The middle of the three values is adopted, so no single storyteller sets it."]);
 
74
  ? `${f.segments.join(", ")} have different economics, so each storyteller must value them one by one.`
75
  : `${f.segments.join(", ")} are valued together; their economics are close enough.`]);
76
  if (f.consensus) it.push(["Near term", "The first two years lean on analysts' revenue estimates; after that, growth fades toward the economy's."]);
77
+ if (f.ml_rank === "LIMITED" || f.ml_rank === "VALIDATED") it.push(["Machine learning", "A model trained on twelve years of data ranks the stock against the largest US companies. Its test record makes it supporting evidence for the analysts; it does not set the rating."]);
78
+ else if (f.ml_rank === "UNRELIABLE") it.push(["Machine learning", "A model that ranks the stock against the largest US companies was checked and switched off for now: its recent test record is too weak to rely on."]);
79
  it.push(["Storytellers", "Three different AI models are each writing a story at the same time: market size, share, margins and reinvestment."]);
80
  it.push(["Cash flows", "Each story becomes ten years of cash flow, discounted at today's rate for five years and then at a rate drifting toward a mature company's."]);
81
  it.push(["The value", "The middle of the three values is adopted, so no single storyteller sets it."]);
static/index.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
  <meta name="theme-color" content="#0c0a0d">
7
  <title>Crucible</title>
8
- <link rel="stylesheet" href="/static/app.css?v=20260923x">
9
  </head>
10
  <body>
11
  <header>
@@ -89,6 +89,6 @@
89
  <span class="mini-fill" id="mini-fill"></span><span class="mini-text" id="mini-text">Starting…</span>
90
  </button>
91
 
92
- <script src="/static/app.js?v=20260923x"></script>
93
  </body>
94
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
  <meta name="theme-color" content="#0c0a0d">
7
  <title>Crucible</title>
8
+ <link rel="stylesheet" href="/static/app.css?v=20260923y">
9
  </head>
10
  <body>
11
  <header>
 
89
  <span class="mini-fill" id="mini-fill"></span><span class="mini-text" id="mini-text">Starting…</span>
90
  </button>
91
 
92
+ <script src="/static/app.js?v=20260923y"></script>
93
  </body>
94
  </html>