main
py 174 lines 8.57 KB
Raw
1 from datetime import timedelta
2 from unittest.mock import AsyncMock
3 from uuid import UUID
4
5 import httpx
6 import pytest
7
8 from app.global_opportunity_orchestration import GlobalOpportunityOrchestrator
9 from app.global_scanner import GlobalScanner
10 from app.persistence import SqliteResearchPersistence
11 from app.portfolio_orchestration import PortfolioResearchOrchestrator
12 from app.repository import ResearchRepository
13 from app.settings import Settings
14 from test_global_scanner import instrument, persisted, NOW
15 from test_global_opportunity_ranker import inputs
16
17
18 def setup(monkeypatch, count=3, *, stub_deep=True):
19 store = SqliteResearchPersistence()
20 repo = ResearchRepository(persistence=store)
21 hydrator = PortfolioResearchOrchestrator(repo, Settings(), client=object())
22 service = GlobalOpportunityOrchestrator(repo, store,
23 profile_hydrator=hydrator.register_global_profile_metadata, clock=lambda:NOW)
24 rows = [instrument(n) for n in range(1,count+1)]
25 for row in rows: persisted(store, row)
26 pairs = {UUID(int=n):inputs(n) for n in range(1,count+1)}
27 monkeypatch.setattr(GlobalScanner, 'enrich_candidates', lambda self, scan, **kwargs:
28 [pairs[c.global_instrument_id][0] for c in reversed(scan.candidates) if c.eligible_for_deep_analysis])
29 if stub_deep:
30 service.readiness.read = AsyncMock(return_value=object())
31 async def analyze(profile, readiness, **kwargs):
32 assert kwargs == dict(allow_partial=False, now=NOW)
33 return pairs[profile.instrument_id][1]
34 service.rule_engine.analyze = AsyncMock(side_effect=analyze)
35 return service, rows, pairs, store
36
37
38 @pytest.mark.asyncio
39 async def test_shortlist_limit_order_and_v1_only_shortlist(monkeypatch):
40 service, rows, pairs, _ = setup(monkeypatch, 4)
41 pairs[UUID(int=3)][0].stage_b_score = 100
42 result = await service.run(reversed(rows), as_of=NOW, shortlist_limit=2)
43 assert [d.global_instrument_id.int for d in result.diagnostics] == [3,1]
44 assert result.universe_count == result.phase1_eligible_count == result.stage_b_count == 4
45 assert result.shortlist_count == result.deep_evaluated_count == 2
46 assert service.rule_engine.analyze.await_count == 2
47 # Final opportunity rank uses the ranker, not shortlist order.
48 assert [entry.global_instrument_id.int for entry in result.top_n] == [1,3]
49
50
51 @pytest.mark.asyncio
52 async def test_sector_unavailable_allowed_and_fewer_than_top_n(monkeypatch):
53 service, rows, pairs, _ = setup(monkeypatch, 1)
54 stage = pairs[UUID(int=1)][0]
55 stage.sector_score = stage.sector_relative_strength_snapshot.relative_strength_score = None
56 stage.sector_relative_strength_snapshot.sector_state = 'INSUFFICIENT_DATA'
57 result = await service.run(rows, as_of=NOW, top_n=10)
58 assert result.rank_eligible_count == len(result.top_n) == 1
59 assert result.top_n[0].sector_score is None
60 assert result.top_n[0].score_coverage == 97
61
62
63 @pytest.mark.asyncio
64 async def test_failure_isolated_safe_and_suppressed_not_top_n(monkeypatch):
65 service, rows, pairs, _ = setup(monkeypatch)
66 pairs[UUID(int=2)][1].partial = True
67 async def analyze(profile, readiness, **kwargs):
68 if profile.instrument_id.int == 1: raise RuntimeError('secret-cookie/password')
69 return pairs[profile.instrument_id][1]
70 service.rule_engine.analyze.side_effect = analyze
71 result = await service.run(rows, as_of=NOW)
72 assert [d.status for d in result.diagnostics] == ['FAILED','SUPPRESSED','RANK_ELIGIBLE']
73 assert result.diagnostics[0].failure_reason == 'RULE_ENGINE_UNAVAILABLE'
74 assert result.deep_evaluated_count == 2 and result.rank_eligible_count == 1
75 assert [e.global_instrument_id.int for e in result.top_n] == [3]
76 assert 'secret' not in result.model_dump_json()
77
78
79 @pytest.mark.asyncio
80 async def test_repeat_membership_independence_uuid_ties_and_no_network(monkeypatch):
81 service, rows, pairs, _ = setup(monkeypatch)
82 def forbidden(*a, **k): pytest.fail('Provider/network/membership acquisition attempted')
83 import socket
84 import yfinance
85 from app.nse_historical_daily import NseHistoricalDailyProvider
86 from app.nse_index_history import NseIndexHistoryProvider
87 from app.market_data_population import IndiaMarketDataPopulationJobs
88 monkeypatch.setattr(socket, 'create_connection', forbidden)
89 monkeypatch.setattr(httpx.AsyncClient, 'request', forbidden)
90 monkeypatch.setattr(httpx.Client, 'request', forbidden)
91 monkeypatch.setattr(yfinance, 'Ticker', forbidden)
92 monkeypatch.setattr(NseHistoricalDailyProvider, 'fetch', forbidden)
93 monkeypatch.setattr(NseIndexHistoryProvider, 'fetch', forbidden)
94 monkeypatch.setattr(IndiaMarketDataPopulationJobs, 'backfill_daily_bars', forbidden)
95 service.readiness.ensure = forbidden
96 service.repository.holdings = forbidden
97 service.repository.watchlist = forbidden
98 first = await service.run(rows, as_of=NOW, top_n=2)
99 service.clock = lambda: NOW+timedelta(seconds=1)
100 second = await service.run([r | dict(portfolioId='irrelevant',watchlistMember=True) for r in reversed(rows)], as_of=NOW, top_n=2)
101 assert first.model_dump(exclude={'generated_at'}) == second.model_dump(exclude={'generated_at'})
102 assert [r.global_instrument_id.int for r in second.top_n] == [1,2]
103 assert [r.rank for r in second.top_n] == [1,2]
104 assert second.top_n[0].company_name == 'Company 1'
105 assert 'decisionSignal' not in second.model_dump_json(by_alias=True)
106
107
108 @pytest.mark.asyncio
109 async def test_phase1_exclusion_not_deep_evaluated(monkeypatch):
110 service, rows, pairs, store = setup(monkeypatch)
111 persisted(store, rows[0], {})
112 result = await service.run(rows, as_of=NOW)
113 assert result.phase1_eligible_count == result.stage_b_count == 2
114 assert all(d.global_instrument_id.int != 1 for d in result.diagnostics)
115
116
117 @pytest.mark.asyncio
118 async def test_empty_universe(monkeypatch):
119 service, _, _, store = setup(monkeypatch, 0)
120 queries = []
121 store._connection.set_trace_callback(queries.append)
122 result = await service.run([], as_of=NOW)
123 assert not result.top_n and not result.diagnostics and not queries
124 assert result.universe_count == result.stage_b_count == result.shortlist_count == 0
125 assert service.rule_engine.analyze.await_count == 0
126
127
128 @pytest.mark.asyncio
129 async def test_real_readiness_v1_fingerprint_cache_without_refresh(monkeypatch):
130 service, rows, _, store = setup(monkeypatch, 1, stub_deep=False)
131 def forbidden(*a, **k): pytest.fail('Network or refresh attempted')
132 monkeypatch.setattr(httpx.AsyncClient, 'request', forbidden)
133 monkeypatch.setattr(httpx.Client, 'request', forbidden)
134 import socket
135 monkeypatch.setattr(socket.socket, 'connect', forbidden)
136 service.readiness.ensure = forbidden
137 first = await service.run(rows, as_of=NOW)
138 second = await service.run(rows, as_of=NOW)
139 third = await service.run([r | dict(portfolioId='irrelevant',watchlistMember=True) for r in rows], as_of=NOW)
140 assert first.deep_evaluated_count == second.deep_evaluated_count == 1
141 assert first.diagnostics[0].cache_hit is False and second.diagnostics[0].cache_hit is True
142 assert first.top_n == second.top_n and second == third
143 assert not first.top_n # Sparse evidence cannot be promoted merely to fill Top-N.
144 assert store._connection.execute('SELECT count(*) FROM global_stock_rule_engine_results').fetchone()[0] == 1
145
146
147 @pytest.mark.asyncio
148 @pytest.mark.parametrize('kwargs', [dict(shortlist_limit=0),dict(shortlist_limit=101),dict(top_n=-1),dict(top_n=True)])
149 async def test_invalid_limits(monkeypatch, kwargs):
150 service, rows, _, _ = setup(monkeypatch)
151 with pytest.raises(ValueError, match='INVALID_OPPORTUNITY_LIMIT'):
152 await service.run(rows, as_of=NOW, **kwargs)
153
154
155 @pytest.mark.asyncio
156 async def test_shortlist_uses_screening_confidence_then_prescore(monkeypatch):
157 service, rows, pairs, _ = setup(monkeypatch)
158 pairs[UUID(int=2)][0].confidence = 100
159 pairs[UUID(int=1)][0].stage_b_score = None
160 result = await service.run(rows, as_of=NOW)
161 assert [d.global_instrument_id.int for d in result.diagnostics] == [2,3,1]
162
163
164 @pytest.mark.asyncio
165 async def test_exact_top_n_uses_opportunity_order_not_screening_order(monkeypatch):
166 service, rows, pairs, _ = setup(monkeypatch)
167 for key, score in ((1,40),(2,90),(3,60)):
168 for area in pairs[UUID(int=key)][1].area_scores:
169 area.raw_score = score
170 result = await service.run(rows, as_of=NOW, top_n=2)
171 assert [d.global_instrument_id.int for d in result.diagnostics] == [1,2,3]
172 assert [e.global_instrument_id.int for e in result.top_n] == [2,3]
173 assert result.rank_eligible_count == 3
174 assert result.top_n[0].opportunity_score > result.top_n[1].opportunity_score