main
py 170 lines 8.7 KB
Raw
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)))