feat: add NSE sector benchmark history
prakhar82 committed
Sep 13, 2026 at 23:27 UTC
e3951c45478330891a3d7cf11119893ad500ec97
18 files changed
+985
-25
ai/research-engine/app/global_scanner.py
+5
-7
@@ -314,12 +314,6 @@ class GlobalScanner:
314
candidates = [c for c in scan.candidates if c.eligible_for_deep_analysis]
315
ids = {c.global_instrument_id for c in candidates}
316
daily_histories = defaultdict(list)
317
- candidate_ids = sorted(ids, key=str)
318
- for offset in range(0, len(candidate_ids), self.batch_size):
319
- batch = set(candidate_ids[offset:offset+self.batch_size])
320
- for row in self.persistence.load_daily_market_bars(batch, provider="NSE"):
321
- if row.global_instrument_id in batch:
322
- daily_histories[row.global_instrument_id].append(row)
317
for candidate in candidates:
318
context = contexts.get(candidate.global_instrument_id, SectorContext())
319
for reference in (context.sector_benchmark, context.market_benchmark):
@@ -329,6 +323,9 @@ class GlobalScanner:
323
ordered = sorted(ids, key=str)
324
for offset in range(0, len(ordered), self.batch_size):
325
batch = set(ordered[offset:offset+self.batch_size])
326
+ for row in self.persistence.load_daily_market_bars(batch, provider="NSE"):
327
+ if row.global_instrument_id in batch:
328
+ daily_histories[row.global_instrument_id].append(row)
329
for row in self.persistence.load_market_price_observations(batch):
330
if row.instrument_id in batch:
331
histories[row.instrument_id].append(row)
@@ -339,7 +336,8 @@ class GlobalScanner:
336
technical = technical_engine.compute(key, histories[key], as_of=scan.as_of, currency=candidate.currency,
337
trusted_providers=providers.get(key), daily_bar_history=daily_histories[key])
338
sector = sector_engine.compute(key, histories[key], as_of=scan.as_of, currency=candidate.currency,
342
- context=contexts.get(key), benchmark_histories=histories, trusted_providers=providers.get(key))
339
+ context=contexts.get(key), benchmark_histories=histories, trusted_providers=providers.get(key),
340
+ daily_bar_histories=daily_histories)
341
scores = [(technical.technical_score, technical_weight), (sector.relative_strength_score, sector_weight)]
342
available = [(score, weight) for score, weight in scores if score is not None and weight > 0]
343
available_weight = sum(weight for _, weight in available)
ai/research-engine/app/market_data_population.py
+31
@@ -79,6 +79,37 @@ class IndiaMarketDataPopulationJobs:
79
correlation_id=correlation_id, offset=offset, instrument_ids=instrument_ids,
80
start=start, end=end, force=force)
81
82
+ async def populate_benchmark_history(self, instrument_ids: set[UUID], *, start: date, end: date,
83
+ identity_headers: dict[str, str | None], correlation_id=None):
84
+ """Explicit bounded index acquisition; registration is a separate master operation."""
85
+ from app.nse_index_history import NseIndexHistoryProvider, ENDPOINT
86
+ from app.nse_historical_daily import NseHistoricalResult, persist_daily_result
87
+ if len(instrument_ids) > self.settings.market_data_population_batch_size:
88
+ raise ValueError('BENCHMARK_BATCH_LIMIT')
89
+ results = []
90
+ async with self._daily_bar_lock:
91
+ provider = NseIndexHistoryProvider(self.orchestrator, self.settings)
92
+ try:
93
+ for key in sorted(instrument_ids, key=str):
94
+ now = self._clock()
95
+ failures = [t for t in (self._daily_bar_failures.get(key), self._daily_bar_throttled_at) if t]
96
+ failed_at = max(failures) if failures else None
97
+ if failed_at and now - failed_at < timedelta(hours=self.settings.market_data_population_retry_cooldown_hours):
98
+ results.append(NseHistoricalResult(key, start, end, source_url=ENDPOINT, failure_reason='RETRY_COOLDOWN'))
99
+ continue
100
+ result = await provider.fetch(key, start=start, end=end,
101
+ identity_headers=identity_headers, correlation_id=correlation_id)
102
+ result = await persist_daily_result(self.repository, result)
103
+ results.append(result)
104
+ if result.failure_reason:
105
+ self._daily_bar_failures[key] = self._clock()
106
+ if '429' in result.failure_reason:
107
+ self._daily_bar_throttled_at = self._clock()
108
+ finally:
109
+ await provider.aclose()
110
+ await self._sleep(self.settings.market_data_population_request_interval_seconds)
111
+ return results
112
+
113
async def populate_daily_bars(
114
self, global_instrument_id: UUID, *, start: date, end: date,
115
identity_headers: dict[str, str | None], correlation_id: str | None = None,
ai/research-engine/app/nse_index_history.py
new
+109
@@ -0,0 +1,109 @@
1
+"""Official index JSON acquisition; reuses NSE transport, not equity CSV parsing."""
2
+from datetime import date, datetime, timezone
3
+from decimal import Decimal
4
+import json
5
+
6
+import httpx
7
+from app.models import DailyMarketBar
8
+from app.nse_historical_daily import NseHistoricalDailyProvider, NseHistoricalResult, BOOTSTRAP, number, trading_date
9
+from app.sector_benchmarks import BENCHMARKS, benchmark_id, benchmark_identity
10
+
11
+ENDPOINT = 'https://www.nseindia.com/api/historicalOR/indicesHistory'
12
+HISTORY_VERSION = 'NSE_INDEX_HISTORY_V1'
13
+# Exact response names observed in the checked-in official response fixtures.
14
+# These are contract aliases, never canonical identities or fuzzy matches.
15
+RESPONSE_NAMES = {'NIFTY 500': 'NIFTY 500', 'NIFTY IT': 'NIFTY IT',
16
+ 'NIFTY FINANCIAL SERVICES': 'NIFTY FIN SERVICE',
17
+ 'NIFTY HEALTHCARE INDEX': 'NIFTY HEALTHCARE'}
18
+REQUIRED = {'EOD_INDEX_NAME', 'EOD_TIMESTAMP', 'EOD_OPEN_INDEX_VAL', 'EOD_HIGH_INDEX_VAL',
19
+ 'EOD_LOW_INDEX_VAL', 'EOD_CLOSE_INDEX_VAL'}
20
+
21
+
22
+def parse_index_history(content, result, currency):
23
+ try:
24
+ payload = json.loads(content.decode('utf-8-sig'), parse_float=Decimal)
25
+ except (ValueError, UnicodeError):
26
+ raise ValueError('NON_JSON_INDEX_RESPONSE') from None
27
+ data = payload.get('data') if isinstance(payload, dict) else None
28
+ if not isinstance(data, list):
29
+ raise ValueError('INVALID_INDEX_RESPONSE_CONTRACT')
30
+ if not data:
31
+ raise ValueError('EMPTY_RESPONSE')
32
+ accepted = {}
33
+ for row in data:
34
+ result.rows_parsed += 1
35
+ if not isinstance(row, dict) or not REQUIRED.issubset(row):
36
+ raise ValueError('MISSING_INDEX_FIELDS')
37
+ result.headers = sorted(row)
38
+ if ' '.join(str(row['EOD_INDEX_NAME']).upper().split()) != RESPONSE_NAMES.get(result.provider_symbol):
39
+ raise ValueError('INDEX_SYMBOL_MISMATCH')
40
+ try:
41
+ day = trading_date(row['EOD_TIMESTAMP'])
42
+ if not result.request_from <= day <= result.request_to or day in accepted:
43
+ raise ValueError('INVALID_OR_DUPLICATE_INDEX_DATE')
44
+ bar = DailyMarketBar(global_instrument_id=result.global_instrument_id, trading_date=day,
45
+ **{name:number(str(row[field]), required=True) for name,field in (
46
+ ('open','EOD_OPEN_INDEX_VAL'),('high','EOD_HIGH_INDEX_VAL'),
47
+ ('low','EOD_LOW_INDEX_VAL'),('close','EOD_CLOSE_INDEX_VAL'))},
48
+ currency=currency, provider='NSE', provider_symbol=result.provider_symbol,
49
+ source_mode='REAL', source_url=result.source_url, retrieved_at=result.retrieved_at,
50
+ volume=None, turnover=None, previous_close=None)
51
+ except (ValueError, TypeError, KeyError):
52
+ raise ValueError('INVALID_INDEX_ROW') from None
53
+ accepted[day] = bar
54
+ result.bars = [accepted[day] for day in sorted(accepted)]
55
+
56
+
57
+class NseIndexHistoryProvider:
58
+ def __init__(self, orchestrator, settings, *, client=None, sleep=None):
59
+ kwargs = {'client':client}
60
+ if sleep is not None:
61
+ kwargs['sleep'] = sleep
62
+ self.transport = NseHistoricalDailyProvider(orchestrator, settings, **kwargs)
63
+ self.orchestrator, self.settings = orchestrator, settings
64
+
65
+ async def aclose(self):
66
+ await self.transport.aclose()
67
+
68
+ async def fetch(self, global_instrument_id, *, start, end, identity_headers=None, correlation_id=None):
69
+ result = NseHistoricalResult(global_instrument_id, start, end, source_url=ENDPOINT)
70
+ stage = 'IDENTITY'
71
+ try:
72
+ keys = [key for key in BENCHMARKS if benchmark_id(key) == global_instrument_id]
73
+ if len(keys) != 1:
74
+ raise ValueError('BENCHMARK_IDENTITY_UNAVAILABLE')
75
+ if type(start) is not date or type(end) is not date or start > end or (end-start).days+1 > self.settings.nse_historical_request_window_days:
76
+ raise ValueError('INVALID_REQUEST_WINDOW')
77
+ key = keys[0]
78
+ metadata = await self.orchestrator.global_instrument_metadata(global_instrument_id,
79
+ identity_headers=identity_headers, correlation_id=correlation_id)
80
+ ref = benchmark_identity(metadata, key)
81
+ result.provider_symbol = BENCHMARKS[key]
82
+ async with self.transport._lock:
83
+ if not self.transport._bootstrapped:
84
+ stage = 'BOOTSTRAP'
85
+ await self.transport._get(BOOTSTRAP, result)
86
+ self.transport._bootstrapped = True
87
+ stage = 'HISTORICAL'
88
+ response = await self.transport._get(ENDPOINT, result, params={'indexType':result.provider_symbol,
89
+ 'from':start.strftime('%d-%m-%Y'),'to':end.strftime('%d-%m-%Y')})
90
+ result.retrieved_at = datetime.now(timezone.utc)
91
+ result.source_url = str(response.url)
92
+ parse_index_history(response.content, result, ref.currency)
93
+ result.status = 'SUCCESS'
94
+ except httpx.HTTPStatusError as exc:
95
+ result.failure_reason = f'{stage}_HTTP_{exc.response.status_code}'
96
+ if exc.response.status_code == 403:
97
+ self.transport._bootstrapped = False
98
+ except httpx.HTTPError:
99
+ result.failure_reason = f'{stage}_PROVIDER_UNAVAILABLE'
100
+ except ValueError as exc:
101
+ safe = {'BENCHMARK_IDENTITY_UNAVAILABLE','INVALID_REQUEST_WINDOW','NON_JSON_INDEX_RESPONSE',
102
+ 'INVALID_INDEX_RESPONSE_CONTRACT','EMPTY_RESPONSE','MISSING_INDEX_FIELDS','INDEX_SYMBOL_MISMATCH','INVALID_INDEX_ROW'}
103
+ result.failure_reason = str(exc) if str(exc) in safe else f'{stage}_UNAVAILABLE'
104
+ except Exception:
105
+ result.failure_reason = f'{stage}_UNAVAILABLE'
106
+ if result.failure_reason:
107
+ result.rows_rejected = result.rows_parsed
108
+ result.bars.clear()
109
+ return result
ai/research-engine/app/portfolio_orchestration.py
+14
@@ -91,6 +91,20 @@ class PortfolioResearchOrchestrator:
91
except (httpx.HTTPError, ValueError) as exc:
92
raise PortfolioServiceUnavailableError("Portfolio service unavailable for instrument enumeration") from exc
93
94
+ async def sector_benchmark_contexts(self, instrument_ids: set[UUID], *, correlation_id=None, identity_headers=None):
95
+ """Read-only canonical dependencies, prepared before pure Stage-B computation."""
96
+ if not instrument_ids:
97
+ return {}
98
+ from app.sector_benchmarks import build_sector_contexts
99
+ classifications = await self.india_nifty500_universe(correlation_id=correlation_id, identity_headers=identity_headers)
100
+ response = await self._client.get(f"{self.settings.portfolio_service_base_url}/api/v1/instruments/benchmarks",
101
+ headers={k:v for k,v in (identity_headers or {}).items() if v})
102
+ response.raise_for_status()
103
+ registered = response.json()
104
+ if not isinstance(registered, list):
105
+ raise PortfolioServiceUnavailableError('BENCHMARK_IDENTITY_UNAVAILABLE')
106
+ return build_sector_contexts(classifications, registered, instrument_ids)
107
+
108
async def india_nifty500_universe(self, *, correlation_id: str | None = None, identity_headers: dict[str, str | None] | None = None) -> list[dict]:
109
"""Read portfolio-service owned NSE/Nifty universe; never uses portfolios."""
110
headers = {key: value for key, value in (identity_headers or {}).items() if value}
ai/research-engine/app/sector_benchmarks.py
new
+87
@@ -0,0 +1,87 @@
1
+"""Versioned platform benchmark keys resolved only through registered master rows."""
2
+from hashlib import md5
3
+from uuid import UUID
4
+from datetime import datetime
5
+
6
+from app.sector_relative_strength import BenchmarkReference, SectorContext
7
+
8
+MAPPING_VERSION = 'SECTOR_BENCHMARK_MAPPING_V1'
9
+CATALOG_VERSION = 'NSE_BENCHMARK_CATALOG_V1'
10
+BENCHMARKS = {
11
+ 'INDIA_BROAD_PRICE': 'NIFTY 500',
12
+ 'INDIA_TECHNOLOGY_PRICE': 'NIFTY IT',
13
+ 'INDIA_FINANCIALS_PRICE': 'NIFTY FINANCIAL SERVICES',
14
+ 'INDIA_HEALTHCARE_PRICE': 'NIFTY HEALTHCARE INDEX',
15
+}
16
+SECTOR_KEYS = {'Technology': 'INDIA_TECHNOLOGY_PRICE', 'Financials': 'INDIA_FINANCIALS_PRICE',
17
+ 'Healthcare': 'INDIA_HEALTHCARE_PRICE'}
18
+BROAD_KEY = 'INDIA_BROAD_PRICE'
19
+
20
+
21
+def benchmark_id(key):
22
+ """Same platform-key UUIDv3 as portfolio-service; never a provider-symbol UUID."""
23
+ if key not in BENCHMARKS:
24
+ raise ValueError('UNKNOWN_BENCHMARK_KEY')
25
+ return UUID(bytes=md5(('aip:benchmark:' + key).encode(), usedforsecurity=False).digest(), version=3)
26
+
27
+
28
+def benchmark_identity(metadata, key):
29
+ if not isinstance(metadata, dict):
30
+ raise ValueError('BENCHMARK_IDENTITY_UNAVAILABLE')
31
+ mappings = metadata.get('providerMappings') or []
32
+ nse = [m for m in mappings if isinstance(m, dict) and m.get('provider') == 'NSE']
33
+ if (str(metadata.get('globalInstrumentId')) != str(benchmark_id(key))
34
+ or metadata.get('assetType') != 'INDEX' or metadata.get('status') != 'ACTIVE'
35
+ or metadata.get('country') != 'IN' or metadata.get('primaryExchange') != 'NSE'
36
+ or metadata.get('currency') != 'INR' or len(nse) != 1):
37
+ raise ValueError('BENCHMARK_IDENTITY_UNAVAILABLE')
38
+ mapping = nse[0]
39
+ if (mapping.get('status') != 'VERIFIED' or mapping.get('active') is False
40
+ or mapping.get('resolutionSource') != CATALOG_VERSION or mapping.get('currency') != 'INR'
41
+ or mapping.get('exchange') != 'NSE' or mapping.get('providerSymbol') != BENCHMARKS[key]):
42
+ raise ValueError('BENCHMARK_IDENTITY_UNAVAILABLE')
43
+ return BenchmarkReference(benchmark_id(key), 'INR', frozenset({'NSE'}))
44
+
45
+
46
+def build_sector_contexts(classifications, registered, instrument_ids):
47
+ """Pure adapter over canonical Nifty500 cache and registered benchmark metadata."""
48
+ by_id = {}
49
+ for row in registered:
50
+ by_id.setdefault(str(row.get('globalInstrumentId')), []).append(row)
51
+ def reference(key):
52
+ matches = by_id.get(str(benchmark_id(key)), [])
53
+ try:
54
+ return benchmark_identity(matches[0], key) if len(matches) == 1 else None
55
+ except ValueError:
56
+ return None
57
+ broad = reference(BROAD_KEY)
58
+ grouped = {}
59
+ for row in classifications:
60
+ grouped.setdefault(str(row.get('globalInstrumentId')), []).append(row)
61
+ output = {}
62
+ for key in sorted(instrument_ids, key=str):
63
+ matches = grouped.get(str(key), [])
64
+ row = matches[0] if len(matches) == 1 else {}
65
+ sector = row.get('canonicalSector')
66
+ stamp = row.get('retrievedAt')
67
+ try:
68
+ stamp = datetime.fromisoformat(stamp.replace('Z', '+00:00')) if isinstance(stamp, str) else stamp
69
+ except ValueError:
70
+ stamp = None
71
+ india_member = (row.get('source') == 'NSE_INDICES_NIFTY500' and row.get('status') == 'ACTIVE'
72
+ and row.get('assetType') == 'EQUITY' and row.get('country') == 'IN'
73
+ and row.get('exchange') in {'NSE', 'XNSE'})
74
+ if not india_member:
75
+ sector = None
76
+ target = SECTOR_KEYS.get(sector)
77
+ sector_ref = reference(target) if target else None
78
+ classification_ok = bool(sector and stamp)
79
+ status = ('NO_SECTOR_CLASSIFICATION' if not classification_ok else
80
+ 'UNMAPPED_SECTOR_BENCHMARK' if not target else
81
+ 'BENCHMARK_IDENTITY_UNAVAILABLE' if sector_ref is None else 'AVAILABLE')
82
+ market_ref = broad if india_member else None
83
+ output[key] = SectorContext(sector=sector, source=row.get('source'), as_of=stamp, region='INDIA' if india_member else None,
84
+ sector_benchmark=sector_ref if classification_ok else None, market_benchmark=market_ref,
85
+ mapping_version=MAPPING_VERSION, sector_mapping_status=status,
86
+ market_mapping_status='AVAILABLE' if market_ref else 'BENCHMARK_IDENTITY_UNAVAILABLE')
87
+ return output
ai/research-engine/app/sector_relative_strength.py
+56
-17
@@ -14,9 +14,10 @@ from uuid import UUID
14
15
from pydantic import Field
16
17
-from app.models import MarketPriceObservation, ResearchBaseModel
17
+from app.models import DailyMarketBar, MarketPriceObservation, ResearchBaseModel
18
from app.sector_leaderboard import normalize_sector
19
-from app.technical_features import normalize_price_history, percentage, utc
19
+from app.technical_features import normalize_price_history, normalize_daily_history, percentage, utc
20
+from zoneinfo import ZoneInfo
21
22
23
SECTOR_FEATURE_VERSION = "SECTOR_RELATIVE_STRENGTH_V1"
@@ -43,6 +44,9 @@ class SectorContext:
44
region: str | None = None
45
sector_benchmark: BenchmarkReference | None = None
46
market_benchmark: BenchmarkReference | None = None
47
+ mapping_version: str | None = None
48
+ sector_mapping_status: str | None = None
49
+ market_mapping_status: str | None = None
50
51
52
@dataclass(frozen=True)
@@ -73,6 +77,9 @@ class SectorRelativeStrengthSnapshot(ResearchBaseModel):
77
as_of: datetime
78
feature_version: str = SECTOR_FEATURE_VERSION
79
configuration: dict
80
+ benchmark_mapping_version: str | None = None
81
+ benchmark_states: dict[str, str] = Field(default_factory=dict)
82
+ history_sources: dict[str, str] = Field(default_factory=dict)
83
sector: str | None = None
84
classification_source: str | None = None
85
classification_as_of: datetime | None = None
@@ -116,9 +123,11 @@ class SectorRelativeStrengthEngine:
123
def compute(self, instrument_id: UUID, stock_history: Iterable[MarketPriceObservation], *, as_of: datetime,
124
context: SectorContext | None = None, currency: str | None = None,
125
benchmark_histories: Mapping[UUID, Iterable[MarketPriceObservation]] | None = None,
119
- trusted_providers: frozenset[str] | None = None) -> SectorRelativeStrengthSnapshot:
126
+ trusted_providers: frozenset[str] | None = None,
127
+ daily_bar_histories: Mapping[UUID, Iterable[DailyMarketBar]] | None = None) -> SectorRelativeStrengthSnapshot:
128
cfg, context = self.config, context or SectorContext()
129
histories = benchmark_histories or {}
130
+ daily = daily_bar_histories or {}
131
classification_valid = bool(context.sector and context.source and context.as_of is not None and utc(context.as_of) <= utc(as_of))
132
result = SectorRelativeStrengthSnapshot(global_instrument_id=instrument_id, as_of=utc(as_of), configuration=asdict(cfg),
133
sector=normalize_sector(context.sector)[1] if classification_valid else None,
@@ -126,38 +135,47 @@ class SectorRelativeStrengthEngine:
135
classification_as_of=utc(context.as_of) if classification_valid else None, region=context.region,
136
sector_benchmark_id=context.sector_benchmark.instrument_id if context.sector_benchmark and classification_valid else None,
137
market_benchmark_id=context.market_benchmark.instrument_id if context.market_benchmark else None)
138
+ result.benchmark_mapping_version = context.mapping_version
139
if not classification_valid:
140
result.missing_inputs.append("AUTHORITATIVE_SECTOR_CLASSIFICATION")
131
- stock = normalize_price_history(instrument_id, stock_history, as_of=as_of, currency=currency,
132
- trusted_providers=trusted_providers)
133
- stock_stale = bool(stock.observations) and utc(as_of) - utc(stock.observations[-1].observed_at) > timedelta(days=cfg.max_age_days)
141
+ stock_dates, stock_conflict, stock_stale, source = _dated_history(instrument_id, stock_history,
142
+ daily.get(instrument_id, ()), as_of, currency, trusted_providers, cfg.max_age_days, stock=True)
143
+ result.history_sources['stock'] = source
144
if stock_stale:
145
result.stale_inputs.append("STOCK_HISTORY")
136
- if stock.current_conflict:
146
+ if stock_conflict:
147
result.missing_inputs.append("CONFLICTING_STOCK_PRICE")
148
benchmark_data = {}
149
for name, reference in (("sector", context.sector_benchmark if classification_valid else None), ("market", context.market_benchmark)):
150
if reference is None:
151
result.missing_inputs.append(f"{name.upper()}_BENCHMARK_MAPPING")
152
+ result.benchmark_states[name] = ('NO_SECTOR_CLASSIFICATION' if name == 'sector' and not classification_valid
153
+ else getattr(context, f'{name}_mapping_status') or
154
+ ('UNMAPPED_SECTOR_BENCHMARK' if name == 'sector' else 'BENCHMARK_IDENTITY_UNAVAILABLE'))
155
continue
156
if reference.instrument_id == instrument_id or not reference.currency:
157
result.missing_inputs.append(f"INVALID_{name.upper()}_BENCHMARK_MAPPING")
158
+ result.benchmark_states[name] = 'BENCHMARK_IDENTITY_UNAVAILABLE'
159
continue
146
- history = normalize_price_history(reference.instrument_id, histories.get(reference.instrument_id, ()),
147
- as_of=as_of, currency=reference.currency, trusted_providers=reference.trusted_providers)
148
- if not history.observations or history.current_conflict:
149
- result.missing_inputs.append(f"{name.upper()}_HISTORY" if not history.current_conflict else f"CONFLICTING_{name.upper()}_PRICE")
160
+ dates, conflict, stale, source = _dated_history(reference.instrument_id, histories.get(reference.instrument_id, ()),
161
+ daily.get(reference.instrument_id, ()), as_of, reference.currency, reference.trusted_providers, cfg.max_age_days)
162
+ result.history_sources[name] = source
163
+ if not dates or conflict:
164
+ result.missing_inputs.append(f"{name.upper()}_HISTORY" if not conflict else f"CONFLICTING_{name.upper()}_PRICE")
165
+ result.benchmark_states[name] = 'BENCHMARK_HISTORY_UNAVAILABLE'
166
continue
151
- if utc(as_of) - utc(history.observations[-1].observed_at) > timedelta(days=cfg.max_age_days):
167
+ if stale:
168
result.stale_inputs.append(f"{name.upper()}_HISTORY")
169
+ result.benchmark_states[name] = 'STALE_BENCHMARK_HISTORY'
170
continue
154
- benchmark_data[name] = {utc(row.observed_at).date(): float(row.price) for row in history.observations}
171
+ benchmark_data[name] = dates
172
+ result.benchmark_states[name] = 'INSUFFICIENT_OVERLAP'
173
edges = {}
174
for suffix, lookback, weight in zip(("1_w", "1_m", "3_m", "6_m"), cfg.return_lookbacks, cfg.period_weights):
157
- if not stock.current_conflict and len(stock.observations) > lookback:
158
- start, end = stock.observations[-lookback-1], stock.observations[-1]
159
- dates = utc(start.observed_at).date(), utc(end.observed_at).date()
160
- stock_return = percentage(float(end.price), float(start.price))
175
+ if not stock_conflict and len(stock_dates) > lookback:
176
+ ordered = sorted(stock_dates)
177
+ dates = ordered[-lookback-1], ordered[-1]
178
+ stock_return = percentage(stock_dates[dates[1]], stock_dates[dates[0]])
179
setattr(result, f"stock_return{suffix}", stock_return)
180
result.comparison_windows[suffix.replace("_", "").upper()] = dates
181
period_edges = []
@@ -168,6 +186,7 @@ class SectorRelativeStrengthEngine:
186
setattr(result, f"{name}_return{suffix}", value)
187
setattr(result, f"relative_vs_{name}{suffix}", stock_return - value)
188
period_edges.append((stock_return - value) / lookback)
189
+ result.benchmark_states[name] = 'AVAILABLE'
190
elif rows and not stock_stale:
191
result.missing_inputs.append(f"{name.upper()}_ALIGNED_DATES_{suffix.replace('_', '').upper()}")
192
if period_edges:
@@ -210,3 +229,23 @@ class SectorRelativeStrengthEngine:
229
result.missing_inputs = sorted(set(result.missing_inputs))
230
result.stale_inputs = sorted(set(result.stale_inputs))
231
return result
232
+
233
+
234
+def _dated_history(key, closes, bars, as_of, currency, providers, max_age, stock=False):
235
+ """Keep daily DATEs intact; no timestamp shift, filling, or nearest-date match."""
236
+ history, present = normalize_daily_history(key, bars, as_of=as_of, currency=currency, trusted_providers=providers)
237
+ today = utc(as_of).astimezone(ZoneInfo('Asia/Kolkata')).date()
238
+ stale = bool(history.observations) and today - history.observations[-1].trading_date > timedelta(days=max_age)
239
+ fallback = None
240
+ if present and stock and not history.current_conflict and (len(history.observations) < 20 or stale):
241
+ fallback = normalize_price_history(key, closes, as_of=as_of, currency=currency, trusted_providers=providers)
242
+ if (len(fallback.observations) >= 20 and not fallback.current_conflict and
243
+ utc(as_of) - utc(fallback.observations[-1].observed_at) <= timedelta(days=max_age)):
244
+ present = False
245
+ if present:
246
+ return ({row.trading_date:float(row.close) for row in history.observations}, history.current_conflict,
247
+ stale, 'DAILY_MARKET_BAR_NSE')
248
+ fallback = fallback or normalize_price_history(key, closes, as_of=as_of, currency=currency, trusted_providers=providers)
249
+ stale = bool(fallback.observations) and utc(as_of) - utc(fallback.observations[-1].observed_at) > timedelta(days=max_age)
250
+ return ({utc(row.observed_at).date():float(row.price) for row in fallback.observations},
251
+ fallback.current_conflict, stale, 'CLOSE_ONLY_FALLBACK')
ai/research-engine/tests/fixtures/nse_index_500.json
new
+1
@@ -0,0 +1 @@
1
+{"data":[{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":22774.05,"EOD_HIGH_INDEX_VAL":22894.55,"EOD_CLOSE_INDEX_VAL":22866.5,"EOD_LOW_INDEX_VAL":22672.85,"HIT_TURN_OVER":75777.72,"HIT_TRADED_QTY":2584529506,"EOD_TIMESTAMP":"11-SEP-2026","HI_TIMESTAMP":"2026-09-10T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":22966.65,"EOD_HIGH_INDEX_VAL":22993.5,"EOD_CLOSE_INDEX_VAL":22952.35,"EOD_LOW_INDEX_VAL":22884.6,"HIT_TURN_OVER":68192.4,"HIT_TRADED_QTY":2567863094,"EOD_TIMESTAMP":"10-SEP-2026","HI_TIMESTAMP":"2026-09-09T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23015.8,"EOD_HIGH_INDEX_VAL":23061.7,"EOD_CLOSE_INDEX_VAL":22958.75,"EOD_LOW_INDEX_VAL":22957.6,"HIT_TURN_OVER":85249.17,"HIT_TRADED_QTY":2333313018,"EOD_TIMESTAMP":"09-SEP-2026","HI_TIMESTAMP":"2026-09-08T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23142.45,"EOD_HIGH_INDEX_VAL":23151.45,"EOD_CLOSE_INDEX_VAL":23106.1,"EOD_LOW_INDEX_VAL":23063.75,"HIT_TURN_OVER":69960.9,"HIT_TRADED_QTY":1902827941,"EOD_TIMESTAMP":"08-SEP-2026","HI_TIMESTAMP":"2026-09-07T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23248.05,"EOD_HIGH_INDEX_VAL":23260.4,"EOD_CLOSE_INDEX_VAL":23156.2,"EOD_LOW_INDEX_VAL":23124.1,"HIT_TURN_OVER":64553.48,"HIT_TRADED_QTY":2211977774,"EOD_TIMESTAMP":"07-SEP-2026","HI_TIMESTAMP":"2026-09-06T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23286.5,"EOD_HIGH_INDEX_VAL":23349.9,"EOD_CLOSE_INDEX_VAL":23254.15,"EOD_LOW_INDEX_VAL":23254.15,"HIT_TURN_OVER":82366.28,"HIT_TRADED_QTY":2623135270,"EOD_TIMESTAMP":"04-SEP-2026","HI_TIMESTAMP":"2026-09-03T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23300.75,"EOD_HIGH_INDEX_VAL":23332.85,"EOD_CLOSE_INDEX_VAL":23254.05,"EOD_LOW_INDEX_VAL":23237.25,"HIT_TURN_OVER":73802.82,"HIT_TRADED_QTY":1979780573,"EOD_TIMESTAMP":"03-SEP-2026","HI_TIMESTAMP":"2026-09-02T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23176.95,"EOD_HIGH_INDEX_VAL":23226.2,"EOD_CLOSE_INDEX_VAL":23222.8,"EOD_LOW_INDEX_VAL":23058.3,"HIT_TURN_OVER":81439.03,"HIT_TRADED_QTY":2494416409,"EOD_TIMESTAMP":"02-SEP-2026","HI_TIMESTAMP":"2026-09-01T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23429.65,"EOD_HIGH_INDEX_VAL":23435.65,"EOD_CLOSE_INDEX_VAL":23339.9,"EOD_LOW_INDEX_VAL":23243.25,"HIT_TURN_OVER":84191.15,"HIT_TRADED_QTY":2010695056,"EOD_TIMESTAMP":"01-SEP-2026","HI_TIMESTAMP":"2026-08-31T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23477.75,"EOD_HIGH_INDEX_VAL":23485.15,"EOD_CLOSE_INDEX_VAL":23450.35,"EOD_LOW_INDEX_VAL":23307.2,"HIT_TURN_OVER":144198.89,"HIT_TRADED_QTY":3492858587,"EOD_TIMESTAMP":"31-AUG-2026","HI_TIMESTAMP":"2026-08-30T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23513.05,"EOD_HIGH_INDEX_VAL":23552.3,"EOD_CLOSE_INDEX_VAL":23528.55,"EOD_LOW_INDEX_VAL":23451.1,"HIT_TURN_OVER":74118.09,"HIT_TRADED_QTY":2141439146,"EOD_TIMESTAMP":"28-AUG-2026","HI_TIMESTAMP":"2026-08-27T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23615.65,"EOD_HIGH_INDEX_VAL":23642.85,"EOD_CLOSE_INDEX_VAL":23482,"EOD_LOW_INDEX_VAL":23480.15,"HIT_TURN_OVER":81081.51,"HIT_TRADED_QTY":2071692443,"EOD_TIMESTAMP":"27-AUG-2026","HI_TIMESTAMP":"2026-08-26T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23641.35,"EOD_HIGH_INDEX_VAL":23684.1,"EOD_CLOSE_INDEX_VAL":23559.2,"EOD_LOW_INDEX_VAL":23557.95,"HIT_TURN_OVER":80306.45,"HIT_TRADED_QTY":2453487297,"EOD_TIMESTAMP":"26-AUG-2026","HI_TIMESTAMP":"2026-08-25T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23483.95,"EOD_HIGH_INDEX_VAL":23613.9,"EOD_CLOSE_INDEX_VAL":23611.6,"EOD_LOW_INDEX_VAL":23423.35,"HIT_TURN_OVER":76962.3,"HIT_TRADED_QTY":3210315086,"EOD_TIMESTAMP":"25-AUG-2026","HI_TIMESTAMP":"2026-08-24T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23560.6,"EOD_HIGH_INDEX_VAL":23587.85,"EOD_CLOSE_INDEX_VAL":23511.75,"EOD_LOW_INDEX_VAL":23454.1,"HIT_TURN_OVER":73770.36,"HIT_TRADED_QTY":1957565972,"EOD_TIMESTAMP":"24-AUG-2026","HI_TIMESTAMP":"2026-08-23T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23554.15,"EOD_HIGH_INDEX_VAL":23554.15,"EOD_CLOSE_INDEX_VAL":23530.3,"EOD_LOW_INDEX_VAL":23477,"HIT_TURN_OVER":76597.47,"HIT_TRADED_QTY":1695748201,"EOD_TIMESTAMP":"21-AUG-2026","HI_TIMESTAMP":"2026-08-20T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23519.8,"EOD_HIGH_INDEX_VAL":23565.9,"EOD_CLOSE_INDEX_VAL":23512.95,"EOD_LOW_INDEX_VAL":23491.6,"HIT_TURN_OVER":73080.22,"HIT_TRADED_QTY":1721057248,"EOD_TIMESTAMP":"20-AUG-2026","HI_TIMESTAMP":"2026-08-19T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23475.35,"EOD_HIGH_INDEX_VAL":23488.25,"EOD_CLOSE_INDEX_VAL":23386.2,"EOD_LOW_INDEX_VAL":23348.3,"HIT_TURN_OVER":75345.71,"HIT_TRADED_QTY":1799195227,"EOD_TIMESTAMP":"19-AUG-2026","HI_TIMESTAMP":"2026-08-18T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23521.85,"EOD_HIGH_INDEX_VAL":23555.3,"EOD_CLOSE_INDEX_VAL":23472.4,"EOD_LOW_INDEX_VAL":23470.9,"HIT_TURN_OVER":71834.51,"HIT_TRADED_QTY":2276032124,"EOD_TIMESTAMP":"18-AUG-2026","HI_TIMESTAMP":"2026-08-17T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23581.75,"EOD_HIGH_INDEX_VAL":23617.3,"EOD_CLOSE_INDEX_VAL":23564.45,"EOD_LOW_INDEX_VAL":23488.45,"HIT_TURN_OVER":68597.08,"HIT_TRADED_QTY":1837199203,"EOD_TIMESTAMP":"17-AUG-2026","HI_TIMESTAMP":"2026-08-16T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23635.65,"EOD_HIGH_INDEX_VAL":23638.3,"EOD_CLOSE_INDEX_VAL":23594.9,"EOD_LOW_INDEX_VAL":23564.3,"HIT_TURN_OVER":74739.54,"HIT_TRADED_QTY":2287796107,"EOD_TIMESTAMP":"14-AUG-2026","HI_TIMESTAMP":"2026-08-13T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY 500","EOD_OPEN_INDEX_VAL":23678.7,"EOD_HIGH_INDEX_VAL":23678.7,"EOD_CLOSE_INDEX_VAL":23658.1,"EOD_LOW_INDEX_VAL":23585.6,"HIT_TURN_OVER":80099.77,"HIT_TRADED_QTY":1996962847,"EOD_TIMESTAMP":"13-AUG-2026","HI_TIMESTAMP":"2026-08-12T18:30:00.000Z"}]}
\ No newline at end of file
ai/research-engine/tests/fixtures/nse_index_financials.json
new
+1
@@ -0,0 +1 @@
1
+{"data":[{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25247.3,"EOD_HIGH_INDEX_VAL":25576.95,"EOD_CLOSE_INDEX_VAL":25545.4,"EOD_LOW_INDEX_VAL":25124.25,"HIT_TURN_OVER":10149.97,"HIT_TRADED_QTY":133588753,"EOD_TIMESTAMP":"11-SEP-2026","HI_TIMESTAMP":"2026-09-10T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25383.9,"EOD_HIGH_INDEX_VAL":25520.3,"EOD_CLOSE_INDEX_VAL":25520.3,"EOD_LOW_INDEX_VAL":25374.75,"HIT_TURN_OVER":8149.08,"HIT_TRADED_QTY":97208606,"EOD_TIMESTAMP":"10-SEP-2026","HI_TIMESTAMP":"2026-09-09T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25538.05,"EOD_HIGH_INDEX_VAL":25592.4,"EOD_CLOSE_INDEX_VAL":25376.8,"EOD_LOW_INDEX_VAL":25376.8,"HIT_TURN_OVER":11812.77,"HIT_TRADED_QTY":134019127,"EOD_TIMESTAMP":"09-SEP-2026","HI_TIMESTAMP":"2026-09-08T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25885.15,"EOD_HIGH_INDEX_VAL":25904.95,"EOD_CLOSE_INDEX_VAL":25695.55,"EOD_LOW_INDEX_VAL":25690.15,"HIT_TURN_OVER":8220.76,"HIT_TRADED_QTY":96807672,"EOD_TIMESTAMP":"08-SEP-2026","HI_TIMESTAMP":"2026-09-07T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26069.9,"EOD_HIGH_INDEX_VAL":26080.6,"EOD_CLOSE_INDEX_VAL":25935.6,"EOD_LOW_INDEX_VAL":25910.5,"HIT_TURN_OVER":7178.57,"HIT_TRADED_QTY":78845572,"EOD_TIMESTAMP":"07-SEP-2026","HI_TIMESTAMP":"2026-09-06T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25988.9,"EOD_HIGH_INDEX_VAL":26174,"EOD_CLOSE_INDEX_VAL":26051,"EOD_LOW_INDEX_VAL":25987.1,"HIT_TURN_OVER":10253.44,"HIT_TRADED_QTY":115831222,"EOD_TIMESTAMP":"04-SEP-2026","HI_TIMESTAMP":"2026-09-03T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25967.05,"EOD_HIGH_INDEX_VAL":26100.1,"EOD_CLOSE_INDEX_VAL":25923.05,"EOD_LOW_INDEX_VAL":25922.9,"HIT_TURN_OVER":10168.12,"HIT_TRADED_QTY":110286415,"EOD_TIMESTAMP":"03-SEP-2026","HI_TIMESTAMP":"2026-09-02T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":25777.2,"EOD_HIGH_INDEX_VAL":25842.4,"EOD_CLOSE_INDEX_VAL":25813.05,"EOD_LOW_INDEX_VAL":25670.85,"HIT_TURN_OVER":11992.29,"HIT_TRADED_QTY":137931961,"EOD_TIMESTAMP":"02-SEP-2026","HI_TIMESTAMP":"2026-09-01T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26171.35,"EOD_HIGH_INDEX_VAL":26195.9,"EOD_CLOSE_INDEX_VAL":26003.9,"EOD_LOW_INDEX_VAL":25896.5,"HIT_TURN_OVER":10791.1,"HIT_TRADED_QTY":128068094,"EOD_TIMESTAMP":"01-SEP-2026","HI_TIMESTAMP":"2026-08-31T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26204.4,"EOD_HIGH_INDEX_VAL":26293.65,"EOD_CLOSE_INDEX_VAL":26293.65,"EOD_LOW_INDEX_VAL":26052.25,"HIT_TURN_OVER":17729.06,"HIT_TRADED_QTY":224753516,"EOD_TIMESTAMP":"31-AUG-2026","HI_TIMESTAMP":"2026-08-30T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26238.9,"EOD_HIGH_INDEX_VAL":26316.25,"EOD_CLOSE_INDEX_VAL":26286.5,"EOD_LOW_INDEX_VAL":26155.45,"HIT_TURN_OVER":8170.46,"HIT_TRADED_QTY":92992612,"EOD_TIMESTAMP":"28-AUG-2026","HI_TIMESTAMP":"2026-08-27T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26445.35,"EOD_HIGH_INDEX_VAL":26494.9,"EOD_CLOSE_INDEX_VAL":26280.65,"EOD_LOW_INDEX_VAL":26280.65,"HIT_TURN_OVER":12196.08,"HIT_TRADED_QTY":158165854,"EOD_TIMESTAMP":"27-AUG-2026","HI_TIMESTAMP":"2026-08-26T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26293.7,"EOD_HIGH_INDEX_VAL":26478.4,"EOD_CLOSE_INDEX_VAL":26386.75,"EOD_LOW_INDEX_VAL":26286.55,"HIT_TURN_OVER":9165.36,"HIT_TRADED_QTY":102910520,"EOD_TIMESTAMP":"26-AUG-2026","HI_TIMESTAMP":"2026-08-25T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26092,"EOD_HIGH_INDEX_VAL":26246.95,"EOD_CLOSE_INDEX_VAL":26246.95,"EOD_LOW_INDEX_VAL":26034.15,"HIT_TURN_OVER":7570.71,"HIT_TRADED_QTY":89101957,"EOD_TIMESTAMP":"25-AUG-2026","HI_TIMESTAMP":"2026-08-24T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26272.3,"EOD_HIGH_INDEX_VAL":26361.65,"EOD_CLOSE_INDEX_VAL":26158.5,"EOD_LOW_INDEX_VAL":26088.9,"HIT_TURN_OVER":8440.44,"HIT_TRADED_QTY":90319568,"EOD_TIMESTAMP":"24-AUG-2026","HI_TIMESTAMP":"2026-08-23T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26277.35,"EOD_HIGH_INDEX_VAL":26324.1,"EOD_CLOSE_INDEX_VAL":26261,"EOD_LOW_INDEX_VAL":26192.95,"HIT_TURN_OVER":9279.19,"HIT_TRADED_QTY":101826278,"EOD_TIMESTAMP":"21-AUG-2026","HI_TIMESTAMP":"2026-08-20T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26173.4,"EOD_HIGH_INDEX_VAL":26279.3,"EOD_CLOSE_INDEX_VAL":26203.9,"EOD_LOW_INDEX_VAL":26159.9,"HIT_TURN_OVER":9628.37,"HIT_TRADED_QTY":105979433,"EOD_TIMESTAMP":"20-AUG-2026","HI_TIMESTAMP":"2026-08-19T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26106.65,"EOD_HIGH_INDEX_VAL":26129.55,"EOD_CLOSE_INDEX_VAL":26013,"EOD_LOW_INDEX_VAL":25936.15,"HIT_TURN_OVER":8258.74,"HIT_TRADED_QTY":90292804,"EOD_TIMESTAMP":"19-AUG-2026","HI_TIMESTAMP":"2026-08-18T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26132.7,"EOD_HIGH_INDEX_VAL":26220.1,"EOD_CLOSE_INDEX_VAL":26108,"EOD_LOW_INDEX_VAL":26088.15,"HIT_TURN_OVER":8228.01,"HIT_TRADED_QTY":86662090,"EOD_TIMESTAMP":"18-AUG-2026","HI_TIMESTAMP":"2026-08-17T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26148.65,"EOD_HIGH_INDEX_VAL":26322.55,"EOD_CLOSE_INDEX_VAL":26217.15,"EOD_LOW_INDEX_VAL":26051.75,"HIT_TURN_OVER":8481.25,"HIT_TRADED_QTY":85025387,"EOD_TIMESTAMP":"17-AUG-2026","HI_TIMESTAMP":"2026-08-16T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26306.2,"EOD_HIGH_INDEX_VAL":26312.9,"EOD_CLOSE_INDEX_VAL":26213.65,"EOD_LOW_INDEX_VAL":26174.95,"HIT_TURN_OVER":8169.89,"HIT_TRADED_QTY":97623322,"EOD_TIMESTAMP":"14-AUG-2026","HI_TIMESTAMP":"2026-08-13T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY FIN SERVICE","EOD_OPEN_INDEX_VAL":26428.15,"EOD_HIGH_INDEX_VAL":26428.15,"EOD_CLOSE_INDEX_VAL":26327.95,"EOD_LOW_INDEX_VAL":26282.25,"HIT_TURN_OVER":10340.28,"HIT_TRADED_QTY":141343038,"EOD_TIMESTAMP":"13-AUG-2026","HI_TIMESTAMP":"2026-08-12T18:30:00.000Z"}]}
\ No newline at end of file
ai/research-engine/tests/fixtures/nse_index_healthcare.json
new
+1
@@ -0,0 +1 @@
1
+{"data":[{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16428.6,"EOD_HIGH_INDEX_VAL":16560.15,"EOD_CLOSE_INDEX_VAL":16492.1,"EOD_LOW_INDEX_VAL":16368.05,"HIT_TURN_OVER":3292.18,"HIT_TRADED_QTY":22990674,"EOD_TIMESTAMP":"11-SEP-2026","HI_TIMESTAMP":"2026-09-10T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16622.45,"EOD_HIGH_INDEX_VAL":16640.05,"EOD_CLOSE_INDEX_VAL":16518.65,"EOD_LOW_INDEX_VAL":16465.35,"HIT_TURN_OVER":2597.13,"HIT_TRADED_QTY":19870008,"EOD_TIMESTAMP":"10-SEP-2026","HI_TIMESTAMP":"2026-09-09T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16619.85,"EOD_HIGH_INDEX_VAL":16720.1,"EOD_CLOSE_INDEX_VAL":16581.15,"EOD_LOW_INDEX_VAL":16548.2,"HIT_TURN_OVER":3232.57,"HIT_TRADED_QTY":28578008,"EOD_TIMESTAMP":"09-SEP-2026","HI_TIMESTAMP":"2026-09-08T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16525.15,"EOD_HIGH_INDEX_VAL":16651.55,"EOD_CLOSE_INDEX_VAL":16624.6,"EOD_LOW_INDEX_VAL":16475.45,"HIT_TURN_OVER":3731.05,"HIT_TRADED_QTY":37938889,"EOD_TIMESTAMP":"08-SEP-2026","HI_TIMESTAMP":"2026-09-07T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16403.25,"EOD_HIGH_INDEX_VAL":16520.35,"EOD_CLOSE_INDEX_VAL":16520.3,"EOD_LOW_INDEX_VAL":16385.1,"HIT_TURN_OVER":2715.46,"HIT_TRADED_QTY":21000792,"EOD_TIMESTAMP":"07-SEP-2026","HI_TIMESTAMP":"2026-09-06T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16548.95,"EOD_HIGH_INDEX_VAL":16573.3,"EOD_CLOSE_INDEX_VAL":16408.05,"EOD_LOW_INDEX_VAL":16400.7,"HIT_TURN_OVER":2140.36,"HIT_TRADED_QTY":15278284,"EOD_TIMESTAMP":"04-SEP-2026","HI_TIMESTAMP":"2026-09-03T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16650.55,"EOD_HIGH_INDEX_VAL":16660.35,"EOD_CLOSE_INDEX_VAL":16552.2,"EOD_LOW_INDEX_VAL":16528.2,"HIT_TURN_OVER":2335.93,"HIT_TRADED_QTY":13625951,"EOD_TIMESTAMP":"03-SEP-2026","HI_TIMESTAMP":"2026-09-02T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16580.95,"EOD_HIGH_INDEX_VAL":16661.35,"EOD_CLOSE_INDEX_VAL":16629.1,"EOD_LOW_INDEX_VAL":16547.5,"HIT_TURN_OVER":2612.61,"HIT_TRADED_QTY":16639917,"EOD_TIMESTAMP":"02-SEP-2026","HI_TIMESTAMP":"2026-09-01T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16816.85,"EOD_HIGH_INDEX_VAL":16816.85,"EOD_CLOSE_INDEX_VAL":16669.85,"EOD_LOW_INDEX_VAL":16616.9,"HIT_TURN_OVER":3684.24,"HIT_TRADED_QTY":23994278,"EOD_TIMESTAMP":"01-SEP-2026","HI_TIMESTAMP":"2026-08-31T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16806.7,"EOD_HIGH_INDEX_VAL":16941.7,"EOD_CLOSE_INDEX_VAL":16941.5,"EOD_LOW_INDEX_VAL":16638.05,"HIT_TURN_OVER":9949.84,"HIT_TRADED_QTY":55642068,"EOD_TIMESTAMP":"31-AUG-2026","HI_TIMESTAMP":"2026-08-30T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16744.45,"EOD_HIGH_INDEX_VAL":16818.5,"EOD_CLOSE_INDEX_VAL":16798.5,"EOD_LOW_INDEX_VAL":16673.2,"HIT_TURN_OVER":3149.96,"HIT_TRADED_QTY":20511402,"EOD_TIMESTAMP":"28-AUG-2026","HI_TIMESTAMP":"2026-08-27T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16626.75,"EOD_HIGH_INDEX_VAL":16728,"EOD_CLOSE_INDEX_VAL":16707.7,"EOD_LOW_INDEX_VAL":16579.9,"HIT_TURN_OVER":3469.78,"HIT_TRADED_QTY":22576990,"EOD_TIMESTAMP":"27-AUG-2026","HI_TIMESTAMP":"2026-08-26T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16592.7,"EOD_HIGH_INDEX_VAL":16689.9,"EOD_CLOSE_INDEX_VAL":16586.65,"EOD_LOW_INDEX_VAL":16486.35,"HIT_TURN_OVER":3296.16,"HIT_TRADED_QTY":20264811,"EOD_TIMESTAMP":"26-AUG-2026","HI_TIMESTAMP":"2026-08-25T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16410.4,"EOD_HIGH_INDEX_VAL":16600.55,"EOD_CLOSE_INDEX_VAL":16597.45,"EOD_LOW_INDEX_VAL":16335,"HIT_TURN_OVER":3237.05,"HIT_TRADED_QTY":20610924,"EOD_TIMESTAMP":"25-AUG-2026","HI_TIMESTAMP":"2026-08-24T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16412.7,"EOD_HIGH_INDEX_VAL":16448.05,"EOD_CLOSE_INDEX_VAL":16418,"EOD_LOW_INDEX_VAL":16306.85,"HIT_TURN_OVER":2980.38,"HIT_TRADED_QTY":19962447,"EOD_TIMESTAMP":"24-AUG-2026","HI_TIMESTAMP":"2026-08-23T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16487.3,"EOD_HIGH_INDEX_VAL":16525.15,"EOD_CLOSE_INDEX_VAL":16423.9,"EOD_LOW_INDEX_VAL":16319.75,"HIT_TURN_OVER":2277.66,"HIT_TRADED_QTY":18648469,"EOD_TIMESTAMP":"21-AUG-2026","HI_TIMESTAMP":"2026-08-20T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16483.2,"EOD_HIGH_INDEX_VAL":16496.1,"EOD_CLOSE_INDEX_VAL":16465.15,"EOD_LOW_INDEX_VAL":16415.1,"HIT_TURN_OVER":2255.12,"HIT_TRADED_QTY":18118432,"EOD_TIMESTAMP":"20-AUG-2026","HI_TIMESTAMP":"2026-08-19T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16459.85,"EOD_HIGH_INDEX_VAL":16500.25,"EOD_CLOSE_INDEX_VAL":16412.8,"EOD_LOW_INDEX_VAL":16363.25,"HIT_TURN_OVER":2869.39,"HIT_TRADED_QTY":19227526,"EOD_TIMESTAMP":"19-AUG-2026","HI_TIMESTAMP":"2026-08-18T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16432.6,"EOD_HIGH_INDEX_VAL":16546,"EOD_CLOSE_INDEX_VAL":16463.7,"EOD_LOW_INDEX_VAL":16403.05,"HIT_TURN_OVER":3046.72,"HIT_TRADED_QTY":21263398,"EOD_TIMESTAMP":"18-AUG-2026","HI_TIMESTAMP":"2026-08-17T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16543.75,"EOD_HIGH_INDEX_VAL":16559,"EOD_CLOSE_INDEX_VAL":16429.35,"EOD_LOW_INDEX_VAL":16424.1,"HIT_TURN_OVER":4481.3,"HIT_TRADED_QTY":29281733,"EOD_TIMESTAMP":"17-AUG-2026","HI_TIMESTAMP":"2026-08-16T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16569.45,"EOD_HIGH_INDEX_VAL":16588.2,"EOD_CLOSE_INDEX_VAL":16505.3,"EOD_LOW_INDEX_VAL":16467.35,"HIT_TURN_OVER":4375.98,"HIT_TRADED_QTY":33043912,"EOD_TIMESTAMP":"14-AUG-2026","HI_TIMESTAMP":"2026-08-13T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY HEALTHCARE","EOD_OPEN_INDEX_VAL":16621.95,"EOD_HIGH_INDEX_VAL":16629.15,"EOD_CLOSE_INDEX_VAL":16581.85,"EOD_LOW_INDEX_VAL":16528.7,"HIT_TURN_OVER":3672.2,"HIT_TRADED_QTY":23560102,"EOD_TIMESTAMP":"13-AUG-2026","HI_TIMESTAMP":"2026-08-12T18:30:00.000Z"}]}
\ No newline at end of file
ai/research-engine/tests/fixtures/nse_index_it.json
new
+1
@@ -0,0 +1 @@
1
+{"data":[{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":28672.85,"EOD_HIGH_INDEX_VAL":29124.6,"EOD_CLOSE_INDEX_VAL":28921.5,"EOD_LOW_INDEX_VAL":28671.8,"HIT_TURN_OVER":3112.87,"HIT_TRADED_QTY":25370316,"EOD_TIMESTAMP":"11-SEP-2026","HI_TIMESTAMP":"2026-09-10T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":28919.35,"EOD_HIGH_INDEX_VAL":29264.9,"EOD_CLOSE_INDEX_VAL":28890.9,"EOD_LOW_INDEX_VAL":28740.6,"HIT_TURN_OVER":3378.58,"HIT_TRADED_QTY":28911969,"EOD_TIMESTAMP":"10-SEP-2026","HI_TIMESTAMP":"2026-09-09T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":29299.55,"EOD_HIGH_INDEX_VAL":29299.55,"EOD_CLOSE_INDEX_VAL":28913.95,"EOD_LOW_INDEX_VAL":28779.8,"HIT_TURN_OVER":5841.27,"HIT_TRADED_QTY":45263531,"EOD_TIMESTAMP":"09-SEP-2026","HI_TIMESTAMP":"2026-09-08T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30032.3,"EOD_HIGH_INDEX_VAL":30078.75,"EOD_CLOSE_INDEX_VAL":29883.45,"EOD_LOW_INDEX_VAL":29722.9,"HIT_TURN_OVER":2584.22,"HIT_TRADED_QTY":21723387,"EOD_TIMESTAMP":"08-SEP-2026","HI_TIMESTAMP":"2026-09-07T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30343.2,"EOD_HIGH_INDEX_VAL":30377.1,"EOD_CLOSE_INDEX_VAL":29995.2,"EOD_LOW_INDEX_VAL":29847.25,"HIT_TURN_OVER":3043.8,"HIT_TRADED_QTY":27245871,"EOD_TIMESTAMP":"07-SEP-2026","HI_TIMESTAMP":"2026-09-06T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30956.15,"EOD_HIGH_INDEX_VAL":31263.2,"EOD_CLOSE_INDEX_VAL":30695.1,"EOD_LOW_INDEX_VAL":30656.7,"HIT_TURN_OVER":2603.29,"HIT_TRADED_QTY":20693928,"EOD_TIMESTAMP":"04-SEP-2026","HI_TIMESTAMP":"2026-09-03T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31180.8,"EOD_HIGH_INDEX_VAL":31185.7,"EOD_CLOSE_INDEX_VAL":30838.85,"EOD_LOW_INDEX_VAL":30621.65,"HIT_TURN_OVER":2783.34,"HIT_TRADED_QTY":20571191,"EOD_TIMESTAMP":"03-SEP-2026","HI_TIMESTAMP":"2026-09-02T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31164.3,"EOD_HIGH_INDEX_VAL":31241.15,"EOD_CLOSE_INDEX_VAL":31102.9,"EOD_LOW_INDEX_VAL":30668.1,"HIT_TURN_OVER":3673.29,"HIT_TRADED_QTY":31034664,"EOD_TIMESTAMP":"02-SEP-2026","HI_TIMESTAMP":"2026-09-01T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31006.25,"EOD_HIGH_INDEX_VAL":31529.2,"EOD_CLOSE_INDEX_VAL":31496.7,"EOD_LOW_INDEX_VAL":30834.1,"HIT_TURN_OVER":4598.08,"HIT_TRADED_QTY":33505972,"EOD_TIMESTAMP":"01-SEP-2026","HI_TIMESTAMP":"2026-08-31T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31225.5,"EOD_HIGH_INDEX_VAL":31277.5,"EOD_CLOSE_INDEX_VAL":31191.45,"EOD_LOW_INDEX_VAL":30634.45,"HIT_TURN_OVER":4767.84,"HIT_TRADED_QTY":38941906,"EOD_TIMESTAMP":"31-AUG-2026","HI_TIMESTAMP":"2026-08-30T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30493.15,"EOD_HIGH_INDEX_VAL":31322.9,"EOD_CLOSE_INDEX_VAL":31281.7,"EOD_LOW_INDEX_VAL":30473.5,"HIT_TURN_OVER":6259.34,"HIT_TRADED_QTY":44244200,"EOD_TIMESTAMP":"28-AUG-2026","HI_TIMESTAMP":"2026-08-27T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30342.55,"EOD_HIGH_INDEX_VAL":30576.95,"EOD_CLOSE_INDEX_VAL":30221.15,"EOD_LOW_INDEX_VAL":30164.5,"HIT_TURN_OVER":2547.2,"HIT_TRADED_QTY":19634787,"EOD_TIMESTAMP":"27-AUG-2026","HI_TIMESTAMP":"2026-08-26T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30809.2,"EOD_HIGH_INDEX_VAL":30841.15,"EOD_CLOSE_INDEX_VAL":30318.85,"EOD_LOW_INDEX_VAL":30307.45,"HIT_TURN_OVER":2561.57,"HIT_TRADED_QTY":22484895,"EOD_TIMESTAMP":"26-AUG-2026","HI_TIMESTAMP":"2026-08-25T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30692.35,"EOD_HIGH_INDEX_VAL":30771.8,"EOD_CLOSE_INDEX_VAL":30771.8,"EOD_LOW_INDEX_VAL":30283.4,"HIT_TURN_OVER":2930.26,"HIT_TRADED_QTY":24339283,"EOD_TIMESTAMP":"25-AUG-2026","HI_TIMESTAMP":"2026-08-24T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30579.6,"EOD_HIGH_INDEX_VAL":30892.9,"EOD_CLOSE_INDEX_VAL":30596.9,"EOD_LOW_INDEX_VAL":30484.8,"HIT_TURN_OVER":2842.25,"HIT_TRADED_QTY":25353286,"EOD_TIMESTAMP":"24-AUG-2026","HI_TIMESTAMP":"2026-08-23T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30676.4,"EOD_HIGH_INDEX_VAL":30693.35,"EOD_CLOSE_INDEX_VAL":30532.25,"EOD_LOW_INDEX_VAL":30337,"HIT_TURN_OVER":2687.41,"HIT_TRADED_QTY":18926312,"EOD_TIMESTAMP":"21-AUG-2026","HI_TIMESTAMP":"2026-08-20T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30850.5,"EOD_HIGH_INDEX_VAL":30942.9,"EOD_CLOSE_INDEX_VAL":30673.05,"EOD_LOW_INDEX_VAL":30637.8,"HIT_TURN_OVER":3831.9,"HIT_TRADED_QTY":25241767,"EOD_TIMESTAMP":"20-AUG-2026","HI_TIMESTAMP":"2026-08-19T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30461.1,"EOD_HIGH_INDEX_VAL":30659.8,"EOD_CLOSE_INDEX_VAL":30433.05,"EOD_LOW_INDEX_VAL":30165,"HIT_TURN_OVER":3638.05,"HIT_TRADED_QTY":25384894,"EOD_TIMESTAMP":"19-AUG-2026","HI_TIMESTAMP":"2026-08-18T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":30584.1,"EOD_HIGH_INDEX_VAL":30615.45,"EOD_CLOSE_INDEX_VAL":30213.45,"EOD_LOW_INDEX_VAL":30212.95,"HIT_TURN_OVER":3074.91,"HIT_TRADED_QTY":25486159,"EOD_TIMESTAMP":"18-AUG-2026","HI_TIMESTAMP":"2026-08-17T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31242.4,"EOD_HIGH_INDEX_VAL":31331.2,"EOD_CLOSE_INDEX_VAL":30807.8,"EOD_LOW_INDEX_VAL":30722.05,"HIT_TURN_OVER":2434.68,"HIT_TRADED_QTY":17225551,"EOD_TIMESTAMP":"17-AUG-2026","HI_TIMESTAMP":"2026-08-16T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31419.85,"EOD_HIGH_INDEX_VAL":31529.55,"EOD_CLOSE_INDEX_VAL":31357.75,"EOD_LOW_INDEX_VAL":31052.7,"HIT_TURN_OVER":3159.26,"HIT_TRADED_QTY":22906479,"EOD_TIMESTAMP":"14-AUG-2026","HI_TIMESTAMP":"2026-08-13T18:30:00.000Z"},{"EOD_INDEX_NAME":"NIFTY IT","EOD_OPEN_INDEX_VAL":31277,"EOD_HIGH_INDEX_VAL":31459.3,"EOD_CLOSE_INDEX_VAL":31453.9,"EOD_LOW_INDEX_VAL":31098.6,"HIT_TURN_OVER":2809.83,"HIT_TRADED_QTY":20855020,"EOD_TIMESTAMP":"13-AUG-2026","HI_TIMESTAMP":"2026-08-12T18:30:00.000Z"}]}
\ No newline at end of file
ai/research-engine/tests/test_sector_benchmarks.py
new
+314
@@ -0,0 +1,314 @@
1
+"""Canonical identity, official captured contract, and persisted exact-date integration."""
2
+import json
3
+from datetime import date, datetime, timedelta, timezone
4
+from decimal import Decimal
5
+from pathlib import Path
6
+from types import SimpleNamespace
7
+from unittest.mock import AsyncMock
8
+from uuid import UUID
9
+
10
+import httpx
11
+import pytest
12
+
13
+from app.nse_index_history import ENDPOINT, NseIndexHistoryProvider, parse_index_history
14
+from app.nse_historical_daily import BOOTSTRAP, NseHistoricalResult, persist_daily_result
15
+from app.sector_benchmarks import *
16
+from app.sector_relative_strength import SectorRelativeStrengthEngine
17
+from app.persistence import SqliteResearchPersistence
18
+from app.settings import Settings
19
+
20
+START, END = date(2026, 8, 13), date(2026, 9, 11)
21
+NOW = datetime(2026, 9, 13, 20, tzinfo=timezone.utc)
22
+FIXTURES = Path(__file__).parent / 'fixtures'
23
+FILES = dict(zip(BENCHMARKS, ('500', 'it', 'financials', 'healthcare')))
24
+STOCK = UUID(int=1)
25
+
26
+
27
+def metadata(key=BROAD_KEY):
28
+ return dict(globalInstrumentId=str(benchmark_id(key)), assetType='INDEX', status='ACTIVE',
29
+ country='IN', primaryExchange='NSE', primarySymbol='NOT_IDENTITY', currency='INR',
30
+ providerMappings=[dict(provider='NSE', providerSymbol=BENCHMARKS[key], status='VERIFIED',
31
+ resolutionSource=CATALOG_VERSION, currency='INR', exchange='NSE')])
32
+
33
+
34
+def classification(sector='Technology', key=STOCK):
35
+ return dict(globalInstrumentId=str(key), canonicalSector=sector, source='NSE_INDICES_NIFTY500',
36
+ retrievedAt=NOW.isoformat(), status='ACTIVE', assetType='EQUITY', country='IN', exchange='NSE')
37
+
38
+
39
+def parsed(key=BROAD_KEY):
40
+ result = NseHistoricalResult(benchmark_id(key), START, END, provider_symbol=BENCHMARKS[key],
41
+ source_url=ENDPOINT, retrieved_at=NOW)
42
+ parse_index_history((FIXTURES / f'nse_index_{FILES[key]}.json').read_bytes(), result, 'INR')
43
+ result.status = 'SUCCESS'
44
+ return result
45
+
46
+
47
+@pytest.mark.parametrize('sector', list(SECTOR_KEYS))
48
+def test_exact_mapping(sector):
49
+ c = build_sector_contexts([classification(sector)], [metadata(k) for k in BENCHMARKS], {STOCK})[STOCK]
50
+ assert c.mapping_version == 'SECTOR_BENCHMARK_MAPPING_V1'
51
+ assert c.sector_benchmark.instrument_id == benchmark_id(SECTOR_KEYS[sector])
52
+ assert c.market_benchmark.instrument_id == benchmark_id(BROAD_KEY)
53
+ assert c.sector_mapping_status == 'AVAILABLE'
54
+ with pytest.raises(ValueError): benchmark_id(BENCHMARKS[BROAD_KEY])
55
+
56
+
57
+@pytest.mark.parametrize('sector', ['Industrials', 'Materials', 'Consumer Staples', 'Consumer Discretionary',
58
+ 'Energy', 'Utilities', 'Real Estate', 'Communication Services', 'FINANCIAL SERVICES', 'Bank'])
59
+def test_unmapped_not_substituted(sector):
60
+ c = build_sector_contexts([classification(sector)], [metadata(k) for k in BENCHMARKS], {STOCK})[STOCK]
61
+ assert c.sector_benchmark is None and c.sector_mapping_status == 'UNMAPPED_SECTOR_BENCHMARK'
62
+ assert c.market_benchmark is not None
63
+
64
+
65
+@pytest.mark.parametrize('rows', [[], [classification(None)], [classification(), classification('Financials')],
66
+ [classification() | dict(source='GUESSED')]])
67
+def test_missing_ambiguous_classification(rows):
68
+ c = build_sector_contexts(rows, [], {STOCK})[STOCK]
69
+ assert c.sector_mapping_status == 'NO_SECTOR_CLASSIFICATION'
70
+ assert c.market_mapping_status == 'BENCHMARK_IDENTITY_UNAVAILABLE'
71
+
72
+
73
+@pytest.mark.parametrize('change', [dict(status='INACTIVE'), dict(assetType='EQUITY'), dict(currency=None),
74
+ dict(globalInstrumentId=str(UUID(int=99))), dict(providerMappings=[]),
75
+ dict(providerMappings=metadata()['providerMappings'] * 2),
76
+ *[dict(providerMappings=[metadata()['providerMappings'][0] | x]) for x in
77
+ [dict(active=False), dict(status='UNVERIFIED'), dict(providerSymbol='NIFTY 50'),
78
+ dict(resolutionSource='GUESSED'), dict(currency='USD')]]])
79
+def test_identity_rejected(change):
80
+ with pytest.raises(ValueError, match='BENCHMARK_IDENTITY_UNAVAILABLE'):
81
+ benchmark_identity(metadata() | change, BROAD_KEY)
82
+
83
+
84
+@pytest.mark.parametrize('key', list(BENCHMARKS))
85
+def test_observed_official_contract(key):
86
+ r = parsed(key)
87
+ assert r.rows_accepted == 22
88
+ assert r.first_trading_date == START and r.last_trading_date == END
89
+ assert all(b.volume is b.turnover is b.previous_close is None for b in r.bars)
90
+ assert all(type(b.close) is Decimal and b.high >= b.low > 0 for b in r.bars)
91
+ assert r.bars == sorted(r.bars, key=lambda b: b.trading_date)
92
+ # EOD_TIMESTAMP is the trading date; HI_TIMESTAMP is the prior UTC day.
93
+ assert r.bars[-1].trading_date == date(2026, 9, 11)
94
+
95
+
96
+@pytest.mark.parametrize('field,value', [('EOD_INDEX_NAME', 'NIFTY 50'), ('EOD_TIMESTAMP', 'bad'),
97
+ ('EOD_TIMESTAMP', '12-SEP-2026'), ('EOD_OPEN_INDEX_VAL', 'NaN'), ('EOD_LOW_INDEX_VAL', 0),
98
+ ('EOD_HIGH_INDEX_VAL', 1), ('EOD_CLOSE_INDEX_VAL', None)])
99
+def test_invalid_rows_fail_whole_window(field, value):
100
+ payload = json.loads((FIXTURES / 'nse_index_500.json').read_bytes())
101
+ payload['data'][0][field] = value
102
+ r = NseHistoricalResult(benchmark_id(BROAD_KEY), START, END, provider_symbol='NIFTY 500')
103
+ with pytest.raises(ValueError): parse_index_history(json.dumps(payload).encode(), r, 'INR')
104
+ assert r.bars == []
105
+
106
+
107
+@pytest.mark.parametrize('payload', [b'', b'<html>blocked</html>', b'{}', b'{"data":[]}', b'{"data":[{}]}'])
108
+def test_invalid_contract(payload):
109
+ with pytest.raises(ValueError): parse_index_history(payload, parsed(), 'INR')
110
+
111
+
112
+def test_duplicate_and_precision():
113
+ payload = json.loads((FIXTURES / 'nse_index_500.json').read_bytes())
114
+ payload['data'][0]['EOD_OPEN_INDEX_VAL'] = '12345.123456789012'
115
+ r = parsed()
116
+ parse_index_history(json.dumps(payload).encode(), r, 'INR')
117
+ assert r.bars[-1].open == Decimal('12345.123456789012')
118
+ payload['data'].append(payload['data'][0])
119
+ with pytest.raises(ValueError): parse_index_history(json.dumps(payload).encode(), parsed(), 'INR')
120
+
121
+
122
+@pytest.mark.asyncio
123
+async def test_session_reuse_query_spacing_no_cookie_logs(caplog):
124
+ requests = []
125
+ def handler(request):
126
+ requests.append(request)
127
+ assert request.headers['user-agent'].startswith('Mozilla/')
128
+ if str(request.url) == BOOTSTRAP:
129
+ return httpx.Response(200, headers={'set-cookie': 'session=private-test-cookie; Path=/; Secure'})
130
+ assert request.headers['cookie'] == 'session=private-test-cookie'
131
+ assert dict(request.url.params) == {'indexType':'NIFTY 500', 'from':'13-08-2026', 'to':'11-09-2026'}
132
+ return httpx.Response(200, content=(FIXTURES / 'nse_index_500.json').read_bytes())
133
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
134
+ sleep = AsyncMock()
135
+ p = NseIndexHistoryProvider(SimpleNamespace(global_instrument_metadata=AsyncMock(return_value=metadata())),
136
+ Settings(), client=client, sleep=sleep)
137
+ for _ in range(2):
138
+ r = await p.fetch(benchmark_id(BROAD_KEY), start=START, end=END)
139
+ assert r.status == 'SUCCESS' and r.rows_accepted == 22
140
+ assert len(requests) == 3 and sleep.await_count >= 2
141
+ await p.aclose()
142
+ assert 'private-test-cookie' not in caplog.text
143
+
144
+
145
+@pytest.mark.asyncio
146
+@pytest.mark.parametrize('status,attempts', [(403,1), (404,1), (429,3), (500,3)])
147
+async def test_http_failures(status, attempts):
148
+ requests = []
149
+ def handler(request):
150
+ if str(request.url) == BOOTSTRAP: return httpx.Response(200)
151
+ requests.append(request)
152
+ return httpx.Response(status)
153
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
154
+ p = NseIndexHistoryProvider(SimpleNamespace(global_instrument_metadata=AsyncMock(return_value=metadata())),
155
+ Settings(), client=client, sleep=AsyncMock())
156
+ r = await p.fetch(benchmark_id(BROAD_KEY), start=START, end=END)
157
+ assert r.failure_reason == f'HISTORICAL_HTTP_{status}' and not r.bars
158
+ assert len(requests) == attempts
159
+
160
+
161
+@pytest.mark.asyncio
162
+async def test_identity_before_network_and_bounded_dates():
163
+ def forbidden(request): pytest.fail('Network before identity/window gate')
164
+ async with httpx.AsyncClient(transport=httpx.MockTransport(forbidden)) as client:
165
+ p = NseIndexHistoryProvider(SimpleNamespace(global_instrument_metadata=AsyncMock(return_value=None)), Settings(), client=client)
166
+ r = await p.fetch(benchmark_id(BROAD_KEY), start=START, end=END)
167
+ assert r.failure_reason == 'BENCHMARK_IDENTITY_UNAVAILABLE'
168
+ r = await p.fetch(benchmark_id(BROAD_KEY), start=START, end=END+timedelta(days=1))
169
+ assert r.failure_reason == 'INVALID_REQUEST_WINDOW'
170
+
171
+
172
+@pytest.mark.asyncio
173
+async def test_persist_repeat_correction_other_provider_and_failure():
174
+ store = SqliteResearchPersistence()
175
+ repo = SimpleNamespace(upsert_daily_market_bars_async=AsyncMock(side_effect=store.upsert_daily_market_bars))
176
+ r = parsed()
177
+ await persist_daily_result(repo, r)
178
+ await persist_daily_result(repo, r)
179
+ corrected = r.bars[-1].model_copy(update={'close': r.bars[-1].close + Decimal('.01')})
180
+ store.upsert_daily_market_bar(corrected.model_copy(update={'provider':'OTHER'}))
181
+ r.bars[-1] = corrected
182
+ await persist_daily_result(repo, r)
183
+ rows = store.load_daily_market_bars({r.global_instrument_id}, provider='NSE')
184
+ assert len(rows) == 22 and rows[-1].close == corrected.close
185
+ assert len(store.load_daily_market_bars({r.global_instrument_id})) == 23
186
+ repo.upsert_daily_market_bars_async.side_effect = RuntimeError('do not expose')
187
+ await persist_daily_result(repo, r)
188
+ assert r.failure_reason == 'DAILY_BAR_PERSISTENCE_UNAVAILABLE'
189
+ assert len(store.load_daily_market_bars({r.global_instrument_id}, provider='NSE')) == 22
190
+
191
+
192
+def test_daily_alignment_reversed_no_filling_and_provenance(monkeypatch):
193
+ import socket
194
+ monkeypatch.setattr(socket, 'create_connection', lambda *a, **k: pytest.fail('network during compute'))
195
+ sector_key = SECTOR_KEYS['Technology']
196
+ sector, market = parsed(sector_key), parsed()
197
+ stock = [b.model_copy(update={'global_instrument_id':STOCK}) for b in sector.bars]
198
+ contexts = build_sector_contexts([classification()], [metadata(k) for k in BENCHMARKS], {STOCK})
199
+ daily = {STOCK:stock, sector.global_instrument_id:sector.bars, market.global_instrument_id:market.bars}
200
+ def compute(rows):
201
+ return SectorRelativeStrengthEngine().compute(STOCK, [], as_of=NOW, currency='INR', context=contexts[STOCK], daily_bar_histories=rows)
202
+ first = compute(daily)
203
+ assert first == compute({k:list(reversed(v)) for k,v in daily.items()})
204
+ assert first.benchmark_mapping_version == MAPPING_VERSION
205
+ assert first.relative_vs_sector1_m == 0 and first.sector_state != 'INSUFFICIENT_DATA'
206
+ assert first.sector_return1_m == pytest.approx(float((sector.bars[-1].close / sector.bars[0].close - 1)*100))
207
+ assert first.history_sources['sector'] == 'DAILY_MARKET_BAR_NSE'
208
+ daily[sector.global_instrument_id] = sector.bars[1:]
209
+ second = compute(daily)
210
+ assert second.relative_vs_sector1_m is None and second.relative_vs_sector1_w == 0
211
+ daily[sector.global_instrument_id] = [b.model_copy(update={'provider':'OTHER'}) for b in sector.bars]
212
+ assert compute(daily).benchmark_states['sector'] == 'BENCHMARK_HISTORY_UNAVAILABLE'
213
+
214
+
215
+@pytest.mark.asyncio
216
+@pytest.mark.parametrize('count,multiple', [(0,False),(1,False),(18,False),(18,True)])
217
+async def test_stage_b_bounded_queries(count, multiple):
218
+ from app.global_scanner import GlobalScanner
219
+ from test_global_scanner import instrument, persisted, scan
220
+ store = SqliteResearchPersistence()
221
+ items = [instrument(n) for n in range(10, 10+count)]
222
+ for item in items: persisted(store, item)
223
+ initial = await scan(items, store, top_n=max(count, 1))
224
+ ids = {UUID(item['globalInstrumentId']) for item in items}
225
+ sectors = list(SECTOR_KEYS)
226
+ rows = [classification(sectors[i%3] if multiple else 'Technology', k) for i,k in enumerate(sorted(ids))]
227
+ contexts = build_sector_contexts(rows, [metadata(k) for k in BENCHMARKS], ids)
228
+ queries = []
229
+ store._connection.set_trace_callback(queries.append)
230
+ enriched = GlobalScanner(None, store).enrich_candidates(initial, sector_contexts=contexts)
231
+ assert len(enriched) == count
232
+ assert len(queries) == (2 if count else 0)
233
+ assert all('SELECT' in q for q in queries)
234
+
235
+
236
+@pytest.mark.asyncio
237
+@pytest.mark.parametrize('reason', ['HISTORICAL_HTTP_403', 'INVALID_INDEX_ROW', 'DAILY_BAR_PERSISTENCE_UNAVAILABLE'])
238
+async def test_worker_failure_isolation_session_closed(monkeypatch, reason):
239
+ from app.market_data_population import IndiaMarketDataPopulationJobs
240
+ import app.nse_index_history as module
241
+ keys = sorted([benchmark_id(k) for k in BENCHMARKS], key=str)[:2]
242
+ responses = [NseHistoricalResult(keys[0], START, END, failure_reason=reason), parsed()]
243
+ provider = SimpleNamespace(fetch=AsyncMock(side_effect=responses), aclose=AsyncMock())
244
+ factory = lambda *a, **k: provider
245
+ monkeypatch.setattr(module, 'NseIndexHistoryProvider', factory)
246
+ repo = SimpleNamespace(upsert_daily_market_bars_async=AsyncMock(return_value=22))
247
+ jobs = IndiaMarketDataPopulationJobs(repo, None, None, Settings(), sleep=AsyncMock(), clock=lambda:NOW)
248
+ results = await jobs.populate_benchmark_history(set(keys), start=START, end=END, identity_headers={})
249
+ assert len(results) == 2 and results[0].failure_reason == reason and results[1].persisted_rows == 22
250
+ assert provider.fetch.await_count == 2 and provider.aclose.await_count == 1
251
+ assert repo.upsert_daily_market_bars_async.await_count == 1
252
+
253
+
254
+@pytest.mark.asyncio
255
+async def test_worker_throttling_cooldown_and_batch_bound(monkeypatch):
256
+ from app.market_data_population import IndiaMarketDataPopulationJobs
257
+ import app.nse_index_history as module
258
+ keys = {benchmark_id(k) for k in BENCHMARKS}
259
+ provider = SimpleNamespace(fetch=AsyncMock(return_value=NseHistoricalResult(next(iter(keys)), START, END,
260
+ failure_reason='HISTORICAL_HTTP_429')), aclose=AsyncMock())
261
+ monkeypatch.setattr(module, 'NseIndexHistoryProvider', lambda *a, **k:provider)
262
+ jobs = IndiaMarketDataPopulationJobs(None, None, None, Settings(), sleep=AsyncMock(), clock=lambda:NOW)
263
+ results = await jobs.populate_benchmark_history(keys, start=START, end=END, identity_headers={})
264
+ assert provider.fetch.await_count == 1
265
+ assert [r.failure_reason for r in results][1:] == ['RETRY_COOLDOWN']*3
266
+ jobs.settings.market_data_population_batch_size = 1
267
+ with pytest.raises(ValueError, match='BENCHMARK_BATCH_LIMIT'):
268
+ await jobs.populate_benchmark_history(keys, start=START, end=END, identity_headers={})
269
+
270
+
271
+def test_hand_calculated_daily_horizons_and_staleness():
272
+ from app.models import DailyMarketBar
273
+ keys = [STOCK, benchmark_id(SECTOR_KEYS['Technology']), benchmark_id(BROAD_KEY)]
274
+ daily = {}
275
+ # Independent linear prices: stock +2, sector +1, market flat per observation.
276
+ for key, slope in zip(keys, (2,1,0)):
277
+ daily[key] = [DailyMarketBar(global_instrument_id=key, trading_date=(NOW-timedelta(days=159-i)).date(),
278
+ open=Decimal(1000+slope*i), high=Decimal(1000+slope*i), low=Decimal(1000+slope*i),
279
+ close=Decimal(1000+slope*i), currency='INR', provider='NSE', provider_symbol='provenance',
280
+ source_mode='REAL', source_url=ENDPOINT, retrieved_at=NOW) for i in range(160)]
281
+ ctx = build_sector_contexts([classification()], [metadata(k) for k in BENCHMARKS], {STOCK})[STOCK]
282
+ def compute(rows): return SectorRelativeStrengthEngine().compute(STOCK, [], as_of=NOW, context=ctx, daily_bar_histories=rows)
283
+ result = compute(daily)
284
+ for suffix, n in zip(('1_w','1_m','3_m','6_m'), (5,21,63,126)):
285
+ assert getattr(result,'stock_return'+suffix) == pytest.approx(100*2*n/(1318-2*n))
286
+ assert getattr(result,'sector_return'+suffix) == pytest.approx(100*n/(1159-n))
287
+ assert getattr(result,'market_return'+suffix) == 0
288
+ daily[keys[1]] = daily[keys[1]][:-10]
289
+ assert compute(daily).benchmark_states['sector'] == 'STALE_BENCHMARK_HISTORY'
290
+
291
+
292
+@pytest.mark.asyncio
293
+async def test_metadata_adapter_is_batched_read_only():
294
+ from app.portfolio_orchestration import PortfolioResearchOrchestrator
295
+ requests = []
296
+ def handler(request):
297
+ requests.append(request)
298
+ assert request.method == 'GET' and request.url.path == '/api/v1/instruments/benchmarks'
299
+ return httpx.Response(200, json=[metadata(k) for k in BENCHMARKS])
300
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
301
+ obj = PortfolioResearchOrchestrator(None, Settings(), client=client)
302
+ obj.india_nifty500_universe = AsyncMock(return_value=[classification()])
303
+ assert await obj.sector_benchmark_contexts(set()) == {}
304
+ assert not requests and obj.india_nifty500_universe.await_count == 0
305
+ result = await obj.sector_benchmark_contexts({STOCK})
306
+ assert result[STOCK].sector_mapping_status == 'AVAILABLE'
307
+ assert len(requests) == obj.india_nifty500_universe.await_count == 1
308
+
309
+
310
+def test_india_market_benchmark_never_assigned_to_foreign_or_unknown_stock():
311
+ registered = [metadata(k) for k in BENCHMARKS]
312
+ for rows in ([], [classification() | dict(country='US', exchange='NASDAQ')]):
313
+ result = build_sector_contexts(rows, registered, {STOCK})[STOCK]
314
+ assert result.market_benchmark is None and result.sector_benchmark is None
ai/research-engine/tests/test_sector_relative_strength.py
+1
-1
@@ -167,7 +167,7 @@ async def test_stage_b_batches_only_deep_eligible_and_preserves_phase1(monkeypat
167
assert "instrument_id IN" in close_query
168
assert all(str(UUID(int=5)) not in q for q in queries)
169
assert str(SECTOR) in close_query and str(MARKET) in close_query
170
- assert str(SECTOR) not in daily_query and str(MARKET) not in daily_query
170
+ assert str(SECTOR) in daily_query and str(MARKET) in daily_query
171
assert all(c.technical_feature_snapshot.global_instrument_id == c.global_instrument_id for c in enriched)
172
# Private inputs are not part of the enrichment contract.
173
assert enriched == scanner.enrich_candidates(initial, sector_contexts={STOCK: context()})
docs/NSE_SECTOR_BENCHMARKS.md
new
+226
@@ -0,0 +1,226 @@
1
+# NSE canonical sector benchmarks and price history
2
+
3
+## Identity and classification
4
+
5
+Baseline: `076215b5ee757061357aa6a71e5a6e85988d8baa` (OHLCV technical features).
6
+The audited master contained 2,568 equities and no indices. Stock classification
7
+comes from `portfolio.nifty500_universe.canonical_sector`, populated by
8
+`Nifty500ReferenceService` and `NiftyIndustrySectorClassifier` from the official
9
+Nifty 500 constituent file. The persisted source is `NSE_INDICES_NIFTY500`.
10
+Portfolio/watchlist membership is irrelevant.
11
+
12
+`SECTOR_BENCHMARK_MAPPING_V1` resolves exact canonical classifications to platform
13
+benchmark keys. `NSE_BENCHMARK_CATALOG_V1` records the authoritative registration
14
+evidence. These are **price indices**, not total-return series.
15
+
16
+| Canonical classification | Platform key | Canonical UUID | Verified NSE request symbol |
17
+|---|---|---|---|
18
+| India broad market | INDIA_BROAD_PRICE | 56623ea0-224c-3481-b6d9-66c448b282a0 | NIFTY 500 |
19
+| Technology | INDIA_TECHNOLOGY_PRICE | 4af7634a-1894-333d-ba68-a1c41a32c71c | NIFTY IT |
20
+| Financials | INDIA_FINANCIALS_PRICE | 13ba7849-43e2-3e1e-a3f3-d93ee32d3d2f | NIFTY FINANCIAL SERVICES |
21
+| Healthcare | INDIA_HEALTHCARE_PRICE | 055c4e9f-5592-3be6-8f87-3d05bb25b95a | NIFTY HEALTHCARE INDEX |
22
+
23
+UUIDs use Java `UUID.nameUUIDFromBytes(UTF8("aip:benchmark:" + platformKey))`;
24
+the Python adapter implements the identical UUIDv3 operation. A calculated UUID
25
+alone is **not** registration or identity evidence. Both acquisition and context
26
+resolution require the actual master row and exactly one verified NSE mapping.
27
+Provider symbols never identify stocks or benchmark instruments.
28
+
29
+The existing master service now registers `AssetType.INDEX` through explicit
30
+`POST /api/v1/instruments/benchmarks/{benchmarkKey}/register`. It uses the existing
31
+transaction/advisory-lock, master, and mapping repositories. Unknown keys and
32
+conflicting identities fail. Repeating a registration is idempotent. The existing
33
+VARCHAR asset-type column accepts INDEX; no migration is required or supplied.
34
+`GET /api/v1/instruments/benchmarks` only reads registered identities and mappings.
35
+It does not register instruments or acquire data. Authentication follows the
36
+existing global-instrument controller convention.
37
+
38
+Acquisition requires ACTIVE / INDEX / IN / NSE / INR master metadata and exactly
39
+one NSE mapping with VERIFIED status, matching exchange/currency/request symbol,
40
+and catalog resolution source. Inactive, missing, conflicting, or untrusted
41
+identity fails closed. Context construction also requires trusted India universe
42
+membership before assigning the India broad benchmark.
43
+
44
+## Mapping evidence and exclusions
45
+
46
+- [Nifty 500](https://www.niftyindices.com/indices/equity/broad-based-indices/nifty-500)
47
+ is the broad benchmark: its large/mid/small-cap coverage matches the platform's
48
+ existing Nifty 500 classification universe. There is no Nifty 50 fallback.
49
+- [Nifty IT](https://www.niftyindices.com/indices/equity/sectoral-indices/nifty-it)
50
+ matches the classifier's Information Technology → Technology mapping.
51
+- [Nifty Financial Services](https://www.niftyindices.com/indices/equity/sectoral-indices/nifty-financial-services)
52
+ covers banks and other financial services, matching Financial Services →
53
+ Financials. A bank-only index would exclude part of this classification.
54
+- [Nifty Healthcare](https://www.niftyindices.com/indices/equity/sectoral-indices/nifty-healthcare-index)
55
+ matches Healthcare; a pharma-only substitution is not made.
56
+
57
+The official NSE `/api/allIndices` catalog and the captured history responses
58
+confirmed these four index contracts on 2026-09-13. Fixtures retain those responses.
59
+
60
+Observed unmapped classifications: Communication Services (14), Consumer
61
+Discretionary (88), Consumer Staples (28), Energy (17), Industrials (75), Materials
62
+(55), Real Estate (11), Utilities (17); 17 records lacked classification. Mapped
63
+counts were Financials 101, Healthcare 48, Technology 27. These counts describe
64
+the audit snapshot, not an ongoing invariant. Broad compound classifications
65
+are not assigned to narrower indices. Raw `FINANCIAL SERVICES`, `Bank`, and other
66
+aliases are not accepted by the mapping adapter; classification must first come
67
+from the canonical classifier. Duplicate classification evidence fails closed.
68
+
69
+## Official history contract
70
+
71
+History version: `NSE_INDEX_HISTORY_V1`.
72
+
73
+`GET https://www.nseindia.com/api/historicalOR/indicesHistory`
74
+with `indexType=<verified mapping>`, `from=DD-MM-YYYY`, `to=DD-MM-YYYY`.
75
+Bootstrap: `https://www.nseindia.com/report-detail/eq_security`.
76
+The older `/api/historical/indicesHistory` returned HTTP 200 HTML during discovery
77
+and is deliberately not used. The equity security CSV endpoint is not reused.
78
+
79
+The index provider composes the existing NSE provider's transport/session,
80
+browser headers, cookies, lock, request spacing and bounded retry behavior; it
81
+does not call its equity identity gate or CSV parser. One transport/session is
82
+reused sequentially per explicit benchmark population invocation and closed in
83
+`finally`. There is no global cookie pool, parallel fetch, proxy, or bypass.
84
+This small composition uses the existing private transport methods; a future
85
+transport refactor must preserve the tested shared-session contract.
86
+
87
+JSON is `{ "data": [ ... ] }`. Required fields:
88
+`EOD_INDEX_NAME`, `EOD_TIMESTAMP`, `EOD_OPEN_INDEX_VAL`, `EOD_HIGH_INDEX_VAL`,
89
+`EOD_LOW_INDEX_VAL`, `EOD_CLOSE_INDEX_VAL`.
90
+
91
+Verified response-name aliases, confined to this parser:
92
+
93
+| Requested symbol | EOD_INDEX_NAME |
94
+|---|---|
95
+| NIFTY 500 | NIFTY 500 |
96
+| NIFTY IT | NIFTY IT |
97
+| NIFTY FINANCIAL SERVICES | NIFTY FIN SERVICE |
98
+| NIFTY HEALTHCARE INDEX | NIFTY HEALTHCARE |
99
+
100
+Case/whitespace normalization is explicit; no other symbol equivalence is inferred.
101
+`EOD_TIMESTAMP` (for example `11-SEP-2026`) is the exchange-local DATE.
102
+`HI_TIMESTAMP` can be the previous UTC day and is never used for trading dates.
103
+JSON decimals are decoded directly to Decimal; domain OHLC validation applies.
104
+Missing fields, malformed values, out-of-window dates, duplicate dates, or wrong
105
+index names reject the complete response before persistence. Empty responses and
106
+HTTP 200 HTML are explicit failures, not zero-valued observations.
107
+
108
+`HIT_TRADED_QTY` and `HIT_TURN_OVER` were observed, but their index aggregation and
109
+units are not normalized in this phase. Volume, turnover, and previous close
110
+remain null. Actual OHLC values are index points; INR is the trusted index market
111
+denomination, not an assertion that each point is a tradable currency amount.
112
+
113
+## Population and persistence
114
+
115
+An explicit worker calls `IndiaMarketDataPopulationJobs.populate_benchmark_history`
116
+with registered canonical IDs and one bounded inclusive start/end window. It
117
+uses `ResearchRepository.upsert_daily_market_bars_async` via `persist_daily_result`.
118
+Primary identity remains `(global_instrument_id, trading_date, provider='NSE')`.
119
+Corrections update that key; another provider remains separate. No deletes,
120
+fabricated OHLC, or close-history dual-writes occur.
121
+
122
+The existing `nse_historical_request_window_days` defaults to 30 inclusive
123
+calendar days. This is an operational bound, **not an NSE guaranteed maximum**.
124
+Invocations are capped by `market_data_population_batch_size`, ordered by UUID,
125
+and persist per successful window. This explicit bounded operation does not
126
+schedule a broad backfill or automatically extend lookbacks.
127
+
128
+Spacing uses `market_data_population_request_interval_seconds` (default 0.20).
129
+The existing default two retries apply to transient network/5xx/429 failures;
130
+ordinary 4xx, including 403, are not retried. Exponential backoff and Retry-After
131
+handling are inherited. Failure cooldown uses the existing population cooldown;
132
+429 also inhibits subsequent instruments in the worker. Cooldowns are process
133
+local, like the existing population jobs. Results reuse `NseHistoricalResult`,
134
+including HTTP status, failure reason, accepted/rejected counts and persisted rows.
135
+
136
+## Computation and batching
137
+
138
+Prepare contexts with
139
+`await orchestrator.sector_benchmark_contexts(candidate_ids, identity_headers=...)`,
140
+then pass them to `GlobalScanner.enrich_candidates(..., sector_contexts=contexts)`.
141
+This keeps the existing explicit Stage-B dependency boundary. No dashboard,
142
+watchlist, portfolio, scanner GET, or feature computation invokes acquisition.
143
+
144
+The context adapter reads the existing paged canonical universe and one benchmark
145
+catalog response. Portfolio-service batches master and mapping reads. Stage B
146
+collects distinct canonical stock/benchmark IDs and performs the existing bounded
147
+close-history read plus one NSE daily-bar read, grouping in memory. With the
148
+default batch bound, history SELECT counts are 0 for empty, 2 for one stock,
149
+2 for 18 same-sector stocks, and 2 for 18 stocks across the three mapped sectors.
150
+Canonical metadata preparation is separate from these history query counts.
151
+
152
+The existing SectorRelativeStrengthEngine consumes persisted NSE daily closes
153
+with DATE semantics, selecting a coherent provider series. Existing close-only
154
+fallback remains supported. Stock short/stale OHLC fallback follows the existing
155
+technical input policy; individual dates/providers are never spliced together.
156
+
157
+Lookbacks preserve the existing 5 / 21 / 63 / 126 **stock observations** for
158
+1W / 1M / 3M / 6M. Both exact stock endpoints must exist in the benchmark history.
159
+Returns are `(end / start - 1) * 100`. Weekends, holidays, and missing intermediate
160
+dates are not synthesized. No forward/back fill, nearest-date matching, or
161
+calendar-session guessing is performed. A missing endpoint makes that horizon
162
+unavailable; at least two horizons are needed for the existing score/state rules.
163
+Thresholds, score weights, Technical Features V2 and Rule Engine V1 are unchanged.
164
+
165
+Snapshots expose mapping version, canonical benchmark IDs, history source and
166
+explicit benchmark states: NO_SECTOR_CLASSIFICATION, UNMAPPED_SECTOR_BENCHMARK,
167
+BENCHMARK_IDENTITY_UNAVAILABLE, BENCHMARK_HISTORY_UNAVAILABLE,
168
+INSUFFICIENT_OVERLAP, STALE_BENCHMARK_HISTORY, AVAILABLE. Per-horizon diagnostics
169
+remain available even when another horizon has overlap. Missing evidence is never
170
+zero or a weak-sector conclusion. Existing seven-day freshness policy remains.
171
+
172
+## Controlled runtime validation and limits
173
+
174
+One four-day Nifty 500 probe established the JSON contract. Subsequently just
175
+four 30-day requests (2026-08-13 through 2026-09-11, one per registered index)
176
+returned HTTP 200 and 22 rows each. No all-sector or all-equity backfill ran.
177
+The captured responses were replayed locally without additional provider calls.
178
+
179
+The real master service registered/read back all four identities in the existing
180
+H2 test schema; its public metadata fed the Python runtime. The existing repository
181
+persisted 88 captured rows to local SQLite. Repeat upserts left 88 rows. Real stock
182
+classification and close history were copied read-only from local PostgreSQL:
183
+
184
+| Stock | Verified canonical sector | Actual overlapping dates | Result |
185
+|---|---|---:|---|
186
+| HDFCBANK | Financials | 22 | Sector and market evidence available |
187
+| SUNPHARMA | Healthcare | 22 | Sector and market evidence available |
188
+| TCS | Technology | 22 | Sector and market evidence available |
189
+| LT | Industrials | 22 with market | Sector explicitly unmapped |
190
+
191
+Repeated/reversed-input results matched, with networking blocked during compute.
192
+The small benchmark capture supports short horizons only; 3M/6M formulas are
193
+validated independently in tests, not claimed as available from this smoke.
194
+These outputs are engineering diagnostics, not production investment conclusions.
195
+
196
+The deployed PostgreSQL instance lacks the pre-existing daily-bar table. No
197
+migration was created/applied, and no benchmark was directly inserted into that
198
+master by SQL. Deployment of the prior daily-bar schema plus this canonical
199
+registration path remains necessary before deployed acquisition can work.
200
+
201
+Unresolved: unmapped sectors, deployed PostgreSQL validation, Global Opportunity
202
+Ranker, short/long action model, recommendation history/lifecycle, news/macro,
203
+prediction/backtesting. None is implemented by this slice.
204
+
205
+## Validation results
206
+
207
+Final research-engine suite: **992 passed**, one existing dependency deprecation
208
+warning. Included: 64 new benchmark tests, 21 sector-relative tests, 25 technical
209
+tests, 42 OHLCV tests, 35 daily-bar persistence tests, 45 backfill tests, 55 NSE
210
+equity provider tests, 22 population tests, 31 Phase-1/batch scanner tests, and
211
+29 Rule Engine V1 tests.
212
+
213
+Java benchmark registration/instrument-master tests: 19 passed, including the
214
+H2 registration/read-back integration test. The broader portfolio reactor run
215
+reported 194 tests with two failures: `CanonicalIdentityBootstrapTest` assumes
216
+an empty shared H2 database (passes when rerun alone), and
217
+`AppUserProvisionerTest` expects an obsolete conflict target/SQL shape. Their
218
+implementation/test files are unchanged by this phase; no unrelated fixes were
219
+included. The benchmark H2 test rolls its writes back after exporting public
220
+metadata for the runtime smoke.
221
+
222
+`git diff --check` passed. No changes to Technical Features V2, Rule Engine V1,
223
+NSE equity provider/backfill, frontend, or migrations. `platform.ps1` is unchanged
224
+by this phase (phase-start SHA256
225
+`4577DA738957A6C35A8035812586344E76A557DFE41A7355F5A4D5F3E9D2E3AF`).
226
+Nothing staged, committed, or pushed. `smtp.password` was not opened.
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/GlobalInstrumentController.java
+18
@@ -48,6 +48,24 @@ public class GlobalInstrumentController {
48
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Global instrument not found"));
49
}
50
51
+ @GetMapping("/benchmarks")
52
+ public java.util.List<GlobalInstrumentResponse> benchmarks(HttpServletRequest request) {
53
+ appUserProvisioner.upsert(AuthenticatedUserResolver.require(request));
54
+ return instrumentMasterService.registeredBenchmarks().stream().map(GlobalInstrumentResponse::from).toList();
55
+ }
56
+
57
+ @PostMapping("/benchmarks/{benchmarkKey}/register")
58
+ public GlobalInstrumentResponse registerBenchmark(@PathVariable String benchmarkKey, HttpServletRequest request) {
59
+ appUserProvisioner.upsert(AuthenticatedUserResolver.require(request));
60
+ try {
61
+ return GlobalInstrumentResponse.from(instrumentMasterService.registerBenchmark(benchmarkKey));
62
+ } catch (IllegalArgumentException e) {
63
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "UNKNOWN_BENCHMARK_KEY");
64
+ } catch (IllegalStateException e) {
65
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "BENCHMARK_IDENTITY_CONFLICT");
66
+ }
67
+ }
68
+
69
@GetMapping
70
public InstrumentUniverseResponse enumerate(@RequestParam(name = "status", defaultValue = "ACTIVE") String status,
71
@RequestParam(name = "assetType", defaultValue = "EQUITY") AssetType assetType,
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/InstrumentMasterService.java
+44
@@ -80,6 +80,50 @@ public class InstrumentMasterService {
80
81
public record GlobalInstrument(InstrumentMasterEntity master, List<InstrumentProviderMappingEntity> providerMappings) {}
82
83
+ /** Platform keys are independent of provider symbols; catalog evidence is versioned. */
84
+ public static final String BENCHMARK_CATALOG_VERSION = "NSE_BENCHMARK_CATALOG_V1";
85
+ public static final Map<String, String> BENCHMARKS = Map.of(
86
+ "INDIA_BROAD_PRICE", "NIFTY 500", "INDIA_TECHNOLOGY_PRICE", "NIFTY IT",
87
+ "INDIA_FINANCIALS_PRICE", "NIFTY FINANCIAL SERVICES", "INDIA_HEALTHCARE_PRICE", "NIFTY HEALTHCARE INDEX");
88
+
89
+ public static UUID benchmarkId(String key) {
90
+ if (!BENCHMARKS.containsKey(key)) throw new IllegalArgumentException("UNKNOWN_BENCHMARK_KEY");
91
+ return UUID.nameUUIDFromBytes(("aip:benchmark:" + key).getBytes(java.nio.charset.StandardCharsets.UTF_8));
92
+ }
93
+
94
+ @Transactional
95
+ public GlobalInstrument registerBenchmark(String key) {
96
+ UUID id = benchmarkId(key);
97
+ String symbol = BENCHMARKS.get(key);
98
+ lockIdentity("BENCHMARK:" + key);
99
+ var bySymbol = mappings.findByProviderAndExchangeIgnoreCaseAndProviderSymbolIgnoreCase("NSE", "NSE", symbol);
100
+ if (bySymbol.isPresent() && !id.equals(bySymbol.get().getInstrumentId()))
101
+ throw new IllegalStateException("BENCHMARK_IDENTITY_CONFLICT");
102
+ var existing = masters.findById(id);
103
+ var currentNse = mappings.findByInstrumentId(id).stream().filter(m -> "NSE".equals(m.getProvider())).toList();
104
+ if (currentNse.size() > 1 || currentNse.stream().anyMatch(m -> !symbol.equals(m.getProviderSymbol())))
105
+ throw new IllegalStateException("BENCHMARK_IDENTITY_CONFLICT");
106
+ if (existing.isPresent() && (existing.get().getAssetType() != com.aiinvestment.shared.domain.AssetType.INDEX
107
+ || !"ACTIVE".equals(existing.get().getStatus()) || !"INR".equals(existing.get().getCurrency())
108
+ || !"NSE".equals(existing.get().getPrimaryExchange()) || !"IN".equals(existing.get().getCountry())
109
+ || !symbol.equals(existing.get().getPrimarySymbol())))
110
+ throw new IllegalStateException("BENCHMARK_IDENTITY_CONFLICT");
111
+ InstrumentMasterEntity master = existing.orElseGet(() -> masters.saveAndFlush(new InstrumentMasterEntity(id, null,
112
+ symbol, com.aiinvestment.shared.domain.AssetType.INDEX, "INR", "IN", "NSE", symbol, "ACTIVE", Instant.now())));
113
+ persistMapping(id, "NSE", symbol, null, "NSE", "INR", "VERIFIED", BENCHMARK_CATALOG_VERSION, new BigDecimal("0.99"));
114
+ return new GlobalInstrument(master, mappings.findByInstrumentId(id));
115
+ }
116
+
117
+ @Transactional(readOnly=true)
118
+ public List<GlobalInstrument> registeredBenchmarks() {
119
+ var ids = BENCHMARKS.keySet().stream().sorted().map(InstrumentMasterService::benchmarkId).toList();
120
+ var rows = masters.findAllById(ids);
121
+ var byId = mappings.findByInstrumentIdIn(ids).stream()
122
+ .collect(java.util.stream.Collectors.groupingBy(InstrumentProviderMappingEntity::getInstrumentId));
123
+ return rows.stream().sorted(Comparator.comparing(row -> row.getInstrumentId().toString()))
124
+ .map(row -> new GlobalInstrument(row, byId.getOrDefault(row.getInstrumentId(), List.of()))).toList();
125
+ }
126
+
127
@Transactional(readOnly=true)
128
public Page<InstrumentMasterEntity> enumerate(String status, com.aiinvestment.shared.domain.AssetType assetType, Pageable pageable) {
129
if (status == null || assetType == null) throw new IllegalArgumentException("STATUS_AND_ASSET_TYPE_REQUIRED");
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/BenchmarkRegistrationIntegrationTest.java
new
+45
@@ -0,0 +1,45 @@
1
+package com.aiinvestment.portfolio.application;
2
+
3
+import com.aiinvestment.portfolio.api.GlobalInstrumentResponse;
4
+import com.aiinvestment.portfolio.infrastructure.persistence.InstrumentMasterRepository;
5
+import com.aiinvestment.portfolio.infrastructure.persistence.InstrumentProviderMappingRepository;
6
+import com.aiinvestment.shared.domain.AssetType;
7
+import com.fasterxml.jackson.databind.ObjectMapper;
8
+import org.junit.jupiter.api.Test;
9
+import org.springframework.beans.factory.annotation.Autowired;
10
+import org.springframework.boot.test.context.SpringBootTest;
11
+import org.springframework.test.context.ActiveProfiles;
12
+
13
+import java.nio.file.Path;
14
+import static org.assertj.core.api.Assertions.assertThat;
15
+
16
+@SpringBootTest
17
+@ActiveProfiles("test")
18
+@org.springframework.transaction.annotation.Transactional
19
+class BenchmarkRegistrationIntegrationTest {
20
+ @Autowired InstrumentMasterService service;
21
+ @Autowired InstrumentMasterRepository masters;
22
+ @Autowired InstrumentProviderMappingRepository mappings;
23
+ @Autowired ObjectMapper mapper;
24
+
25
+ @Test
26
+ void registersAndReadsCanonicalIndicesUsingExistingSchema() throws Exception {
27
+ for (String key : InstrumentMasterService.BENCHMARKS.keySet()) {
28
+ var first = service.registerBenchmark(key);
29
+ var second = service.registerBenchmark(key);
30
+ var id = InstrumentMasterService.benchmarkId(key);
31
+ assertThat(first.master().getInstrumentId()).isEqualTo(second.master().getInstrumentId()).isEqualTo(id);
32
+ assertThat(masters.findById(id).orElseThrow().getAssetType()).isEqualTo(AssetType.INDEX);
33
+ assertThat(mappings.findByInstrumentId(id)).singleElement().satisfies(mapping -> {
34
+ assertThat(mapping.getProvider()).isEqualTo("NSE");
35
+ assertThat(mapping.getStatus()).isEqualTo("VERIFIED");
36
+ assertThat(mapping.getResolutionSource()).isEqualTo(InstrumentMasterService.BENCHMARK_CATALOG_VERSION);
37
+ });
38
+ }
39
+ var registered = service.registeredBenchmarks();
40
+ assertThat(registered).hasSize(4);
41
+ // Public canonical metadata exported for the captured-response local runtime smoke.
42
+ mapper.writeValue(Path.of("target", "benchmark-registered-runtime.json").toFile(),
43
+ registered.stream().map(GlobalInstrumentResponse::from).toList());
44
+ }
45
+}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/InstrumentMasterServiceTest.java
+30
@@ -28,6 +28,36 @@ import static org.mockito.ArgumentMatchers.eq;
28
import static org.mockito.Mockito.*;
29
30
class InstrumentMasterServiceTest {
31
+ @Test
32
+ void benchmarkRegistrationUsesPlatformKeyAndIsIdempotent() {
33
+ String key = "INDIA_FINANCIALS_PRICE";
34
+ UUID id = InstrumentMasterService.benchmarkId(key);
35
+ assertThat(id.toString()).isEqualTo("13ba7849-43e2-3e1e-a3f3-d93ee32d3d2f");
36
+ var first = service.registerBenchmark(key);
37
+ assertThat(first.master().getInstrumentId()).isEqualTo(id);
38
+ assertThat(first.master().getAssetType()).isEqualTo(AssetType.INDEX);
39
+ assertThat(first.master().getPrimarySymbol()).isEqualTo("NIFTY FINANCIAL SERVICES");
40
+ assertThat(savedMappings().get(0).getResolutionSource()).isEqualTo("NSE_BENCHMARK_CATALOG_V1");
41
+ when(masters.findById(id)).thenReturn(Optional.of(first.master()));
42
+ service.registerBenchmark(key);
43
+ verify(masters, times(1)).saveAndFlush(any());
44
+ verify(legacy, never()).save(any());
45
+ verifyNoInteractions(events);
46
+ }
47
+
48
+ @Test
49
+ void benchmarkRegistrationRejectsUnknownKeysAndConflictingIdentity() {
50
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.registerBenchmark("NIFTY 500"))
51
+ .isInstanceOf(IllegalArgumentException.class);
52
+ var mapping = mock(InstrumentProviderMappingEntity.class);
53
+ when(mapping.getInstrumentId()).thenReturn(UUID.randomUUID());
54
+ when(mappings.findByProviderAndExchangeIgnoreCaseAndProviderSymbolIgnoreCase("NSE", "NSE", "NIFTY 500"))
55
+ .thenReturn(Optional.of(mapping));
56
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.registerBenchmark("INDIA_BROAD_PRICE"))
57
+ .isInstanceOf(IllegalStateException.class).hasMessage("BENCHMARK_IDENTITY_CONFLICT");
58
+ verify(masters, never()).saveAndFlush(any());
59
+ }
60
+
61
private InstrumentMasterRepository masters;
62
private InstrumentProviderMappingRepository mappings;
63
private InstrumentRepository legacy;
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/AssetType.java
+1
@@ -2,6 +2,7 @@ package com.aiinvestment.shared.domain;
2
3
public enum AssetType {
4
EQUITY,
5
+ INDEX,
6
ETF,
7
FUND,
8
BOND,