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.
- alpha_engine_lib/__init__.py +3 -0
- alpha_engine_lib/agent_schemas.py +663 -0
- alpha_engine_lib/alerts.py +576 -0
- alpha_engine_lib/arcticdb.py +340 -0
- alpha_engine_lib/collector_results.py +69 -0
- alpha_engine_lib/cost.py +665 -0
- alpha_engine_lib/dates.py +273 -0
- alpha_engine_lib/decision_capture.py +462 -0
- alpha_engine_lib/ec2_spot.py +363 -0
- alpha_engine_lib/email_sender.py +206 -0
- alpha_engine_lib/eval_artifacts.py +361 -0
- alpha_engine_lib/logging.py +303 -0
- alpha_engine_lib/model_pricing.yaml +73 -0
- alpha_engine_lib/pillars.py +756 -0
- alpha_engine_lib/pipeline_status/__init__.py +70 -0
- alpha_engine_lib/pipeline_status/read.py +541 -0
- alpha_engine_lib/pipeline_status/registry.py +368 -0
- alpha_engine_lib/pipeline_status/templates.py +120 -0
- alpha_engine_lib/preflight.py +444 -0
- alpha_engine_lib/rag/__init__.py +39 -0
- alpha_engine_lib/rag/db.py +96 -0
- alpha_engine_lib/rag/embeddings.py +63 -0
- alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
- alpha_engine_lib/rag/rerank.py +377 -0
- alpha_engine_lib/rag/retrieval.py +465 -0
- alpha_engine_lib/rag/schema.sql +65 -0
- alpha_engine_lib/reconcile.py +203 -0
- alpha_engine_lib/secrets.py +186 -0
- alpha_engine_lib/sources/__init__.py +35 -0
- alpha_engine_lib/sources/protocols.py +227 -0
- alpha_engine_lib/ssm_log_capture.py +274 -0
- alpha_engine_lib/telegram.py +165 -0
- alpha_engine_lib/trading_calendar.py +236 -0
- alpha_engine_lib/transparency.py +746 -0
- alpha_engine_lib/transparency_inventory.yaml +260 -0
- alpha_engine_lib/universe.py +83 -0
- alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
- alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
- alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
- 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}
|