feat: add global equity scanner foundation
prakhar82 committed
Sep 13, 2026 at 17:21 UTC
85975161f2d1fa9ec8f02616ce34787c135b527e
7 files changed
+707
-40
ai/research-engine/app/global_scanner.py
new
+309
@@ -0,0 +1,309 @@
1
+"""Provider-free candidate selection. This is not an investment score or V1 analysis.
2
+
3
+Scores use equal-weight available dimensions, renormalized over evidence only.
4
+Confidence measures coverage/readiness, independently of positive/negative values.
5
+The explicit as_of clock is part of the reproducible input state.
6
+"""
7
+from __future__ import annotations
8
+
9
+from collections import Counter, defaultdict
10
+from datetime import datetime, timedelta, timezone
11
+from decimal import Decimal, InvalidOperation
12
+from enum import StrEnum
13
+from typing import Protocol
14
+from uuid import UUID
15
+
16
+import httpx
17
+from app.models import ResearchBaseModel
18
+from app.fact_precedence import SUPPORTED_FINANCIAL_SOURCE_TIERS
19
+from app.persistence import ResearchPersistence
20
+
21
+
22
+class EvidenceState(StrEnum):
23
+ AVAILABLE = "AVAILABLE"
24
+ PARTIAL = "PARTIAL"
25
+ STALE = "STALE"
26
+ MISSING = "MISSING"
27
+ CONFLICTING = "CONFLICTING"
28
+ NOT_APPLICABLE = "NOT_APPLICABLE"
29
+
30
+
31
+class PreScoreDimension(ResearchBaseModel):
32
+ state: EvidenceState
33
+ score: float | None = None
34
+ coverage: float = 0
35
+
36
+
37
+class GlobalScanCandidate(ResearchBaseModel):
38
+ global_instrument_id: UUID
39
+ market: str | None = None
40
+ region: str | None = None
41
+ country: str | None = None
42
+ exchange: str | None = None
43
+ currency: str | None = None
44
+ asset_type: str | None = None
45
+ status: str | None = None
46
+ symbol: str | None = None
47
+ company_name: str | None = None
48
+ verified_provider_mapping_status: str
49
+ pre_score: float | None
50
+ confidence: float
51
+ critical_completeness: float
52
+ price_data_available: bool
53
+ financial_data_available: bool
54
+ technical_history_available: bool
55
+ sector_data_available: bool
56
+ dimensions: dict[str, PreScoreDimension]
57
+ missing_inputs: list[str]
58
+ stale_inputs: list[str]
59
+ eligible_for_deep_analysis: bool
60
+ exclusion_reasons: list[str]
61
+
62
+
63
+class GlobalScanResult(ResearchBaseModel):
64
+ as_of: datetime
65
+ pre_score_version: str = "GLOBAL_PRE_SCORE_V1"
66
+ total_canonical_active_equities: int
67
+ eligible_candidates: int
68
+ excluded_candidates_by_reason: dict[str, int]
69
+ deep_analysis_eligible_count: int
70
+ candidates: list[GlobalScanCandidate]
71
+ top_candidates: list[GlobalScanCandidate]
72
+ deep_analysis_candidate_ids: list[UUID]
73
+
74
+
75
+class EquityUniverse(Protocol):
76
+ async def active_global_equities(self, **kwargs) -> list[dict]: ...
77
+
78
+
79
+class CanonicalEquityUniverse:
80
+ """Read canonical metadata only; no portfolio ownership or provider acquisition."""
81
+
82
+ def __init__(self, client: httpx.AsyncClient, base_url: str):
83
+ self.client, self.base_url = client, base_url.rstrip("/")
84
+
85
+ async def active_global_equities(self, *, correlation_id=None, identity_headers=None):
86
+ headers = {k: v for k, v in (identity_headers or {}).items() if v}
87
+ if correlation_id:
88
+ headers["X-Correlation-Id"] = correlation_id
89
+ values, page = [], 0
90
+ while True:
91
+ response = await self.client.get(
92
+ f"{self.base_url}/api/v1/instruments",
93
+ params={"status": "ACTIVE", "assetType": "EQUITY", "page": page, "size": 500},
94
+ headers=headers or None,
95
+ )
96
+ response.raise_for_status()
97
+ payload = response.json()
98
+ if not isinstance(payload, dict) or not isinstance(payload.get("instruments"), list):
99
+ raise ValueError("Invalid canonical universe response")
100
+ batch = payload["instruments"]
101
+ total = int(payload.get("totalElements", len(values) + len(batch)))
102
+ values.extend(batch)
103
+ if len(values) >= total:
104
+ return values
105
+ if not batch:
106
+ raise ValueError("Incomplete canonical universe pagination")
107
+ page += 1
108
+
109
+
110
+def _utc(value):
111
+ return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
112
+
113
+
114
+def _number(value):
115
+ if value is None or isinstance(value, bool):
116
+ return None
117
+ try:
118
+ number = Decimal(str(value))
119
+ return number if number.is_finite() else None
120
+ except (InvalidOperation, ValueError):
121
+ return None
122
+
123
+
124
+class GlobalPreScore:
125
+ """Cheap readiness and sign heuristics; no valuation targets or V1 formulas.
126
+
127
+ Critical inputs are a fresh trusted price and at least one fresh profitability
128
+ metric. Optional dimensions never supply a synthetic zero. Quality metrics
129
+ use only sign (positive=100, actual zero=50, negative=0), avoiding unit-dependent
130
+ thresholds. No financial ratios or technical indicators are derived here.
131
+ """
132
+
133
+ def __init__(self, *, price_max_age=timedelta(days=7), financial_max_age=timedelta(days=550), history_points=50):
134
+ if price_max_age.total_seconds() <= 0 or financial_max_age.total_seconds() <= 0 or history_points < 2:
135
+ raise ValueError("Readiness limits must be positive; history_points must be >= 2")
136
+ self.price_max_age = price_max_age
137
+ self.financial_max_age = financial_max_age
138
+ self.history_points = history_points
139
+
140
+ def score(self, instrument, snapshots, facts, prices, *, as_of):
141
+ as_of = _utc(as_of)
142
+ missing, stale = set(), set()
143
+ evidence = defaultdict(list)
144
+ currency = instrument.get("currency")
145
+ mappings = [m for m in instrument.get("providerMappings", []) if isinstance(m, dict)
146
+ and m.get("status") == "VERIFIED" and m.get("provider")
147
+ and (m.get("providerInstrumentId") or m.get("providerSymbol"))
148
+ and m.get("resolutionSource") != "BROKER_IMPORT_IDENTITY"]
149
+ providers = {m["provider"] for m in mappings}
150
+ for record in snapshots:
151
+ mapped_ids = {str(m.get(key)) for m in mappings if m["provider"] == record.provider
152
+ for key in ("providerInstrumentId", "providerSymbol") if m.get(key)}
153
+ if (record.provider not in providers or _utc(record.retrieved_at) > as_of
154
+ or record.provider_instrument_id not in mapped_ids or record.currency != currency):
155
+ continue
156
+ for key, value in record.snapshot.facts.items():
157
+ if _utc(value.retrieved_at) > as_of:
158
+ continue
159
+ stamp = value.as_of_date or value.published_at or record.market_as_of or record.retrieved_at
160
+ if _utc(stamp) <= as_of:
161
+ evidence[key].append((value.value, _utc(stamp)))
162
+ financial_evidence = defaultdict(list)
163
+ for fact in facts:
164
+ if fact.source_tier not in SUPPORTED_FINANCIAL_SOURCE_TIERS or str(fact.source_mode) != "REAL" or _utc(fact.value.retrieved_at) > as_of:
165
+ continue
166
+ stamp = fact.value.as_of_date or fact.value.published_at or fact.value.retrieved_at
167
+ if fact.key.period_end:
168
+ try:
169
+ stamp = datetime.fromisoformat(fact.key.period_end)
170
+ except ValueError:
171
+ continue
172
+ if _utc(stamp) <= as_of:
173
+ metric = {"profit_margin": "profitMargin", "return_on_equity": "roe",
174
+ "net_income": "pat", "net_profit": "pat", "revenue_growth": "revenueGrowth",
175
+ "earnings_growth": "earningsGrowth", "equity": "total_equity"}.get(fact.key.metric, fact.key.metric)
176
+ # Annual and quarterly amounts (or standalone and consolidated
177
+ # statements) are not conflicting observations of one metric.
178
+ basis_rank = {"CONSOLIDATED": 2, "UNKNOWN": 1}.get(fact.key.reporting_basis, 0)
179
+ period_rank = {"ANNUAL": 2, "QUARTERLY": 1}.get(fact.key.period_type, 0)
180
+ financial_evidence[metric].append((fact.value.value, _utc(stamp), basis_rank, period_rank))
181
+ for metric, values in financial_evidence.items():
182
+ selected = max((stamp, basis, period) for _, stamp, basis, period in values)
183
+ evidence[metric].extend((value, stamp) for value, stamp, basis, period in values
184
+ if (stamp, basis, period) == selected)
185
+
186
+ def dimension(keys, *, quality=False, text=False, max_age=None):
187
+ scores, ready, conflicts, old = [], 0, False, False
188
+ for key in keys:
189
+ values = evidence.get(key, [])
190
+ valid = [(str(v).strip() if text and v is not None else _number(v), t) for v, t in values]
191
+ valid = [(v, t) for v, t in valid if v is not None and v != ""]
192
+ if not valid:
193
+ missing.add(key)
194
+ continue
195
+ latest = max(t for _, t in valid)
196
+ current = {v for v, t in valid if t == latest}
197
+ if len(current) > 1:
198
+ conflicts = True
199
+ continue
200
+ value = next(iter(current))
201
+ is_stale = as_of - latest > (max_age or self.financial_max_age)
202
+ if is_stale:
203
+ stale.add(key)
204
+ old = True
205
+ else:
206
+ ready += 1
207
+ scores.append((100 if value > 0 else 50 if value == 0 else 0) if quality else 100)
208
+ state = (EvidenceState.CONFLICTING if conflicts else EvidenceState.STALE if old else
209
+ EvidenceState.MISSING if not scores else EvidenceState.PARTIAL if len(scores) < len(keys)
210
+ else EvidenceState.AVAILABLE)
211
+ return PreScoreDimension(state=state, score=sum(scores)/len(scores) if scores and not conflicts else None,
212
+ coverage=ready/len(keys) if not conflicts else 0)
213
+
214
+ usable = [p for p in prices if p.provider in providers and _number(p.price) is not None and p.price > 0
215
+ and currency and p.currency == currency and _utc(p.observed_at) <= as_of and _utc(p.retrieved_at) <= as_of]
216
+ latest = max((_utc(p.observed_at) for p in usable), default=None)
217
+ price_conflict = len({p.price for p in usable if _utc(p.observed_at) == latest}) > 1
218
+ fresh_price = latest is not None and as_of - latest <= self.price_max_age and not price_conflict
219
+ price_state = (EvidenceState.CONFLICTING if price_conflict else EvidenceState.MISSING if latest is None
220
+ else EvidenceState.AVAILABLE if fresh_price else EvidenceState.STALE)
221
+ if latest is None:
222
+ missing.add("price")
223
+ elif not fresh_price and not price_conflict:
224
+ stale.add("price")
225
+ days = len({_utc(p.observed_at).date() for p in usable})
226
+ dimensions = {
227
+ "PRICE_DATA_QUALITY": PreScoreDimension(state=price_state, score=100 if usable and not price_conflict else None, coverage=float(fresh_price)),
228
+ "VALUATION_AVAILABILITY": dimension(["trailingPE", "priceToBook"], max_age=self.price_max_age),
229
+ "PROFITABILITY_QUALITY": dimension(["profitMargin", "roe", "pat"], quality=True),
230
+ "GROWTH_QUALITY": dimension(["revenueGrowth", "earningsGrowth"], quality=True),
231
+ "BALANCE_SHEET_QUALITY": dimension(["total_equity"], quality=True),
232
+ "TECHNICAL_DATA_READINESS": PreScoreDimension(state=EvidenceState.MISSING if not days else EvidenceState.STALE if not fresh_price else EvidenceState.AVAILABLE if days >= self.history_points else EvidenceState.PARTIAL,
233
+ score=100 if days >= self.history_points else None, coverage=min(1, days/self.history_points) if fresh_price else 0),
234
+ "SECTOR_DATA_READINESS": dimension(["sector"], text=True),
235
+ }
236
+ if days < self.history_points:
237
+ missing.add("technicalHistory")
238
+ profitability = dimensions["PROFITABILITY_QUALITY"]
239
+ financial_available = profitability.score is not None
240
+ financial_ready = profitability.coverage > 0 and profitability.state in {"AVAILABLE", "PARTIAL"}
241
+ critical = (int(fresh_price) + int(financial_ready)) / 2
242
+ readiness = sum(d.coverage for d in dimensions.values()) / len(dimensions)
243
+ dimensions["FRESHNESS"] = PreScoreDimension(state=EvidenceState.STALE if stale else EvidenceState.AVAILABLE,
244
+ score=None, coverage=readiness)
245
+ dimensions["CRITICAL_COMPLETENESS"] = PreScoreDimension(state=EvidenceState.AVAILABLE if critical == 1 else EvidenceState.PARTIAL if critical else EvidenceState.MISSING,
246
+ score=None, coverage=critical)
247
+ symbol = instrument.get("ticker") or instrument.get("primarySymbol")
248
+ name = instrument.get("canonicalName")
249
+ reasons = []
250
+ if instrument.get("status") != "ACTIVE": reasons.append("INACTIVE")
251
+ if instrument.get("assetType") != "EQUITY": reasons.append("NON_EQUITY")
252
+ if not (mappings and symbol and name and instrument.get("exchange") and currency): reasons.append("UNTRUSTED_CANONICAL_IDENTITY")
253
+ if not usable: reasons.append("NO_USABLE_PRICE")
254
+ elif price_conflict: reasons.append("CONFLICTING_PRICE")
255
+ elif not fresh_price: reasons.append("STALE_PRICE")
256
+ if not financial_ready: reasons.append("CRITICAL_FINANCIAL_EVIDENCE_UNREADY")
257
+ scores = [d.score for d in dimensions.values() if d.score is not None and d.state in {"AVAILABLE", "PARTIAL"}]
258
+ return GlobalScanCandidate(global_instrument_id=instrument["globalInstrumentId"], market=instrument.get("exchange"),
259
+ region=instrument.get("region"), country=instrument.get("country"), exchange=instrument.get("exchange"),
260
+ currency=currency, asset_type=instrument.get("assetType"), status=instrument.get("status"), symbol=symbol, company_name=name,
261
+ verified_provider_mapping_status="VERIFIED" if mappings else "MISSING", pre_score=round(sum(scores)/len(scores), 6) if scores else None,
262
+ confidence=round(100*readiness*critical, 6), critical_completeness=critical*100,
263
+ price_data_available=bool(usable) and not price_conflict, financial_data_available=financial_available,
264
+ technical_history_available=days >= self.history_points, sector_data_available=dimensions["SECTOR_DATA_READINESS"].score is not None,
265
+ dimensions=dimensions, missing_inputs=sorted(missing), stale_inputs=sorted(stale),
266
+ eligible_for_deep_analysis=not reasons, exclusion_reasons=reasons)
267
+
268
+
269
+class GlobalScanner:
270
+ def __init__(self, universe: EquityUniverse, persistence: ResearchPersistence, *, pre_score=None, batch_size=250):
271
+ if not 1 <= batch_size <= 500:
272
+ raise ValueError("batch_size must be between 1 and 500")
273
+ self.universe, self.persistence = universe, persistence
274
+ self.pre_score, self.batch_size = pre_score or GlobalPreScore(), batch_size
275
+
276
+ async def scan(self, *, as_of: datetime, top_n: int, **universe_context) -> GlobalScanResult:
277
+ if top_n < 0:
278
+ raise ValueError("top_n must be nonnegative")
279
+ instruments = await self.universe.active_global_equities(**universe_context)
280
+ by_id = {}
281
+ invalid = 0
282
+ for item in instruments:
283
+ try:
284
+ key = UUID(str(item.get("globalInstrumentId")))
285
+ except (ValueError, TypeError):
286
+ invalid += 1
287
+ continue
288
+ if key in by_id and by_id[key] != item:
289
+ raise ValueError(f"Conflicting canonical records for {key}")
290
+ by_id[key] = item
291
+ candidates = []
292
+ ordered = sorted(by_id, key=str)
293
+ for offset in range(0, len(ordered), self.batch_size):
294
+ ids = set(ordered[offset:offset+self.batch_size])
295
+ snapshots, facts, prices = defaultdict(list), defaultdict(list), defaultdict(list)
296
+ for row in self.persistence.load_structured_market_snapshots(ids): snapshots[row.instrument_id].append(row)
297
+ for row in self.persistence.load_financial_facts(ids): facts[row.key.instrument_id].append(row)
298
+ for row in self.persistence.load_market_price_observations(ids): prices[row.instrument_id].append(row)
299
+ for key in sorted(ids, key=str):
300
+ candidates.append(self.pre_score.score(by_id[key], snapshots[key], facts[key], prices[key], as_of=as_of))
301
+ candidates.sort(key=lambda c: (-(c.pre_score if c.pre_score is not None else -1), -c.confidence, -c.critical_completeness, str(c.global_instrument_id)))
302
+ eligible = [c for c in candidates if c.eligible_for_deep_analysis]
303
+ excluded = Counter(reason for c in candidates for reason in c.exclusion_reasons)
304
+ if invalid: excluded["UNTRUSTED_CANONICAL_IDENTITY"] += invalid
305
+ top = eligible[:top_n]
306
+ return GlobalScanResult(as_of=_utc(as_of), total_canonical_active_equities=sum(i.get("status") == "ACTIVE" and i.get("assetType") == "EQUITY" for i in by_id.values()),
307
+ eligible_candidates=len(eligible), excluded_candidates_by_reason=dict(sorted(excluded.items())),
308
+ deep_analysis_eligible_count=len(eligible), candidates=candidates, top_candidates=top,
309
+ deep_analysis_candidate_ids=[c.global_instrument_id for c in top])
ai/research-engine/app/persistence.py
+35
-16
@@ -49,7 +49,7 @@ class ResearchPersistence(Protocol):
49
def load_documents(self) -> list[ResearchDocument]:
50
...
51
52
- def load_events(self) -> list[ResearchEvent]:
52
+ def load_events(self, instrument_ids: set[UUID] | None = None) -> list[ResearchEvent]:
53
...
54
55
def upsert_document(self, document: ResearchDocument) -> bool:
@@ -67,7 +67,7 @@ class ResearchPersistence(Protocol):
67
def upsert_event(self, event: ResearchEvent) -> bool:
68
...
69
70
- def load_shareholding_snapshots(self) -> list[ShareholdingSnapshot]:
70
+ def load_shareholding_snapshots(self, instrument_ids: set[UUID] | None = None) -> list[ShareholdingSnapshot]:
71
...
72
73
def upsert_shareholding_snapshot(self, snapshot: ShareholdingSnapshot) -> bool:
@@ -99,7 +99,7 @@ class ResearchPersistence(Protocol):
99
) -> None:
100
...
101
102
- def load_financial_facts(self) -> list[FinancialFact]: ...
102
+ def load_financial_facts(self, instrument_ids: set[UUID] | None = None) -> list[FinancialFact]: ...
103
def upsert_financial_fact(self, fact: FinancialFact, *, allow_same_tier_correction: bool = False) -> bool: ...
104
def load_structured_market_snapshots(self, instrument_ids: set[UUID] | None = None) -> list[StructuredMarketSnapshotRecord]: ...
105
def upsert_structured_market_snapshot(self, record: StructuredMarketSnapshotRecord) -> None: ...
@@ -121,7 +121,7 @@ class DisabledResearchPersistence:
121
def load_documents(self) -> list[ResearchDocument]:
122
return []
123
124
- def load_events(self) -> list[ResearchEvent]:
124
+ def load_events(self, instrument_ids: set[UUID] | None = None) -> list[ResearchEvent]:
125
return []
126
127
def upsert_document(self, document: ResearchDocument) -> bool:
@@ -130,7 +130,7 @@ class DisabledResearchPersistence:
130
def upsert_event(self, event: ResearchEvent) -> bool:
131
return True
132
133
- def load_shareholding_snapshots(self) -> list[ShareholdingSnapshot]:
133
+ def load_shareholding_snapshots(self, instrument_ids: set[UUID] | None = None) -> list[ShareholdingSnapshot]:
134
return []
135
136
def upsert_shareholding_snapshot(self, snapshot: ShareholdingSnapshot) -> bool:
@@ -149,7 +149,7 @@ class DisabledResearchPersistence:
149
def complete_refresh_run(self, run: RefreshRun, **kwargs) -> None:
150
return None
151
152
- def load_financial_facts(self): return []
152
+ def load_financial_facts(self, instrument_ids=None): return []
153
def upsert_financial_fact(self, fact, **kwargs): return True
154
def load_structured_market_snapshots(self, instrument_ids=None): return []
155
def upsert_structured_market_snapshot(self, record): return None
@@ -186,8 +186,25 @@ class SqliteResearchPersistence:
186
self._connection.executescript(_sqlite_schema())
187
self._connection.commit()
188
189
- def load_financial_facts(self) -> list[FinancialFact]:
190
- return [_financial_fact_from_row(row) for row in self._connection.execute("SELECT * FROM global_financial_facts").fetchall()]
189
+ def _filtered_rows(self, table, ids, *, column="instrument_id", order=None):
190
+ """Internal identifiers only; values are parameterized in bounded batches."""
191
+ if ids is not None:
192
+ ordered = sorted({str(value) for value in ids})
193
+ rows = []
194
+ for offset in range(0, len(ordered), 500):
195
+ batch = ordered[offset:offset + 500]
196
+ sql = f"SELECT * FROM {table} WHERE {column} IN ({','.join('?' for _ in batch)})"
197
+ if order:
198
+ sql += f" ORDER BY {order}"
199
+ rows.extend(self._connection.execute(sql, batch).fetchall())
200
+ return rows
201
+ sql = f"SELECT * FROM {table}"
202
+ if order:
203
+ sql += f" ORDER BY {order}"
204
+ return self._connection.execute(sql).fetchall()
205
+
206
+ def load_financial_facts(self, instrument_ids: set[UUID] | None = None) -> list[FinancialFact]:
207
+ return [_financial_fact_from_row(row) for row in self._filtered_rows("global_financial_facts", instrument_ids)]
208
209
def upsert_financial_fact(self, fact: FinancialFact, *, allow_same_tier_correction: bool = False) -> bool:
210
key = fact.key
@@ -258,6 +275,8 @@ class SqliteResearchPersistence:
275
(str(key.instrument_id), key.metric, key.period_end or "", key.period_type, key.reporting_basis or "", str(fact.value.value), fact.value.unit, fact.source_provider, fact.source_identity, fact.value.source_url, fact.value.source_name, fact.value.source_type, _dt(fact.value.published_at), _dt(fact.value.retrieved_at), fact.value.confidence, str(fact.source_mode), int(fact.source_tier)))
276
277
def load_structured_market_snapshots(self, instrument_ids: set[UUID] | None = None) -> list[StructuredMarketSnapshotRecord]:
278
+ if instrument_ids is not None and not instrument_ids:
279
+ return []
280
params: list[str] = []
281
sql = "SELECT * FROM global_structured_market_snapshots"
282
if instrument_ids:
@@ -292,6 +311,8 @@ class SqliteResearchPersistence:
311
observation.currency, observation.provider, observation.source_url, _dt(observation.retrieved_at)))
312
313
def load_market_price_observations(self, instrument_ids: set[UUID] | None = None) -> list[MarketPriceObservation]:
314
+ if instrument_ids is not None and not instrument_ids:
315
+ return []
316
params: list[str] = []
317
sql = "SELECT * FROM global_market_price_observations"
318
if instrument_ids:
@@ -405,11 +426,11 @@ class SqliteResearchPersistence:
426
rows = self._connection.execute("SELECT * FROM research_documents ORDER BY retrieved_at").fetchall()
427
return [_document_from_row(row) for row in rows]
428
408
- def load_events(self) -> list[ResearchEvent]:
409
- rows = self._connection.execute("SELECT * FROM research_events ORDER BY detected_at").fetchall()
429
+ def load_events(self, instrument_ids: set[UUID] | None = None) -> list[ResearchEvent]:
430
+ rows = self._filtered_rows("research_events", instrument_ids, order="detected_at")
431
events = [_event_from_row(row) for row in rows]
432
sources_by_event: dict[UUID, list[ResearchEvidenceSource]] = {}
412
- for row in self._connection.execute("SELECT * FROM research_event_sources ORDER BY created_at").fetchall():
433
+ for row in self._filtered_rows("research_event_sources", {row["event_id"] for row in rows}, column="event_id", order="created_at"):
434
event_id = _parse_uuid(row["event_id"])
435
if event_id is None:
436
continue
@@ -418,12 +439,10 @@ class SqliteResearchPersistence:
439
event.supporting_sources = sources_by_event.get(event.event_id, [])
440
return events
441
421
- def load_shareholding_snapshots(self) -> list[ShareholdingSnapshot]:
422
- rows = self._connection.execute(
423
- "SELECT * FROM global_shareholding_snapshots ORDER BY period_end DESC, retrieved_at DESC"
424
- ).fetchall()
442
+ def load_shareholding_snapshots(self, instrument_ids: set[UUID] | None = None) -> list[ShareholdingSnapshot]:
443
+ rows = self._filtered_rows("global_shareholding_snapshots", instrument_ids, order="period_end DESC, retrieved_at DESC")
444
values_by_snapshot: dict[UUID, list[ShareholdingSnapshotValue]] = {}
426
- for row in self._connection.execute("SELECT * FROM global_shareholding_snapshot_values ORDER BY created_at").fetchall():
445
+ for row in self._filtered_rows("global_shareholding_snapshot_values", {row["id"] for row in rows}, column="snapshot_id", order="created_at"):
446
snapshot_id = _required_uuid(row["snapshot_id"], "global_shareholding_snapshot_values.snapshot_id")
447
values_by_snapshot.setdefault(snapshot_id, []).append(ShareholdingSnapshotValue(
448
id=_required_uuid(row["id"], "global_shareholding_snapshot_values.id"), category=ShareholdingCategory(row["category"]),
ai/research-engine/app/portfolio_orchestration.py
+6
-17
@@ -82,24 +82,13 @@ class PortfolioResearchOrchestrator:
82
identity_headers: dict[str, str | None] | None = None,
83
) -> list[dict]:
84
"""Read the portfolio-service owned active-equity universe; never mutate it."""
85
+ from app.global_scanner import CanonicalEquityUniverse
86
+
87
try:
86
- page, values, total = 0, [], None
87
- headers = {key: value for key, value in (identity_headers or {}).items() if value}
88
- if correlation_id:
89
- headers["X-Correlation-Id"] = correlation_id
90
- while total is None or len(values) < total:
91
- response = await self._client.get(
92
- f"{self.settings.portfolio_service_base_url}/api/v1/instruments",
93
- params={"status": "ACTIVE", "assetType": "EQUITY", "page": page, "size": 500},
94
- headers=headers or None,
95
- )
96
- response.raise_for_status(); payload = response.json()
97
- batch = payload.get("instruments", []) if isinstance(payload, dict) else []
98
- values.extend(batch); total = payload.get("totalElements", len(values)) if isinstance(payload, dict) else len(values)
99
- if not batch: break
100
- page += 1
101
- return values
102
- except httpx.HTTPError as exc:
88
+ return await CanonicalEquityUniverse(self._client, self.settings.portfolio_service_base_url).active_global_equities(
89
+ correlation_id=correlation_id, identity_headers=identity_headers,
90
+ )
91
+ except (httpx.HTTPError, ValueError) as exc:
92
raise PortfolioServiceUnavailableError("Portfolio service unavailable for instrument enumeration") from exc
93
94
async def india_nifty500_universe(self, *, correlation_id: str | None = None, identity_headers: dict[str, str | None] | None = None) -> list[dict]:
ai/research-engine/app/repository.py
+2
-2
@@ -206,11 +206,11 @@ class ResearchRepository:
206
return self.etf_profiles
207
208
def financial_facts_for(self, instrument_id: UUID):
209
- return [fact for fact in self._persistence.load_financial_facts() if fact.key.instrument_id == instrument_id]
209
+ return self._persistence.load_financial_facts({instrument_id})
210
211
async def financial_facts_for_instruments(self, instrument_ids: set[UUID]) -> dict[UUID, list[FinancialFact]]:
212
started = time.perf_counter()
213
- facts = await self._run_blocking_persistence(self._persistence.load_financial_facts)
213
+ facts = await self._run_blocking_persistence(self._persistence.load_financial_facts, instrument_ids)
214
grouped: dict[UUID, list[FinancialFact]] = {instrument_id: [] for instrument_id in instrument_ids}
215
for fact in facts:
216
if fact.key.instrument_id in grouped:
ai/research-engine/tests/test_global_scanner.py
new
+253
@@ -0,0 +1,253 @@
1
+from datetime import datetime, timedelta, timezone
2
+from decimal import Decimal
3
+from uuid import UUID
4
+
5
+import httpx
6
+import pytest
7
+
8
+from app.global_scanner import CanonicalEquityUniverse, GlobalPreScore, GlobalScanner
9
+from app.models import MarketPriceObservation, ProvenancedValue
10
+from app.persistence import SqliteResearchPersistence
11
+from app.fact_precedence import FinancialFact, FinancialFactKey, FactSourceTier
12
+from test_structured_market_persistence import _record
13
+
14
+NOW = datetime(2026, 9, 13, tzinfo=timezone.utc)
15
+
16
+
17
+def instrument(n=1, **updates):
18
+ return dict(globalInstrumentId=str(UUID(int=n)), canonicalName=f"Company {n}", ticker=f"C{n}",
19
+ exchange="NSE", country="IN", currency="INR", status="ACTIVE", assetType="EQUITY",
20
+ providerMappings=[dict(provider="YAHOO_FINANCE", providerSymbol="ABC.NS", status="VERIFIED")], **updates)
21
+
22
+
23
+def persisted(store, item, metrics=None, age=0, price=True):
24
+ key = UUID(item["globalInstrumentId"])
25
+ stamp = NOW - timedelta(days=age)
26
+ record = _record(key)
27
+ record.currency = item["currency"]
28
+ record.retrieved_at = record.market_as_of = stamp
29
+ record.snapshot.facts = {k: ProvenancedValue(value=v, source_url="https://example.test", source_name="test", retrieved_at=stamp, as_of_date=stamp)
30
+ for k, v in (metrics if metrics is not None else {"profitMargin": 10, "roe": 5}).items()}
31
+ store.upsert_structured_market_snapshot(record)
32
+ if price:
33
+ store.upsert_market_price_observation(MarketPriceObservation(instrument_id=key, observed_at=stamp, retrieved_at=stamp,
34
+ price=Decimal(100), currency=item["currency"], provider="YAHOO_FINANCE", source_url="https://example.test"))
35
+
36
+
37
+async def scan(items, store, **kwargs):
38
+ def handler(request):
39
+ assert request.method == "GET" and request.url.path == "/api/v1/instruments"
40
+ assert request.url.params["status"] == "ACTIVE" and request.url.params["assetType"] == "EQUITY"
41
+ return httpx.Response(200, json={"instruments": items, "totalElements": len(items)})
42
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
43
+ return await GlobalScanner(CanonicalEquityUniverse(client, "http://canonical"), store).scan(as_of=NOW, top_n=kwargs.get("top_n", 10))
44
+
45
+
46
+@pytest.mark.asyncio
47
+@pytest.mark.parametrize("change,reason", [({}, None), ({"status": "INACTIVE"}, "INACTIVE"),
48
+ ({"assetType": "ETF"}, "NON_EQUITY"), ({"providerMappings": []}, "UNTRUSTED_CANONICAL_IDENTITY"),
49
+ ({"canonicalName": None}, "UNTRUSTED_CANONICAL_IDENTITY")])
50
+async def test_eligibility(change, reason):
51
+ store = SqliteResearchPersistence()
52
+ item = instrument() | change
53
+ persisted(store, item)
54
+ result = await scan([item], store)
55
+ candidate = result.candidates[0]
56
+ assert candidate.eligible_for_deep_analysis == (reason is None)
57
+ if reason: assert reason in candidate.exclusion_reasons
58
+
59
+
60
+@pytest.mark.asyncio
61
+async def test_no_price_and_stale_price_fail_critical_gate():
62
+ store = SqliteResearchPersistence()
63
+ items = [instrument(n) for n in range(1, 4)]
64
+ persisted(store, items[0])
65
+ persisted(store, items[1], price=False)
66
+ persisted(store, items[2], age=20)
67
+ result = await scan(items, store)
68
+ by_id = {c.global_instrument_id.int: c for c in result.candidates}
69
+ assert "NO_USABLE_PRICE" in by_id[2].exclusion_reasons
70
+ assert "STALE_PRICE" in by_id[3].exclusion_reasons
71
+ assert by_id[3].confidence < by_id[1].confidence
72
+ assert by_id[3].stale_inputs == ["price"]
73
+
74
+
75
+@pytest.mark.asyncio
76
+async def test_missing_optional_renormalizes_and_actual_zero_survives():
77
+ store = SqliteResearchPersistence()
78
+ items = [instrument(n) for n in range(1, 4)]
79
+ persisted(store, items[0], {"profitMargin": 10, "roe": 5, "revenueGrowth": None})
80
+ persisted(store, items[1], {"profitMargin": 10, "roe": 5, "revenueGrowth": 0})
81
+ persisted(store, items[2], {"profitMargin": 10, "roe": 5, "revenueGrowth": -10})
82
+ result = await scan(items, store)
83
+ a, b, c = result.candidates
84
+ assert a.dimensions["GROWTH_QUALITY"].score is None
85
+ assert b.dimensions["GROWTH_QUALITY"].score == 50
86
+ assert c.dimensions["GROWTH_QUALITY"].score == 0
87
+ assert a.pre_score > b.pre_score > c.pre_score
88
+
89
+
90
+@pytest.mark.asyncio
91
+async def test_determinism_ties_top_n_and_membership_independence():
92
+ store = SqliteResearchPersistence()
93
+ items = [instrument(n) for n in range(1, 13)]
94
+ for item in items: persisted(store, item)
95
+ first = await scan(items, store, top_n=7)
96
+ private_items = [item | {"portfolioId": "private", "quantity": 900, "averageCost": 1, "PnL": -500,
97
+ "allocation": .9, "watchlistMembership": True} for item in reversed(items)]
98
+ second = await scan(private_items, store, top_n=7)
99
+ assert first == second
100
+ assert [c.global_instrument_id.int for c in first.candidates] == list(range(1, 13))
101
+ assert len(first.deep_analysis_candidate_ids) == 7
102
+
103
+
104
+@pytest.mark.asyncio
105
+@pytest.mark.parametrize("country,exchange,currency", [("IN", "NSE", "INR"), ("US", "XNAS", "USD"), ("DE", "XETR", "EUR")])
106
+async def test_region_neutral_public_evidence(country, exchange, currency):
107
+ store = SqliteResearchPersistence()
108
+ item = instrument() | dict(country=country, exchange=exchange, currency=currency)
109
+ persisted(store, item)
110
+ candidate = (await scan([item], store)).candidates[0]
111
+ assert candidate.eligible_for_deep_analysis
112
+ assert candidate.market == exchange and candidate.country == country
113
+
114
+
115
+@pytest.mark.asyncio
116
+async def test_provider_adapters_and_v1_never_invoked(monkeypatch):
117
+ def forbidden(*args, **kwargs): raise AssertionError("Provider or V1 invoked")
118
+ from app.structured_market import YahooFinanceProvider
119
+ from app.stock_rule_engine import StockRuleEngineService
120
+ monkeypatch.setattr(YahooFinanceProvider, "__init__", forbidden)
121
+ monkeypatch.setattr(StockRuleEngineService, "analyze", forbidden)
122
+ monkeypatch.setattr(httpx.Client, "send", forbidden)
123
+ store = SqliteResearchPersistence()
124
+ item = instrument()
125
+ persisted(store, item)
126
+ assert (await scan([item], store)).eligible_candidates == 1
127
+
128
+
129
+@pytest.mark.asyncio
130
+async def test_conflicting_financial_evidence_and_future_prices():
131
+ store = SqliteResearchPersistence()
132
+ item = instrument()
133
+ persisted(store, item, {"profitMargin": 10})
134
+ store.upsert_financial_fact(FinancialFact(FinancialFactKey(UUID(int=1), "profitMargin", None, "ANNUAL"),
135
+ ProvenancedValue(value=-10, source_url="https://example.test", source_name="official", retrieved_at=NOW),
136
+ FactSourceTier.OFFICIAL_REGULATORY, "OFFICIAL", "fact"))
137
+ candidate = (await scan([item], store)).candidates[0]
138
+ assert candidate.dimensions["PROFITABILITY_QUALITY"].state == "CONFLICTING"
139
+ assert not candidate.eligible_for_deep_analysis
140
+ store = SqliteResearchPersistence()
141
+ persisted(store, item, age=-1)
142
+ assert "NO_USABLE_PRICE" in (await scan([item], store)).candidates[0].exclusion_reasons
143
+
144
+
145
+@pytest.mark.asyncio
146
+async def test_stale_financial_evidence_cannot_be_revived_by_retrieval():
147
+ store = SqliteResearchPersistence()
148
+ item = instrument()
149
+ persisted(store, item, {"profitMargin": 5}, age=600)
150
+ record = store.load_structured_market_snapshots({UUID(int=1)})[0]
151
+ record.retrieved_at = NOW
152
+ store.upsert_structured_market_snapshot(record)
153
+ candidate = (await scan([item], store)).candidates[0]
154
+ assert candidate.dimensions["PROFITABILITY_QUALITY"].state == "STALE"
155
+ assert "profitMargin" in candidate.stale_inputs
156
+
157
+
158
+@pytest.mark.asyncio
159
+async def test_empty_universe_performs_no_persistence_reads():
160
+ class NoReads:
161
+ def __getattr__(self, name): raise AssertionError(name)
162
+ assert (await scan([], NoReads())).candidates == []
163
+
164
+
165
+def test_history_counts_distinct_days_and_rejects_currency_mismatch():
166
+ store = SqliteResearchPersistence()
167
+ item = instrument()
168
+ persisted(store, item)
169
+ prices = store.load_market_price_observations({UUID(int=1)})
170
+ scorer = GlobalPreScore(history_points=2)
171
+ candidate = scorer.score(item, [], [], prices*50, as_of=NOW)
172
+ assert not candidate.technical_history_available
173
+ prices[0].currency = "USD"
174
+ assert not scorer.score(item, [], [], prices, as_of=NOW).price_data_available
175
+
176
+
177
+@pytest.mark.asyncio
178
+async def test_equal_prescore_orders_confidence_before_id():
179
+ store = SqliteResearchPersistence()
180
+ items = [instrument(1), instrument(2)]
181
+ persisted(store, items[0], {"profitMargin": 10})
182
+ persisted(store, items[1], {"profitMargin": 10, "roe": 5, "revenueGrowth": 10})
183
+ candidates = (await scan(items, store)).candidates
184
+ assert candidates[0].pre_score == candidates[1].pre_score
185
+ assert candidates[0].confidence > candidates[1].confidence
186
+ assert candidates[0].global_instrument_id.int == 2
187
+
188
+
189
+@pytest.mark.asyncio
190
+async def test_scanner_batches_requested_instruments_only():
191
+ from app.persistence import DisabledResearchPersistence
192
+ calls = []
193
+ class Store(DisabledResearchPersistence):
194
+ def load_financial_facts(self, ids): calls.append(("facts", ids)); return []
195
+ def load_market_price_observations(self, ids): calls.append(("prices", ids)); return []
196
+ def load_structured_market_snapshots(self, ids): calls.append(("snapshots", ids)); return []
197
+ result = await scan([instrument(n) for n in range(1, 502)], Store())
198
+ assert len(result.candidates) == 501
199
+ assert [len(ids) for name, ids in calls if name == "facts"] == [250, 250, 1]
200
+ assert len(calls) == 9
201
+
202
+
203
+@pytest.mark.asyncio
204
+async def test_untrusted_mapping_and_conflicting_price_fail_closed():
205
+ store = SqliteResearchPersistence()
206
+ item = instrument()
207
+ persisted(store, item)
208
+ price = store.load_market_price_observations({UUID(int=1)})[0]
209
+ item["providerMappings"].append(dict(provider="OTHER", providerSymbol="ABC", status="VERIFIED"))
210
+ store.upsert_market_price_observation(price.model_copy(update={"provider": "OTHER", "price": Decimal(200)}))
211
+ candidate = (await scan([item], store)).candidates[0]
212
+ assert "CONFLICTING_PRICE" in candidate.exclusion_reasons
213
+ assert candidate.dimensions["PRICE_DATA_QUALITY"].score is None
214
+ item["providerMappings"] = [dict(provider="YAHOO_FINANCE", providerSymbol="ABC.NS", status="VERIFIED", resolutionSource="BROKER_IMPORT_IDENTITY")]
215
+ assert "UNTRUSTED_CANONICAL_IDENTITY" in (await scan([item], store)).candidates[0].exclusion_reasons
216
+
217
+
218
+@pytest.mark.asyncio
219
+async def test_official_persisted_profit_and_equity_support_india_without_structured_financials():
220
+ store = SqliteResearchPersistence()
221
+ item = instrument()
222
+ persisted(store, item, {})
223
+ for metric in ["pat", "equity"]:
224
+ store.upsert_financial_fact(FinancialFact(FinancialFactKey(UUID(int=1), metric, "2026-06-30", "QUARTERLY"),
225
+ ProvenancedValue(value=10, source_url="https://example.test", source_name="NSE", retrieved_at=NOW),
226
+ FactSourceTier.OFFICIAL_NSE, "NSE", metric))
227
+ candidate = (await scan([item], store)).candidates[0]
228
+ assert candidate.eligible_for_deep_analysis
229
+ assert candidate.dimensions["BALANCE_SHEET_QUALITY"].score == 100
230
+
231
+
232
+@pytest.mark.asyncio
233
+async def test_nonfinite_optional_values_stay_missing():
234
+ store = SqliteResearchPersistence()
235
+ item = instrument()
236
+ persisted(store, item, {"profitMargin": 10, "revenueGrowth": "NaN", "earningsGrowth": "Infinity"})
237
+ candidate = (await scan([item], store)).candidates[0]
238
+ assert candidate.dimensions["GROWTH_QUALITY"].state == "MISSING"
239
+ assert candidate.dimensions["GROWTH_QUALITY"].score is None
240
+
241
+
242
+@pytest.mark.asyncio
243
+async def test_different_financial_period_types_are_not_false_conflicts():
244
+ store = SqliteResearchPersistence()
245
+ item = instrument()
246
+ persisted(store, item, {})
247
+ for period_type, value in [("ANNUAL", 100), ("QUARTERLY", 25)]:
248
+ store.upsert_financial_fact(FinancialFact(FinancialFactKey(UUID(int=1), "pat", "2026-03-31", period_type),
249
+ ProvenancedValue(value=value, source_url="https://example.test", source_name="official", retrieved_at=NOW),
250
+ FactSourceTier.OFFICIAL_REGULATORY, "OFFICIAL", period_type))
251
+ candidate = (await scan([item], store)).candidates[0]
252
+ assert candidate.eligible_for_deep_analysis
253
+ assert candidate.dimensions["PROFITABILITY_QUALITY"].state == "PARTIAL"
ai/research-engine/tests/test_official_nse_financial_parsing.py
+10
-5
@@ -793,7 +793,8 @@ def test_direct_aligned_diluted_eps_outranks_malformed_fallback_when_basic_is_un
793
794
class _FactRecorder:
795
def __init__(self): self.facts = []
796
- def load_financial_facts(self): return self.facts
796
+ def load_financial_facts(self, instrument_ids=None):
797
+ return [fact for fact in self.facts if instrument_ids is None or fact.key.instrument_id in instrument_ids]
798
def upsert_financial_fact(self, fact, **_kwargs): self.facts.append(fact); return True
799
800
@@ -1069,11 +1070,15 @@ def test_reused_trusted_nse_document_reconciles_same_document_facts_without_fetc
1070
1071
asyncio.run(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set()))
1072
facts = repository.financial_facts_for(profile.instrument_id)
1072
- eps = next(fact for fact in facts if fact.key.metric == "eps")
1073
+ # Filtered SQL may use a different index; assert the reconciled period,
1074
+ # independently of database row order and comparative-period facts.
1075
+ current = {fact.key.metric: fact for fact in facts
1076
+ if fact.key.period_end == "2026-06-30" and fact.key.period_type == "QUARTERLY"}
1077
+ eps = current["eps"]
1078
assert eps.value.value == Decimal("1.47")
1079
assert eps.value.unit == "INR per share"
1075
- assert next(fact for fact in facts if fact.key.metric == "revenue").value.value == Decimal("8261.11")
1076
- assert next(fact for fact in facts if fact.key.metric == "pat").value.value == Decimal("1927.21")
1080
+ assert current["revenue"].value.value == Decimal("8261.11")
1081
+ assert current["pat"].value.value == Decimal("1927.21")
1082
assert len(repository.events) == events_before
1083
assert len(repository.documents) == documents_before
1084
@@ -1081,7 +1086,7 @@ def test_reused_trusted_nse_document_reconciles_same_document_facts_without_fetc
1086
assert len(repository.financial_facts_for(profile.instrument_id)) == len(facts)
1087
yahoo = FinancialFact(eps.key, ProvenancedValue(value=Decimal("9"), source_url="https://yahoo.test", source_name="Yahoo", source_type="YAHOO", retrieved_at=document.retrieved_at), FactSourceTier.YAHOO, "YAHOO_FINANCE", "yahoo", SourceMode.REAL)
1088
assert not repository._persistence.upsert_financial_fact(yahoo)
1084
- assert next(fact for fact in repository.financial_facts_for(profile.instrument_id) if fact.key.metric == "eps").value.value == Decimal("1.47")
1089
+ assert next(fact for fact in repository.financial_facts_for(profile.instrument_id) if fact.key == eps.key).value.value == Decimal("1.47")
1090
1091
1092
def test_already_persisted_official_document_recovers_zero_facts_without_redownload() -> None:
ai/research-engine/tests/test_scanner_batch_persistence.py
new
+92
@@ -0,0 +1,92 @@
1
+from dataclasses import replace
2
+from uuid import UUID
3
+
4
+import pytest
5
+
6
+from app.persistence import SqliteResearchPersistence, DisabledResearchPersistence
7
+from app.postgres_persistence import PostgresResearchPersistence, _PostgresConnectionAdapter
8
+from app.repository import ResearchRepository
9
+from test_fact_precedence import fact
10
+from app.fact_precedence import FactSourceTier
11
+from test_shareholding import _snapshot
12
+from test_structured_market_persistence import _record
13
+
14
+
15
+@pytest.mark.parametrize("method", ["load_financial_facts", "load_events", "load_shareholding_snapshots",
16
+ "load_structured_market_snapshots", "load_market_price_observations"])
17
+def test_empty_filter_never_reads_all_rows(method):
18
+ store = SqliteResearchPersistence()
19
+ queries = []
20
+ store._connection.set_trace_callback(queries.append)
21
+ assert getattr(store, method)(set()) == []
22
+ assert queries == []
23
+ assert getattr(DisabledResearchPersistence(), method)(set()) == []
24
+
25
+
26
+def test_filtered_facts_snapshots_prices_and_shareholding_values():
27
+ store = SqliteResearchPersistence()
28
+ for n in [1, 2]:
29
+ key = UUID(int=n)
30
+ value = fact("pat", n, FactSourceTier.OFFICIAL_NSE)
31
+ store.upsert_financial_fact(replace(value, key=replace(value.key, instrument_id=key)))
32
+ store.upsert_shareholding_snapshot(_snapshot(key, source=f"source-{n}"))
33
+ store.upsert_structured_market_snapshot(_record(key))
34
+ queries = []
35
+ store._connection.set_trace_callback(queries.append)
36
+ ids = {UUID(int=1)}
37
+ assert [f.key.instrument_id for f in store.load_financial_facts(ids)] == list(ids)
38
+ snapshots = store.load_shareholding_snapshots(ids)
39
+ assert [s.instrument_id for s in snapshots] == list(ids)
40
+ assert len(snapshots[0].values) == 1
41
+ assert [s.instrument_id for s in store.load_structured_market_snapshots(ids)] == list(ids)
42
+ assert [p.instrument_id for p in store.load_market_price_observations(ids)] == list(ids)
43
+ assert all(" WHERE " in q and " IN (" in q for q in queries)
44
+ assert len(store.load_financial_facts()) == 2
45
+
46
+
47
+@pytest.mark.asyncio
48
+async def test_repository_financial_reads_pass_ids_to_persistence():
49
+ class FilterRequired(DisabledResearchPersistence):
50
+ def load_financial_facts(self, instrument_ids=None):
51
+ assert instrument_ids == {UUID(int=1)}
52
+ return []
53
+ repository = ResearchRepository.__new__(ResearchRepository)
54
+ repository._persistence = FilterRequired()
55
+ import threading
56
+ repository._persistence_worker_lock = threading.RLock()
57
+ assert repository.financial_facts_for(UUID(int=1)) == []
58
+ assert await repository.financial_facts_for_instruments({UUID(int=1)}) == {UUID(int=1): []}
59
+
60
+
61
+def test_postgres_inherits_parameterized_bounded_batch_reads():
62
+ queries = []
63
+ class Connection:
64
+ def execute(self, sql, params):
65
+ queries.append((sql, params))
66
+ return self
67
+ def fetchall(self): return []
68
+ store = PostgresResearchPersistence.__new__(PostgresResearchPersistence)
69
+ store._connection = _PostgresConnectionAdapter(Connection())
70
+ ids = {UUID(int=n) for n in range(1, 1002)}
71
+ assert store.load_financial_facts(ids) == []
72
+ assert [len(params) for _, params in queries] == [500, 500, 1]
73
+ assert all("WHERE instrument_id IN (%s" in sql and "?" not in sql for sql, _ in queries)
74
+ assert set(p for _, params in queries for p in params) == {str(i) for i in ids}
75
+
76
+
77
+def test_event_and_supporting_source_reads_are_filtered():
78
+ # Existing domain fixtures supply valid source documents and events.
79
+ repository = ResearchRepository(persistence=DisabledResearchPersistence())
80
+ store = SqliteResearchPersistence()
81
+ events = list(repository.events.values())
82
+ assert events
83
+ for document in repository.documents.values(): store.upsert_document(document)
84
+ for event in events: store.upsert_event(event)
85
+ target = events[0].instrument_id
86
+ queries = []
87
+ store._connection.set_trace_callback(queries.append)
88
+ loaded = store.load_events({target})
89
+ assert loaded and {e.instrument_id for e in loaded} == {target}
90
+ assert len(loaded) == sum(e.instrument_id == target for e in events)
91
+ assert all(" WHERE " in q and " IN (" in q for q in queries)
92
+ assert any("research_event_sources" in q and "event_id IN" in q for q in queries)