| 1 | """Pure composition of Stage B and STOCK_RULE_ENGINE_V1; no acquisition or writes. |
| 2 | |
| 3 | V1 weights are retained, but PRICE_TECHNICAL (7) and SECTOR_MACRO (3) |
| 4 | are replaced by Stage-B technical and exact-date sector evidence. V1 overall, |
| 5 | quality, opportunity, risk, preScore and stageBScore are NOT added again. |
| 6 | |
| 7 | Score = sum(available weight * score) / available weight. |
| 8 | Coverage = 100 * available weight / applicable weight. |
| 9 | Confidence = sum(available weight * source confidence) / applicable weight: |
| 10 | equivalently covered-weight confidence multiplied by coverage. Each retained |
| 11 | V1 area uses V1 confidence; Stage-B dimensions use their own snapshot confidence. |
| 12 | NOT_APPLICABLE removes an area from both denominators; missing retains its |
| 13 | coverage obligation. Zero is evidence. PARTIAL V1 areas remain usable, with |
| 14 | readiness reflected by V1 confidence. Stale/conflicting optional areas are omitted. |
| 15 | |
| 16 | Existing V1 full-analysis eligibility and overrides gate ranking. Additionally, |
| 17 | stale/conflicting valuation, quality, balance sheet, quarterly or governance |
| 18 | evidence, and stale/conflicting current stock price evidence suppress eligibility. |
| 19 | Scores remain diagnostic when suppressed; callers must filter rankEligible. |
| 20 | No numeric risk penalty is applied: that would count V1 resilience areas twice. |
| 21 | |
| 22 | Reason thresholds: >=65 SUPPORT, <=40 WEAK. Missing/stale/conflicting dimensions |
| 23 | have explicit codes. Top reasons are lexically ordered, with eligibility gates |
| 24 | first among negatives; at most six each. All gate reasons are also exposed. |
| 25 | Sort uses rounded output values, missing scores last, and UUID text ascending. |
| 26 | """ |
| 27 | from __future__ import annotations |
| 28 | |
| 29 | from math import isfinite |
| 30 | from types import MappingProxyType |
| 31 | from typing import TYPE_CHECKING, Iterable, Mapping |
| 32 | from uuid import UUID |
| 33 | |
| 34 | from app.models import ResearchBaseModel |
| 35 | |
| 36 | if TYPE_CHECKING: |
| 37 | from app.global_scanner import StageBCandidate |
| 38 | from app.stock_rule_engine import StockRuleEngineResult |
| 39 | |
| 40 | RANKER_VERSION = 'GLOBAL_OPPORTUNITY_RANKER_V1' |
| 41 | # Frozen V1 composition, deliberately independent of future engine versions. |
| 42 | WEIGHTS = MappingProxyType(dict(VALUATION=18, FUNDAMENTAL_BUSINESS_QUALITY=16, |
| 43 | GROWTH=14, BALANCE_SHEET=9, QUARTERLY_EARNINGS_TREND=9, |
| 44 | ORDER_BOOK_CAPACITY_CATALYSTS=8, NEWS_GEOPOLITICAL_EVENTS=7, |
| 45 | SHAREHOLDING=4, MANAGEMENT_GOVERNANCE=5, TECHNICAL=7, SECTOR=3)) |
| 46 | CRITICAL_AREAS = frozenset({'VALUATION', 'FUNDAMENTAL_BUSINESS_QUALITY', |
| 47 | 'BALANCE_SHEET', 'QUARTERLY_EARNINGS_TREND', 'MANAGEMENT_GOVERNANCE'}) |
| 48 | |
| 49 | |
| 50 | class GlobalOpportunityResult(ResearchBaseModel): |
| 51 | global_instrument_id: UUID |
| 52 | opportunity_score: float | None |
| 53 | opportunity_confidence: float |
| 54 | score_coverage: float |
| 55 | rank_eligible: bool |
| 56 | rule_engine_score: float | None |
| 57 | technical_score: float | None |
| 58 | technical_state: str |
| 59 | sector_score: float | None |
| 60 | sector_state: str |
| 61 | risk_score: float | None |
| 62 | top_positive_reasons: list[str] |
| 63 | top_negative_reasons: list[str] |
| 64 | eligibility_reasons: list[str] |
| 65 | ranker_version: str = RANKER_VERSION |
| 66 | |
| 67 | |
| 68 | def _score(value): |
| 69 | if value is None: |
| 70 | return None |
| 71 | value = float(value) |
| 72 | if not isfinite(value) or not 0 <= value <= 100: |
| 73 | raise ValueError('INVALID_RANKER_SCORE') |
| 74 | return value |
| 75 | |
| 76 | |
| 77 | class GlobalOpportunityRanker: |
| 78 | def score(self, candidate: StageBCandidate, rule: StockRuleEngineResult | None) -> GlobalOpportunityResult: |
| 79 | key = candidate.global_instrument_id |
| 80 | technical, sector = candidate.technical_feature_snapshot, candidate.sector_relative_strength_snapshot |
| 81 | if technical.global_instrument_id != key or sector.global_instrument_id != key or (rule and rule.global_instrument_id != key): |
| 82 | raise ValueError('RANKER_IDENTITY_MISMATCH') |
| 83 | if rule and rule.rule_engine_version != 'STOCK_RULE_ENGINE_V1': |
| 84 | raise ValueError('UNSUPPORTED_RULE_ENGINE_VERSION') |
| 85 | if candidate.feature_version != 'GLOBAL_STAGE_B_V1': |
| 86 | raise ValueError('UNSUPPORTED_STAGE_B_VERSION') |
| 87 | positives, negatives, gates = set(), set(), set() |
| 88 | applicable_weight = sum(WEIGHTS.values()) |
| 89 | values = {} |
| 90 | areas = {} |
| 91 | if rule: |
| 92 | for area in rule.area_scores: |
| 93 | if area.area in areas: |
| 94 | raise ValueError('DUPLICATE_RULE_AREA') |
| 95 | areas[area.area] = area |
| 96 | if rule.partial or not rule.eligibility.full_analysis_allowed or rule.overall_score is None: |
| 97 | gates.add('V1_ANALYSIS_NOT_ELIGIBLE') |
| 98 | for override in rule.risk_overrides: |
| 99 | if override.effect == 'BLOCK_BUY' or override.severity == 'CRITICAL': |
| 100 | gates.add('V1_RISK_OVERRIDE:' + override.code) |
| 101 | else: |
| 102 | gates.add('V1_RESULT_MISSING') |
| 103 | for name, weight in WEIGHTS.items(): |
| 104 | if name in {'TECHNICAL', 'SECTOR'}: |
| 105 | continue |
| 106 | area = areas.get(name) |
| 107 | if area and (not area.applicable or area.status == 'NOT_APPLICABLE'): |
| 108 | applicable_weight -= weight |
| 109 | continue |
| 110 | if area and area.status in {'READY_STALE', 'CONFLICTING'}: |
| 111 | code = ('STALE:' if area.status == 'READY_STALE' else 'CONFLICTING:') + name |
| 112 | negatives.add(code) |
| 113 | if name in CRITICAL_AREAS: |
| 114 | gates.add('CRITICAL_' + code) |
| 115 | elif area and area.status in {'READY_FRESH', 'PARTIAL'} and area.raw_score is not None: |
| 116 | values[name] = (_score(area.raw_score), _score(rule.confidence_score)) |
| 117 | else: |
| 118 | negatives.add('MISSING:' + name) |
| 119 | |
| 120 | if 'PRICE_HISTORY' in technical.stale_inputs or 'STOCK_HISTORY' in sector.stale_inputs: |
| 121 | gates.add('CRITICAL_STALE_PRICE') |
| 122 | if ('CONFLICTING' in technical.feature_states.values() |
| 123 | or 'CONFLICTING_STOCK_PRICE' in sector.missing_inputs): |
| 124 | gates.add('CRITICAL_CONFLICTING_PRICE') |
| 125 | # Snapshots are authoritative; reject inconsistent duplicated summary fields. |
| 126 | if candidate.technical_score != technical.technical_score or candidate.sector_score != sector.relative_strength_score: |
| 127 | raise ValueError('STAGE_B_SCORE_MISMATCH') |
| 128 | for name, value, confidence, usable in ( |
| 129 | ('TECHNICAL', candidate.technical_score, technical.confidence, |
| 130 | not {'CRITICAL_STALE_PRICE', 'CRITICAL_CONFLICTING_PRICE'} & gates), |
| 131 | ('SECTOR', candidate.sector_score, sector.confidence, |
| 132 | sector.sector_state != 'INSUFFICIENT_DATA' and not {'CRITICAL_STALE_PRICE', 'CRITICAL_CONFLICTING_PRICE'} & gates)): |
| 133 | if usable and value is not None: |
| 134 | values[name] = (_score(value), _score(confidence)) |
| 135 | else: |
| 136 | negatives.add('UNAVAILABLE:' + name) |
| 137 | available_weight = sum(WEIGHTS[name] for name in values) |
| 138 | for name, (value, _) in values.items(): |
| 139 | if value >= 65: |
| 140 | positives.add('SUPPORT:' + name) |
| 141 | elif value <= 40: |
| 142 | negatives.add('WEAK:' + name) |
| 143 | if not available_weight: |
| 144 | gates.add('NO_SCORABLE_EVIDENCE') |
| 145 | opportunity = sum(WEIGHTS[n]*v for n,(v,_) in values.items()) / available_weight if available_weight else None |
| 146 | confidence = sum(WEIGHTS[n]*c for n,(_,c) in values.items()) / applicable_weight |
| 147 | return GlobalOpportunityResult(global_instrument_id=key, |
| 148 | opportunity_score=round(opportunity, 8) if opportunity is not None else None, |
| 149 | opportunity_confidence=round(confidence, 8), |
| 150 | score_coverage=round(100*available_weight/applicable_weight, 8), rank_eligible=not gates, |
| 151 | rule_engine_score=_score(rule.overall_score) if rule else None, |
| 152 | technical_score=_score(candidate.technical_score), technical_state=technical.technical_state, |
| 153 | sector_score=_score(candidate.sector_score), sector_state=sector.sector_state, |
| 154 | risk_score=_score(rule.risk_score) if rule else None, |
| 155 | top_positive_reasons=sorted(positives)[:6], |
| 156 | top_negative_reasons=(sorted(gates) + sorted(negatives - gates))[:6], |
| 157 | eligibility_reasons=sorted(gates)) |
| 158 | |
| 159 | def rank(self, candidates: Iterable[StageBCandidate], rules: Mapping[UUID, StockRuleEngineResult]) -> list[GlobalOpportunityResult]: |
| 160 | results, seen = [], set() |
| 161 | for candidate in candidates: |
| 162 | key = candidate.global_instrument_id |
| 163 | if key in seen: |
| 164 | raise ValueError('DUPLICATE_RANKER_INSTRUMENT') |
| 165 | seen.add(key) |
| 166 | results.append(self.score(candidate, rules.get(key))) |
| 167 | def descending(value): |
| 168 | return -value if value is not None else float('inf') |
| 169 | return sorted(results, key=lambda r: (descending(r.opportunity_score), |
| 170 | -r.opportunity_confidence, -r.score_coverage, descending(r.rule_engine_score), str(r.global_instrument_id))) |