| 1 | """Pure deterministic features over persisted daily candles or close fallback. |
| 2 | |
| 3 | No adjusted-close, OHLC or volume semantics are inferred from the price table. |
| 4 | Candle dates remain exchange DATEs; fallback uses UTC observation dates. Returns |
| 5 | use observed-session offsets; all percentages are percentage units, not ratios. |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | from collections import defaultdict |
| 10 | from dataclasses import asdict, dataclass |
| 11 | from datetime import date, datetime, time, timedelta, timezone |
| 12 | from decimal import Decimal, InvalidOperation |
| 13 | from math import isfinite |
| 14 | from typing import Iterable, Literal |
| 15 | from uuid import UUID |
| 16 | from zoneinfo import ZoneInfo |
| 17 | |
| 18 | from pydantic import Field |
| 19 | |
| 20 | from app.models import DailyMarketBar, MarketPriceObservation, ResearchBaseModel |
| 21 | |
| 22 | |
| 23 | TECHNICAL_FEATURE_VERSION = "TECHNICAL_FEATURES_V2" |
| 24 | TechnicalState = Literal["UPTREND", "DOWNTREND", "BASE_BUILDING", "BREAKOUT", |
| 25 | "PULLBACK_IN_UPTREND", "REVERSAL_CANDIDATE", "RANGE_BOUND", |
| 26 | "OVEREXTENDED", "INSUFFICIENT_DATA"] |
| 27 | |
| 28 | |
| 29 | @dataclass(frozen=True) |
| 30 | class TechnicalConfig: |
| 31 | """Selection heuristics, not calibrated financial forecasts. |
| 32 | |
| 33 | 1% breakout buffers small price noise; 2% retreat/3% MA proximity defines |
| 34 | a bounded pullback. 0.02% per observation and a 5% band define a flat base. |
| 35 | 10% DMA20 distance plus RSI70 flags extension. These are explicit starting |
| 36 | tolerances, configurable for later validation, not universal market rules. |
| 37 | """ |
| 38 | max_age_days: int = 7 |
| 39 | return_lookbacks: tuple[int, ...] = (5, 21, 63, 126, 252) |
| 40 | level_lookback: int = 20 |
| 41 | year_observations: int = 252 |
| 42 | slope_threshold_pct: float = 0.02 |
| 43 | breakout_buffer_pct: float = 1.0 |
| 44 | pullback_retreat_pct: float = 2.0 |
| 45 | pullback_proximity_pct: float = 3.0 |
| 46 | base_range_pct: float = 5.0 |
| 47 | extension_distance_pct: float = 10.0 |
| 48 | extension_rsi: float = 70.0 |
| 49 | volume_confirmation_ratio: float = 1.5 |
| 50 | volume_contraction_ratio: float = 0.75 |
| 51 | score_weights: tuple[float, ...] = (50.0, 30.0, 20.0) |
| 52 | momentum_full_scale_pct: float = 20.0 |
| 53 | extension_penalty: float = 15.0 |
| 54 | volume_breakout_bonus: float = 5.0 |
| 55 | |
| 56 | def __post_init__(self): |
| 57 | if (self.max_age_days < 1 or self.level_lookback < 2 or self.year_observations < 2 |
| 58 | or len(self.return_lookbacks) != 5 or any(n < 1 for n in self.return_lookbacks) |
| 59 | or tuple(sorted(set(self.return_lookbacks))) != self.return_lookbacks |
| 60 | or len(self.score_weights) != 3 or sum(self.score_weights) <= 0): |
| 61 | raise ValueError("Invalid feature lookbacks or weights") |
| 62 | for name, value in asdict(self).items(): |
| 63 | numbers = value if isinstance(value, tuple) else (value,) |
| 64 | if any(not isfinite(v) or v < 0 for v in numbers): |
| 65 | raise ValueError(f"Invalid technical configuration: {name}") |
| 66 | if self.momentum_full_scale_pct == 0: |
| 67 | raise ValueError("Momentum scale must be positive") |
| 68 | if not 0 <= self.volume_contraction_ratio < 1 < self.volume_confirmation_ratio: |
| 69 | raise ValueError("Volume thresholds must bracket one") |
| 70 | |
| 71 | |
| 72 | class PersistedVolumeObservation(ResearchBaseModel): |
| 73 | """Optional durable volume input; no volume is manufactured from quote counts.""" |
| 74 | instrument_id: UUID |
| 75 | observed_at: datetime |
| 76 | retrieved_at: datetime |
| 77 | volume: Decimal | None |
| 78 | provider: str |
| 79 | source_url: str |
| 80 | |
| 81 | |
| 82 | @dataclass(frozen=True) |
| 83 | class PriceHistory: |
| 84 | observations: tuple[MarketPriceObservation | DailyMarketBar, ...] |
| 85 | conflicting_dates: tuple[date, ...] |
| 86 | current_conflict: bool |
| 87 | rejected_count: int |
| 88 | duplicate_count: int |
| 89 | currency: str | None |
| 90 | |
| 91 | |
| 92 | def utc(value: datetime) -> datetime: |
| 93 | return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) |
| 94 | |
| 95 | |
| 96 | def finite_number(value) -> Decimal | None: |
| 97 | if value is None or isinstance(value, bool): |
| 98 | return None |
| 99 | try: |
| 100 | number = Decimal(str(value)) |
| 101 | return number if number.is_finite() and isfinite(float(number)) else None |
| 102 | except (InvalidOperation, ValueError, OverflowError): |
| 103 | return None |
| 104 | |
| 105 | |
| 106 | def normalize_price_history(instrument_id: UUID, observations: Iterable[MarketPriceObservation], *, |
| 107 | as_of: datetime, currency: str | None = None, |
| 108 | trusted_providers: frozenset[str] | None = None) -> PriceHistory: |
| 109 | """Latest timestamp per UTC date; identical ties collapse, unequal ties fail. |
| 110 | |
| 111 | This extends Phase 1's latest-timestamp conflict gate to each historical day. |
| 112 | Conflicted historical dates are omitted with diagnostics. A conflicted latest |
| 113 | date blocks current features instead of silently falling back to yesterday. |
| 114 | Retrieval timestamps must also be known at as_of (no look-ahead). |
| 115 | """ |
| 116 | grouped = defaultdict(list) |
| 117 | rejected = 0 |
| 118 | currencies = set() |
| 119 | for row in observations: |
| 120 | price = finite_number(row.price) |
| 121 | if (row.instrument_id != instrument_id or utc(row.observed_at) > utc(as_of) |
| 122 | or utc(row.retrieved_at) > utc(as_of) or price is None or price <= 0 |
| 123 | or (trusted_providers is not None and row.provider not in trusted_providers) |
| 124 | or (currency is not None and row.currency != currency)): |
| 125 | rejected += 1 |
| 126 | continue |
| 127 | currencies.add(row.currency) |
| 128 | grouped[utc(row.observed_at).date()].append(row) |
| 129 | if len(currencies) > 1: |
| 130 | return PriceHistory((), tuple(sorted(grouped)), True, rejected, 0, currency) |
| 131 | output, conflicts = [], [] |
| 132 | duplicates = 0 |
| 133 | for day, rows in sorted(grouped.items()): |
| 134 | duplicates += len(rows) - 1 |
| 135 | latest = max(utc(row.observed_at) for row in rows) |
| 136 | current = [row for row in rows if utc(row.observed_at) == latest] |
| 137 | if len({row.price for row in current}) != 1: |
| 138 | conflicts.append(day) |
| 139 | continue |
| 140 | # No provider is assumed more authoritative. Only identical-price ties |
| 141 | # use provenance to select a stable representative. |
| 142 | output.append(min(current, key=lambda row: (row.provider, row.source_url, utc(row.retrieved_at)))) |
| 143 | return PriceHistory(tuple(output), tuple(conflicts), bool(conflicts and max(grouped) in conflicts), |
| 144 | rejected, duplicates, currency or next(iter(currencies), None)) |
| 145 | |
| 146 | |
| 147 | class TechnicalFeatureSnapshot(ResearchBaseModel): |
| 148 | global_instrument_id: UUID |
| 149 | as_of: datetime |
| 150 | feature_version: str = TECHNICAL_FEATURE_VERSION |
| 151 | configuration: dict |
| 152 | price_basis: str = "CANONICAL_PERSISTED_PRICE_UNADJUSTED" |
| 153 | extrema_basis: str = "ROLLING_CLOSE_EXTREMA" |
| 154 | technical_input_source: str = "CLOSE_ONLY_FALLBACK" |
| 155 | source_diagnostics: list[str] = Field(default_factory=list) |
| 156 | daily_bar_observation_count: int = 0 |
| 157 | history_trading_start: date | None = None |
| 158 | history_trading_end: date | None = None |
| 159 | feature_readiness: dict[str, str] = Field(default_factory=dict) |
| 160 | ohlcv_feature_coverage: float = 0 |
| 161 | observation_count: int |
| 162 | history_start: datetime | None = None |
| 163 | history_end: datetime | None = None |
| 164 | history_readiness: str |
| 165 | currency: str | None = None |
| 166 | latest_price: float | None = None |
| 167 | dma20: float | None = None |
| 168 | dma50: float | None = None |
| 169 | dma100: float | None = None |
| 170 | dma200: float | None = None |
| 171 | distance_to_dma20_pct: float | None = None |
| 172 | distance_to_dma50_pct: float | None = None |
| 173 | distance_to_dma100_pct: float | None = None |
| 174 | distance_to_dma200_pct: float | None = None |
| 175 | rsi14: float | None = None |
| 176 | macd: float | None = None |
| 177 | macd_signal: float | None = None |
| 178 | macd_histogram: float | None = None |
| 179 | adx14: float | None = None |
| 180 | atr14: float | None = None |
| 181 | atr_pct: float | None = None |
| 182 | return1_w: float | None = Field(default=None, alias="return1W") |
| 183 | return1_m: float | None = Field(default=None, alias="return1M") |
| 184 | return3_m: float | None = Field(default=None, alias="return3M") |
| 185 | return6_m: float | None = Field(default=None, alias="return6M") |
| 186 | return1_y: float | None = Field(default=None, alias="return1Y") |
| 187 | trend_slope20: float | None = None |
| 188 | trend_slope50: float | None = None |
| 189 | volume_average20: float | None = None |
| 190 | current_volume: int | Decimal | None = None |
| 191 | volume_ratio20: float | None = None |
| 192 | volume_state: str = "UNAVAILABLE" |
| 193 | volume_expansion: bool | None = None |
| 194 | volume_contraction: bool | None = None |
| 195 | breakout_volume_confirmed: bool | None = None |
| 196 | reversal_volume_confirmed: bool | None = None |
| 197 | distance_from52_week_high_pct: float | None = Field(default=None, alias="distanceFrom52WeekHighPct") |
| 198 | distance_from52_week_low_pct: float | None = Field(default=None, alias="distanceFrom52WeekLowPct") |
| 199 | support_level: float | None = None |
| 200 | resistance_level: float | None = None |
| 201 | distance_to_support_pct: float | None = None |
| 202 | distance_to_resistance_pct: float | None = None |
| 203 | higher_highs_higher_lows: bool | None = None |
| 204 | lower_highs_lower_lows: bool | None = None |
| 205 | breakout_state: str = "INSUFFICIENT_DATA" |
| 206 | technical_state: TechnicalState = "INSUFFICIENT_DATA" |
| 207 | technical_score: float | None = None |
| 208 | score_components: dict[str, float] = Field(default_factory=dict) |
| 209 | confidence: float = 0 |
| 210 | feature_states: dict[str, str] = Field(default_factory=dict) |
| 211 | missing_inputs: list[str] = Field(default_factory=list) |
| 212 | stale_inputs: list[str] = Field(default_factory=list) |
| 213 | conflicting_dates: list[date] = Field(default_factory=list) |
| 214 | rejected_observation_count: int = 0 |
| 215 | duplicate_observation_count: int = 0 |
| 216 | |
| 217 | |
| 218 | def percentage(value: float, reference: float) -> float: |
| 219 | return (value / reference - 1) * 100 |
| 220 | |
| 221 | |
| 222 | def normalize_daily_history(instrument_id, bars, *, as_of, currency, trusted_providers): |
| 223 | """NSE REAL candles only; never assemble a candle from different sources. |
| 224 | |
| 225 | Dates remain exchange DATEs. Latest known retrieval wins a correction; |
| 226 | unequal OHLCV at the same retrieval time is a conflict, not a tie-break. |
| 227 | A latest-date conflict blocks fallback to a different price source. |
| 228 | """ |
| 229 | grouped = defaultdict(list) |
| 230 | rejected = duplicates = 0 |
| 231 | local_day = utc(as_of).astimezone(ZoneInfo('Asia/Kolkata')).date() |
| 232 | for bar in bars: |
| 233 | if (bar.global_instrument_id != instrument_id or bar.provider != 'NSE' or bar.source_mode != 'REAL' |
| 234 | or (trusted_providers is not None and bar.provider not in trusted_providers) |
| 235 | or (currency is not None and bar.currency != currency) |
| 236 | or utc(bar.retrieved_at) > utc(as_of) or bar.trading_date > local_day): |
| 237 | rejected += 1 |
| 238 | continue |
| 239 | grouped[bar.trading_date].append(bar) |
| 240 | currencies = {bar.currency for group in grouped.values() for bar in group} |
| 241 | if len(currencies) > 1: |
| 242 | return PriceHistory((), tuple(sorted(grouped)), True, rejected, 0, currency), bool(grouped) |
| 243 | output, conflicts = [], [] |
| 244 | for day, group in sorted(grouped.items()): |
| 245 | duplicates += len(group) - 1 |
| 246 | stamp = max(utc(bar.retrieved_at) for bar in group) |
| 247 | current = [bar for bar in group if utc(bar.retrieved_at) == stamp] |
| 248 | values = {(bar.open, bar.high, bar.low, bar.close, bar.previous_close, bar.volume, bar.turnover) for bar in current} |
| 249 | close = finite_number(current[0].close) |
| 250 | if len(values) != 1 or close is None or close <= 0: |
| 251 | conflicts.append(day) |
| 252 | else: |
| 253 | output.append(min(current, key=lambda bar: (bar.provider_symbol or '', bar.source_url))) |
| 254 | return PriceHistory(tuple(output), tuple(conflicts), bool(conflicts and max(grouped) in conflicts), |
| 255 | rejected, duplicates, currency or next(iter(currencies), None)), bool(grouped) |
| 256 | |
| 257 | |
| 258 | def _wilder(values, period=14): |
| 259 | if len(values) < period: |
| 260 | return [] |
| 261 | output = [sum(values[:period]) / period] |
| 262 | for value in values[period:]: |
| 263 | output.append((output[-1] * (period - 1) + value) / period) |
| 264 | return output |
| 265 | |
| 266 | |
| 267 | def _atr_adx(candles, period=14): |
| 268 | """15 candles seed ATR14; 28 candles seed ADX14 (14 DX values). |
| 269 | |
| 270 | First candle provides the actual prior close/high/low, not a fabricated TR. |
| 271 | Equal positive up/down movement yields neither +DM nor -DM. Zero TR or |
| 272 | zero DI sum yields DX=0, so a flat market has ATR=ADX=0 once ready. |
| 273 | """ |
| 274 | tr, plus, minus = [], [], [] |
| 275 | for previous, current in zip(candles, candles[1:]): |
| 276 | high, low, previous_close = float(current.high), float(current.low), float(previous.close) |
| 277 | tr.append(max(high - low, abs(high - previous_close), abs(low - previous_close))) |
| 278 | up, down = high - float(previous.high), float(previous.low) - low |
| 279 | plus.append(up if up > 0 and up > down else 0.0) |
| 280 | minus.append(down if down > 0 and down > up else 0.0) |
| 281 | ranges, positive, negative = _wilder(tr, period), _wilder(plus, period), _wilder(minus, period) |
| 282 | dx = [] |
| 283 | for total, up, down in zip(ranges, positive, negative): |
| 284 | plus_di, minus_di = (100 * up / total, 100 * down / total) if total else (0.0, 0.0) |
| 285 | denominator = plus_di + minus_di |
| 286 | dx.append(100 * abs(plus_di - minus_di) / denominator if denominator else 0.0) |
| 287 | adx = _wilder(dx, period) |
| 288 | return (max(0.0, ranges[-1]) if ranges else None, _clamp(adx[-1]) if adx else None) |
| 289 | |
| 290 | |
| 291 | def _ema(values: list[float], period: int) -> list[float]: |
| 292 | """SMA seed, then alpha=2/(period+1); result begins at period-1.""" |
| 293 | if len(values) < period: |
| 294 | return [] |
| 295 | output = [sum(values[:period]) / period] |
| 296 | alpha = 2 / (period + 1) |
| 297 | for value in values[period:]: |
| 298 | output.append(alpha * value + (1 - alpha) * output[-1]) |
| 299 | return output |
| 300 | |
| 301 | |
| 302 | def _rsi(values: list[float], period=14) -> float | None: |
| 303 | if len(values) <= period: |
| 304 | return None |
| 305 | changes = [b - a for a, b in zip(values, values[1:])] |
| 306 | gain = sum(max(v, 0) for v in changes[:period]) / period |
| 307 | loss = sum(max(-v, 0) for v in changes[:period]) / period |
| 308 | for change in changes[period:]: |
| 309 | gain = (gain * (period - 1) + max(change, 0)) / period |
| 310 | loss = (loss * (period - 1) + max(-change, 0)) / period |
| 311 | return 50.0 if gain == loss == 0 else 100.0 if loss == 0 else 100 - 100 / (1 + gain / loss) |
| 312 | |
| 313 | |
| 314 | def _slope(values: list[float]) -> float: |
| 315 | """OLS slope as percent of window mean per observed session.""" |
| 316 | center = (len(values) - 1) / 2 |
| 317 | mean = sum(values) / len(values) |
| 318 | slope = sum((i - center) * (v - mean) for i, v in enumerate(values)) / sum((i - center)**2 for i in range(len(values))) |
| 319 | return slope / mean * 100 |
| 320 | |
| 321 | |
| 322 | def _clamp(value: float) -> float: |
| 323 | return min(100.0, max(0.0, value)) |
| 324 | |
| 325 | |
| 326 | class TechnicalFeatureEngine: |
| 327 | def __init__(self, config: TechnicalConfig | None = None): |
| 328 | self.config = config or TechnicalConfig() |
| 329 | |
| 330 | def compute(self, instrument_id: UUID, observations: Iterable[MarketPriceObservation], *, as_of: datetime, |
| 331 | currency: str | None = None, trusted_providers: frozenset[str] | None = None, |
| 332 | volume_history: Iterable[PersistedVolumeObservation] = (), |
| 333 | daily_bar_history: Iterable[DailyMarketBar] = ()) -> TechnicalFeatureSnapshot: |
| 334 | cfg = self.config |
| 335 | daily_history, use_daily = normalize_daily_history(instrument_id, daily_bar_history, |
| 336 | as_of=as_of, currency=currency, trusted_providers=trusted_providers) |
| 337 | fallback = None |
| 338 | selection_reason = None |
| 339 | if use_daily and not daily_history.current_conflict: |
| 340 | daily_stale = bool(daily_history.observations) and (utc(as_of).astimezone(ZoneInfo('Asia/Kolkata')).date() |
| 341 | - daily_history.observations[-1].trading_date > timedelta(days=cfg.max_age_days)) |
| 342 | if len(daily_history.observations) < 20 or daily_stale: |
| 343 | fallback = normalize_price_history(instrument_id, observations, as_of=as_of, |
| 344 | currency=currency, trusted_providers=trusted_providers) |
| 345 | if (len(fallback.observations) >= 20 and not fallback.current_conflict and |
| 346 | utc(as_of) - utc(fallback.observations[-1].observed_at) <= timedelta(days=cfg.max_age_days)): |
| 347 | use_daily = False |
| 348 | selection_reason = 'NSE_DAILY_HISTORY_STALE' if daily_stale else 'NSE_DAILY_HISTORY_BELOW_20' |
| 349 | history = daily_history if use_daily else fallback or normalize_price_history(instrument_id, |
| 350 | observations, as_of=as_of, currency=currency, trusted_providers=trusted_providers) |
| 351 | rows = history.observations |
| 352 | prices = [float(row.close if use_daily else row.price) for row in rows] |
| 353 | # Display metadata only; candle calculations use the exchange DATE directly. |
| 354 | def stamp(row): |
| 355 | return datetime.combine(row.trading_date, time.min, ZoneInfo('Asia/Kolkata')) if use_daily else utc(row.observed_at) |
| 356 | n = len(prices) |
| 357 | readiness = ("FULL_HISTORY" if n >= 200 else "EXTENDED_HISTORY" if n >= 100 else |
| 358 | "MEDIUM_HISTORY" if n >= 50 else "SHORT_HISTORY" if n >= 20 else "INSUFFICIENT_HISTORY") |
| 359 | result = TechnicalFeatureSnapshot(global_instrument_id=instrument_id, as_of=utc(as_of), configuration=asdict(cfg), |
| 360 | observation_count=n, history_readiness=readiness, currency=history.currency, |
| 361 | history_start=stamp(rows[0]) if rows else None, history_end=stamp(rows[-1]) if rows else None, |
| 362 | conflicting_dates=list(history.conflicting_dates), rejected_observation_count=history.rejected_count, |
| 363 | duplicate_observation_count=history.duplicate_count) |
| 364 | result.daily_bar_observation_count = len(daily_history.observations) |
| 365 | if selection_reason: |
| 366 | result.source_diagnostics.append(selection_reason) |
| 367 | if use_daily: |
| 368 | result.technical_input_source = 'DAILY_MARKET_BAR_NSE' |
| 369 | result.price_basis = 'PERSISTED_NSE_DAILY_CLOSE_UNADJUSTED' |
| 370 | result.history_trading_start = rows[0].trading_date if rows else None |
| 371 | result.history_trading_end = rows[-1].trading_date if rows else None |
| 372 | if daily_history.rejected_count: |
| 373 | result.source_diagnostics.append('INELIGIBLE_DAILY_BARS_EXCLUDED') |
| 374 | if use_daily and history.conflicting_dates: |
| 375 | result.source_diagnostics.append('MIXED_NOT_ALLOWED') |
| 376 | # A latest-price conflict invalidates current features, even with a long |
| 377 | # historical tail. History metadata and conflict diagnostics are retained. |
| 378 | usable = bool(rows) and not history.current_conflict |
| 379 | stale = bool(rows) and ((utc(as_of).astimezone(ZoneInfo('Asia/Kolkata')).date() - rows[-1].trading_date |
| 380 | if use_daily else utc(as_of) - utc(rows[-1].observed_at)) > timedelta(days=cfg.max_age_days)) |
| 381 | if stale: |
| 382 | result.stale_inputs.append("PRICE_HISTORY") |
| 383 | if usable: |
| 384 | result.latest_price = prices[-1] |
| 385 | for period in (20, 50, 100, 200): |
| 386 | if n >= period: |
| 387 | dma = sum(prices[-period:]) / period |
| 388 | setattr(result, f"dma{period}", dma) |
| 389 | setattr(result, f"distance_to_dma{period}_pct", percentage(prices[-1], dma)) |
| 390 | result.rsi14 = _rsi(prices) |
| 391 | slow, fast = _ema(prices, 26), _ema(prices, 12) |
| 392 | if slow: |
| 393 | macd_series = [a - b for a, b in zip(fast[14:], slow)] |
| 394 | result.macd = macd_series[-1] |
| 395 | signal = _ema(macd_series, 9) |
| 396 | if signal: |
| 397 | result.macd_signal = signal[-1] |
| 398 | result.macd_histogram = result.macd - result.macd_signal |
| 399 | for field, lookback in zip(("return1_w", "return1_m", "return3_m", "return6_m", "return1_y"), cfg.return_lookbacks): |
| 400 | if n > lookback: |
| 401 | setattr(result, field, percentage(prices[-1], prices[-lookback-1])) |
| 402 | for period in (20, 50): |
| 403 | if n >= period: |
| 404 | setattr(result, f"trend_slope{period}", _slope(prices[-period:])) |
| 405 | if n >= cfg.year_observations: |
| 406 | result.distance_from52_week_high_pct = percentage(prices[-1], max(prices[-cfg.year_observations:])) |
| 407 | result.distance_from52_week_low_pct = percentage(prices[-1], min(prices[-cfg.year_observations:])) |
| 408 | if n > cfg.level_lookback: |
| 409 | window = prices[-cfg.level_lookback-1:-1] |
| 410 | result.support_level, result.resistance_level = min(window), max(window) |
| 411 | result.distance_to_support_pct = percentage(prices[-1], result.support_level) |
| 412 | result.distance_to_resistance_pct = percentage(prices[-1], result.resistance_level) |
| 413 | if n >= cfg.level_lookback * 2: |
| 414 | before, after = prices[-2*cfg.level_lookback:-cfg.level_lookback], prices[-cfg.level_lookback:] |
| 415 | result.higher_highs_higher_lows = max(after) > max(before) and min(after) > min(before) |
| 416 | result.lower_highs_lower_lows = max(after) < max(before) and min(after) < min(before) |
| 417 | if use_daily: |
| 418 | self._candles(result, rows, history.conflicting_dates) |
| 419 | else: |
| 420 | self._volume(result, rows, volume_history, trusted_providers) |
| 421 | if not stale and n >= 20: |
| 422 | self._classify(result, prices) |
| 423 | self._score(result) |
| 424 | self._volume_signals(result) |
| 425 | for name, minimum in [('RSI14', 15), ('MA20', 20), ('MA50', 50), ('MA100', 100), ('MA200', 200), |
| 426 | ('BREAKOUT', cfg.level_lookback + 1)]: |
| 427 | result.feature_readiness[name] = 'AVAILABLE' if usable and n >= minimum else 'INSUFFICIENT_HISTORY' |
| 428 | for name in ('ATR14', 'ADX14'): |
| 429 | result.feature_readiness.setdefault(name, 'MISSING_OHLC') |
| 430 | result.feature_readiness.setdefault('VOLUME20', 'AVAILABLE' if result.volume_ratio20 is not None else 'MISSING_VOLUME') |
| 431 | if history.current_conflict: |
| 432 | result.feature_readiness = {key: 'CONFLICTING' for key in result.feature_readiness} |
| 433 | elif stale: |
| 434 | result.feature_readiness = {key: 'STALE' if value == 'AVAILABLE' else value for key, value in result.feature_readiness.items()} |
| 435 | result.ohlcv_feature_coverage = 100 * sum(value is not None for value in |
| 436 | (result.atr14, result.adx14, result.volume_ratio20)) / 3 |
| 437 | feature_names = ["latest_price", "dma20", "dma50", "dma100", "dma200", "rsi14", "macd", "macd_signal", |
| 438 | "macd_histogram", "adx14", "atr14", "atr_pct", "trend_slope20", "trend_slope50", |
| 439 | "return1_w", "return1_m", "return3_m", "return6_m", "return1_y", |
| 440 | "volume_average20", "volume_ratio20", "support_level", "resistance_level", |
| 441 | "distance_to_support_pct", "distance_to_resistance_pct", "higher_highs_higher_lows", |
| 442 | "lower_highs_lower_lows", "distance_from52_week_high_pct", "distance_from52_week_low_pct", |
| 443 | *(f"distance_to_dma{p}_pct" for p in (20, 50, 100, 200))] |
| 444 | for name in feature_names: |
| 445 | alias = TechnicalFeatureSnapshot.model_fields[name].alias or name |
| 446 | value = getattr(result, name) |
| 447 | result.feature_states[alias] = ("CONFLICTING" if history.current_conflict else |
| 448 | "MISSING" if value is None else "STALE" if stale else "AVAILABLE") |
| 449 | if value is None: |
| 450 | result.missing_inputs.append(alias) |
| 451 | if not use_daily or 'MISSING_OHLC' in result.feature_readiness.values(): |
| 452 | result.missing_inputs.append("PERSISTED_OHLC") |
| 453 | if result.volume_average20 is None: |
| 454 | result.missing_inputs.append("PERSISTED_VOLUME_HISTORY") |
| 455 | core = ["dma20", "dma50", "dma100", "dma200", "rsi14", "macd_signal", "return1_m", "return3_m", |
| 456 | "return6_m", "return1_y", "trend_slope20", "trend_slope50"] |
| 457 | coverage = sum(getattr(result, field) is not None for field in core) / len(core) |
| 458 | conflict_factor = n / (n + len(history.conflicting_dates)) if n else 0 |
| 459 | result.confidence = round(100 * coverage * conflict_factor * (0.5 if stale else 1), 6) if usable else 0 |
| 460 | result.missing_inputs = sorted(set(result.missing_inputs)) |
| 461 | # Rounding is only at the output boundary, after state/score decisions. |
| 462 | for name in TechnicalFeatureSnapshot.model_fields: |
| 463 | value = getattr(result, name) |
| 464 | if isinstance(value, float): |
| 465 | setattr(result, name, round(value, 8)) |
| 466 | return result |
| 467 | |
| 468 | def _candles(self, result, rows, conflicts): |
| 469 | # Restart warmup after a missing/invalid candle or a rejected date; |
| 470 | # never bridge the missing dependency with another source's close. |
| 471 | suffix = [] |
| 472 | cutoff = max(conflicts) if conflicts else None |
| 473 | missing = bool(conflicts) |
| 474 | for row in rows: |
| 475 | values = [finite_number(getattr(row, key)) for key in ('open', 'high', 'low', 'close')] |
| 476 | if (cutoff is not None and row.trading_date <= cutoff) or any(v is None or v <= 0 for v in values) or row.high < row.low: |
| 477 | suffix = [] |
| 478 | missing = True |
| 479 | else: |
| 480 | suffix.append(row) |
| 481 | result.atr14, result.adx14 = _atr_adx(suffix) |
| 482 | if result.atr14 is not None: |
| 483 | result.atr_pct = 100 * result.atr14 / result.latest_price |
| 484 | for name, value in [('ATR14', result.atr14), ('ADX14', result.adx14)]: |
| 485 | result.feature_readiness[name] = 'AVAILABLE' if value is not None else 'MISSING_OHLC' if missing else 'INSUFFICIENT_HISTORY' |
| 486 | result.current_volume = rows[-1].volume |
| 487 | result.feature_readiness['VOLUME20'] = 'INSUFFICIENT_HISTORY' |
| 488 | if len(rows) >= 21: |
| 489 | recent = rows[-21:] |
| 490 | # Do not compress conflicted dates out of a volume baseline. |
| 491 | if any(recent[0].trading_date <= day <= recent[-1].trading_date for day in conflicts): |
| 492 | result.feature_readiness['VOLUME20'] = 'CONFLICTING' |
| 493 | elif any(row.volume is None for row in recent[:-1]): |
| 494 | result.feature_readiness['VOLUME20'] = 'MISSING_VOLUME' |
| 495 | else: |
| 496 | average = sum(Decimal(row.volume) for row in recent[:-1]) / 20 |
| 497 | result.volume_average20 = float(average) |
| 498 | if recent[-1].volume is None: |
| 499 | result.feature_readiness['VOLUME20'] = 'MISSING_VOLUME' |
| 500 | elif average == 0: |
| 501 | result.feature_readiness['VOLUME20'] = 'ZERO_BASELINE' |
| 502 | result.missing_inputs.append('NONZERO_VOLUME_BASELINE') |
| 503 | else: |
| 504 | result.volume_ratio20 = float(Decimal(recent[-1].volume) / average) |
| 505 | result.feature_readiness['VOLUME20'] = 'AVAILABLE' |
| 506 | |
| 507 | def _volume_signals(self, result): |
| 508 | ratio = result.volume_ratio20 |
| 509 | if ratio is not None: |
| 510 | result.volume_expansion = ratio >= self.config.volume_confirmation_ratio |
| 511 | result.volume_contraction = ratio <= self.config.volume_contraction_ratio |
| 512 | result.volume_state = 'EXPANSION' if result.volume_expansion else 'CONTRACTION' if result.volume_contraction else 'NORMAL' |
| 513 | price_signal = result.breakout_state in {'PRICE_BREAKOUT', 'VOLUME_CONFIRMED'} |
| 514 | reversal = result.technical_state == 'REVERSAL_CANDIDATE' |
| 515 | if ratio is not None and price_signal: |
| 516 | result.breakout_volume_confirmed = result.volume_expansion |
| 517 | if ratio is not None and reversal: |
| 518 | result.reversal_volume_confirmed = result.volume_expansion |
| 519 | result.feature_readiness['VOLUME_CONFIRMATION'] = ('NOT_APPLICABLE' if not (price_signal or reversal) |
| 520 | else 'MISSING_VOLUME' if ratio is None else 'AVAILABLE') |
| 521 | |
| 522 | def _volume(self, result, prices, volumes, trusted_providers): |
| 523 | grouped = defaultdict(list) |
| 524 | for row in volumes: |
| 525 | number = finite_number(row.volume) |
| 526 | if (row.instrument_id == result.global_instrument_id and number is not None and number >= 0 |
| 527 | and utc(row.observed_at) <= result.as_of and utc(row.retrieved_at) <= result.as_of |
| 528 | and (trusted_providers is None or row.provider in trusted_providers)): |
| 529 | grouped[utc(row.observed_at).date()].append(row) |
| 530 | daily = {} |
| 531 | for day, rows in sorted(grouped.items()): |
| 532 | latest = max(utc(row.observed_at) for row in rows) |
| 533 | values = {row.volume for row in rows if utc(row.observed_at) == latest} |
| 534 | if len(values) == 1: |
| 535 | daily[day] = float(next(iter(values))) |
| 536 | else: |
| 537 | result.missing_inputs.append(f"CONFLICTING_VOLUME:{day.isoformat()}") |
| 538 | # Prior 20 completed observations; current volume never enters its own baseline. |
| 539 | if prices: |
| 540 | current = daily.get(utc(prices[-1].observed_at).date()) |
| 541 | result.current_volume = Decimal(str(current)) if current is not None else None |
| 542 | if len(prices) >= 21: |
| 543 | days = [utc(row.observed_at).date() for row in prices[-21:-1]] |
| 544 | if all(day in daily for day in days): |
| 545 | result.volume_average20 = sum(daily[day] for day in days) / 20 |
| 546 | latest = daily.get(utc(prices[-1].observed_at).date()) |
| 547 | if latest is not None and result.volume_average20 > 0: |
| 548 | result.volume_ratio20 = latest / result.volume_average20 |
| 549 | elif result.volume_average20 == 0: |
| 550 | result.missing_inputs.append("NONZERO_VOLUME_BASELINE") |
| 551 | |
| 552 | def _classify(self, r, prices): |
| 553 | cfg, price = self.config, prices[-1] |
| 554 | broad_up = (r.dma50 is not None and r.trend_slope50 > cfg.slope_threshold_pct |
| 555 | and (r.dma200 is None or r.dma50 > r.dma200)) |
| 556 | breakout = r.resistance_level is not None and percentage(price, r.resistance_level) > cfg.breakout_buffer_pct |
| 557 | breakdown = r.support_level is not None and percentage(price, r.support_level) < -cfg.breakout_buffer_pct |
| 558 | r.breakout_state = ("VOLUME_CONFIRMED" if breakout and r.volume_ratio20 is not None and r.volume_ratio20 >= cfg.volume_confirmation_ratio |
| 559 | else "PRICE_BREAKOUT" if breakout else "PRICE_BREAKDOWN" if breakdown else "NONE") |
| 560 | if r.distance_to_dma20_pct >= cfg.extension_distance_pct and r.rsi14 >= cfg.extension_rsi: |
| 561 | r.technical_state = "OVEREXTENDED" |
| 562 | elif breakout: |
| 563 | r.technical_state = "BREAKOUT" |
| 564 | elif (broad_up and price > r.dma50 and percentage(price, max(prices[-6:-1])) <= -cfg.pullback_retreat_pct |
| 565 | and min(abs(r.distance_to_dma20_pct), abs(r.distance_to_dma50_pct)) <= cfg.pullback_proximity_pct): |
| 566 | r.technical_state = "PULLBACK_IN_UPTREND" |
| 567 | elif (r.trend_slope50 is not None and r.trend_slope50 < -cfg.slope_threshold_pct |
| 568 | and r.trend_slope20 > cfg.slope_threshold_pct and price > r.dma20): |
| 569 | r.technical_state = "REVERSAL_CANDIDATE" |
| 570 | elif broad_up and price > r.dma50: |
| 571 | r.technical_state = "UPTREND" |
| 572 | elif r.dma50 is not None and price < r.dma50 and r.trend_slope50 < -cfg.slope_threshold_pct: |
| 573 | r.technical_state = "DOWNTREND" |
| 574 | elif abs(r.trend_slope20) <= cfg.slope_threshold_pct and percentage(max(prices[-20:]), min(prices[-20:])) <= cfg.base_range_pct: |
| 575 | r.technical_state = "BASE_BUILDING" |
| 576 | else: |
| 577 | r.technical_state = "RANGE_BOUND" |
| 578 | |
| 579 | def _score(self, r): |
| 580 | cfg = self.config |
| 581 | alignments = [percentage(r.latest_price, d) for d in (r.dma20, r.dma50) if d is not None] |
| 582 | if r.dma200 is not None: |
| 583 | alignments.append(percentage(r.dma50, r.dma200)) |
| 584 | components = {"trendAlignment": sum(100 if v > 0 else 0 if v < 0 else 50 for v in alignments) / len(alignments)} |
| 585 | momentum = [r.rsi14] if r.rsi14 is not None else [] |
| 586 | if r.return1_m is not None: |
| 587 | momentum.append(_clamp(50 + 50 * r.return1_m / cfg.momentum_full_scale_pct)) |
| 588 | if momentum: |
| 589 | components["momentum"] = sum(momentum) / len(momentum) |
| 590 | if r.support_level is not None: |
| 591 | span = r.resistance_level - r.support_level |
| 592 | components["pricePosition"] = _clamp(100 * (r.latest_price - r.support_level) / span) if span else 50.0 |
| 593 | weights = dict(zip(("trendAlignment", "momentum", "pricePosition"), cfg.score_weights)) |
| 594 | total = sum(weights[key] for key in components) |
| 595 | score = sum(value * weights[key] for key, value in components.items()) / total if total else None |
| 596 | if score is not None: |
| 597 | if r.technical_state == "OVEREXTENDED": |
| 598 | score -= cfg.extension_penalty |
| 599 | if r.breakout_state == "VOLUME_CONFIRMED": |
| 600 | score += cfg.volume_breakout_bonus |
| 601 | r.technical_score = round(_clamp(score), 8) |
| 602 | r.score_components = {key: round(value, 8) for key, value in components.items()} |