| 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 | ) |