alpha-engine-lib 0.32.0__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 (40) hide show
  1. alpha_engine_lib/__init__.py +3 -0
  2. alpha_engine_lib/agent_schemas.py +663 -0
  3. alpha_engine_lib/alerts.py +576 -0
  4. alpha_engine_lib/arcticdb.py +340 -0
  5. alpha_engine_lib/collector_results.py +69 -0
  6. alpha_engine_lib/cost.py +665 -0
  7. alpha_engine_lib/dates.py +273 -0
  8. alpha_engine_lib/decision_capture.py +462 -0
  9. alpha_engine_lib/ec2_spot.py +363 -0
  10. alpha_engine_lib/email_sender.py +206 -0
  11. alpha_engine_lib/eval_artifacts.py +361 -0
  12. alpha_engine_lib/logging.py +303 -0
  13. alpha_engine_lib/model_pricing.yaml +73 -0
  14. alpha_engine_lib/pillars.py +756 -0
  15. alpha_engine_lib/pipeline_status/__init__.py +70 -0
  16. alpha_engine_lib/pipeline_status/read.py +541 -0
  17. alpha_engine_lib/pipeline_status/registry.py +368 -0
  18. alpha_engine_lib/pipeline_status/templates.py +120 -0
  19. alpha_engine_lib/preflight.py +444 -0
  20. alpha_engine_lib/rag/__init__.py +39 -0
  21. alpha_engine_lib/rag/db.py +96 -0
  22. alpha_engine_lib/rag/embeddings.py +63 -0
  23. alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
  24. alpha_engine_lib/rag/rerank.py +377 -0
  25. alpha_engine_lib/rag/retrieval.py +465 -0
  26. alpha_engine_lib/rag/schema.sql +65 -0
  27. alpha_engine_lib/reconcile.py +203 -0
  28. alpha_engine_lib/secrets.py +186 -0
  29. alpha_engine_lib/sources/__init__.py +35 -0
  30. alpha_engine_lib/sources/protocols.py +227 -0
  31. alpha_engine_lib/ssm_log_capture.py +274 -0
  32. alpha_engine_lib/telegram.py +165 -0
  33. alpha_engine_lib/trading_calendar.py +236 -0
  34. alpha_engine_lib/transparency.py +746 -0
  35. alpha_engine_lib/transparency_inventory.yaml +260 -0
  36. alpha_engine_lib/universe.py +83 -0
  37. alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
  38. alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
  39. alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
  40. alpha_engine_lib-0.32.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,756 @@
1
+ """Pillar-decomposed attractiveness scoring — canonical 6-pillar
2
+ Pydantic shapes for the institutional / SOTA refactor of research-module
3
+ composite scoring.
4
+
5
+ Origin: 2026-05-20 Brian's "moat is one factor among many — what are the
6
+ SOTA institutional factors for stock attractiveness?" arc. Plan doc at
7
+ ``alpha-engine-docs/private/attractiveness-pillars-260520.md``; ROADMAP
8
+ entry under Research (P1) in ``alpha-engine-config/private-docs/ROADMAP.md``.
9
+
10
+ Why these live in the shared lib (not in alpha-engine-research):
11
+
12
+ Same isomorphism rationale as ``agent_schemas`` — the Qual Analyst in
13
+ alpha-engine-research emits ``QualitativePillarAssessment`` via tool-use
14
+ forced output; the replay harness in alpha-engine-backtester and the
15
+ per-pillar attribution analyser need to call ``with_structured_output(
16
+ QualitativePillarAssessment)`` using the EXACT same Pydantic schema the
17
+ production agent used. Without a shared lib, backtester would either
18
+ need a cross-repo dep on research or vendor a drifting local copy.
19
+
20
+ What's here:
21
+
22
+ - ``PILLARS`` tuple — canonical ordering of the 6 pillars.
23
+ - ``PillarLiteral`` — Literal type used in all pillar-keyed fields.
24
+ - ``MoatType`` — the 6 Morningstar / Porter moat archetypes plus
25
+ ``"none"`` for the absent case.
26
+ - ``MoatWidth`` — wide / narrow / none, the Morningstar economic-moat
27
+ rating vocabulary.
28
+ - ``MoatTrend`` — widening / stable / eroding; moat trend captures the
29
+ *time derivative* that a one-shot score loses.
30
+ - ``MoatAssessment`` — the qualitative core of the Quality pillar.
31
+ - ``PillarSubscore`` — per-pillar 0-100 score with confidence + optional
32
+ quant_component / qual_component traceability + evidence list.
33
+ - ``QualitativePillarAssessment`` — the full 6-pillar emission shape the
34
+ Qual Analyst produces via tool-use forced output.
35
+
36
+ What's NOT here (intentionally):
37
+
38
+ - The quant-pillar substrate (factor-substrate composites for Growth +
39
+ Stewardship) — those live in alpha-engine-data's factor profile JSON
40
+ and are consumed by research's ``score_aggregator`` as floats.
41
+ - Stance derivation — Phase 5 of the arc; ``scoring/stance_deriver.py``
42
+ in alpha-engine-research, fed BY pillar subscores but not part of the
43
+ schema layer.
44
+
45
+ What WAS originally carved out but is now here (Phase 4, 2026-05-21):
46
+
47
+ - ``CompositeBreakdown`` + ``PillarContribution`` + ``LegacyComponentBlend``
48
+ — Phase 4 lifted these into lib (originally scoped as research-internal)
49
+ because alpha-engine-backtester's Phase 6 weight optimizer + alpha-engine-
50
+ dashboard's Phase 7 radar surfaces both need to consume the SAME shape
51
+ research emits. Same isomorphism rationale as ``QualitativePillarAssessment``:
52
+ cross-repo schema drift is a worse failure mode than the slight coupling
53
+ overhead. macro_shift + sector_modifier are passed as inputs to the
54
+ composite, not embedded as policy, so the shape is policy-agnostic.
55
+
56
+ Schema-validation discipline mirrors ``agent_schemas``:
57
+
58
+ - ``model_config = ConfigDict(extra="allow")`` on every class because LLM
59
+ outputs may include additional fields (forward-compatible drift).
60
+ - Hard validators only where they defend an observed or anticipated
61
+ LLM failure mode (e.g. moat ``durability_years`` upper bound, score
62
+ range clamps).
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ from typing import Literal
68
+
69
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
70
+
71
+
72
+ # ── Pillar vocabulary ────────────────────────────────────────────────────
73
+
74
+
75
+ PILLARS: tuple[str, ...] = (
76
+ "quality",
77
+ "value",
78
+ "momentum",
79
+ "growth",
80
+ "stewardship",
81
+ "defensiveness",
82
+ )
83
+ """Canonical ordering of the 6 attractiveness pillars.
84
+
85
+ Source of truth for both the ``PillarLiteral`` type AND iteration order
86
+ in any downstream consumer (composite scoring, dashboard radar charts,
87
+ backtester per-pillar attribution). Pinning the tuple lets tests assert
88
+ equality rather than set-equality, surfacing unintended reordering.
89
+
90
+ Why these 6:
91
+
92
+ - **quality** — durable profitability + capital efficiency (Asness "QMJ"
93
+ + Morningstar Economic Moat + Piotroski F-score). Moat is the
94
+ qualitative core (see ``MoatAssessment``).
95
+ - **value** — multi-metric, industry-relative price-to-fundamentals
96
+ (Fama-French HML + Greenblatt earnings yield).
97
+ - **momentum** — 12-1m price momentum (Jegadeesh-Titman / Carhart MOM)
98
+ plus earnings-revision momentum + SUE.
99
+ - **growth** — *sustainable* growth: reinvestment rate × ROIC, not raw
100
+ revenue CAGR. Compounder vs cash-cow distinction.
101
+ - **stewardship** — capital allocation discipline (Morningstar
102
+ Stewardship rating + Buffett/Munger lens): buyback timing, insider
103
+ alignment, M&A track record.
104
+ - **defensiveness** — low-vol / beta / drawdown profile (Frazzini-Pedersen
105
+ "Betting Against Beta" + Asness defensive).
106
+
107
+ Catalyst is preserved as the orthogonal stance-survivor (see
108
+ ``QualitativePillarAssessment.catalyst_horizon_modulation``) rather than
109
+ a 7th pillar weight, because catalyst is a *horizon* modulation rather
110
+ than an attractiveness pillar — a compounder thesis can be attractive
111
+ across pillars without any near-term catalyst."""
112
+
113
+
114
+ PillarLiteral = Literal[
115
+ "quality",
116
+ "value",
117
+ "momentum",
118
+ "growth",
119
+ "stewardship",
120
+ "defensiveness",
121
+ ]
122
+ """Pillar name as a Literal type. Used by ``PillarSubscore.pillar`` and any
123
+ downstream pillar-keyed field. Kept in sync with ``PILLARS`` by
124
+ ``test_pillar_literal_matches_pillars_tuple``."""
125
+
126
+
127
+ # ── Moat sub-rubric (qualitative core of Quality pillar) ─────────────────
128
+
129
+
130
+ MoatType = Literal[
131
+ "network_effects",
132
+ "switching_costs",
133
+ "cost_advantage",
134
+ "intangibles",
135
+ "efficient_scale",
136
+ "process_power",
137
+ "none",
138
+ ]
139
+ """The 6 canonical Morningstar / Porter moat archetypes plus ``"none"``.
140
+
141
+ - **network_effects** — value to each user grows with total users (Visa,
142
+ Meta, exchanges).
143
+ - **switching_costs** — high cost / friction to switch providers
144
+ (Microsoft enterprise, SAP, Oracle, mission-critical SaaS).
145
+ - **cost_advantage** — structurally lower unit costs from scale, location,
146
+ or process (Costco, Walmart, Waste Management).
147
+ - **intangibles** — brand, patents, regulatory licenses, IP (LVMH,
148
+ pharma patent portfolios, ASML EUV patents).
149
+ - **efficient_scale** — small market served well by few players;
150
+ attractive economics + structural deterrent to new entrants (regional
151
+ utilities, pipelines, railroads, Waste Management in many markets).
152
+ - **process_power** — proprietary process / know-how others can't
153
+ replicate at scale (TSMC leading-edge nodes, ASML EUV manufacturing,
154
+ certain advanced materials).
155
+ - **none** — no identifiable moat. Most stocks fall here; this is the
156
+ honest default rather than fabricating a moat to fill the field."""
157
+
158
+
159
+ MoatWidth = Literal["wide", "narrow", "none"]
160
+ """Morningstar economic-moat width vocabulary.
161
+
162
+ - **wide** — moat expected to persist 20+ years.
163
+ - **narrow** — moat expected to persist 10-20 years.
164
+ - **none** — no durable moat / moat under near-term threat.
165
+
166
+ Most public companies are ``"none"``; a meaningful minority are
167
+ ``"narrow"``; ``"wide"`` is reserved for the small set of truly durable
168
+ franchises (Microsoft / Visa / Costco / Moody's / ASML class)."""
169
+
170
+
171
+ MoatTrend = Literal["widening", "stable", "eroding"]
172
+ """Moat trend — the *time derivative* that a one-shot moat score loses.
173
+
174
+ A wide-but-eroding moat is structurally different from a narrow-but-
175
+ widening moat even if their current widths are 2 ranks apart. Trend is
176
+ how the agent expresses competitive dynamics: new entrants emerging,
177
+ disruption pressure, regulatory shifts, technology cycles."""
178
+
179
+
180
+ class MoatAssessment(BaseModel):
181
+ """Structured moat assessment — qualitative core of the Quality pillar.
182
+
183
+ Emitted by the Qual Analyst as part of ``QualitativePillarAssessment``
184
+ via tool-use forced output. Persisted per ticker as a time-series in
185
+ ``archive/universe/{TICKER}/moat_profile.json`` for trend tracking
186
+ (moats decay slowly; the *time series* is the real signal, not any
187
+ single weekly score).
188
+
189
+ Permissive (``extra="allow"``) for forward-compatible LLM drift.
190
+ Hard validators only on the structural bounds.
191
+ """
192
+
193
+ model_config = ConfigDict(extra="allow")
194
+
195
+ primary_type: MoatType = Field(
196
+ description=(
197
+ "Dominant moat archetype. ``'none'`` is the honest default; do "
198
+ "not fabricate a moat type to fill the field."
199
+ )
200
+ )
201
+ secondary_types: list[MoatType] = Field(
202
+ default_factory=list,
203
+ description=(
204
+ "Additional moat archetypes present but subordinate to primary. "
205
+ "Empty list is fine; many strong moats are single-archetype."
206
+ ),
207
+ )
208
+ width: MoatWidth = Field(
209
+ description=(
210
+ "Morningstar economic-moat width: wide (20y+), narrow (10-20y), "
211
+ "or none. Defaults to ``'none'`` when primary_type is ``'none'``."
212
+ )
213
+ )
214
+ durability_years: int = Field(
215
+ ge=0,
216
+ le=50,
217
+ description=(
218
+ "Estimated years the moat persists. Soft heuristic — useful for "
219
+ "trend tracking + dashboard narrative, not a hard composite "
220
+ "input. Upper-bound at 50y is a sanity cap; real moats rarely "
221
+ "outlast that horizon predictably."
222
+ ),
223
+ )
224
+ trend: MoatTrend = Field(
225
+ description=(
226
+ "Time-derivative of moat strength: widening (improving), stable, "
227
+ "or eroding. Wide-but-eroding is meaningfully different from "
228
+ "narrow-but-widening at the same current width."
229
+ )
230
+ )
231
+ evidence: list[str] = Field(
232
+ default_factory=list,
233
+ description=(
234
+ "Citations from 10-K / 10-Q / 8-K / earnings transcripts in RAG "
235
+ "supporting the assessment. Empty is permitted (LLM may not "
236
+ "always cite) but reviewers should expect ≥1 evidence string "
237
+ "for any non-'none' moat. Free-form strings, not structured "
238
+ "citation objects, for forward compatibility."
239
+ ),
240
+ )
241
+
242
+ @field_validator("secondary_types")
243
+ @classmethod
244
+ def _unique_secondary(cls, v: list[MoatType]) -> list[MoatType]:
245
+ """Secondary moat types must be unique within the list."""
246
+ if len(set(v)) != len(v):
247
+ raise ValueError(
248
+ f"moat.secondary_types must be unique; got {v}"
249
+ )
250
+ return v
251
+
252
+ @field_validator("evidence")
253
+ @classmethod
254
+ def _trim_evidence_strings(cls, v: list[str]) -> list[str]:
255
+ """Strip whitespace + drop empty strings. LLM outputs sometimes
256
+ produce ``["", " ...some text..."]`` from format-token confusion."""
257
+ return [s.strip() for s in v if s and s.strip()]
258
+
259
+ @model_validator(mode="after")
260
+ def _primary_not_in_secondary(self) -> MoatAssessment:
261
+ """Primary moat archetype must not appear in secondary list.
262
+
263
+ Anticipated LLM failure mode: agents sometimes restate the primary
264
+ archetype in secondary for emphasis. This drops semantic clarity —
265
+ secondary means *additional, subordinate* archetypes.
266
+ """
267
+ if self.primary_type in self.secondary_types:
268
+ raise ValueError(
269
+ f"moat.primary_type ({self.primary_type!r}) must not "
270
+ f"appear in secondary_types ({self.secondary_types!r})"
271
+ )
272
+ return self
273
+
274
+
275
+ # ── Pillar subscore (per-pillar 0-100 with traceability) ─────────────────
276
+
277
+
278
+ class PillarSubscore(BaseModel):
279
+ """Per-pillar attractiveness subscore — 0-100 with optional
280
+ quant/qual decomposition for traceability.
281
+
282
+ The 6 ``PillarSubscore`` instances in ``QualitativePillarAssessment``
283
+ are the qualitative side; ``quant_component`` is populated downstream
284
+ by ``scoring/composite.py`` after the factor-substrate quantitative
285
+ subscore is read. Storing both surfaces (quant + qual + the blended
286
+ score) lets the dashboard render the decomposition and the backtester
287
+ decompose realized alpha into quant-pillar vs qual-pillar contribution.
288
+
289
+ At LLM emission time (the qual-analyst tool-use call), only ``pillar``
290
+ + ``score`` + ``confidence`` + ``qual_component`` + ``evidence`` are
291
+ populated; ``quant_component`` and the blended ``score`` may be
292
+ rewritten by the composite scoring layer.
293
+
294
+ Permissive (``extra="allow"``) for forward-compatible LLM drift.
295
+ """
296
+
297
+ model_config = ConfigDict(extra="allow")
298
+
299
+ pillar: PillarLiteral = Field(
300
+ description="Which of the 6 pillars this subscore covers."
301
+ )
302
+ score: int = Field(
303
+ ge=0,
304
+ le=100,
305
+ description=(
306
+ "0-100 blended attractiveness score on this pillar. At LLM "
307
+ "emission time this is the qualitative score; downstream "
308
+ "composite scoring may rewrite to a quant+qual blend."
309
+ ),
310
+ )
311
+ confidence: Literal["low", "medium", "high"] = Field(
312
+ description=(
313
+ "Agent's confidence in this pillar's assessment. Used by "
314
+ "downstream consumers to weight the within-pillar quant/qual "
315
+ "blend (low-confidence qual → lean on quant)."
316
+ )
317
+ )
318
+ quant_component: float | None = Field(
319
+ default=None,
320
+ description=(
321
+ "Optional quantitative subscore from the factor substrate "
322
+ "(``factors/profiles/latest.json``). Populated by the composite "
323
+ "scoring layer post-LLM-emission. ``None`` for pillars without "
324
+ "quant coverage (e.g. stewardship has thin quant signal)."
325
+ ),
326
+ )
327
+ qual_component: int | None = Field(
328
+ default=None,
329
+ ge=0,
330
+ le=100,
331
+ description=(
332
+ "Optional qualitative-only component the agent emits before "
333
+ "any quant blend. When present, ``score`` may differ from "
334
+ "``qual_component`` post-blend; both are persisted for "
335
+ "traceability."
336
+ ),
337
+ )
338
+ evidence: list[str] = Field(
339
+ default_factory=list,
340
+ description=(
341
+ "Citations / observations the agent used to score this pillar. "
342
+ "Free-form strings for forward compatibility; expect 1-5 entries "
343
+ "per non-trivial pillar score."
344
+ ),
345
+ )
346
+
347
+ @field_validator("evidence")
348
+ @classmethod
349
+ def _trim_evidence_strings(cls, v: list[str]) -> list[str]:
350
+ return [s.strip() for s in v if s and s.strip()]
351
+
352
+
353
+ # ── Full qualitative pillar emission (Qual Analyst tool-use output) ──────
354
+
355
+
356
+ class QualitativePillarAssessment(BaseModel):
357
+ """Structured 6-pillar assessment emitted by the Qual Analyst via
358
+ tool-use forced output.
359
+
360
+ This is the SOTA structured-output replacement for the current
361
+ opaque-scalar ``qual_score: int 0-100`` emission. Each pillar carries
362
+ its own subscore + evidence, plus the Quality pillar carries a moat
363
+ assessment, plus a catalyst horizon-modulation field captures the
364
+ orthogonal "near-term catalyst shifts effective composite by ±N"
365
+ signal that survives from the legacy stance-taxonomy framing.
366
+
367
+ Consumer flow:
368
+
369
+ 1. ``alpha-engine-research/agents/sector_teams/qual_analyst.py``
370
+ emits this via ``ChatAnthropic.with_structured_output(
371
+ QualitativePillarAssessment)`` as its terminal step.
372
+ 2. ``alpha-engine-research/scoring/composite.py`` consumes the 6
373
+ ``PillarSubscore`` fields, blends in quantitative subscores from
374
+ ``factors/profiles/latest.json``, and produces a
375
+ ``CompositeBreakdown`` (composite_score + per-pillar breakdown +
376
+ catalyst modulation + macro shift).
377
+ 3. The moat assessment is persisted to
378
+ ``archive/universe/{TICKER}/moat_profile.json`` for trend
379
+ tracking.
380
+ 4. ``alpha-engine-research/scoring/stance_deriver.py`` (Phase 5 of
381
+ the arc) reads pillar subscores + catalyst_horizon_modulation
382
+ and emits the derived stance label.
383
+ 5. ``alpha-engine-dashboard/pages/2_Signals_and_Research.py``
384
+ renders the 6-axis pillar radar + moat block.
385
+
386
+ Permissive (``extra="allow"``) for forward-compatible LLM drift.
387
+
388
+ Backward-compatibility translation: when the legacy composite is
389
+ needed (Phase 2 flag-gated soak before Phase 4 cutover), use
390
+ ``derive_legacy_qual_score()`` below.
391
+ """
392
+
393
+ model_config = ConfigDict(extra="allow")
394
+
395
+ quality: PillarSubscore = Field(
396
+ description="Quality pillar subscore — durable profitability + capital efficiency."
397
+ )
398
+ quality_moat: MoatAssessment = Field(
399
+ description=(
400
+ "Structured moat assessment — qualitative core of the Quality "
401
+ "pillar. Persisted as a time-series per ticker for trend "
402
+ "tracking; the time derivative is the real signal."
403
+ )
404
+ )
405
+ value: PillarSubscore = Field(
406
+ description="Value pillar subscore — multi-metric industry-relative."
407
+ )
408
+ momentum: PillarSubscore = Field(
409
+ description="Momentum pillar subscore — price + earnings momentum."
410
+ )
411
+ growth: PillarSubscore = Field(
412
+ description="Growth pillar subscore — sustainable (reinvestment × ROIC), not raw CAGR."
413
+ )
414
+ stewardship: PillarSubscore = Field(
415
+ description="Stewardship pillar subscore — capital allocation discipline."
416
+ )
417
+ defensiveness: PillarSubscore = Field(
418
+ description="Defensiveness pillar subscore — low-vol / beta / drawdown profile."
419
+ )
420
+ catalyst_horizon_modulation: int = Field(
421
+ default=0,
422
+ ge=-20,
423
+ le=20,
424
+ description=(
425
+ "Near-term catalyst horizon shift on effective composite, ±20. "
426
+ "Positive = imminent catalyst raises near-term attractiveness "
427
+ "(earnings beat expected, FDA approval pending, etc.); negative "
428
+ "= imminent risk lowers it (litigation, guide-down). Orthogonal "
429
+ "to the 6 pillars: a compounder can be attractive across "
430
+ "pillars with ``catalyst_horizon_modulation=0``."
431
+ ),
432
+ )
433
+
434
+ def pillar_subscores(self) -> dict[str, PillarSubscore]:
435
+ """Return the 6 pillar subscores as a dict keyed by pillar name.
436
+
437
+ Iteration follows ``PILLARS`` ordering. Convenience for downstream
438
+ consumers (composite scoring, stance derivation, dashboard radar)
439
+ that want pillar-keyed access without listing each field by name.
440
+ """
441
+ return {
442
+ "quality": self.quality,
443
+ "value": self.value,
444
+ "momentum": self.momentum,
445
+ "growth": self.growth,
446
+ "stewardship": self.stewardship,
447
+ "defensiveness": self.defensiveness,
448
+ }
449
+
450
+ def derive_legacy_qual_score(self) -> int:
451
+ """Translation layer — derive the legacy ``qual_score: int 0-100``
452
+ scalar from the per-pillar subscores.
453
+
454
+ Used during Phase 2 soak (``EMIT_PILLAR_ASSESSMENT`` flag-gated)
455
+ when the new shape is emitted but the legacy composite must
456
+ consume a scalar to preserve behavior. Equal-weight mean across
457
+ the 6 pillar scores; catalyst_horizon_modulation NOT folded in
458
+ because the legacy composite already had catalyst handling via
459
+ the stance taxonomy.
460
+
461
+ Returns int (rounded) in [0, 100]. The field bounds on each
462
+ pillar's ``score`` (0-100) plus Python's ``round`` make the
463
+ return type-safe; no clamp needed.
464
+ """
465
+ scores = [
466
+ self.quality.score,
467
+ self.value.score,
468
+ self.momentum.score,
469
+ self.growth.score,
470
+ self.stewardship.score,
471
+ self.defensiveness.score,
472
+ ]
473
+ return round(sum(scores) / len(scores))
474
+
475
+
476
+ # ── Composite breakdown (Phase 4 — pillar-decomposed scoring output) ─────
477
+
478
+
479
+ class PillarContribution(BaseModel):
480
+ """Per-pillar contribution to the composite score.
481
+
482
+ One ``PillarContribution`` per pillar in ``PILLARS``. Carries the
483
+ within-pillar blend (``α × qual + (1-α) × quant``) plus the
484
+ across-pillar weight so the composite is fully reconstructible and
485
+ decomposable for attribution.
486
+
487
+ Effective ``within_pillar_qual_weight`` may differ from the configured
488
+ default when one of the two components is unavailable:
489
+ * pillar_assessment absent for this ticker → ``qual_weight = 0.0``
490
+ (degrades to pure factor-profile-quant for this pillar)
491
+ * factor_profile absent for this ticker / pillar → ``qual_weight = 1.0``
492
+ (degrades to pure pillar-assessment-qual for this pillar)
493
+
494
+ ``blended`` is ``None`` only when BOTH components are unavailable —
495
+ in that case ``contribution = 0.0`` and the pillar drops out of the
496
+ weighted_base sum (with weight reallocating pro-rata is a Phase 6
497
+ concern, not a Phase 4 concern; Phase 4 keeps the static weights).
498
+ """
499
+
500
+ model_config = ConfigDict(extra="allow")
501
+
502
+ pillar: PillarLiteral = Field(
503
+ description="Which of the 6 pillars this contribution covers."
504
+ )
505
+ qual_component: float | None = Field(
506
+ default=None,
507
+ description=(
508
+ "Qualitative subscore from ``QualitativePillarAssessment.{pillar}.score`` "
509
+ "(0-100). ``None`` when pillar emission disabled or absent for ticker."
510
+ ),
511
+ )
512
+ quant_component: float | None = Field(
513
+ default=None,
514
+ description=(
515
+ "Quantitative subscore from the factor substrate "
516
+ "(``factors/profiles/latest.json``). ``None`` when no factor profile "
517
+ "exists for this ticker / pillar (Stewardship has thin quant signal "
518
+ "and may often be None until the factor side accumulates more "
519
+ "history)."
520
+ ),
521
+ )
522
+ within_pillar_qual_weight: float = Field(
523
+ ge=0.0,
524
+ le=1.0,
525
+ description=(
526
+ "Effective α used in this pillar's qual/quant blend "
527
+ "(``blended = α × qual + (1-α) × quant``). May differ from the "
528
+ "configured default when one component is unavailable — see class "
529
+ "docstring."
530
+ ),
531
+ )
532
+ blended: float | None = Field(
533
+ default=None,
534
+ description=(
535
+ "Within-pillar blend ``α × qual + (1-α) × quant``. ``None`` only "
536
+ "when both qual_component AND quant_component are ``None``."
537
+ ),
538
+ )
539
+ pillar_weight: float = Field(
540
+ ge=0.0,
541
+ le=1.0,
542
+ description=(
543
+ "Across-pillar weight in the composite weighted_base sum. "
544
+ "At Phase 4 default this is 0 for every pillar (legacy_blend "
545
+ "carries the entire composite). Phase 6 optimizer ramps these up."
546
+ ),
547
+ )
548
+ contribution: float = Field(
549
+ description=(
550
+ "Actual contribution to weighted_base: "
551
+ "``pillar_weight × blended`` (0 when blended is None)."
552
+ )
553
+ )
554
+
555
+
556
+ class LegacyComponentBlend(BaseModel):
557
+ """Legacy quant/qual/factor blend kept alongside pillar contributions.
558
+
559
+ At Phase 4 default weights — ``w_legacy_quant = 0.35``,
560
+ ``w_legacy_qual = 0.35``, ``w_factor = 0.30`` — this term IS the
561
+ composite (pillar weights all 0), so ``weighted_base`` matches the
562
+ legacy ``compute_composite_score`` output BY CONSTRUCTION. The
563
+ plan-doc ±0.5 fixture regression criterion is satisfied structurally,
564
+ not by fixture-tuning hope.
565
+
566
+ Phase 6 weight optimizer ramps these weights DOWN as pillar weights
567
+ ramp UP. Sum of all weights (legacy + pillar) MUST equal 1.0 — see
568
+ ``CompositeBreakdown.check_weights_sum_to_one``.
569
+ """
570
+
571
+ model_config = ConfigDict(extra="allow")
572
+
573
+ quant_score: float | None = Field(
574
+ default=None,
575
+ description=(
576
+ "Opaque quant_score scalar from the Quant Analyst (0-100). "
577
+ "Carries information not available in the pillar decomposition "
578
+ "(quant_analyst doesn't emit per-pillar quant subscores yet), so "
579
+ "kept here at non-zero weight at Phase 4."
580
+ ),
581
+ )
582
+ qual_score: float | None = Field(
583
+ default=None,
584
+ description=(
585
+ "Opaque qual_score scalar from the Qual Analyst (0-100). When "
586
+ "pillar emission is enabled this is also derivable via "
587
+ "``QualitativePillarAssessment.derive_legacy_qual_score()`` for "
588
+ "consistency checking."
589
+ ),
590
+ )
591
+ factor_subscore: float | None = Field(
592
+ default=None,
593
+ description=(
594
+ "Regime-conditional linear blend of factor pillar composites "
595
+ "(quality / momentum / value / low_vol [+ growth / stewardship "
596
+ "post Phase 3b]). Already pillar-decomposed on the quant side, "
597
+ "so retained at non-zero weight pre-Phase-6."
598
+ ),
599
+ )
600
+ w_legacy_quant: float = Field(
601
+ ge=0.0,
602
+ le=1.0,
603
+ description="Weight on the legacy opaque quant_score scalar."
604
+ )
605
+ w_legacy_qual: float = Field(
606
+ ge=0.0,
607
+ le=1.0,
608
+ description="Weight on the legacy opaque qual_score scalar."
609
+ )
610
+ w_factor: float = Field(
611
+ ge=0.0,
612
+ le=1.0,
613
+ description="Weight on factor_subscore."
614
+ )
615
+ contribution: float = Field(
616
+ description=(
617
+ "Actual contribution to weighted_base: "
618
+ "``w_legacy_quant × quant_score + w_legacy_qual × qual_score "
619
+ "+ w_factor × factor_subscore`` (with None components zero-treated)."
620
+ )
621
+ )
622
+
623
+
624
+ class CompositeBreakdown(BaseModel):
625
+ """Pillar-decomposed composite score breakdown — Phase 4 output of
626
+ the attractiveness-pillars-260520 arc.
627
+
628
+ Produced by alpha-engine-research's ``score_aggregator`` per ticker.
629
+ Consumed by alpha-engine-backtester (Phase 6 — weight optimizer for
630
+ per-pillar attribution + auto-tuned weights) and alpha-engine-
631
+ dashboard (Phase 7 — per-pillar radar rendering + drill-downs).
632
+
633
+ Why this shape lives in lib rather than research:
634
+ The original Phase 1 design (lib v0.22.0) carved this out as
635
+ research-internal "because it's coupled to research-internal
636
+ regime/macro state." Phase 4 lifts it because backtester and
637
+ dashboard both need to consume it; cross-repo schema drift is a
638
+ worse failure mode than the slight policy coupling. Policy
639
+ coupling is avoided by treating macro_shift + sector_modifier as
640
+ INPUT SCALARS in research's compute path — the shape itself is
641
+ policy-agnostic.
642
+
643
+ Invariants:
644
+ * ``Σ pillar_weights + (w_legacy_quant + w_legacy_qual + w_factor) == 1.0``
645
+ within 1e-6 tolerance. Enforced by ``check_weights_sum_to_one``.
646
+ * ``final_score == clamp(weighted_base + macro_shift + boosts_total
647
+ + catalyst_modulation, 0, 100)`` — round to 1 decimal.
648
+ * At Phase 4 default weights (pillar_weights all 0, legacy weights
649
+ 0.35 / 0.35 / 0.30), ``final_score`` reproduces
650
+ ``compute_composite_score`` output exactly when all components
651
+ are present.
652
+ """
653
+
654
+ model_config = ConfigDict(extra="allow")
655
+
656
+ final_score: float | None = Field(
657
+ default=None,
658
+ description=(
659
+ "Composite score (0-100, clamped) used by CIO + executor for "
660
+ "rating + ranking. ``None`` when score_failed."
661
+ ),
662
+ )
663
+ weighted_base: float | None = Field(
664
+ default=None,
665
+ description=(
666
+ "Composite before macro_shift + boosts + catalyst_modulation are "
667
+ "added. Equals Σ pillar_contributions.contribution + "
668
+ "legacy_blend.contribution. ``None`` when score_failed."
669
+ ),
670
+ )
671
+ macro_shift: float = Field(
672
+ description=(
673
+ "Macro sector modifier shift in score points. Range "
674
+ "[-MACRO_MAX_SHIFT_POINTS, +MACRO_MAX_SHIFT_POINTS] — currently "
675
+ "±25.0 in research config; passed in as a computed scalar."
676
+ )
677
+ )
678
+ boosts_total: float = Field(
679
+ description=(
680
+ "Sum of additive signal boosts after the per-composite cap. "
681
+ "Range [-max_aggregate_boost, +max_aggregate_boost] — currently "
682
+ "±10.0 in research config."
683
+ )
684
+ )
685
+ catalyst_modulation: int = Field(
686
+ default=0,
687
+ ge=-20,
688
+ le=20,
689
+ description=(
690
+ "Near-term catalyst horizon shift from "
691
+ "``QualitativePillarAssessment.catalyst_horizon_modulation``. "
692
+ "0 when pillar emission disabled or absent for ticker — keeps "
693
+ "Phase 4 default behavior identical to legacy."
694
+ ),
695
+ )
696
+ pillar_contributions: list[PillarContribution] = Field(
697
+ default_factory=list,
698
+ description=(
699
+ "Per-pillar contribution to the composite (0 or 6 entries — "
700
+ "either all 6 pillars or none if pillar_assessment absent). At "
701
+ "Phase 4 default, every pillar_weight is 0 so every contribution "
702
+ "is 0; the structural breakdown is still emitted for "
703
+ "observability + downstream attribution."
704
+ ),
705
+ )
706
+ legacy_blend: LegacyComponentBlend = Field(
707
+ description=(
708
+ "Legacy quant/qual/factor blend with its own weights. At Phase 4 "
709
+ "default this term IS the composite (pillar weights all 0)."
710
+ )
711
+ )
712
+ score_failed: bool = Field(
713
+ default=False,
714
+ description=(
715
+ "True when input components are all None — composite cannot be "
716
+ "computed and final_score is None. Downstream rating defaults to "
717
+ "HOLD."
718
+ ),
719
+ )
720
+
721
+ @model_validator(mode="after")
722
+ def check_weights_sum_to_one(self) -> CompositeBreakdown:
723
+ """Sum of all weights (pillar + legacy) must equal 1.0.
724
+
725
+ Holds the invariant that weighted_base is bounded by [0, 100]
726
+ when every component is in [0, 100] — without this, the optimizer
727
+ could pick a weight config where weighted_base routinely exceeds
728
+ 100 and the clamp eats real signal.
729
+
730
+ Tolerance 1e-6 accommodates floating-point error from
731
+ backtester-auto-tuned weights.
732
+ """
733
+ pillar_sum = sum(c.pillar_weight for c in self.pillar_contributions)
734
+ legacy_sum = (
735
+ self.legacy_blend.w_legacy_quant
736
+ + self.legacy_blend.w_legacy_qual
737
+ + self.legacy_blend.w_factor
738
+ )
739
+ total = pillar_sum + legacy_sum
740
+ if abs(total - 1.0) > 1e-6:
741
+ raise ValueError(
742
+ f"CompositeBreakdown weights must sum to 1.0; got "
743
+ f"pillar_sum={pillar_sum:.6f} + legacy_sum={legacy_sum:.6f} "
744
+ f"= {total:.6f}"
745
+ )
746
+ return self
747
+
748
+ def pillar_contributions_by_name(self) -> dict[str, PillarContribution]:
749
+ """Return pillar_contributions as a dict keyed by pillar name.
750
+
751
+ Convenience for consumers that want pillar-keyed access. Returns
752
+ empty dict when pillar_contributions is empty (legacy-only path).
753
+ Iteration order follows ``PILLARS``.
754
+ """
755
+ by_name = {c.pillar: c for c in self.pillar_contributions}
756
+ return {p: by_name[p] for p in PILLARS if p in by_name}