feat: add global opportunity ranker
prakhar82 committed
Sep 13, 2026 at 23:47 UTC
e9f7ef3a21b4af663d8bb43747f51a9004b4d23e
2 files changed
+355
ai/research-engine/app/global_opportunity_ranker.py
new
+170
@@ -0,0 +1,170 @@
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)))
ai/research-engine/tests/test_global_opportunity_ranker.py
new
+185
@@ -0,0 +1,185 @@
1
+from datetime import datetime, timezone
2
+from uuid import UUID
3
+
4
+import pytest
5
+
6
+from app.global_opportunity_ranker import GlobalOpportunityRanker, WEIGHTS, RANKER_VERSION
7
+from app.global_scanner import StageBCandidate
8
+from app.sector_relative_strength import SectorRelativeStrengthSnapshot
9
+from app.technical_features import TechnicalFeatureSnapshot
10
+from app.stock_rule_engine import StockRuleEngineResult, AreaScoreResult, RiskOverrideResult, STOCK_RULE_ENGINE_AREA_WEIGHTS
11
+
12
+NOW = datetime(2026, 9, 13, tzinfo=timezone.utc)
13
+
14
+
15
+def inputs(n=1, core=80, tech=60, sector=40, confidence=90):
16
+ key = UUID(int=n)
17
+ technical = TechnicalFeatureSnapshot(global_instrument_id=key, as_of=NOW, configuration={},
18
+ observation_count=200, history_readiness='FULL', technical_score=tech, confidence=80,
19
+ technical_state='UPTREND')
20
+ relative = SectorRelativeStrengthSnapshot(global_instrument_id=key, as_of=NOW, configuration={},
21
+ relative_strength_score=sector, confidence=70,
22
+ sector_state='NEUTRAL' if sector is not None else 'INSUFFICIENT_DATA')
23
+ candidate = StageBCandidate(global_instrument_id=key, symbol='PROVENANCE', pre_score=99,
24
+ technical_feature_snapshot=technical, sector_relative_strength_snapshot=relative,
25
+ technical_score=tech, sector_score=sector, stage_b_score=99, confidence=99,
26
+ score_coverage=100, score_weights={})
27
+ rule = StockRuleEngineResult(global_instrument_id=key, calculated_at=NOW, input_fingerprint='fixture',
28
+ overall_score=75, quality_score=99, opportunity_score=99, risk_score=20,
29
+ confidence_score=confidence, confidence='HIGH', decision_signal='HOLD', partial=False,
30
+ eligibility=dict(full_analysis_allowed=True, partial_analysis_allowed=True, reason='READY'),
31
+ area_scores=[AreaScoreResult(area=name, weight=weight, raw_score=core, applicable=True,
32
+ status='READY_FRESH') for name, weight in STOCK_RULE_ENGINE_AREA_WEIGHTS.items()])
33
+ return candidate, rule
34
+
35
+
36
+def area(rule, name):
37
+ return next(a for a in rule.area_scores if a.area == name)
38
+
39
+
40
+def test_full_evidence_hand_calculation_and_no_double_counting():
41
+ c, r = inputs()
42
+ result = GlobalOpportunityRanker().score(c, r)
43
+ assert sum(WEIGHTS.values()) == 100
44
+ assert dict(WEIGHTS) == {str(k):v for k,v in STOCK_RULE_ENGINE_AREA_WEIGHTS.items()
45
+ if k not in {'PRICE_TECHNICAL', 'SECTOR_MACRO'}} | {'TECHNICAL':7, 'SECTOR':3}
46
+ assert result.opportunity_score == 77.4 # (90*80 + 7*60 + 3*40)/100
47
+ assert result.opportunity_confidence == 88.7 # (90*90 + 7*80 + 3*70)/100
48
+ assert result.score_coverage == 100 and result.rank_eligible
49
+ assert result.rule_engine_score == 75 and result.ranker_version == RANKER_VERSION
50
+ # These aggregate scores and replaced V1 slots cannot alter the composition.
51
+ c.pre_score = c.stage_b_score = 0
52
+ r.quality_score = r.opportunity_score = 0
53
+ area(r, 'PRICE_TECHNICAL').raw_score = area(r, 'SECTOR_MACRO').raw_score = 0
54
+ assert GlobalOpportunityRanker().score(c, r) == result
55
+ payload = result.model_dump(by_alias=True)
56
+ assert 'opportunityScore' in payload and 'rankEligible' in payload
57
+ assert 'decisionSignal' not in payload
58
+
59
+
60
+def test_missing_sector_renormalization():
61
+ c, r = inputs(sector=None)
62
+ result = GlobalOpportunityRanker().score(c, r)
63
+ assert result.opportunity_score == pytest.approx(7620/97)
64
+ assert result.score_coverage == 97
65
+ assert result.opportunity_confidence == 86.6
66
+ assert result.rank_eligible and 'UNAVAILABLE:SECTOR' in result.top_negative_reasons
67
+
68
+
69
+def test_zero_is_not_missing():
70
+ c, r = inputs(core=0, tech=0, sector=0)
71
+ zero = GlobalOpportunityRanker().score(c, r)
72
+ assert zero.opportunity_score == 0 and zero.score_coverage == 100 and zero.rank_eligible
73
+ area(r, 'SHAREHOLDING').raw_score = None
74
+ missing = GlobalOpportunityRanker().score(c, r)
75
+ assert missing.opportunity_score == 0 and missing.score_coverage == 96
76
+
77
+
78
+def test_non_applicable_shareholding_removed_from_denominator():
79
+ c, r = inputs()
80
+ area(r, 'SHAREHOLDING').status = 'NOT_APPLICABLE'
81
+ area(r, 'SHAREHOLDING').applicable = False
82
+ area(r, 'SHAREHOLDING').raw_score = None
83
+ result = GlobalOpportunityRanker().score(c, r)
84
+ assert result.score_coverage == 100
85
+ assert result.opportunity_score == pytest.approx(7420/96)
86
+ assert result.opportunity_confidence == pytest.approx(8510/96)
87
+ assert 'MISSING:SHAREHOLDING' not in result.top_negative_reasons
88
+
89
+
90
+@pytest.mark.parametrize('severity', ['HIGH', 'CRITICAL'])
91
+def test_existing_risk_override_suppresses_eligibility_not_diagnostic_score(severity):
92
+ c, r = inputs()
93
+ r.risk_overrides = [RiskOverrideResult(code='EXTREME_BALANCE_SHEET_STRESS', severity=severity)]
94
+ result = GlobalOpportunityRanker().score(c, r)
95
+ assert not result.rank_eligible and result.opportunity_score == 77.4
96
+ assert result.top_negative_reasons[0] == 'V1_RISK_OVERRIDE:EXTREME_BALANCE_SHEET_STRESS'
97
+
98
+
99
+@pytest.mark.parametrize('status', ['READY_STALE', 'CONFLICTING'])
100
+def test_critical_evidence_gate(status):
101
+ c, r = inputs()
102
+ area(r, 'BALANCE_SHEET').status = status
103
+ result = GlobalOpportunityRanker().score(c, r)
104
+ assert not result.rank_eligible and result.score_coverage == 91
105
+ assert any(code.startswith('CRITICAL_') for code in result.eligibility_reasons)
106
+
107
+
108
+@pytest.mark.parametrize('kind', ['stale', 'conflict'])
109
+def test_current_price_gate(kind):
110
+ c, r = inputs()
111
+ if kind == 'stale': c.technical_feature_snapshot.stale_inputs = ['PRICE_HISTORY']
112
+ else: c.technical_feature_snapshot.feature_states = {'latestPrice':'CONFLICTING'}
113
+ result = GlobalOpportunityRanker().score(c, r)
114
+ assert not result.rank_eligible and result.score_coverage == 90
115
+
116
+
117
+def test_optional_stale_sector_is_excluded_not_zero_or_gate():
118
+ c, r = inputs()
119
+ c.sector_relative_strength_snapshot.sector_state = 'INSUFFICIENT_DATA'
120
+ c.sector_relative_strength_snapshot.stale_inputs = ['SECTOR_HISTORY']
121
+ result = GlobalOpportunityRanker().score(c, r)
122
+ assert result.rank_eligible and result.score_coverage == 97
123
+ assert result.opportunity_score == pytest.approx(7620/97)
124
+
125
+
126
+def test_partial_and_missing_v1_fail_closed():
127
+ c, r = inputs()
128
+ r.partial = True
129
+ assert not GlobalOpportunityRanker().score(c, r).rank_eligible
130
+ missing = GlobalOpportunityRanker().score(c, None)
131
+ assert not missing.rank_eligible and missing.rule_engine_score is None
132
+ assert missing.eligibility_reasons == ['V1_RESULT_MISSING']
133
+
134
+
135
+def test_deterministic_reasons_and_repeat_without_mutation_or_network(monkeypatch):
136
+ import socket
137
+ monkeypatch.setattr(socket, 'create_connection', lambda *a, **k: pytest.fail('network'))
138
+ c, r = inputs()
139
+ before = c.model_dump(), r.model_dump()
140
+ ranker = GlobalOpportunityRanker()
141
+ first = ranker.score(c, r)
142
+ assert first == ranker.score(c, r)
143
+ assert before == (c.model_dump(), r.model_dump())
144
+ r.area_scores.reverse()
145
+ assert first == ranker.score(c, r)
146
+ assert first.top_positive_reasons == ['SUPPORT:BALANCE_SHEET', 'SUPPORT:FUNDAMENTAL_BUSINESS_QUALITY',
147
+ 'SUPPORT:GROWTH', 'SUPPORT:MANAGEMENT_GOVERNANCE', 'SUPPORT:NEWS_GEOPOLITICAL_EVENTS',
148
+ 'SUPPORT:ORDER_BOOK_CAPACITY_CATALYSTS']
149
+ assert first.top_negative_reasons == ['WEAK:SECTOR']
150
+
151
+
152
+def test_sort_score_confidence_coverage_rule_score_and_uuid():
153
+ pairs = [inputs(n, core=50, tech=50, sector=50, confidence=100) for n in range(1,7)]
154
+ # Equal opportunity 50 throughout except candidate 6 at 80.
155
+ pairs[0][1].overall_score = 80
156
+ pairs[1][1].overall_score = 80 # UUID 1 beats UUID 2.
157
+ pairs[2][1].overall_score = 70
158
+ # Same confidence as full rows (88.7), but coverage 97 vs 100.
159
+ c, r = pairs[3]
160
+ c.sector_score = c.sector_relative_strength_snapshot.relative_strength_score = None
161
+ c.sector_relative_strength_snapshot.sector_state = 'INSUFFICIENT_DATA'
162
+ c.technical_feature_snapshot.confidence = 100
163
+ for i in range(3): pairs[i][1].confidence_score = 90
164
+ r.confidence_score = (8870-700)/90
165
+ pairs[4][1].confidence_score = 20
166
+ c, r = inputs(6, core=80, tech=80, sector=80, confidence=0)
167
+ pairs[5] = c, r
168
+ ranker = GlobalOpportunityRanker()
169
+ rules = {r.global_instrument_id:r for _,r in pairs}
170
+ rows = ranker.rank([c for c,_ in reversed(pairs)], rules)
171
+ assert [r.global_instrument_id.int for r in rows] == [6,1,2,3,4,5]
172
+ assert rows == ranker.rank([c for c,_ in pairs], rules)
173
+
174
+
175
+def test_identity_duplicates_and_versions_rejected():
176
+ c, r = inputs()
177
+ ranker = GlobalOpportunityRanker()
178
+ with pytest.raises(ValueError, match='DUPLICATE_RANKER_INSTRUMENT'):
179
+ ranker.rank([c,c], {r.global_instrument_id:r})
180
+ r.global_instrument_id = UUID(int=2)
181
+ with pytest.raises(ValueError, match='IDENTITY_MISMATCH'): ranker.score(c,r)
182
+ r.global_instrument_id = c.global_instrument_id
183
+ r.rule_engine_version = 'FUTURE'
184
+ with pytest.raises(ValueError, match='UNSUPPORTED_RULE_ENGINE_VERSION'): ranker.score(c,r)
185
+ assert ranker.rank([], {}) == []