| 1 | """Pure, date-aligned stock/sector/market relative returns. |
| 2 | |
| 3 | Option B: missing durable benchmark mappings/history remain unavailable. A |
| 4 | sector performer leaderboard is not a sector index and is never substituted. |
| 5 | Comparisons are local-currency price returns (no invented FX conversion). |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | from dataclasses import asdict, dataclass |
| 10 | from datetime import date, datetime, timedelta |
| 11 | from math import isfinite |
| 12 | from typing import Iterable, Literal, Mapping |
| 13 | from uuid import UUID |
| 14 | |
| 15 | from pydantic import Field |
| 16 | |
| 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, normalize_daily_history, percentage, utc |
| 20 | from zoneinfo import ZoneInfo |
| 21 | |
| 22 | |
| 23 | SECTOR_FEATURE_VERSION = "SECTOR_RELATIVE_STRENGTH_V1" |
| 24 | |
| 25 | |
| 26 | @dataclass(frozen=True) |
| 27 | class BenchmarkReference: |
| 28 | instrument_id: UUID |
| 29 | currency: str |
| 30 | trusted_providers: frozenset[str] | None = None |
| 31 | |
| 32 | |
| 33 | @dataclass(frozen=True) |
| 34 | class SectorContext: |
| 35 | """Authoritative classification and explicit canonical mappings, supplied by caller. |
| 36 | |
| 37 | India uses canonicalSector from the persisted NSE universe. Other markets |
| 38 | may use their persisted canonical classification. No company-name inference. |
| 39 | source/as_of retain classification provenance; future metadata is rejected. |
| 40 | """ |
| 41 | sector: str | None = None |
| 42 | source: str | None = None |
| 43 | as_of: datetime | None = None |
| 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) |
| 53 | class SectorRelativeStrengthConfig: |
| 54 | return_lookbacks: tuple[int, ...] = (5, 21, 63, 126) |
| 55 | period_weights: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0) |
| 56 | max_age_days: int = 7 |
| 57 | # Normalize horizon lengths before classifying direction: 0.02 percentage |
| 58 | # points/observed session is a configurable noise band, not a forecast. |
| 59 | material_edge_per_observation_pct: float = 0.02 |
| 60 | improvement_per_observation_pct: float = 0.02 |
| 61 | # An average 0.2pp/session edge reaches score100, -0.2 reaches zero. |
| 62 | full_scale_edge_per_observation_pct: float = 0.2 |
| 63 | |
| 64 | def __post_init__(self): |
| 65 | if (len(self.return_lookbacks) != 4 or any(n < 1 for n in self.return_lookbacks) |
| 66 | or tuple(sorted(set(self.return_lookbacks))) != self.return_lookbacks |
| 67 | or len(self.period_weights) != 4 or sum(self.period_weights) <= 0 or self.max_age_days < 1 |
| 68 | or self.full_scale_edge_per_observation_pct <= 0): |
| 69 | raise ValueError("Invalid sector configuration") |
| 70 | for value in asdict(self).values(): |
| 71 | if any(not isfinite(v) or v < 0 for v in (value if isinstance(value, tuple) else (value,))): |
| 72 | raise ValueError("Sector thresholds/weights must be finite and nonnegative") |
| 73 | |
| 74 | |
| 75 | class SectorRelativeStrengthSnapshot(ResearchBaseModel): |
| 76 | global_instrument_id: UUID |
| 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 |
| 86 | region: str | None = None |
| 87 | sector_benchmark_id: UUID | None = None |
| 88 | market_benchmark_id: UUID | None = None |
| 89 | return_basis: str = "DATE_ALIGNED_LOCAL_CURRENCY_PRICE_RETURN_PCT" |
| 90 | stock_return1_w: float | None = Field(default=None, alias="stockReturn1W") |
| 91 | stock_return1_m: float | None = Field(default=None, alias="stockReturn1M") |
| 92 | stock_return3_m: float | None = Field(default=None, alias="stockReturn3M") |
| 93 | stock_return6_m: float | None = Field(default=None, alias="stockReturn6M") |
| 94 | sector_return1_w: float | None = Field(default=None, alias="sectorReturn1W") |
| 95 | sector_return1_m: float | None = Field(default=None, alias="sectorReturn1M") |
| 96 | sector_return3_m: float | None = Field(default=None, alias="sectorReturn3M") |
| 97 | sector_return6_m: float | None = Field(default=None, alias="sectorReturn6M") |
| 98 | market_return1_w: float | None = Field(default=None, alias="marketReturn1W") |
| 99 | market_return1_m: float | None = Field(default=None, alias="marketReturn1M") |
| 100 | market_return3_m: float | None = Field(default=None, alias="marketReturn3M") |
| 101 | market_return6_m: float | None = Field(default=None, alias="marketReturn6M") |
| 102 | relative_vs_sector1_w: float | None = Field(default=None, alias="relativeVsSector1W") |
| 103 | relative_vs_sector1_m: float | None = Field(default=None, alias="relativeVsSector1M") |
| 104 | relative_vs_sector3_m: float | None = Field(default=None, alias="relativeVsSector3M") |
| 105 | relative_vs_sector6_m: float | None = Field(default=None, alias="relativeVsSector6M") |
| 106 | relative_vs_market1_w: float | None = Field(default=None, alias="relativeVsMarket1W") |
| 107 | relative_vs_market1_m: float | None = Field(default=None, alias="relativeVsMarket1M") |
| 108 | relative_vs_market3_m: float | None = Field(default=None, alias="relativeVsMarket3M") |
| 109 | relative_vs_market6_m: float | None = Field(default=None, alias="relativeVsMarket6M") |
| 110 | comparison_windows: dict[str, tuple[date, date]] = Field(default_factory=dict) |
| 111 | feature_states: dict[str, str] = Field(default_factory=dict) |
| 112 | sector_state: Literal["LEADING", "IMPROVING", "NEUTRAL", "WEAKENING", "LAGGING", "INSUFFICIENT_DATA"] = "INSUFFICIENT_DATA" |
| 113 | relative_strength_score: float | None = None |
| 114 | confidence: float = 0 |
| 115 | missing_inputs: list[str] = Field(default_factory=list) |
| 116 | stale_inputs: list[str] = Field(default_factory=list) |
| 117 | |
| 118 | |
| 119 | class SectorRelativeStrengthEngine: |
| 120 | def __init__(self, config: SectorRelativeStrengthConfig | None = None): |
| 121 | self.config = config or SectorRelativeStrengthConfig() |
| 122 | |
| 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, |
| 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, |
| 134 | classification_source=context.source if classification_valid else None, |
| 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") |
| 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") |
| 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 |
| 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 |
| 167 | if stale: |
| 168 | result.stale_inputs.append(f"{name.upper()}_HISTORY") |
| 169 | result.benchmark_states[name] = 'STALE_BENCHMARK_HISTORY' |
| 170 | continue |
| 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): |
| 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 = [] |
| 182 | for name in ("sector", "market"): |
| 183 | rows = benchmark_data.get(name, {}) |
| 184 | if not stock_stale and dates[0] in rows and dates[1] in rows: |
| 185 | value = percentage(rows[dates[1]], rows[dates[0]]) |
| 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: |
| 193 | edges[suffix] = (sum(period_edges) / len(period_edges), weight) |
| 194 | # Require at least two horizons to claim consistency. Missing benchmark |
| 195 | # legs are omitted, never assigned a zero relative return. |
| 196 | if len(edges) >= 2 and sum(weight for _, weight in edges.values()) > 0: |
| 197 | average = sum(edge * weight for edge, weight in edges.values()) / sum(weight for _, weight in edges.values()) |
| 198 | result.relative_strength_score = min(100, max(0, 50 + 50 * average / cfg.full_scale_edge_per_observation_pct)) |
| 199 | # sectorState describes the stock's relative leadership with sector |
| 200 | # context. Market-only evidence may score partially, but is not a sector state. |
| 201 | sector_periods = sum(getattr(result, f"relative_vs_sector{s}") is not None for s in ("1_w", "1_m", "3_m", "6_m")) |
| 202 | if sector_periods >= 2: |
| 203 | values = [v for v, _ in edges.values()] |
| 204 | if all(v > cfg.material_edge_per_observation_pct for v in values): |
| 205 | result.sector_state = "LEADING" |
| 206 | elif all(v < -cfg.material_edge_per_observation_pct for v in values): |
| 207 | result.sector_state = "LAGGING" |
| 208 | else: |
| 209 | short = [edges[s][0] for s in ("1_w", "1_m") if s in edges] |
| 210 | long = [edges[s][0] for s in ("3_m", "6_m") if s in edges] |
| 211 | change = sum(short)/len(short) - sum(long)/len(long) if short and long else 0 |
| 212 | result.sector_state = ("IMPROVING" if change > cfg.improvement_per_observation_pct else |
| 213 | "WEAKENING" if change < -cfg.improvement_per_observation_pct else "NEUTRAL") |
| 214 | relative_fields = [] |
| 215 | for name, field in SectorRelativeStrengthSnapshot.model_fields.items(): |
| 216 | if name.startswith(("stock_return", "sector_return", "market_return", "relative_vs_")): |
| 217 | value = getattr(result, name) |
| 218 | alias = field.alias or name |
| 219 | result.feature_states[alias] = "MISSING" if value is None else "STALE" if stock_stale else "AVAILABLE" |
| 220 | if value is None: |
| 221 | result.missing_inputs.append(alias) |
| 222 | else: |
| 223 | setattr(result, name, round(value, 8)) |
| 224 | if name.startswith("relative_vs_"): |
| 225 | relative_fields.append(value) |
| 226 | result.confidence = round(100 * sum(v is not None for v in relative_fields) / 8, 6) |
| 227 | if result.relative_strength_score is not None: |
| 228 | result.relative_strength_score = round(result.relative_strength_score, 8) |
| 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') |