| 1 | """Broad-market discovery boundary; it is not a portfolio or dashboard read path.""" |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | from dataclasses import dataclass |
| 5 | from datetime import datetime |
| 6 | from typing import Any, Protocol |
| 7 | from uuid import UUID |
| 8 | |
| 9 | |
| 10 | @dataclass(frozen=True) |
| 11 | class MarketUniverseListing: |
| 12 | ticker: str |
| 13 | exchange: str |
| 14 | country: str |
| 15 | currency: str | None |
| 16 | company_name: str | None |
| 17 | sector: str | None |
| 18 | industry: str | None |
| 19 | isin: str | None = None |
| 20 | |
| 21 | |
| 22 | class MarketUniverseUnavailable(RuntimeError): pass |
| 23 | |
| 24 | |
| 25 | @dataclass(frozen=True) |
| 26 | class MarketUniverseInstrument: |
| 27 | """Provider-neutral, canonical market-universe identity. |
| 28 | |
| 29 | Classification remains owned upstream. In particular, a missing |
| 30 | ``canonical_sector`` is retained so operational population can report it, |
| 31 | while Sector Performance can exclude it without recreating India's |
| 32 | classification rules in Python. |
| 33 | """ |
| 34 | |
| 35 | global_instrument_id: UUID |
| 36 | ticker: str |
| 37 | company_name: str | None |
| 38 | isin: str | None |
| 39 | exchange: str |
| 40 | mic: str | None |
| 41 | country: str |
| 42 | currency: str | None |
| 43 | asset_type: str |
| 44 | status: str |
| 45 | region: str |
| 46 | canonical_sector: str | None |
| 47 | official_industry: str | None |
| 48 | source: str | None |
| 49 | retrieved_at: datetime | str | None |
| 50 | |
| 51 | def as_payload(self) -> dict[str, Any]: |
| 52 | return { |
| 53 | "globalInstrumentId": str(self.global_instrument_id), |
| 54 | "ticker": self.ticker, |
| 55 | "symbol": self.ticker, |
| 56 | "companyName": self.company_name, |
| 57 | "canonicalName": self.company_name, |
| 58 | "isin": self.isin, |
| 59 | "exchange": self.exchange, |
| 60 | "mic": self.mic, |
| 61 | "country": self.country, |
| 62 | "currency": self.currency, |
| 63 | "assetType": self.asset_type, |
| 64 | "status": self.status, |
| 65 | "region": self.region, |
| 66 | "canonicalSector": self.canonical_sector, |
| 67 | "officialIndustry": self.official_industry, |
| 68 | "source": self.source, |
| 69 | "retrievedAt": self.retrieved_at, |
| 70 | } |
| 71 | |
| 72 | |
| 73 | class MarketUniverseProvider(Protocol): |
| 74 | region: str |
| 75 | async def listings(self) -> list[MarketUniverseListing]: ... |
| 76 | |
| 77 | |
| 78 | class UsaMarketUniverseProvider: |
| 79 | """SEC supplies listed issuer identity but not complete sector classification. |
| 80 | |
| 81 | A licensed/verified listing metadata feed is deliberately required before |
| 82 | this provider can publish a broad, sector-filterable universe. |
| 83 | """ |
| 84 | region = "USA" |
| 85 | async def listings(self) -> list[MarketUniverseListing]: |
| 86 | raise MarketUniverseUnavailable("USA_UNIVERSE_REQUIRES_SECTOR_METADATA_SOURCE") |
| 87 | |
| 88 | |
| 89 | class EuropeMarketUniverseProvider: |
| 90 | """EODHD exchange-symbol universe boundary; requires AIP_EODHD_API_KEY.""" |
| 91 | region = "EUROPE" |
| 92 | async def listings(self) -> list[MarketUniverseListing]: |
| 93 | raise MarketUniverseUnavailable("EUROPE_UNIVERSE_REQUIRES_EODHD_API_KEY") |
| 94 | |
| 95 | |
| 96 | class IndiaMarketUniverseProvider: |
| 97 | """Adapter over portfolio-service's durable official NSE/Nifty 500 cache.""" |
| 98 | region = "INDIA" |
| 99 | |
| 100 | def __init__(self, portfolio_orchestrator) -> None: |
| 101 | self._portfolio_orchestrator = portfolio_orchestrator |
| 102 | |
| 103 | async def listings( |
| 104 | self, |
| 105 | *, |
| 106 | correlation_id: str | None = None, |
| 107 | identity_headers: dict[str, str | None] | None = None, |
| 108 | ) -> list[MarketUniverseInstrument]: |
| 109 | try: |
| 110 | values = await self._portfolio_orchestrator.india_nifty500_universe( |
| 111 | correlation_id=correlation_id, |
| 112 | identity_headers=identity_headers, |
| 113 | ) |
| 114 | except Exception as exc: |
| 115 | # Avoid importing the orchestration module here and creating a |
| 116 | # provider-layer cycle. The route/job translates one stable |
| 117 | # provider-neutral outcome. |
| 118 | raise MarketUniverseUnavailable("INDIA_NIFTY500_UNIVERSE_UNAVAILABLE") from exc |
| 119 | |
| 120 | instruments: list[MarketUniverseInstrument] = [] |
| 121 | for value in values: |
| 122 | try: |
| 123 | instrument_id = UUID(str(value.get("globalInstrumentId"))) |
| 124 | except (TypeError, ValueError, AttributeError): |
| 125 | continue |
| 126 | ticker = str(value.get("symbol") or "").strip() |
| 127 | status = str(value.get("status") or "").strip().upper() |
| 128 | asset_type = str(value.get("assetType") or "").strip().upper() |
| 129 | country = str(value.get("country") or "").strip().upper() |
| 130 | exchange = str(value.get("exchange") or "").strip().upper() |
| 131 | if not ticker or status != "ACTIVE" or asset_type != "EQUITY" or country != "IN" or not exchange: |
| 132 | continue |
| 133 | instruments.append(MarketUniverseInstrument( |
| 134 | global_instrument_id=instrument_id, |
| 135 | ticker=ticker, |
| 136 | company_name=_optional_text(value.get("companyName")), |
| 137 | isin=_optional_text(value.get("isin")), |
| 138 | exchange=exchange, |
| 139 | mic=_optional_text(value.get("mic")), |
| 140 | country=country, |
| 141 | currency=_optional_text(value.get("currency")), |
| 142 | asset_type=asset_type, |
| 143 | status=status, |
| 144 | region=self.region, |
| 145 | canonical_sector=_optional_text(value.get("canonicalSector")), |
| 146 | official_industry=_optional_text(value.get("officialIndustry")), |
| 147 | source=_optional_text(value.get("source")), |
| 148 | retrieved_at=value.get("retrievedAt"), |
| 149 | )) |
| 150 | return instruments |
| 151 | |
| 152 | |
| 153 | def _optional_text(value: Any) -> str | None: |
| 154 | text = str(value).strip() if value is not None else "" |
| 155 | return text or None |