main
py 602 lines 32.2 KB
Raw
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()}