main
py 155 lines 5.49 KB
Raw
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