feat: add bounded NSE daily bar backfill
prakhar82 committed
Sep 13, 2026 at 21:19 UTC
abeb9b9a48db5878413f2937df4aedc885bec610
4 files changed
+757
ai/research-engine/NSE_DAILY_BACKFILL.md
new
+123
@@ -0,0 +1,123 @@
1
+# Bounded NSE daily OHLCV backfill
2
+
3
+## Entry point and universe
4
+
5
+`IndiaMarketDataPopulationJobs.backfill_daily_bars(identity_headers=...,
6
+correlation_id=None, offset=0, instrument_ids=None, start=None, end=None,
7
+force=False)` is an explicit async worker operation. It shares the existing
8
+daily-bar lock with single-instrument acquisition. No GET, scanner, submit,
9
+ensure, or scheduler automatically invokes it. Yahoo close-only behavior stays
10
+unchanged. This extends the population boundary, not a second job framework.
11
+
12
+The source is `active_global_equities()` (paginated canonical ACTIVE EQUITY
13
+metadata). India/NSE candidates are deduplicated and sorted by UUID string;
14
+at most `market_data_population_batch_size` (default 50) are selected.
15
+Each selected candidate then resolves current detail metadata and passes the
16
+committed `verified_identity` gate before NSE acquisition. Invalid mappings
17
+consume a bounded batch slot and appear as failures; they are never fetched.
18
+Listing metadata uses `exchange`; detail metadata uses `primaryExchange`, as
19
+specified by the existing canonical API. No holdings/watchlists/sector filter.
20
+
21
+Optional IDs restrict canonical membership; IDs absent from the universe are
22
+not acquired. `next_offset` is null at completion, otherwise the next offset in
23
+the sorted candidate universe or explicit-ID intersection. Selection is stable
24
+for the same snapshot. Universe membership changes can shift offsets; no durable
25
+snapshot cursor is claimed. Only identity metadata is enumerated globally;
26
+history is read one instrument at a time and persisted per window.
27
+
28
+## Date and coverage policy
29
+
30
+Default interval: Asia/Kolkata current DATE minus existing initial lookback
31
+(default 400 days), through current DATE inclusive. Optional bounds may narrow
32
+but not extend that horizon or request future dates. Retrieval timestamps remain
33
+UTC. `plan_windows` produces consecutive non-overlapping oldest-first windows,
34
+using the configured provider limit (default 30 inclusive calendar days). A
35
+401-calendar-date interval uses 14 windows. Thirty days is an operational bound,
36
+not a claimed official NSE maximum.
37
+
38
+Reads reuse `daily_market_bars_for_instruments({id}, end_date=end, provider='NSE')`.
39
+Only the canonical ID, current symbol/currency, REAL NSE rows with non-null OHLC
40
+satisfy coverage. Optional volume/turnover are not required.
41
+
42
+- NO_HISTORY: fetch the target interval.
43
+- PARTIAL_HISTORY: fetch the missing prefix before the earliest usable date;
44
+ also fetch a stale tail when needed.
45
+- STALE_HISTORY: retrieval age or latest-date lag exceeds configured historical
46
+ freshness (72 hours). Fetch latest date + 1 through target end. If latest date
47
+ already equals target end, refresh that final date for corrections/freshness.
48
+- CURRENT_HISTORY: observed prefix is covered and tail sufficiently fresh; skip.
49
+ This does not assert that every internal exchange session is present.
50
+- GAP_DETECTED is deliberately not inferred without authoritative sessions.
51
+
52
+INTERNAL_GAP_REPAIR = UNSUPPORTED_IN_THIS_PHASE. Weekly schedules and optional
53
+calendar exceptions do not establish a complete historical NSE holiday calendar.
54
+No weekday/holiday is synthesized or labelled missing market data.
55
+
56
+Successful request ranges are remembered for the existing freshness interval
57
+within the worker, keyed by canonical ID/symbol/currency. This suppresses repeated
58
+boundary probes when a completed range has no bar on its exact first date.
59
+Memory is used only alongside existing usable persisted rows. Missing rows still
60
+cause NO_HISTORY acquisition. Empty/failed requests are never marked complete.
61
+State is process-local, like existing jobs; after restart boundary probes can
62
+recur. Listing-date-aware prefix suppression and durable empty-range evidence
63
+are not implemented.
64
+
65
+Force defaults false. Explicit force reacquires the requested bounded interval
66
+for idempotent correction, but does not bypass cooldown or throttling.
67
+
68
+## Execution and results
69
+
70
+One lazily created NseHistoricalDailyProvider/session per job, closed in finally
71
+including cancellation. Its existing cookie/header, spacing and bounded retry
72
+behavior is reused. The population lock and inter-job spacing serialize this
73
+operation with single-instrument acquisition. No global HTTP session, proxy
74
+logic or concurrent NSE requests. Coordination is process-local, not distributed.
75
+
76
+Each successful window uses `persist_daily_result` and existing
77
+`ResearchRepository.upsert_daily_market_bars_async`. No SQL/schema change and no
78
+close-only dual-write. Earlier good rows survive later failures.
79
+
80
+Failures distinguish identity lookup, mapping, provider, throttling, HTTP, parser,
81
+empty response, persistence and planning. Most failures stop the current
82
+instrument's remaining windows, then continue with the next instrument. Empty
83
+windows remain failures but allow later windows (for example, after listing).
84
+Partial parser rejections persist accepted rows but count as failed windows and
85
+never mark request coverage complete. A 429 stops further NSE calls for the job
86
+and sets the existing 12-hour cooldown for subsequent backfill calls. Other
87
+failures use per-instrument cooldown. Force cannot override either cooldown.
88
+
89
+Requested-window counts are actual attempts, excluding unattempted windows
90
+after failures. Processed instruments include failures and skips, and equal
91
+succeeded + failed + skipped_current + skipped_cooldown. Per-instrument results
92
+include requested DATE windows, coverage state, status/failure class/reason,
93
+HTTP status, row counts and observed persisted bounds. Persisted row counts are
94
+upserts, not necessarily newly inserted keys. No cookies, authentication headers
95
+or raw exception text appear in summaries.
96
+
97
+## Runtime smoke, 2026-09-13
98
+
99
+A read-only canonical PostgreSQL snapshot contained 2,568 ACTIVE equities. The
100
+existing trusted gate accepted all six examples: NILKAMAL, RAYMOND, GODREJAGRO,
101
+ADANIPORTS, TDPOWERSYS, GEEKAYWIRE, plus POLYCAB. Names are validation examples,
102
+not implementation selection logic.
103
+
104
+A runtime adapter exposed this canonical snapshot through existing universe and
105
+metadata method shapes. The generic worker was restricted to NILKAMAL
106
+`4b085a61-0864-4ef1-ae23-0ba7e3ec6afb` and POLYCAB
107
+`f8cb0fc7-082c-4d95-a77d-b1a9ca21d5a4`, September 1–4, 2026.
108
+
109
+First run: two NO_HISTORY instruments, two successful windows, eight rows
110
+received/accepted/persisted. Three NSE HTTP calls total: one bootstrap and two
111
+history requests, all HTTP 200. Retries were disabled for this smoke. Current
112
+ResearchRepository/SQLite persistence read back eight unique provider/day rows.
113
+Immediate repeat: two CURRENT_HISTORY skips, zero windows, zero NSE calls,
114
+identical rows. Runtime artifacts are under ignored `.tmp/`.
115
+
116
+This validates live NSE and current local orchestration/persistence, not
117
+authenticated canonical HTTP enumeration or deployed PostgreSQL application
118
+end-to-end behavior. Mocked provider/HTTP and real SQLite repository tests cover
119
+multi-window failure isolation, continuation, corrections, and cooldowns.
120
+
121
+Out of scope: deployed PostgreSQL validation, ATR/ADX and volume technical
122
+wiring, internal-gap repair without an authoritative calendar, sector benchmark
123
+mapping/history, and broad-market benchmark history.
ai/research-engine/app/market_data_population.py
+21
@@ -57,6 +57,27 @@ class IndiaMarketDataPopulationJobs:
57
self._tasks: dict[str, asyncio.Task] = {}
58
self._active_job_id: str | None = None
59
self._daily_bar_lock = asyncio.Lock()
60
+ self._daily_bar_failures: dict[UUID, datetime] = {}
61
+ self._daily_bar_throttled_at: datetime | None = None
62
+ self._daily_bar_completed_ranges: dict[tuple, list] = {}
63
+
64
+ async def backfill_daily_bars(
65
+ self, *, identity_headers: dict[str, str | None],
66
+ correlation_id: str | None = None, offset: int = 0,
67
+ instrument_ids: set[UUID] | None = None, start: date | None = None,
68
+ end: date | None = None, force: bool = False,
69
+ ) -> dict[str, Any]:
70
+ """Explicit bounded worker operation; never called by submit/ensure/GET.
71
+
72
+ Optional IDs restrict canonical universe membership; they do not supply
73
+ identity. State/cooldowns are process-local, like existing population jobs.
74
+ """
75
+ from app.nse_daily_backfill import run_backfill
76
+
77
+ async with self._daily_bar_lock:
78
+ return await run_backfill(self, identity_headers=identity_headers,
79
+ correlation_id=correlation_id, offset=offset, instrument_ids=instrument_ids,
80
+ start=start, end=end, force=force)
81
82
async def populate_daily_bars(
83
self, global_instrument_id: UUID, *, start: date, end: date,
ai/research-engine/app/nse_daily_backfill.py
new
+256
@@ -0,0 +1,256 @@
1
+"""Bounded NSE daily-bar planning and execution within population jobs.
2
+
3
+No exchange sessions are synthesized. Coverage means observed boundary coverage,
4
+not proof that every internal trading session is present.
5
+"""
6
+from __future__ import annotations
7
+
8
+from datetime import date, datetime, timedelta
9
+from uuid import UUID
10
+from zoneinfo import ZoneInfo
11
+
12
+from app.nse_historical_daily import NseHistoricalDailyProvider, persist_daily_result, verified_identity
13
+
14
+DAY = timedelta(days=1)
15
+
16
+
17
+def plan_windows(start: date, end: date, size: int) -> list[tuple[date, date]]:
18
+ if type(start) is not date or type(end) is not date or start > end or type(size) is not int or size < 1:
19
+ raise ValueError('WINDOW_PLANNING_FAILURE')
20
+ windows = []
21
+ cursor = start
22
+ while cursor <= end:
23
+ last = cursor + timedelta(days=min(size - 1, (end - cursor).days))
24
+ windows.append((cursor, last))
25
+ if last == end:
26
+ break
27
+ cursor = last + DAY
28
+ return windows
29
+
30
+
31
+def usable_bars(rows, key, symbol, currency, end):
32
+ return sorted((b for b in (rows or []) if b is not None
33
+ and b.global_instrument_id == key and b.provider == 'NSE'
34
+ and b.source_mode == 'REAL' and b.provider_symbol == symbol and b.currency == currency
35
+ and b.trading_date <= end and all(getattr(b, name) is not None for name in ('open', 'high', 'low', 'close'))),
36
+ key=lambda b: b.trading_date)
37
+
38
+
39
+def coverage_plan(rows, start, end, now, freshness_hours, *, force=False):
40
+ """Conservative prefix/tail policy; short tail lag is freshness, not a holiday claim."""
41
+ if not rows:
42
+ return 'NO_HISTORY', [(start, end)]
43
+ first, last = rows[0], rows[-1]
44
+ freshness = timedelta(hours=freshness_hours)
45
+ prefix = first.trading_date > start
46
+ stale = now - last.retrieved_at >= freshness or timedelta(days=(end - last.trading_date).days) >= freshness
47
+ state = 'PARTIAL_HISTORY' if prefix else 'STALE_HISTORY' if stale else 'CURRENT_HISTORY'
48
+ if force:
49
+ return state, [(start, end)]
50
+ intervals = []
51
+ if prefix:
52
+ intervals.append((start, min(end, first.trading_date - DAY)))
53
+ if stale:
54
+ # Revisit the last bar only when no later date exists, to allow correction
55
+ # and refresh retrieval provenance; otherwise acquire an incremental tail.
56
+ tail = min(end, last.trading_date + DAY) if last.trading_date < end else end
57
+ intervals.append((max(start, tail), end))
58
+ return state, intervals
59
+
60
+
61
+def uncovered(intervals, completed):
62
+ """Subtract successful request ranges, not imagined exchange sessions."""
63
+ remaining = list(intervals)
64
+ for low, high in sorted(completed):
65
+ next_ranges = []
66
+ for start, end in remaining:
67
+ if high < start or low > end:
68
+ next_ranges.append((start, end))
69
+ else:
70
+ if start < low:
71
+ next_ranges.append((start, low - DAY))
72
+ if high < end:
73
+ next_ranges.append((high + DAY, end))
74
+ remaining = next_ranges
75
+ return remaining
76
+
77
+
78
+def failure_class(reason):
79
+ if reason == 'DAILY_BAR_PERSISTENCE_UNAVAILABLE':
80
+ return 'PERSISTENCE_FAILURE'
81
+ if '429' in reason:
82
+ return 'THROTTLED'
83
+ if 'HTTP_' in reason:
84
+ return 'HTTP_ERROR'
85
+ if reason in {'EMPTY_RESPONSE', 'NO_VALID_HISTORY'}:
86
+ return 'EMPTY_RESPONSE'
87
+ if any(s in reason for s in ('CSV', 'HEADERS', 'REJECTED_ROWS')):
88
+ return 'PARSER_FAILURE'
89
+ if any(s in reason for s in ('MAPPING', 'CURRENCY', 'UNSUPPORTED_INSTRUMENT', 'IDENTITY_MISMATCH')):
90
+ return 'NO_TRUSTED_MAPPING'
91
+ if reason.startswith('IDENTITY'):
92
+ return 'IDENTITY_UNAVAILABLE'
93
+ if 'WINDOW' in reason or 'DATE_RANGE' in reason:
94
+ return 'WINDOW_PLANNING_FAILURE'
95
+ return 'PROVIDER_UNAVAILABLE'
96
+
97
+
98
+def _item(key):
99
+ return dict(globalInstrumentId=str(key), nse_symbol=None, coverage_state_before='UNKNOWN',
100
+ requested_windows=0, successful_windows=0, failed_windows=0, rows_received=0,
101
+ rows_accepted=0, rows_persisted=0, status='FAILED', failure_reason=None,
102
+ failure_class=None, earliest_persisted_date=None, latest_persisted_date=None, windows=[])
103
+
104
+
105
+async def run_backfill(jobs, *, identity_headers, correlation_id, offset, instrument_ids, start, end, force):
106
+ now = jobs._clock()
107
+ today = now.astimezone(ZoneInfo('Asia/Kolkata')).date()
108
+ settings = jobs.settings
109
+ end = today if end is None else end
110
+ start = today - timedelta(days=settings.market_data_population_initial_lookback_days) if start is None else start
111
+ summary = dict(job_started_at=now, job_finished_at=None, status='RUNNING', failure_reason=None,
112
+ requested_instruments=0, processed_instruments=0, succeeded_instruments=0, failed_instruments=0,
113
+ skipped_current=0, skipped_cooldown=0, requested_windows=0, successful_windows=0,
114
+ failed_windows=0, rows_received=0, rows_accepted=0, rows_persisted=0,
115
+ first_requested_date=None, last_requested_date=None, target_start=start, target_end=end,
116
+ offset=offset, next_offset=None, candidate_instruments=0, instruments=[],
117
+ internal_gap_repair='UNSUPPORTED_IN_THIS_PHASE')
118
+ provider = None
119
+ job_failure_stage = 'WINDOW_PLANNING_FAILURE'
120
+ try:
121
+ if (type(offset) is not int or offset < 0 or type(force) is not bool
122
+ or type(start) is not date or type(end) is not date or start > end or end > today
123
+ or start < today - timedelta(days=settings.market_data_population_initial_lookback_days)):
124
+ raise ValueError('WINDOW_PLANNING_FAILURE')
125
+ # Canonical metadata only; no portfolio/watchlist/scanner membership.
126
+ job_failure_stage = 'UNIVERSE_UNAVAILABLE'
127
+ universe = await jobs.orchestrator.active_global_equities(
128
+ identity_headers=identity_headers, correlation_id=correlation_id)
129
+ candidates = set()
130
+ for value in universe:
131
+ if not isinstance(value, dict):
132
+ continue
133
+ if (value.get('status') != 'ACTIVE' or value.get('assetType') != 'EQUITY'
134
+ or value.get('country') not in {'IN', 'IND', 'INDIA'}
135
+ or value.get('exchange') not in {'NSE', 'XNSE'}):
136
+ continue
137
+ try:
138
+ key = UUID(str(value.get('globalInstrumentId')))
139
+ except ValueError:
140
+ continue
141
+ if instrument_ids is None or key in instrument_ids:
142
+ candidates.add(key)
143
+ ordered = sorted(candidates, key=str)
144
+ selected = ordered[offset:offset + settings.market_data_population_batch_size]
145
+ summary['candidate_instruments'] = len(ordered)
146
+ summary['requested_instruments'] = len(selected)
147
+ summary['next_offset'] = offset + len(selected) if offset + len(selected) < len(ordered) else None
148
+ throttled = (jobs._daily_bar_throttled_at is not None and now - jobs._daily_bar_throttled_at
149
+ < timedelta(hours=settings.market_data_population_retry_cooldown_hours))
150
+ for key in selected:
151
+ item = _item(key)
152
+ summary['instruments'].append(item)
153
+ summary['processed_instruments'] += 1
154
+ stage = 'IDENTITY_UNAVAILABLE'
155
+ try:
156
+ metadata = await jobs.orchestrator.global_instrument_metadata(key,
157
+ identity_headers=identity_headers, correlation_id=correlation_id)
158
+ symbol, currency = verified_identity(metadata, key)
159
+ item['nse_symbol'] = symbol
160
+ stage = 'PERSISTENCE_FAILURE'
161
+ existing = await jobs.repository.daily_market_bars_for_instruments({key}, end_date=end, provider='NSE')
162
+ rows = usable_bars(existing.get(key), key, symbol, currency, end)
163
+ if rows:
164
+ item['earliest_persisted_date'], item['latest_persisted_date'] = rows[0].trading_date, rows[-1].trading_date
165
+ stage = 'WINDOW_PLANNING_FAILURE'
166
+ state, intervals = coverage_plan(rows, start, end, now,
167
+ settings.market_data_historical_freshness_hours, force=force)
168
+ item['coverage_state_before'] = state
169
+ cache_key = (key, symbol, currency)
170
+ fresh_ranges = [(a, b, stamp) for a, b, stamp in jobs._daily_bar_completed_ranges.get(cache_key, [])
171
+ if now - stamp < timedelta(hours=settings.market_data_historical_freshness_hours)]
172
+ jobs._daily_bar_completed_ranges[cache_key] = fresh_ranges
173
+ if not force and rows:
174
+ intervals = uncovered(intervals, [(a, b) for a, b, _ in fresh_ranges])
175
+ failed_at = jobs._daily_bar_failures.get(key)
176
+ if throttled or (failed_at is not None and now - failed_at < timedelta(hours=settings.market_data_population_retry_cooldown_hours)):
177
+ item.update(status='SKIPPED_COOLDOWN', failure_reason='JOB_THROTTLED' if throttled else 'RETRY_COOLDOWN')
178
+ continue
179
+ if not intervals:
180
+ item.update(status='SKIPPED_CURRENT')
181
+ continue
182
+ windows = [window for a, b in intervals for window in plan_windows(a, b, settings.nse_historical_request_window_days)]
183
+ stage = 'PROVIDER_UNAVAILABLE'
184
+ if provider is None:
185
+ provider = NseHistoricalDailyProvider(jobs.orchestrator, settings)
186
+ for a, b in windows:
187
+ item['requested_windows'] += 1
188
+ detail = dict(start=a, end=b, status='FAILED', failure_reason=None, http_status=None)
189
+ item['windows'].append(detail)
190
+ summary['first_requested_date'] = min(summary['first_requested_date'] or a, a)
191
+ summary['last_requested_date'] = max(summary['last_requested_date'] or b, b)
192
+ try:
193
+ result = await provider.fetch(key, start=a, end=b,
194
+ identity_headers=identity_headers, correlation_id=correlation_id)
195
+ item['rows_received'] += result.rows_parsed
196
+ item['rows_accepted'] += result.rows_accepted
197
+ detail['http_status'] = result.http_status
198
+ result = await persist_daily_result(jobs.repository, result)
199
+ item['rows_persisted'] += result.persisted_rows
200
+ if result.persisted_rows:
201
+ item['earliest_persisted_date'] = min(item['earliest_persisted_date'] or result.first_trading_date, result.first_trading_date)
202
+ item['latest_persisted_date'] = max(item['latest_persisted_date'] or result.last_trading_date, result.last_trading_date)
203
+ reason = result.failure_reason
204
+ if result.rows_rejected and reason in {None, 'NO_VALID_HISTORY'}:
205
+ reason = 'REJECTED_ROWS'
206
+ if reason:
207
+ detail['failure_reason'] = reason
208
+ item['failed_windows'] += 1
209
+ item.update(failure_reason=reason, failure_class=failure_class(reason))
210
+ jobs._daily_bar_failures[key] = jobs._clock()
211
+ if '429' in reason:
212
+ throttled = True
213
+ jobs._daily_bar_throttled_at = jobs._clock()
214
+ # Empty windows may precede listing or contain closures;
215
+ # they are failures, not fabricated zero-bar successes.
216
+ if failure_class(reason) != 'EMPTY_RESPONSE':
217
+ break
218
+ else:
219
+ detail['status'] = 'SUCCESS'
220
+ item['successful_windows'] += 1
221
+ jobs._daily_bar_completed_ranges[cache_key].append((a, b, jobs._clock()))
222
+ except Exception:
223
+ item['failed_windows'] += 1
224
+ detail['failure_reason'] = 'PROVIDER_UNAVAILABLE'
225
+ item.update(failure_reason='PROVIDER_UNAVAILABLE', failure_class='PROVIDER_UNAVAILABLE')
226
+ jobs._daily_bar_failures[key] = jobs._clock()
227
+ break
228
+ if not item['failed_windows']:
229
+ item['status'] = 'SUCCESS'
230
+ jobs._daily_bar_failures.pop(key, None)
231
+ except Exception as exc:
232
+ safe_reasons = {'IDENTITY_MISMATCH', 'UNSUPPORTED_INSTRUMENT', 'NO_UNAMBIGUOUS_NSE_MAPPING',
233
+ 'NO_TRUSTED_NSE_MAPPING', 'INVALID_NSE_MAPPING', 'MISSING_OR_AMBIGUOUS_CURRENCY', 'WINDOW_PLANNING_FAILURE'}
234
+ reason = str(exc) if type(exc) is ValueError and str(exc) in safe_reasons else stage
235
+ item.update(failure_reason=reason, failure_class=failure_class(reason) if stage == 'IDENTITY_UNAVAILABLE' else stage)
236
+ jobs._daily_bar_failures[key] = jobs._clock()
237
+ finally:
238
+ for metric in ('requested_windows', 'successful_windows', 'failed_windows', 'rows_received', 'rows_accepted', 'rows_persisted'):
239
+ summary[metric] += item[metric]
240
+ counter = {'SUCCESS':'succeeded_instruments', 'FAILED':'failed_instruments',
241
+ 'SKIPPED_CURRENT':'skipped_current', 'SKIPPED_COOLDOWN':'skipped_cooldown'}[item['status']]
242
+ summary[counter] += 1
243
+ summary['status'] = 'COMPLETED_WITH_ERRORS' if summary['failed_instruments'] else 'COMPLETED'
244
+ except Exception:
245
+ summary['status'] = 'FAILED'
246
+ summary['failure_reason'] = job_failure_stage
247
+ finally:
248
+ if provider is not None:
249
+ try:
250
+ await provider.aclose()
251
+ except Exception:
252
+ summary.update(status='FAILED', failure_reason='SESSION_CLOSE_FAILURE')
253
+ finally:
254
+ await jobs._sleep(settings.market_data_population_request_interval_seconds)
255
+ summary['job_finished_at'] = jobs._clock()
256
+ return summary
ai/research-engine/tests/test_nse_daily_backfill.py
new
+357
@@ -0,0 +1,357 @@
1
+from datetime import date, datetime, timedelta, timezone
2
+from decimal import Decimal
3
+from types import SimpleNamespace
4
+from unittest.mock import AsyncMock
5
+from uuid import UUID
6
+
7
+import httpx
8
+import pytest
9
+
10
+import app.nse_daily_backfill as backfill
11
+from app.market_data_population import IndiaMarketDataPopulationJobs
12
+from app.models import DailyMarketBar
13
+from app.nse_historical_daily import NseHistoricalDailyProvider, NseHistoricalResult, BOOTSTRAP
14
+from app.persistence import SqliteResearchPersistence
15
+from app.repository import ResearchRepository
16
+from app.settings import Settings
17
+
18
+NOW = datetime(2026, 9, 13, tzinfo=timezone.utc)
19
+START, END = date(2026, 9, 1), date(2026, 9, 4)
20
+
21
+
22
+def metadata(n=1, **changes):
23
+ return dict(globalInstrumentId=str(UUID(int=n)), status='ACTIVE', assetType='EQUITY', country='IN',
24
+ primaryExchange='NSE', currency='INR', providerMappings=[dict(provider='NSE', status='VERIFIED',
25
+ providerSymbol=f'S{n}', currency='INR', resolutionSource='OFFICIAL_NSE')]) | changes
26
+
27
+
28
+def bar(n=1, day=START, **changes):
29
+ return DailyMarketBar(global_instrument_id=UUID(int=n), trading_date=day, open=Decimal('10'), high=Decimal('12'),
30
+ low=Decimal('9'), close=Decimal('11'), volume=0, currency='INR', provider='NSE', provider_symbol=f'S{n}',
31
+ source_mode='REAL', source_url='https://www.nseindia.com/history', retrieved_at=NOW).model_copy(update=changes)
32
+
33
+
34
+class Provider:
35
+ def __init__(self):
36
+ self.calls = []
37
+ self.closed = False
38
+ self.failures = {}
39
+ self.price = Decimal('11')
40
+
41
+ async def fetch(self, key, *, start, end, **kwargs):
42
+ self.calls.append((key, start, end))
43
+ reason = self.failures.get((key.int, start), self.failures.get(key.int))
44
+ if isinstance(reason, Exception):
45
+ raise reason
46
+ values = [bar(key.int, day=start, close=self.price)]
47
+ if start != end:
48
+ values.append(bar(key.int, day=end, close=self.price))
49
+ return NseHistoricalResult(key, start, end, provider_symbol=f'S{key.int}', http_status=200,
50
+ status='UNAVAILABLE' if reason else 'SUCCESS', failure_reason=reason,
51
+ rows_parsed=0 if reason else len(values), bars=[] if reason else values)
52
+
53
+ async def aclose(self): self.closed = True
54
+
55
+
56
+def setup(monkeypatch, values=None, **settings):
57
+ values = [metadata()] if values is None else values
58
+ universe = [v | {'exchange': v.get('primaryExchange')} for v in values]
59
+ by_id = {UUID(v['globalInstrumentId']): v for v in values}
60
+ orchestrator = SimpleNamespace(active_global_equities=AsyncMock(return_value=universe),
61
+ global_instrument_metadata=AsyncMock(side_effect=lambda key, **kw: by_id[key]))
62
+ store = SqliteResearchPersistence()
63
+ config = Settings(**settings)
64
+ repo = ResearchRepository(settings=config, persistence=store)
65
+ jobs = IndiaMarketDataPopulationJobs(repo, None, orchestrator, config, clock=lambda: NOW, sleep=AsyncMock())
66
+ providers = []
67
+ def factory(*args):
68
+ p = Provider(); providers.append(p); return p
69
+ monkeypatch.setattr(backfill, 'NseHistoricalDailyProvider', factory)
70
+ return jobs, store, providers
71
+
72
+
73
+async def run(jobs, **kwargs):
74
+ return await jobs.backfill_daily_bars(identity_headers={}, start=START, end=END, **kwargs)
75
+
76
+
77
+@pytest.mark.parametrize('start,end,size,count', [
78
+ (date(2026,1,1),date(2026,1,30),30,1), (date(2026,1,1),date(2026,1,31),30,2),
79
+ (date(2025,12,15),date(2026,3,5),30,3), (date(2024,2,1),date(2024,3,1),30,1),
80
+ (START,START,30,1), (date(2026,1,31),date(2026,2,1),1,2)])
81
+def test_windows(start,end,size,count):
82
+ windows=backfill.plan_windows(start,end,size)
83
+ assert len(windows)==count and windows[0][0]==start and windows[-1][1]==end
84
+ assert windows==backfill.plan_windows(start,end,size)
85
+ assert all(0 <= (b-a).days < size for a,b in windows)
86
+ assert all(windows[i][1]+timedelta(days=1)==windows[i+1][0] for i in range(len(windows)-1))
87
+
88
+
89
+@pytest.mark.parametrize('start,end,size',[(END,START,30),(START,END,0),(START,END,-1),(NOW,END,30)])
90
+def test_invalid_windows(start,end,size):
91
+ with pytest.raises(ValueError): backfill.plan_windows(start,end,size)
92
+
93
+
94
+def test_coverage_states_and_no_calendar_inference():
95
+ assert backfill.coverage_plan([],START,END,NOW,72)[0]=='NO_HISTORY'
96
+ assert backfill.coverage_plan([bar(day=END)],START,END,NOW,72)==('PARTIAL_HISTORY',[(START,END-timedelta(days=1))])
97
+ assert backfill.coverage_plan([bar(),bar(day=END)],START,END,NOW,72)==('CURRENT_HISTORY',[])
98
+ old=NOW-timedelta(days=5)
99
+ assert backfill.coverage_plan([bar(retrieved_at=old)],START,END,NOW,72)==('STALE_HISTORY',[(START+timedelta(days=1),END)])
100
+ # Sparse internal dates are not enough to claim a session gap.
101
+ assert backfill.coverage_plan([bar(),bar(day=END)],START,END,NOW,72)[0]!='GAP_DETECTED'
102
+ assert backfill.coverage_plan([bar(),bar(day=END)],START,END,NOW,72,force=True)[1]==[(START,END)]
103
+
104
+
105
+@pytest.mark.asyncio
106
+async def test_current_no_session_and_other_provider_does_not_count(monkeypatch):
107
+ jobs,store,providers=setup(monkeypatch)
108
+ store.upsert_daily_market_bars([bar(provider='OTHER'),bar(day=END,provider='OTHER')])
109
+ first=await run(jobs)
110
+ assert first['instruments'][0]['coverage_state_before']=='NO_HISTORY' and first['rows_persisted']==2
111
+ assert len(store.load_daily_market_bars({UUID(int=1)}))==4
112
+ second=await run(jobs)
113
+ assert second['skipped_current']==1 and second['requested_windows']==0 and len(providers)==1
114
+ assert providers[0].closed
115
+
116
+
117
+@pytest.mark.asyncio
118
+async def test_prefix_tail_and_null_rows(monkeypatch):
119
+ jobs,store,providers=setup(monkeypatch)
120
+ store.upsert_daily_market_bar(bar(day=date(2026,9,3),retrieved_at=NOW-timedelta(days=5)))
121
+ r=await run(jobs)
122
+ assert [(a,b) for _,a,b in providers[0].calls]==[(START,date(2026,9,2)),(END,END)]
123
+ assert r['instruments'][0]['coverage_state_before']=='PARTIAL_HISTORY'
124
+ assert backfill.usable_bars([None,bar(open=None)],UUID(int=1),'S1','INR',END)==[]
125
+
126
+
127
+@pytest.mark.asyncio
128
+async def test_batch_order_offset_and_membership(monkeypatch):
129
+ jobs,store,providers=setup(monkeypatch,[metadata(3),metadata(1),metadata(2)],market_data_population_batch_size=1)
130
+ first=await run(jobs)
131
+ assert first['requested_instruments']==1 and first['next_offset']==1
132
+ second=await run(jobs,offset=1)
133
+ assert second['instruments'][0]['globalInstrumentId']==str(UUID(int=2)) and second['next_offset']==2
134
+ third=await run(jobs,instrument_ids={UUID(int=3),UUID(int=999)})
135
+ assert third['requested_instruments']==1 and third['instruments'][0]['globalInstrumentId']==str(UUID(int=3))
136
+ assert third['next_offset'] is None
137
+
138
+
139
+@pytest.mark.asyncio
140
+@pytest.mark.parametrize('values',[[],[metadata(assetType='ETF')],[metadata(status='INACTIVE')],[metadata(country='US')],[metadata(primaryExchange='BSE')]])
141
+async def test_empty_eligible_universe(monkeypatch,values):
142
+ jobs,_,providers=setup(monkeypatch,values)
143
+ r=await run(jobs)
144
+ assert r['requested_instruments']==0 and not providers and r['status']=='COMPLETED'
145
+
146
+
147
+@pytest.mark.asyncio
148
+@pytest.mark.parametrize('mapping',[[],[dict(provider='NSE',status='INVALID',providerSymbol='X')],
149
+ [dict(provider='NSE',status='VERIFIED',providerSymbol=' ')], metadata()['providerMappings']*2,
150
+ [metadata()['providerMappings'][0]|dict(active=False)], [metadata()['providerMappings'][0]|dict(currency='USD')]])
151
+async def test_identity_failure_isolated(monkeypatch,mapping):
152
+ jobs,_,providers=setup(monkeypatch,[metadata(providerMappings=mapping),metadata(2)])
153
+ r=await run(jobs)
154
+ assert r['failed_instruments']==1 and r['succeeded_instruments']==1
155
+ assert r['instruments'][0]['failure_class']=='NO_TRUSTED_MAPPING'
156
+ assert [key.int for key,_,_ in providers[0].calls]==[2]
157
+
158
+
159
+@pytest.mark.asyncio
160
+@pytest.mark.parametrize('reason,category',[('HISTORICAL_HTTP_403','HTTP_ERROR'),('NON_CSV_RESPONSE','PARSER_FAILURE'),
161
+ ('EMPTY_RESPONSE','EMPTY_RESPONSE'),('HISTORICAL_TIMEOUT','PROVIDER_UNAVAILABLE'),(RuntimeError('secret-cookie'),'PROVIDER_UNAVAILABLE')])
162
+async def test_provider_failure_isolation_and_cooldown(monkeypatch,reason,category):
163
+ jobs,store,_=setup(monkeypatch,[metadata(),metadata(2)])
164
+ provider=Provider(); provider.failures[1]=reason
165
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:provider)
166
+ r=await run(jobs)
167
+ assert r['failed_instruments']==1 and r['succeeded_instruments']==1 and r['failed_windows']==1
168
+ assert r['instruments'][0]['failure_class']==category and provider.closed
169
+ assert 'secret-cookie' not in str(r)
170
+ repeat=await run(jobs)
171
+ assert repeat['skipped_cooldown']==1 and repeat['skipped_current']==1
172
+
173
+
174
+@pytest.mark.asyncio
175
+async def test_throttle_stops_job_and_next_job(monkeypatch):
176
+ jobs,_,_=setup(monkeypatch,[metadata(),metadata(2)])
177
+ provider=Provider(); provider.failures[1]='HISTORICAL_HTTP_429'
178
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:provider)
179
+ r=await run(jobs)
180
+ assert r['failed_instruments']==1 and r['skipped_cooldown']==1 and len(provider.calls)==1
181
+ r=await run(jobs,force=True)
182
+ assert r['skipped_cooldown']==2 and len(provider.calls)==1
183
+
184
+
185
+@pytest.mark.asyncio
186
+async def test_per_window_persistence_later_failure_preserves_rows(monkeypatch):
187
+ jobs,store,_=setup(monkeypatch,[metadata(),metadata(2)],nse_historical_request_window_days=2)
188
+ provider=Provider(); provider.failures[(1,date(2026,9,3))]='HISTORICAL_HTTP_500'
189
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:provider)
190
+ r=await run(jobs)
191
+ assert r['requested_windows']==4 and r['successful_windows']==3 and r['failed_windows']==1
192
+ assert r['rows_received']==r['rows_accepted']==r['rows_persisted']==6
193
+ assert len(store.load_daily_market_bars({UUID(int=1)},provider='NSE'))==2
194
+ assert r['instruments'][0]['earliest_persisted_date']==START
195
+ assert r['first_requested_date']==START and r['last_requested_date']==END
196
+
197
+
198
+@pytest.mark.asyncio
199
+async def test_persistence_failure_next_instrument(monkeypatch):
200
+ jobs,store,_=setup(monkeypatch,[metadata(),metadata(2)])
201
+ original=jobs.repository.upsert_daily_market_bars_async
202
+ async def writer(bars):
203
+ if bars[0].global_instrument_id.int==1: raise RuntimeError('secret')
204
+ return await original(bars)
205
+ jobs.repository.upsert_daily_market_bars_async=writer
206
+ r=await run(jobs)
207
+ assert r['instruments'][0]['failure_class']=='PERSISTENCE_FAILURE' and r['succeeded_instruments']==1
208
+ assert 'secret' not in str(r)
209
+
210
+
211
+@pytest.mark.asyncio
212
+async def test_force_correction_idempotence(monkeypatch):
213
+ jobs,store,_=setup(monkeypatch)
214
+ provider=Provider(); monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:provider)
215
+ await run(jobs)
216
+ provider.price=Decimal('10.5')
217
+ r=await run(jobs,force=True)
218
+ assert r['rows_persisted']==2
219
+ rows=store.load_daily_market_bars({UUID(int=1)},provider='NSE')
220
+ assert len(rows)==2 and all(b.close==Decimal('10.5') for b in rows)
221
+
222
+
223
+@pytest.mark.asyncio
224
+async def test_default_lookback_exchange_date_and_invalid_range(monkeypatch):
225
+ jobs,_,providers=setup(monkeypatch)
226
+ jobs._clock=lambda:datetime(2026,9,12,20,tzinfo=timezone.utc)
227
+ r=await jobs.backfill_daily_bars(identity_headers={})
228
+ assert r['target_end']==date(2026,9,13) and r['target_start']==date(2026,9,13)-timedelta(days=400)
229
+ assert r['requested_windows']==14 and providers[0].closed
230
+ bad=await jobs.backfill_daily_bars(identity_headers={},start=END,end=START)
231
+ assert bad['failure_reason']=='WINDOW_PLANNING_FAILURE'
232
+
233
+
234
+@pytest.mark.asyncio
235
+async def test_real_provider_one_session_verified_query_and_serialization(monkeypatch):
236
+ jobs,_,_=setup(monkeypatch,[metadata(),metadata(2)],nse_historical_request_window_days=2)
237
+ calls=[]
238
+ def handler(req):
239
+ calls.append(req)
240
+ if str(req.url)==BOOTSTRAP: return httpx.Response(200,headers={'set-cookie':'session=private; Path=/'})
241
+ assert req.headers['cookie']=='session=private'
242
+ symbol=req.url.params['symbol']; day=req.url.params['from']
243
+ return httpx.Response(200,text=f'Date,Symbol,Series,Open,High,Low,Close\n{day},{symbol},EQ,10,12,9,11\n')
244
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
245
+ sleep=AsyncMock()
246
+ provider=NseHistoricalDailyProvider(jobs.orchestrator,jobs.settings,client=client,sleep=sleep)
247
+ provider.aclose=AsyncMock()
248
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:provider)
249
+ r=await run(jobs)
250
+ assert len(calls)==5 and sum(str(c.url)==BOOTSTRAP for c in calls)==1
251
+ assert [c.url.params['symbol'] for c in calls[1:]]==['S1','S1','S2','S2']
252
+ assert sleep.await_count>=4 and r['successful_windows']==4
253
+ provider.aclose.assert_awaited_once()
254
+ assert 'private' not in str(r)
255
+
256
+@pytest.mark.asyncio
257
+async def test_successful_range_evidence_avoids_boundary_refetch(monkeypatch):
258
+ jobs,store,_=setup(monkeypatch)
259
+ p=Provider()
260
+ async def fetch(key, *, start, end, **kw):
261
+ p.calls.append((key,start,end))
262
+ return NseHistoricalResult(key,start,end,status='SUCCESS',bars=[bar(day=date(2026,9,2))],rows_parsed=1)
263
+ p.fetch=fetch
264
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:p)
265
+ await run(jobs)
266
+ r=await run(jobs)
267
+ assert r['skipped_current']==1 and len(p.calls)==1
268
+ jobs._clock=lambda: NOW+timedelta(hours=73)
269
+ r=await run(jobs)
270
+ assert r['requested_windows']>0 and len(p.calls)>1
271
+
272
+
273
+@pytest.mark.asyncio
274
+async def test_cooldown_expiry_and_identity_unavailable(monkeypatch):
275
+ jobs,_,_=setup(monkeypatch)
276
+ original=jobs.orchestrator.global_instrument_metadata.side_effect
277
+ jobs.orchestrator.global_instrument_metadata.side_effect=ValueError('Cookie: SECRET')
278
+ r=await run(jobs)
279
+ assert r['instruments'][0]['failure_class']=='IDENTITY_UNAVAILABLE'
280
+ assert 'SECRET' not in str(r)
281
+ jobs.orchestrator.global_instrument_metadata.side_effect=original
282
+ assert (await run(jobs))['skipped_cooldown']==1
283
+ jobs._clock=lambda: NOW+timedelta(hours=13)
284
+ assert (await run(jobs))['succeeded_instruments']==1
285
+
286
+
287
+@pytest.mark.asyncio
288
+async def test_session_closes_on_cancel(monkeypatch):
289
+ import asyncio
290
+ jobs,_,_=setup(monkeypatch)
291
+ p=Provider()
292
+ p.fetch=AsyncMock(side_effect=asyncio.CancelledError())
293
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:p)
294
+ with pytest.raises(asyncio.CancelledError): await run(jobs)
295
+ assert p.closed
296
+
297
+
298
+@pytest.mark.asyncio
299
+async def test_population_lock_serializes_backfill_jobs(monkeypatch):
300
+ import asyncio
301
+ jobs,_,providers=setup(monkeypatch)
302
+ a,b=await asyncio.gather(run(jobs),run(jobs))
303
+ assert a['succeeded_instruments']==1 and b['skipped_current']==1 and len(providers)==1
304
+
305
+
306
+@pytest.mark.asyncio
307
+async def test_window_planning_failure_and_universe_failure(monkeypatch):
308
+ jobs,_,providers=setup(monkeypatch)
309
+ r=await jobs.backfill_daily_bars(identity_headers={},offset=-1)
310
+ assert r['failure_reason']=='WINDOW_PLANNING_FAILURE' and not providers
311
+ jobs.orchestrator.active_global_equities.side_effect=RuntimeError('secret')
312
+ r=await run(jobs)
313
+ assert r['failure_reason']=='UNIVERSE_UNAVAILABLE' and 'secret' not in str(r)
314
+
315
+
316
+@pytest.mark.asyncio
317
+async def test_empty_window_does_not_block_later_history(monkeypatch):
318
+ jobs,store,_=setup(monkeypatch,nse_historical_request_window_days=2)
319
+ p=Provider(); p.failures[(1,START)]='NO_VALID_HISTORY'
320
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:p)
321
+ r=await run(jobs)
322
+ assert r['requested_windows']==2 and r['failed_windows']==1 and r['successful_windows']==1
323
+ assert r['failed_instruments']==1 and r['rows_persisted']==2
324
+ assert len(store.load_daily_market_bars({UUID(int=1)},provider='NSE'))==2
325
+
326
+
327
+@pytest.mark.asyncio
328
+async def test_partial_parser_rows_persist_but_not_marked_complete(monkeypatch):
329
+ jobs,store,_=setup(monkeypatch)
330
+ p=Provider()
331
+ async def fetch(key,*,start,end,**kw):
332
+ return NseHistoricalResult(key,start,end,status='SUCCESS',rows_parsed=2,rows_rejected=1,bars=[bar()])
333
+ p.fetch=fetch; monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:p)
334
+ r=await run(jobs)
335
+ assert r['failed_windows']==1 and r['rows_persisted']==1 and r['rows_received']==2 and r['rows_accepted']==1
336
+ assert r['instruments'][0]['failure_class']=='PARSER_FAILURE'
337
+ assert jobs._daily_bar_completed_ranges[(UUID(int=1),'S1','INR')]==[]
338
+
339
+@pytest.mark.asyncio
340
+async def test_missing_persisted_rows_override_request_memory(monkeypatch):
341
+ jobs,store,providers=setup(monkeypatch)
342
+ await run(jobs)
343
+ jobs.repository.daily_market_bars_for_instruments=AsyncMock(return_value={UUID(int=1):None})
344
+ again=await run(jobs)
345
+ assert again['instruments'][0]['coverage_state_before']=='NO_HISTORY'
346
+ assert again['requested_windows']==1 and len(providers)==2
347
+
348
+
349
+@pytest.mark.asyncio
350
+async def test_rejected_rows_are_parser_failure_not_empty(monkeypatch):
351
+ jobs,_,_=setup(monkeypatch,nse_historical_request_window_days=2)
352
+ p=Provider()
353
+ p.fetch=AsyncMock(return_value=NseHistoricalResult(UUID(int=1),START,END,
354
+ failure_reason='NO_VALID_HISTORY', rows_parsed=1, rows_rejected=1))
355
+ monkeypatch.setattr(backfill,'NseHistoricalDailyProvider',lambda *args:p)
356
+ result=await run(jobs)
357
+ assert result['requested_windows']==1 and result['instruments'][0]['failure_class']=='PARSER_FAILURE'