main
py 309 lines 11.9 KB
Raw
1 from __future__ import annotations
2
3 import math
4 from datetime import datetime, timezone
5
6 from app.models import CatalystScore, CategoryEvidence, EventImpact, EvidenceState, ResearchEvent, ResearchEventType
7
8
9 DEFAULT_WEIGHTS: dict[ResearchEventType, float] = {
10 ResearchEventType.NEW_ORDER: 16,
11 ResearchEventType.ORDER_BACKLOG_CHANGE: 14,
12 ResearchEventType.NEW_CUSTOMER: 10,
13 ResearchEventType.MAJOR_CONTRACT: 14,
14 ResearchEventType.GOVERNMENT_CONTRACT: 12,
15 ResearchEventType.CAPEX: 8,
16 ResearchEventType.FACTORY_EXPANSION: 10,
17 ResearchEventType.CAPACITY_EXPANSION: 12,
18 ResearchEventType.NEW_FACILITY: 10,
19 ResearchEventType.GEOGRAPHIC_EXPANSION: 8,
20 ResearchEventType.PARTNERSHIP: 6,
21 ResearchEventType.PRODUCT_LAUNCH: 8,
22 ResearchEventType.GUIDANCE_RAISED: 14,
23 ResearchEventType.GUIDANCE_LOWERED: 16,
24 ResearchEventType.GUIDANCE_CUT: 16,
25 ResearchEventType.GUIDANCE_MAINTAINED: 4,
26 ResearchEventType.ORDER_CANCELLED: 16,
27 ResearchEventType.PROJECT_DELAY: 10,
28 ResearchEventType.CUSTOMER_LOSS: 12,
29 ResearchEventType.DEBT_CHANGE: 8,
30 ResearchEventType.REGULATORY_EVENT: 8,
31 ResearchEventType.OTHER: 4,
32 }
33
34 IMPACT_MULTIPLIER = {
35 EventImpact.STRONG_POSITIVE: 1.0,
36 EventImpact.POSITIVE: 0.65,
37 EventImpact.NEUTRAL: 0.0,
38 EventImpact.UNCERTAIN: 0.15,
39 EventImpact.NEGATIVE: -0.65,
40 EventImpact.STRONG_NEGATIVE: -1.0,
41 }
42
43 CATEGORY_EVENT_TYPES: dict[str, set[ResearchEventType]] = {
44 "Growth": {
45 ResearchEventType.GEOGRAPHIC_EXPANSION,
46 ResearchEventType.PARTNERSHIP,
47 ResearchEventType.PRODUCT_LAUNCH,
48 },
49 "Orders & Backlog": {
50 ResearchEventType.NEW_ORDER,
51 ResearchEventType.ORDER_BACKLOG_CHANGE,
52 ResearchEventType.MAJOR_CONTRACT,
53 ResearchEventType.GOVERNMENT_CONTRACT,
54 ResearchEventType.ORDER_CANCELLED,
55 },
56 "CAPEX & Capacity": {
57 ResearchEventType.CAPEX,
58 ResearchEventType.CAPACITY_EXPANSION,
59 ResearchEventType.NEW_FACILITY,
60 ResearchEventType.FACTORY_EXPANSION,
61 ResearchEventType.PROJECT_DELAY,
62 },
63 "Customers": {
64 ResearchEventType.NEW_CUSTOMER,
65 ResearchEventType.CUSTOMER_EXPANSION,
66 ResearchEventType.MAJOR_CUSTOMER,
67 ResearchEventType.CUSTOMER_LOSS,
68 },
69 "Guidance": {
70 ResearchEventType.GUIDANCE_RAISED,
71 ResearchEventType.GUIDANCE_LOWERED,
72 ResearchEventType.GUIDANCE_CUT,
73 ResearchEventType.GUIDANCE_MAINTAINED,
74 ResearchEventType.REVENUE_GUIDANCE,
75 ResearchEventType.MARGIN_GUIDANCE,
76 },
77 "FINANCIAL_RESULTS": {
78 ResearchEventType.EARNINGS_RELEASE,
79 ResearchEventType.ANNUAL_REPORT,
80 },
81 "GUIDANCE": {
82 ResearchEventType.GUIDANCE_RAISED,
83 ResearchEventType.GUIDANCE_LOWERED,
84 ResearchEventType.GUIDANCE_CUT,
85 ResearchEventType.GUIDANCE_MAINTAINED,
86 ResearchEventType.REVENUE_GUIDANCE,
87 ResearchEventType.MARGIN_GUIDANCE,
88 },
89 "ORDERS_BACKLOG": {
90 ResearchEventType.NEW_ORDER,
91 ResearchEventType.ORDER_BACKLOG_CHANGE,
92 ResearchEventType.MAJOR_CONTRACT,
93 ResearchEventType.GOVERNMENT_CONTRACT,
94 ResearchEventType.ORDER_CANCELLED,
95 },
96 "CONTRACTS": {
97 ResearchEventType.NEW_ORDER,
98 ResearchEventType.MAJOR_CONTRACT,
99 ResearchEventType.GOVERNMENT_CONTRACT,
100 },
101 "CAPEX": {
102 ResearchEventType.CAPEX,
103 ResearchEventType.CAPACITY_EXPANSION,
104 ResearchEventType.NEW_FACILITY,
105 ResearchEventType.FACTORY_EXPANSION,
106 ResearchEventType.PROJECT_DELAY,
107 },
108 "NEW_FACILITIES": {
109 ResearchEventType.NEW_FACILITY,
110 ResearchEventType.FACTORY_EXPANSION,
111 ResearchEventType.CAPACITY_EXPANSION,
112 },
113 "ACQUISITIONS": {
114 ResearchEventType.ACQUISITION,
115 },
116 "CLIENTS": {
117 ResearchEventType.NEW_CUSTOMER,
118 ResearchEventType.CUSTOMER_EXPANSION,
119 ResearchEventType.MAJOR_CUSTOMER,
120 ResearchEventType.CUSTOMER_LOSS,
121 },
122 "PRODUCTS": {
123 ResearchEventType.PRODUCT_LAUNCH,
124 },
125 "MANAGEMENT": {
126 ResearchEventType.MANAGEMENT_CHANGE,
127 },
128 "ANALYST_OPINION": set(),
129 "ANALYST_TARGETS": set(),
130 "INSTITUTIONAL_ACTIVITY": set(),
131 "VALUATION": set(),
132 "RISKS": {
133 ResearchEventType.PROJECT_DELAY,
134 ResearchEventType.ORDER_CANCELLED,
135 ResearchEventType.CUSTOMER_LOSS,
136 ResearchEventType.DEBT_CHANGE,
137 ResearchEventType.REGULATORY_EVENT,
138 },
139 "CATALYSTS": {
140 ResearchEventType.NEW_ORDER,
141 ResearchEventType.ORDER_BACKLOG_CHANGE,
142 ResearchEventType.MAJOR_CONTRACT,
143 ResearchEventType.CAPEX,
144 ResearchEventType.NEW_FACILITY,
145 ResearchEventType.ACQUISITION,
146 ResearchEventType.PRODUCT_LAUNCH,
147 ResearchEventType.GUIDANCE_RAISED,
148 ResearchEventType.GUIDANCE_CUT,
149 },
150 }
151
152 LEGACY_BUCKETS = {
153 "New Orders": "Orders & Backlog",
154 "CAPEX": "CAPEX & Capacity",
155 "Capacity Expansion": "CAPEX & Capacity",
156 "New Customers": "Customers",
157 "Guidance": "Guidance",
158 }
159
160
161 # These are read-model identities, not refresh scheduling keys. The scorer
162 # retains its existing detailed categories for refresh logic; callers that
163 # render coverage collapse aliases into this one deterministic vocabulary.
164 CATEGORY_READ_MODEL_ALIASES = {
165 "Growth": "GROWTH",
166 "GROWTH": "GROWTH",
167 "PRODUCTS": "GROWTH",
168 "Orders & Backlog": "ORDERS_BACKLOG",
169 "ORDERS_BACKLOG": "ORDERS_BACKLOG",
170 "CONTRACTS": "ORDERS_BACKLOG",
171 "CAPEX & Capacity": "CAPEX",
172 "CAPEX": "CAPEX",
173 "NEW_FACILITIES": "CAPEX",
174 "Customers": "CLIENTS",
175 "CLIENTS": "CLIENTS",
176 "Guidance": "GUIDANCE",
177 "GUIDANCE": "GUIDANCE",
178 }
179
180
181 def canonical_read_model_score(score: CatalystScore) -> CatalystScore:
182 """Collapse scoring aliases and retain the actual events behind a score."""
183 groups: dict[str, list[CategoryEvidence]] = {}
184 for category, evidence in score.category_evidence.items():
185 groups.setdefault(CATEGORY_READ_MODEL_ALIASES.get(category, category), []).append(evidence)
186 evidence_by_category: dict[str, CategoryEvidence] = {}
187 for category, entries in groups.items():
188 events_by_id = {}
189 for entry in entries:
190 for event in entry.supporting_events:
191 events_by_id.setdefault(event.event_id, event)
192 events = list(events_by_id.values())
193 selected = next((entry for entry in entries if entry.score is not None), entries[0])
194 evidence_by_category[category] = _read_model_category_evidence(category, events, selected)
195 return score.model_copy(update={
196 "buckets": {category: evidence.score for category, evidence in evidence_by_category.items()},
197 "category_evidence": evidence_by_category,
198 })
199
200
201 def _read_model_category_evidence(
202 category: str,
203 events: list[ResearchEvent],
204 selected: CategoryEvidence,
205 ) -> CategoryEvidence:
206 if not events:
207 return CategoryEvidence(category=category, status=EvidenceState.NO_EVIDENCE, score=None)
208 has_positive = any(event.impact in {EventImpact.POSITIVE, EventImpact.STRONG_POSITIVE} for event in events)
209 has_negative = any(event.impact in {EventImpact.NEGATIVE, EventImpact.STRONG_NEGATIVE} for event in events)
210 status = EvidenceState.MIXED_EVIDENCE if has_positive and has_negative else (
211 EvidenceState.NEGATIVE_EVIDENCE if has_negative else (
212 EvidenceState.POSITIVE_EVIDENCE if has_positive else EvidenceState.NEUTRAL_EVIDENCE
213 )
214 )
215 source_identities = {
216 event.independence_key or str(event.source_document_id) or str(event.event_id)
217 for event in events
218 }
219 return CategoryEvidence(
220 category=category,
221 status=status,
222 score=selected.score,
223 event_count=len(events),
224 source_count=len(source_identities),
225 independent_source_count=len(source_identities),
226 has_conflict=has_positive and has_negative,
227 supporting_events=events,
228 )
229
230
231 class CatalystScorer:
232 def __init__(self, weights: dict[ResearchEventType, float] | None = None, half_life_days: float = 365.0):
233 self.weights = weights or DEFAULT_WEIGHTS
234 self.half_life_days = half_life_days
235
236 def score(self, instrument_id, events: list[ResearchEvent]) -> CatalystScore:
237 # Overall score is derived from validated evidence events only. There is
238 # deliberately NO 50.0 neutral baseline: an instrument with no eligible
239 # evidence events scores 0, per CatalystScore.aggregation_rule, which
240 # states NO_EVIDENCE categories are omitted and are not treated as 50.
241 total = 0.0
242 confidence_sum = 0.0
243 category_contributions = {category: 0.0 for category in CATEGORY_EVENT_TYPES}
244 category_events: dict[str, list[ResearchEvent]] = {category: [] for category in CATEGORY_EVENT_TYPES}
245 for event in events:
246 contribution = self._contribution(event)
247 total += contribution
248 confidence_sum += event.confidence
249 for category, event_types in CATEGORY_EVENT_TYPES.items():
250 if event.event_type in event_types:
251 category_contributions[category] += contribution
252 category_events[category].append(event)
253 break
254 category_evidence = {
255 category: self._category_evidence(category, category_events[category], category_contributions[category])
256 for category in CATEGORY_EVENT_TYPES
257 }
258 bucket_scores: dict[str, int | None] = {
259 category: evidence.score for category, evidence in category_evidence.items()
260 }
261 for legacy_name, category in LEGACY_BUCKETS.items():
262 bucket_scores[legacy_name] = category_evidence[category].score
263 research_confidence = int(round((confidence_sum / len(events)) * 100)) if events else 0
264 return CatalystScore(
265 instrument_id=instrument_id,
266 overall_score=int(max(0, min(100, round(total)))),
267 buckets=bucket_scores,
268 category_evidence=category_evidence,
269 research_confidence=research_confidence,
270 )
271
272 def _contribution(self, event: ResearchEvent) -> float:
273 weight = self.weights.get(event.event_type, 3.0)
274 impact = IMPACT_MULTIPLIER[event.impact]
275 return weight * impact * event.confidence * self._decay(event)
276
277 def _decay(self, event: ResearchEvent) -> float:
278 event_date = event.event_date or event.detected_at
279 age_days = max((datetime.now(timezone.utc) - event_date).days, 0)
280 return math.pow(0.5, age_days / self.half_life_days)
281
282 def _category_evidence(self, category: str, events: list[ResearchEvent], contribution: float) -> CategoryEvidence:
283 if not events:
284 return CategoryEvidence(category=category, status=EvidenceState.NO_EVIDENCE, score=None)
285 score = int(max(0, min(100, 50 + contribution)))
286 has_positive = any(event.impact in {EventImpact.POSITIVE, EventImpact.STRONG_POSITIVE} for event in events)
287 has_negative = any(event.impact in {EventImpact.NEGATIVE, EventImpact.STRONG_NEGATIVE} for event in events)
288 status = EvidenceState.NEUTRAL_EVIDENCE
289 if has_positive and has_negative:
290 status = EvidenceState.MIXED_EVIDENCE
291 elif has_negative:
292 status = EvidenceState.NEGATIVE_EVIDENCE
293 elif has_positive:
294 status = EvidenceState.POSITIVE_EVIDENCE
295 independent_keys = {
296 event.independence_key or str(event.source_document_id)
297 for event in events
298 if event.independence_key or event.source_document_id
299 }
300 return CategoryEvidence(
301 category=category,
302 status=status,
303 score=score,
304 event_count=len(events),
305 source_count=len({event.source_document_id for event in events}),
306 independent_source_count=len(independent_keys),
307 has_conflict=has_positive and has_negative,
308 supporting_events=events,
309 )