main
py 362 lines 14.2 KB
Raw
1 """Lightweight dashboard orchestration for global INDIA market data."""
2 from __future__ import annotations
3
4 import asyncio
5 import logging
6 from datetime import datetime, timedelta, timezone
7 from typing import Any, Callable
8
9 from app.historical_market_data import has_year_historical_coverage
10 from app.market_universe import IndiaMarketUniverseProvider, MarketUniverseInstrument, MarketUniverseUnavailable
11 from app.settings import Settings
12
13
14 logger = logging.getLogger(__name__)
15
16
17 class IndiaMarketDataEnsureService:
18 """Checks durable freshness and schedules heavy work without awaiting it."""
19
20 def __init__(
21 self,
22 repository,
23 universe_provider: IndiaMarketUniverseProvider,
24 portfolio_orchestrator,
25 population_jobs,
26 settings: Settings,
27 *,
28 clock: Callable[[], datetime] | None = None,
29 ) -> None:
30 self.repository = repository
31 self.universe_provider = universe_provider
32 self.portfolio_orchestrator = portfolio_orchestrator
33 self.population_jobs = population_jobs
34 self.settings = settings
35 self._clock = clock or (lambda: datetime.now(timezone.utc))
36 self._refresh_lock = asyncio.Lock()
37 self._refresh_task: asyncio.Task | None = None
38 self._last_refresh_failure_at: datetime | None = None
39
40 async def ensure(
41 self,
42 *,
43 identity_headers: dict[str, str | None],
44 correlation_id: str | None = None,
45 ) -> dict[str, Any]:
46 now = self._clock()
47 try:
48 instruments = await self.universe_provider.listings(
49 correlation_id=correlation_id,
50 identity_headers=identity_headers,
51 )
52 except MarketUniverseUnavailable:
53 # An unavailable INDIA universe at Dashboard bootstrap means the
54 # durable Nifty reference cache cannot currently be read. Treat
55 # that as reference data requiring refresh rather than terminating
56 # ensure before the existing protected refresh path can run.
57 refresh_status = await self._ensure_reference_refresh(correlation_id, now)
58 return _response(refresh_status, None, "UNAVAILABLE", None)
59
60 last_updated_at = _latest_retrieved_at(instruments)
61 universe_stale = (
62 not instruments
63 or last_updated_at is None
64 or now - last_updated_at > timedelta(hours=self.settings.market_data_nifty_freshness_hours)
65 )
66 historical = await self._historical_freshness(instruments, now)
67 active_job = self.population_jobs.active()
68
69 if universe_stale:
70 refresh_status = await self._ensure_reference_refresh(correlation_id, now)
71 historical_status = "RUNNING" if active_job else historical["status"]
72 return _response(
73 refresh_status,
74 last_updated_at,
75 historical_status,
76 active_job.get("jobId") if active_job else None,
77 historical,
78 )
79
80 if active_job is not None:
81 logger.info(
82 "market_data_ensure event=POPULATION_ACTIVE_REUSED jobId=%s jobStatus=%s",
83 active_job.get("jobId"),
84 active_job.get("status"),
85 )
86 return _response("FRESH", last_updated_at, "RUNNING", active_job.get("jobId"), historical)
87
88 if historical["status"] == "STALE":
89 if self._population_retry_deferred(now):
90 latest_job = self.population_jobs.latest()
91 logger.info(
92 "market_data_ensure event=POPULATION_RETRY_DEFERRED jobId=%s jobStatus=%s",
93 latest_job.get("jobId") if latest_job else None,
94 latest_job.get("status") if latest_job else None,
95 )
96 return _response("FRESH", last_updated_at, historical["status"], None, historical)
97
98 logger.info("market_data_ensure event=POPULATION_SUBMIT_STARTED")
99 job = await self.population_jobs.submit(
100 identity_headers=_internal_service_identity(self.settings),
101 correlation_id=correlation_id,
102 )
103 logger.info(
104 "market_data_ensure event=POPULATION_SUBMIT_COMPLETED jobId=%s jobStatus=%s",
105 job.get("jobId"),
106 job.get("status"),
107 )
108 return _response("FRESH", last_updated_at, "POPULATION_STARTED", job.get("jobId"), historical)
109
110 return _response("FRESH", last_updated_at, historical["status"], None, historical)
111
112 async def wait_for_reference_refresh(self) -> None:
113 task = self._refresh_task
114 if task is not None:
115 await task
116
117 async def _historical_freshness(
118 self,
119 instruments: list[MarketUniverseInstrument],
120 now: datetime,
121 ) -> dict[str, Any]:
122 eligible = [value for value in instruments if value.canonical_sector]
123 if not eligible:
124 historical = {
125 "status": "STALE",
126 "eligibleInstruments": 0,
127 "coveredInstruments": 0,
128 "lastObservedAt": None,
129 }
130 _log_historical_freshness(historical)
131 return historical
132 ids = {value.global_instrument_id for value in eligible}
133 try:
134 coverage = await self.repository.market_price_coverage_for_instruments(ids)
135 except Exception as exc:
136 logger.warning(
137 "market_data_ensure event=HISTORICAL_COVERAGE_FAILED "
138 "eligibleInstrumentCount=%s coveredInstrumentCount=0 exceptionClass=%s "
139 "errorIdentifier=HISTORICAL_COVERAGE_UNAVAILABLE",
140 len(eligible),
141 type(exc).__name__,
142 )
143 historical = {
144 "status": "UNAVAILABLE",
145 "eligibleInstruments": len(eligible),
146 "coveredInstruments": 0,
147 "lastObservedAt": None,
148 }
149 _log_historical_freshness(historical)
150 return historical
151
152 covered = 0
153 latest_covered: datetime | None = None
154 oldest_latest_covered: datetime | None = None
155 for instrument_id in ids:
156 value = coverage.get(instrument_id)
157 if value is None:
158 continue
159 first_observed_at, latest_observed_at, observation_count = value
160 if not has_year_historical_coverage(
161 first_observed_at,
162 latest_observed_at,
163 observation_count,
164 ):
165 continue
166 covered += 1
167 if latest_covered is None or latest_observed_at > latest_covered:
168 latest_covered = latest_observed_at
169 if oldest_latest_covered is None or latest_observed_at < oldest_latest_covered:
170 oldest_latest_covered = latest_observed_at
171
172 is_fresh = (
173 covered == len(eligible)
174 and oldest_latest_covered is not None
175 and now - oldest_latest_covered <= timedelta(hours=self.settings.market_data_historical_freshness_hours)
176 )
177 historical = {
178 "status": "FRESH" if is_fresh else "STALE",
179 "eligibleInstruments": len(eligible),
180 "coveredInstruments": covered,
181 "lastObservedAt": latest_covered,
182 }
183 _log_historical_freshness(historical)
184 return historical
185
186 async def _ensure_reference_refresh(self, correlation_id: str | None, now: datetime) -> str:
187 async with self._refresh_lock:
188 if self._refresh_task is not None and not self._refresh_task.done():
189 return "REFRESH_STARTED"
190 if (
191 self._last_refresh_failure_at is not None
192 and now - self._last_refresh_failure_at
193 < timedelta(minutes=self.settings.market_data_ensure_retry_cooldown_minutes)
194 ):
195 return "UNAVAILABLE"
196 self._refresh_task = asyncio.create_task(
197 self._refresh_reference_then_population(correlation_id),
198 name="india-nifty-reference-ensure",
199 )
200 return "REFRESH_STARTED"
201
202 async def _refresh_reference_then_population(self, correlation_id: str | None) -> None:
203 identity = _internal_service_identity(self.settings)
204 try:
205 await self.portfolio_orchestrator.refresh_india_nifty500_reference(
206 correlation_id=correlation_id,
207 identity_headers=identity,
208 )
209 self._last_refresh_failure_at = None
210 logger.info("market_data_ensure event=REFERENCE_REFRESH_COMPLETED")
211 except Exception as exc:
212 self._last_refresh_failure_at = self._clock()
213 logger.warning(
214 "market_data_ensure event=REFERENCE_REFRESH_FAILED exceptionClass=%s "
215 "errorIdentifier=REFERENCE_REFRESH_FAILED",
216 _diagnostic_exception_class(exc),
217 )
218 return
219
220 try:
221 instruments = await self.universe_provider.listings(
222 correlation_id=correlation_id,
223 identity_headers=identity,
224 )
225 instrument_count = len(instruments)
226 except Exception as exc:
227 logger.warning(
228 "market_data_ensure event=POST_REFRESH_UNIVERSE_READ_FAILED exceptionClass=%s "
229 "errorIdentifier=POST_REFRESH_UNIVERSE_UNAVAILABLE",
230 type(exc).__name__,
231 )
232 return
233
234 logger.info(
235 "market_data_ensure event=POST_REFRESH_UNIVERSE_READ_COMPLETED instrumentCount=%s",
236 instrument_count,
237 )
238 try:
239 historical = await self._historical_freshness(instruments, self._clock())
240 if historical["status"] != "STALE":
241 return
242
243 active_job = self.population_jobs.active()
244 if active_job is not None:
245 logger.info(
246 "market_data_ensure event=POPULATION_ACTIVE_REUSED jobId=%s jobStatus=%s",
247 active_job.get("jobId"),
248 active_job.get("status"),
249 )
250 return
251
252 now = self._clock()
253 if self._population_retry_deferred(now):
254 latest_job = self.population_jobs.latest()
255 logger.info(
256 "market_data_ensure event=POPULATION_RETRY_DEFERRED jobId=%s jobStatus=%s",
257 latest_job.get("jobId") if latest_job else None,
258 latest_job.get("status") if latest_job else None,
259 )
260 return
261
262 logger.info("market_data_ensure event=POPULATION_SUBMIT_STARTED")
263 # Submit is non-blocking and owns its duplicate-job guard.
264 job = await self.population_jobs.submit(
265 identity_headers=identity,
266 correlation_id=correlation_id,
267 )
268 logger.info(
269 "market_data_ensure event=POPULATION_SUBMIT_COMPLETED jobId=%s jobStatus=%s",
270 job.get("jobId"),
271 job.get("status"),
272 )
273 except Exception as exc:
274 # A later ensure can retry population without repeating the now
275 # successful universe refresh.
276 logger.warning(
277 "market_data_ensure event=POPULATION_HANDOFF_FAILED exceptionClass=%s "
278 "errorIdentifier=POPULATION_HANDOFF_FAILED",
279 type(exc).__name__,
280 )
281 return
282
283 def _population_retry_deferred(self, now: datetime) -> bool:
284 latest = self.population_jobs.latest()
285 if latest is None or latest.get("status") in {"QUEUED", "RUNNING"}:
286 return False
287 completed_at = _as_utc(latest.get("completedAt"))
288 return (
289 completed_at is not None
290 and now - completed_at < timedelta(hours=self.settings.market_data_population_retry_cooldown_hours)
291 )
292
293
294 def _diagnostic_exception_class(exc: Exception) -> str:
295 """Expose only the safe transport/error type hidden by a wrapper."""
296 cause = exc.__cause__
297 return type(cause).__name__ if cause is not None else type(exc).__name__
298
299
300 def _internal_service_identity(settings: Settings) -> dict[str, str]:
301 """Server-owned identity; never derives ADMIN authority from browser input."""
302 return {
303 "X-AIP-User-Id": settings.market_data_internal_user_id,
304 "X-AIP-User-Issuer": settings.market_data_internal_issuer,
305 "X-AIP-User-Subject": settings.market_data_internal_subject,
306 "X-AIP-User-Roles": "ADMIN",
307 }
308
309
310 def _log_historical_freshness(historical: dict[str, Any]) -> None:
311 logger.info(
312 "market_data_ensure event=HISTORICAL_FRESHNESS_RESULT status=%s "
313 "eligibleInstrumentCount=%s coveredInstrumentCount=%s",
314 historical["status"],
315 historical["eligibleInstruments"],
316 historical["coveredInstruments"],
317 )
318
319
320 def _latest_retrieved_at(instruments: list[MarketUniverseInstrument]) -> datetime | None:
321 values = [_as_utc(value.retrieved_at) for value in instruments]
322 usable = [value for value in values if value is not None]
323 return max(usable, default=None)
324
325
326 def _as_utc(value: Any) -> datetime | None:
327 if isinstance(value, datetime):
328 parsed = value
329 elif isinstance(value, str) and value.strip():
330 try:
331 parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
332 except ValueError:
333 return None
334 else:
335 return None
336 if parsed.tzinfo is None:
337 return parsed.replace(tzinfo=timezone.utc)
338 return parsed.astimezone(timezone.utc)
339
340
341 def _response(
342 universe_status: str,
343 last_updated_at: datetime | None,
344 historical_status: str,
345 job_id: str | None,
346 historical: dict[str, Any] | None = None,
347 ) -> dict[str, Any]:
348 history = historical or {}
349 return {
350 "region": "INDIA",
351 "universe": {
352 "status": universe_status,
353 "lastUpdatedAt": last_updated_at,
354 },
355 "historicalPrices": {
356 "status": historical_status,
357 "jobId": job_id,
358 "lastObservedAt": history.get("lastObservedAt"),
359 "eligibleInstruments": history.get("eligibleInstruments", 0),
360 "coveredInstruments": history.get("coveredInstruments", 0),
361 },
362 }