feat: add OHLCV technical features
prakhar82 committed
Sep 13, 2026 at 22:02 UTC
076215b5ee757061357aa6a71e5a6e85988d8baa
6 files changed
+638
-19
ai/research-engine/TECHNICAL_OHLCV.md
new
+148
@@ -0,0 +1,148 @@
1
+# Persisted OHLCV technical features (TECHNICAL_FEATURES_V2)
2
+
3
+## Integration and source selection
4
+
5
+`TechnicalFeatureEngine.compute(..., daily_bar_history=...)` evolves the existing
6
+pure engine. No provider, database or network call occurs inside computation.
7
+Stage-B `GlobalScanner.enrich_candidates` reads NSE DailyMarketBar rows in bounded
8
+candidate-ID batches, then passes grouped histories to this input. It retains
9
+batched close reads for fallback and the unchanged sector engine. Phase-1 scan,
10
+preScore, eligibility, sector formulas, and Stage-B score weights are unchanged.
11
+
12
+Source policy: only REAL persisted NSE candles for the requested canonical UUID,
13
+matching requested currency/provider allowlist, and known at as_of. Exchange
14
+DATEs are compared to Asia/Kolkata DATE; retrieval timestamps remain UTC. History
15
+trading-date fields expose actual DATEs. Display history timestamps are exchange
16
+midnight metadata only and never become candle-series keys.
17
+
18
+A fresh NSE series with at least 20 usable observations takes precedence. When
19
+NSE is shorter than 20 or stale and a current non-conflicting close-only series
20
+has at least 20 observations, the entire close-only series is selected instead,
21
+with `NSE_DAILY_HISTORY_BELOW_20` or `NSE_DAILY_HISTORY_STALE` diagnostics. This
22
+prevents four old candles from disabling hundreds of usable closes. If neither
23
+source can support overall readiness, available NSE evidence remains visible
24
+without fabricating sufficient history. Missing NSE history uses close fallback.
25
+No extension of NSE history with another provider's older closes, and no mixed
26
+high/low/close/volume candles. A selected short NSE series can compute ATR at 15
27
+candles even though overall technical scoring still requires 20.
28
+
29
+Within NSE, latest known retrieval wins a date correction. Identical ties
30
+collapse; conflicting same-retrieval OHLCV ties fail that date. A conflicting
31
+latest date blocks current features and fallback. Older conflicts are excluded
32
+from closes with diagnostics; OHLC warmup restarts after them. Other providers,
33
+wrong currencies, future retrievals/dates and DEMO rows cannot contaminate NSE.
34
+Provider symbols are not identity. Missing candle close is treated as a conflict,
35
+not substituted with another provider's price.
36
+
37
+Provenance: `DAILY_MARKET_BAR_NSE` or `CLOSE_ONLY_FALLBACK`; conflict diagnostics
38
+include `MIXED_NOT_ALLOWED`. Daily-bar count is reported even when fallback is
39
+selected. Source URLs/cookies are not added to the technical output.
40
+
41
+## Formula and readiness contracts
42
+
43
+ATR14 uses actual prior chronological candle close, ignoring provider-supplied
44
+previousClose. TR is max(high-low, abs(high-prior close), abs(low-prior close)).
45
+The first candle supplies dependencies only, not an invented TR. Seed ATR with
46
+the mean of 14 TR values (15 complete candles); subsequent ATR is
47
+(previous ATR * 13 + current TR) / 14. ATR percent is 100*ATR/latest close.
48
+Zero volatility is zero, not null. Missing OHLC restarts warmup rather than
49
+compressing out a missing candle and bridging it.
50
+
51
+ADX14: upMove=current high-prior high; downMove=prior low-current low. Positive
52
+DM is upMove only when positive and strictly greater than downMove; negative DM
53
+is analogous. Ties give both zero. Wilder-smooth TR/+DM/-DM over 14 changes;
54
+DI = 100*smoothed DM/smoothed TR. DX = 100*abs(+DI - -DI)/(+DI + -DI).
55
+Zero denominators yield zero DX. Seed ADX with 14 DX values, then Wilder-smooth.
56
+Minimum 28 complete chronological candles. Output is bounded 0–100. Flat markets
57
+yield ADX=0 after warmup. No ADX approximation from closes.
58
+
59
+Inputs are Decimal-validated; ATR/ADX use the engine's established float numeric
60
+convention and round only at output. Independent tests use hand calculations
61
+and closed-form Decimal weighted sums rather than duplicating the recursion.
62
+Formula references: [Fidelity ATR](https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/atr)
63
+and [Fidelity DMI](https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/DMI).
64
+
65
+Readiness tiers remain <20, 20–49, 50–99, 100–199 and >=200. Feature readiness
66
+separately reports RSI14 (15 closes), ATR14 (15 candles), ADX14 (28 candles),
67
+MA20/50/100/200, breakout (prior 20 + current), VOLUME20 and VOLUME_CONFIRMATION.
68
+Diagnostics distinguish missing OHLC/volume, insufficient history, conflicts,
69
+zero volume baseline and staleness. The existing seven-day technical price-age
70
+policy remains; acquisition freshness settings are not technical-score settings.
71
+
72
+## Volume and scoring
73
+
74
+Current volume retains its actual BIGINT integer. Average20 uses the prior 20
75
+selected candle observations, excluding current; ratio therefore requires 21
76
+candles. Sum/average/ratio normalization uses Decimal before float output, with
77
+no turnover-based inference. Missing prior volume makes the baseline unavailable;
78
+missing current volume leaves ratio unavailable. Current zero gives ratio zero
79
+against a positive baseline. All-zero baseline leaves average=0 and ratio=null
80
+with ZERO_BASELINE diagnostics. Conflicted dates cannot be compressed out of the
81
+21-observation volume window.
82
+
83
+Expansion: ratio >=1.5 (existing confirmation threshold). Contraction: ratio
84
+<=0.75 (explicit TechnicalConfig threshold). Otherwise normal. Thresholds are
85
+validated to bracket one. Confirmation is true/false only when an existing
86
+price breakout/reversal signal and a valid volume ratio are available; missing
87
+volume stays null. Reversal confirmation is diagnostic only. Without a price
88
+signal, confirmation is NOT_APPLICABLE. Legacy explicit PersistedVolumeObservation
89
+input remains supported for compatibility; Stage-B fallback does not supply it.
90
+
91
+Existing close-based state rules, MA/RSI/MACD/returns/slopes, close-based rolling
92
+support/resistance/extrema and their ordering remain unchanged. Score stays
93
+0–100: trend alignment/momentum/price position weights 50/30/20, renormalized over
94
+available components; overextension penalty 15; existing confirmed-breakout
95
+bonus 5. ATR is volatility context and ADX is non-directional strength: neither
96
+adds directional score. No reversal bonus or weak-volume penalty is introduced.
97
+Core confidence remains unchanged; separate ohlcvFeatureCoverage reports ATR,
98
+ADX and volume-ratio availability without penalizing close-only fallback.
99
+
100
+Audited state precedence (unchanged): OVEREXTENDED when DMA20 distance >=10%
101
+and RSI >=70; otherwise BREAKOUT above the prior-20-close resistance by >1%;
102
+otherwise PULLBACK_IN_UPTREND when broad trend is up, price >DMA50, retreat from
103
+the prior five-close maximum is >=2%, and DMA20 or DMA50 proximity is <=3%;
104
+otherwise REVERSAL_CANDIDATE when slope50 <-0.02%, slope20 >0.02% and price
105
+>DMA20; otherwise UPTREND on broad-up and price >DMA50; otherwise DOWNTREND on
106
+price <DMA50 and slope50 <-0.02%; otherwise BASE_BUILDING on absolute slope20
107
+<=0.02% and the latest 20-close range <=5%; otherwise RANGE_BOUND. Broad-up
108
+requires positive slope50 >0.02% and, when DMA200 exists, DMA50 >DMA200.
109
+Unavailable/stale history keeps INSUFFICIENT_DATA. Price breakdown below the
110
+prior-20 support by >1% remains separate breakout-state evidence.
111
+
112
+A deterministic Stage-B engineering test with identical closes changes technical
113
+and Stage-B scores from 84 to 89 only after actual expansion volume confirms an
114
+existing price breakout; preScore and the fallback candidate remain unchanged.
115
+This synthetic comparison is not an investment conclusion.
116
+
117
+## Persistence reads and runtime evidence
118
+
119
+At default batch size 250, no benchmark references: empty candidates = 0 queries;
120
+1 candidate = 1 NSE daily read + 1 close read; 18 candidates = the same 2 reads.
121
+Larger sets retain bounded batching. Benchmarks participate only in close batches.
122
+Tests count SQLite statements, check deterministic grouping, forbid provider
123
+acquisition and verify Phase-1 objects remain unchanged.
124
+
125
+Persisted-only smoke on 2026-09-13 used the prior local backfill SQLite database
126
+(opened read-only) and a read-only snapshot of existing local PostgreSQL closes.
127
+NILKAMAL and POLYCAB each have four persisted NSE candles, September 1–4: not
128
+enough for ATR14, ADX14 or volume20. Candle-only results correctly remain null.
129
+Their longer current close histories are retained through explicit fallback:
130
+
131
+| Instrument | NSE bars | Selected closes | Technical score before/after | State |
132
+|---|---:|---:|---|---|
133
+| NILKAMAL | 4 | 272 | 85.67599167 / 85.67599167 | PULLBACK_IN_UPTREND |
134
+| POLYCAB | 4 | 341 | 25.07289151 / 25.07289151 | DOWNTREND |
135
+| PERSISTENT | 0 | 341 | 52.19912039 / 52.19912039 | UPTREND |
136
+
137
+All repeated outputs, including reversed input order, were identical. Provider
138
+calls were zero; network connection creation was forbidden during computation.
139
+Numerical ATR/ADX/volume validation uses independent sufficient-history tests,
140
+not invented runtime candles. No live NSE request or new history persistence.
141
+Runtime artifacts are under ignored `.tmp/`.
142
+
143
+The local deployed PostgreSQL schema currently lacks the daily-bar table, so
144
+this is not deployed PostgreSQL application validation. Richer live-data sample
145
+conclusions require persisted backfill; this phase does not acquire it.
146
+
147
+Remaining scope: deployed PostgreSQL validation, sector benchmark mapping/history,
148
+broad-market benchmark history, and recommendation/ranker/prediction phases.
ai/research-engine/app/global_scanner.py
+8
-1
@@ -313,6 +313,13 @@ class GlobalScanner:
313
contexts, providers = sector_contexts or {}, trusted_providers or {}
314
candidates = [c for c in scan.candidates if c.eligible_for_deep_analysis]
315
ids = {c.global_instrument_id for c in candidates}
316
+ daily_histories = defaultdict(list)
317
+ candidate_ids = sorted(ids, key=str)
318
+ for offset in range(0, len(candidate_ids), self.batch_size):
319
+ batch = set(candidate_ids[offset:offset+self.batch_size])
320
+ for row in self.persistence.load_daily_market_bars(batch, provider="NSE"):
321
+ if row.global_instrument_id in batch:
322
+ daily_histories[row.global_instrument_id].append(row)
323
for candidate in candidates:
324
context = contexts.get(candidate.global_instrument_id, SectorContext())
325
for reference in (context.sector_benchmark, context.market_benchmark):
@@ -330,7 +337,7 @@ class GlobalScanner:
337
for candidate in sorted(candidates, key=lambda c: str(c.global_instrument_id)):
338
key = candidate.global_instrument_id
339
technical = technical_engine.compute(key, histories[key], as_of=scan.as_of, currency=candidate.currency,
333
- trusted_providers=providers.get(key))
340
+ trusted_providers=providers.get(key), daily_bar_history=daily_histories[key])
341
sector = sector_engine.compute(key, histories[key], as_of=scan.as_of, currency=candidate.currency,
342
context=contexts.get(key), benchmark_histories=histories, trusted_providers=providers.get(key))
343
scores = [(technical.technical_score, technical_weight), (sector.relative_strength_score, sector_weight)]
ai/research-engine/app/technical_features.py
+205
-14
@@ -1,25 +1,26 @@
1
-"""Pure close-based features over durable public observations.
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
-Dates are UTC observation dates, not a fabricated exchange calendar. Returns
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, timedelta, timezone
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
19
-from app.models import MarketPriceObservation, ResearchBaseModel
20
+from app.models import DailyMarketBar, MarketPriceObservation, ResearchBaseModel
21
22
22
-TECHNICAL_FEATURE_VERSION = "TECHNICAL_FEATURES_V1"
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"]
@@ -46,6 +47,7 @@ class TechnicalConfig:
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
@@ -63,6 +65,8 @@ class TechnicalConfig:
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):
@@ -77,7 +81,7 @@ class PersistedVolumeObservation(ResearchBaseModel):
81
82
@dataclass(frozen=True)
83
class PriceHistory:
80
- observations: tuple[MarketPriceObservation, ...]
84
+ observations: tuple[MarketPriceObservation | DailyMarketBar, ...]
85
conflicting_dates: tuple[date, ...]
86
current_conflict: bool
87
rejected_count: int
@@ -147,6 +151,13 @@ class TechnicalFeatureSnapshot(ResearchBaseModel):
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
@@ -176,7 +187,13 @@ class TechnicalFeatureSnapshot(ResearchBaseModel):
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
@@ -202,6 +219,75 @@ 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:
@@ -243,24 +329,55 @@ class TechnicalFeatureEngine:
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,
246
- volume_history: Iterable[PersistedVolumeObservation] = ()) -> TechnicalFeatureSnapshot:
332
+ volume_history: Iterable[PersistedVolumeObservation] = (),
333
+ daily_bar_history: Iterable[DailyMarketBar] = ()) -> TechnicalFeatureSnapshot:
334
cfg = self.config
248
- history = normalize_price_history(instrument_id, observations, as_of=as_of, currency=currency,
249
- trusted_providers=trusted_providers)
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
251
- prices = [float(row.price) for row in rows]
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,
257
- history_start=utc(rows[0].observed_at) if rows else None, history_end=utc(rows[-1].observed_at) if rows else None,
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
263
- stale = bool(rows) and utc(as_of) - utc(rows[-1].observed_at) > timedelta(days=cfg.max_age_days)
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:
@@ -297,10 +414,26 @@ class TechnicalFeatureEngine:
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)
300
- self._volume(result, rows, volume_history, trusted_providers)
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",
@@ -315,7 +448,8 @@ class TechnicalFeatureEngine:
448
"MISSING" if value is None else "STALE" if stale else "AVAILABLE")
449
if value is None:
450
result.missing_inputs.append(alias)
318
- result.missing_inputs.extend(["PERSISTED_OHLC"])
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",
@@ -331,6 +465,60 @@ class TechnicalFeatureEngine:
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:
@@ -348,6 +536,9 @@ class TechnicalFeatureEngine:
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):
ai/research-engine/tests/test_ohlcv_technical_features.py
new
+269
@@ -0,0 +1,269 @@
1
+from datetime import datetime, timedelta, timezone
2
+from decimal import Decimal
3
+from uuid import UUID
4
+from unittest.mock import Mock
5
+
6
+import pytest
7
+
8
+from app.models import DailyMarketBar
9
+from app.technical_features import TechnicalFeatureEngine
10
+from test_technical_features import history, NOW, STOCK
11
+
12
+
13
+def candles(values, *, volumes=None, **changes):
14
+ return [DailyMarketBar(global_instrument_id=STOCK, trading_date=p.observed_at.date(),
15
+ open=p.price.quantize(Decimal('.00000001')), high=(p.price+1).quantize(Decimal('.00000001')),
16
+ low=(p.price-1).quantize(Decimal('.00000001')), close=p.price.quantize(Decimal('.00000001')), previous_close=Decimal('999'),
17
+ volume=100 if volumes is None else volumes[i], currency='INR', provider='NSE', provider_symbol='TEST',
18
+ source_mode='REAL', source_url='https://www.nseindia.com/history', retrieved_at=p.retrieved_at
19
+ ).model_copy(update=changes) for i,p in enumerate(history(values))]
20
+
21
+
22
+def evaluate(rows, fallback=(), **kwargs):
23
+ return TechnicalFeatureEngine().compute(STOCK, fallback, as_of=NOW, currency='INR', daily_bar_history=rows, **kwargs)
24
+
25
+
26
+def test_atr_hand_calculated_gaps_and_wilder_update():
27
+ values=[10]*13+[14,6]
28
+ r=evaluate(candles(values))
29
+ # Twelve TR=2, gap up TR=5, gap down TR=9. Provider prevClose=999 is ignored.
30
+ assert r.atr14==pytest.approx(float(Decimal(38)/14),abs=1e-8)
31
+ assert r.atr_pct==pytest.approx(float(Decimal(38)/14/6*100),abs=1e-8)
32
+ r=evaluate(candles(values+[10]))
33
+ assert r.atr14==pytest.approx(float((Decimal(38)/14*13+5)/14),abs=1e-8)
34
+
35
+
36
+@pytest.mark.parametrize('count,atr,adx',[(14,None,None),(15,2,None),(27,2,None),(28,2,100),(60,2,100)])
37
+def test_atr_adx_minimum_and_constant_range(count,atr,adx):
38
+ r=evaluate(candles(range(100,100+count)))
39
+ assert r.atr14==atr and r.adx14==adx
40
+ assert r.feature_readiness['ATR14']==('AVAILABLE' if atr is not None else 'INSUFFICIENT_HISTORY')
41
+ assert r.feature_readiness['ADX14']==('AVAILABLE' if adx is not None else 'INSUFFICIENT_HISTORY')
42
+
43
+
44
+@pytest.mark.parametrize('values,expected',[(list(range(100,150)),100),(list(range(150,100,-1)),100),([100]*50,0)])
45
+def test_adx_direction_is_not_strength(values,expected):
46
+ assert evaluate(candles(values)).adx14==expected
47
+
48
+
49
+def test_zero_range_and_directional_movement_ties():
50
+ flat=candles([100]*30,open=Decimal(100),high=Decimal(100),low=Decimal(100))
51
+ r=evaluate(flat)
52
+ assert r.atr14==r.atr_pct==r.adx14==0
53
+ # Outside bars with equally expanding high/low give +DM=-DM=0.
54
+ tied=[b.model_copy(update={'high':Decimal(101+i),'low':Decimal(99-i)}) for i,b in enumerate(candles([100]*30))]
55
+ assert evaluate(tied).adx14==0
56
+
57
+
58
+def test_adx_independent_closed_form_decimal_reference():
59
+ rows=candles([100+(i*7 % 17) for i in range(65)])
60
+ trs=[]; plus=[]; minus=[]
61
+ for previous,current in zip(rows,rows[1:]):
62
+ trs.append(max(current.high-current.low,abs(current.high-previous.close),abs(current.low-previous.close)))
63
+ up=current.high-previous.high; down=previous.low-current.low
64
+ plus.append(max(up,Decimal(0)) if up>down else Decimal(0))
65
+ minus.append(max(down,Decimal(0)) if down>up else Decimal(0))
66
+ def weighted(values,index):
67
+ q=Decimal(13)/14
68
+ return sum(values[:14])/14*q**(index-13)+sum(values[k]/14*q**(index-k) for k in range(14,index+1))
69
+ dx=[]
70
+ for i in range(13,len(trs)):
71
+ tr=weighted(trs,i); positive=100*weighted(plus,i)/tr; negative=100*weighted(minus,i)/tr
72
+ dx.append(100*abs(positive-negative)/(positive+negative) if positive+negative else Decimal(0))
73
+ r=evaluate(rows)
74
+ assert r.adx14==pytest.approx(float(weighted(dx,len(dx)-1)),abs=1e-8)
75
+ assert r.atr14==pytest.approx(float(weighted(trs,len(trs)-1)),abs=1e-8)
76
+ assert 0 <= r.adx14 <= 100
77
+
78
+
79
+def test_missing_ohlc_restarts_warmup_chronology_and_no_blending():
80
+ rows=candles(range(100,140))
81
+ rows[-2]=rows[-2].model_copy(update={'high':None})
82
+ r=evaluate(rows,history(range(1000,1040)))
83
+ assert r.atr14 is r.adx14 is None and r.feature_readiness['ATR14']=='MISSING_OHLC'
84
+ assert r.latest_price==139 and r.dma20 is not None
85
+ assert r==evaluate(list(reversed(rows)),history(range(1000,1040)))
86
+ other=[b.model_copy(update={'provider':'YAHOO_FINANCE','high':Decimal(9999)}) for b in rows]
87
+ assert evaluate(rows+other).atr14 is None
88
+
89
+
90
+@pytest.mark.parametrize('count,volumes,average,ratio,state',[
91
+ (20,[100]*20,None,None,'UNAVAILABLE'),(21,[100]*21,100,1,'NORMAL'),
92
+ (21,[100]*20+[150],100,1.5,'EXPANSION'),(21,[100]*20+[75],100,.75,'CONTRACTION'),
93
+ (21,[100]*20+[0],100,0,'CONTRACTION'),(21,[0]*21,0,None,'UNAVAILABLE'),
94
+ (21,[100]*20+[None],100,None,'UNAVAILABLE'),(21,[None]+[100]*20,None,None,'UNAVAILABLE')])
95
+def test_volume_boundaries(count,volumes,average,ratio,state):
96
+ r=evaluate(candles([100]*count,volumes=volumes))
97
+ assert r.current_volume==volumes[-1] and r.volume_average20==average and r.volume_ratio20==ratio and r.volume_state==state
98
+
99
+
100
+def test_bigint_current_exact_ratio_decimal():
101
+ maximum=9223372036854775807
102
+ r=evaluate(candles([100]*21,volumes=[maximum]*21))
103
+ assert r.current_volume==maximum and type(r.current_volume) is int and r.volume_ratio20==1
104
+ assert r.model_dump()['current_volume']==maximum
105
+
106
+
107
+@pytest.mark.parametrize('volume,confirmed,breakout',[(200,True,'VOLUME_CONFIRMED'),(100,False,'PRICE_BREAKOUT'),(None,None,'PRICE_BREAKOUT')])
108
+def test_breakout_confirmation_and_existing_bonus(volume,confirmed,breakout):
109
+ values=[100]*60+[104]
110
+ r=evaluate(candles(values,volumes=[100]*60+[volume]))
111
+ base=TechnicalFeatureEngine().compute(STOCK,history(values),as_of=NOW,currency='INR')
112
+ assert r.technical_state=='BREAKOUT' and r.breakout_state==breakout
113
+ assert r.breakout_volume_confirmed is confirmed
114
+ assert r.technical_score==min(100,base.technical_score+(5 if confirmed else 0))
115
+
116
+
117
+def test_reversal_confirmation_is_diagnostic_only():
118
+ values=[200-.6*i for i in range(240)]+[56.6+.3*i for i in range(20)]
119
+ r=evaluate(candles(values,volumes=[100]*259+[200]))
120
+ base=TechnicalFeatureEngine().compute(STOCK,history(values),as_of=NOW,currency='INR')
121
+ assert r.technical_state=='REVERSAL_CANDIDATE' and r.reversal_volume_confirmed is True
122
+ assert r.technical_score==base.technical_score
123
+
124
+
125
+def test_fallback_and_daily_source_priority():
126
+ fallback=history(range(100,160))
127
+ r=evaluate([],fallback)
128
+ assert r==TechnicalFeatureEngine().compute(STOCK,fallback,as_of=NOW,currency='INR')
129
+ assert r.technical_input_source=='CLOSE_ONLY_FALLBACK' and r.atr14 is r.adx14 is None
130
+ richer=evaluate(candles(range(200,260)),fallback)
131
+ assert richer.latest_price==259 and richer.technical_input_source=='DAILY_MARKET_BAR_NSE'
132
+ assert richer.history_trading_end==candles(range(200,260))[-1].trading_date
133
+ other=evaluate(candles(range(200,260),provider='OTHER'),fallback)
134
+ assert other.latest_price==159 and other.technical_input_source=='CLOSE_ONLY_FALLBACK'
135
+
136
+
137
+def test_deterministic_duplicates_corrections_conflicts_and_asof():
138
+ rows=candles(range(100,140)); last=rows[-1]
139
+ r=evaluate(rows+[last]); assert r.observation_count==40 and r.duplicate_observation_count==1
140
+ newer=last.model_copy(update={'close':Decimal(140),'retrieved_at':NOW})
141
+ assert evaluate(rows+[newer]).latest_price==140
142
+ conflicting=newer.model_copy(update={'close':Decimal(141)})
143
+ r=evaluate(rows+[newer,conflicting],history(range(200,240)))
144
+ assert r.latest_price is r.atr14 is r.adx14 is None and r.technical_score is None
145
+ assert 'MIXED_NOT_ALLOWED' in r.source_diagnostics
146
+ assert r==evaluate(list(reversed(rows+[newer,conflicting])),history(range(200,240)))
147
+ future=last.model_copy(update={'close':Decimal(999),'retrieved_at':NOW+timedelta(seconds=1)})
148
+ assert evaluate(rows+[future]).latest_price==139
149
+
150
+
151
+@pytest.mark.asyncio
152
+@pytest.mark.parametrize('count,queries',[(0,0),(1,2),(18,2)])
153
+async def test_stage_b_batch_queries_and_no_network(count,queries,monkeypatch):
154
+ from app.global_scanner import GlobalScanner
155
+ from app.persistence import SqliteResearchPersistence
156
+ from test_global_scanner import instrument,persisted,scan
157
+ from app.nse_historical_daily import NseHistoricalDailyProvider
158
+ monkeypatch.setattr(NseHistoricalDailyProvider,'fetch',Mock(side_effect=AssertionError('provider called')))
159
+ store=SqliteResearchPersistence(); items=[instrument(n) for n in range(1,count+1)]
160
+ for item in items:
161
+ persisted(store,item)
162
+ key=UUID(item['globalInstrumentId'])
163
+ for b in candles(range(100,140)):
164
+ store.upsert_daily_market_bar(b.model_copy(update={'global_instrument_id':key}))
165
+ initial=await scan(items,store)
166
+ before=initial.model_dump()
167
+ statements=[]; store._connection.set_trace_callback(statements.append)
168
+ result=GlobalScanner(None,store).enrich_candidates(initial)
169
+ assert len(statements)==queries and len(result)==count
170
+ assert initial.model_dump()==before
171
+ if count:
172
+ assert sum('global_daily_market_bars' in s for s in statements)==1
173
+ assert all(c.technical_feature_snapshot.technical_input_source=='DAILY_MARKET_BAR_NSE' for c in result)
174
+ assert all(c.technical_score==c.stage_b_score for c in result)
175
+ assert result==GlobalScanner(None,store).enrich_candidates(initial)
176
+
177
+@pytest.mark.parametrize('change',[{'open':None},{'high':None},{'low':None}])
178
+def test_missing_latest_ohlc(change):
179
+ rows=candles(range(100,140)); rows[-1]=rows[-1].model_copy(update=change)
180
+ r=evaluate(rows)
181
+ assert r.atr14 is r.adx14 is None and r.feature_readiness['ATR14']=='MISSING_OHLC'
182
+ assert r.technical_score is not None
183
+
184
+
185
+def test_conflict_cannot_be_compressed_out_of_ohlc_or_volume():
186
+ rows=candles(range(100,140))
187
+ duplicate=rows[-2].model_copy(update={'close':Decimal(999)})
188
+ r=evaluate(rows+[duplicate])
189
+ assert r.atr14 is r.adx14 is r.volume_ratio20 is None
190
+ assert r.feature_readiness['VOLUME20']=='CONFLICTING'
191
+
192
+
193
+def test_recovery_after_missing_candle_and_no_source_identity_guessing():
194
+ rows=candles(range(100,160))
195
+ rows[10]=rows[10].model_copy(update={'low':None})
196
+ r=evaluate(rows)
197
+ assert r.atr14==2 and r.adx14==100
198
+ unknown=[b.model_copy(update={'global_instrument_id':UUID(int=2)}) for b in rows]
199
+ assert evaluate(unknown).latest_price is None
200
+ assert evaluate(rows,trusted_providers=frozenset()).latest_price is None
201
+ assert evaluate([b.model_copy(update={'source_mode':'DEMO'}) for b in rows]).latest_price is None
202
+
203
+
204
+def test_exchange_date_is_not_utc_observation_date():
205
+ # At 20:00 UTC it is already the next exchange DATE. Retrieval is known.
206
+ asof=datetime(2026,9,13,20,tzinfo=timezone.utc)
207
+ row=candles([100])[0].model_copy(update={'trading_date':datetime(2026,9,14).date(),'retrieved_at':asof})
208
+ r=TechnicalFeatureEngine().compute(STOCK,[],as_of=asof,currency='INR',daily_bar_history=[row])
209
+ assert r.history_trading_start==r.history_trading_end==row.trading_date
210
+ assert r.observation_count==1
211
+
212
+
213
+def test_no_network_in_ohlcv_computation(monkeypatch):
214
+ import socket
215
+ monkeypatch.setattr(socket,'create_connection',Mock(side_effect=AssertionError('network')))
216
+ rows=candles(range(100,160))
217
+ engine=TechnicalFeatureEngine()
218
+ before=[b.model_dump() for b in rows]
219
+ first=engine.compute(STOCK,[],as_of=NOW,daily_bar_history=rows)
220
+ assert first==engine.compute(STOCK,[],as_of=NOW,daily_bar_history=reversed(rows))
221
+ assert before==[b.model_dump() for b in rows]
222
+
223
+
224
+def test_stale_candles_expose_diagnostics_but_do_not_score():
225
+ rows=candles(range(100,140))
226
+ rows=[b.model_copy(update={'trading_date':b.trading_date-timedelta(days=20)}) for b in rows]
227
+ r=evaluate(rows)
228
+ assert r.atr14==2 and r.adx14==100 and r.feature_readiness['ATR14']=='STALE'
229
+ assert r.technical_score is None and r.technical_state=='INSUFFICIENT_DATA'
230
+
231
+
232
+@pytest.mark.asyncio
233
+async def test_stage_b_score_change_is_volume_evidence_only_and_fallback_works():
234
+ from app.global_scanner import GlobalScanner
235
+ from app.persistence import SqliteResearchPersistence
236
+ from test_global_scanner import instrument,persisted,scan
237
+ store=SqliteResearchPersistence(); items=[instrument(1),instrument(2)]
238
+ values=[100]*60+[104]
239
+ for item in items:
240
+ persisted(store,item,price=False)
241
+ for p in history(values,UUID(item['globalInstrumentId'])):
242
+ store.upsert_market_price_observation(p.model_copy(update={'provider':'YAHOO_FINANCE'}))
243
+ initial=await scan(items,store)
244
+ scanner=GlobalScanner(None,store)
245
+ before={r.global_instrument_id:r for r in scanner.enrich_candidates(initial)}
246
+ store.upsert_daily_market_bars(candles(values,volumes=[100]*60+[200]))
247
+ after={r.global_instrument_id:r for r in scanner.enrich_candidates(initial)}
248
+ assert after[UUID(int=2)]==before[UUID(int=2)]
249
+ assert before[STOCK].pre_score==after[STOCK].pre_score
250
+ assert after[STOCK].technical_score==min(100,before[STOCK].technical_score+5)
251
+ assert after[STOCK].stage_b_score==after[STOCK].technical_score
252
+
253
+@pytest.mark.parametrize('daily_count,age,reason',[(4,0,'NSE_DAILY_HISTORY_BELOW_20'),(40,20,'NSE_DAILY_HISTORY_STALE')])
254
+def test_immature_or_stale_daily_source_does_not_disable_current_fallback(daily_count,age,reason):
255
+ rows=candles(range(200,200+daily_count))
256
+ rows=[b.model_copy(update={'trading_date':b.trading_date-timedelta(days=age)}) for b in rows]
257
+ fallback=history(range(100,160))
258
+ before=evaluate([],fallback)
259
+ r=evaluate(rows,fallback)
260
+ assert r.technical_input_source=='CLOSE_ONLY_FALLBACK' and reason in r.source_diagnostics
261
+ assert r.technical_score==before.technical_score and r.latest_price==before.latest_price
262
+ assert r.atr14 is r.adx14 is r.current_volume is None
263
+ assert r.daily_bar_observation_count==daily_count
264
+
265
+
266
+def test_short_daily_still_used_when_all_history_insufficient():
267
+ r=evaluate(candles([100]*4),history([200]*3))
268
+ assert r.technical_input_source=='DAILY_MARKET_BAR_NSE' and r.observation_count==4
269
+ assert r.technical_state=='INSUFFICIENT_DATA'
ai/research-engine/tests/test_sector_relative_strength.py
+7
-3
@@ -161,9 +161,13 @@ async def test_stage_b_batches_only_deep_eligible_and_preserves_phase1(monkeypat
161
enriched = scanner.enrich_candidates(initial, sector_contexts={STOCK: context()})
162
assert {c.global_instrument_id for c in enriched} == {STOCK, UUID(int=4)}
163
assert initial.model_dump() == before
164
- assert len(queries) == 1 and "instrument_id IN" in queries[0]
165
- assert str(UUID(int=5)) not in queries[0]
166
- assert str(SECTOR) in queries[0] and str(MARKET) in queries[0]
164
+ assert len(queries) == 2
165
+ daily_query = next(q for q in queries if "global_daily_market_bars" in q)
166
+ close_query = next(q for q in queries if "global_market_price_observations" in q)
167
+ assert "instrument_id IN" in close_query
168
+ assert all(str(UUID(int=5)) not in q for q in queries)
169
+ assert str(SECTOR) in close_query and str(MARKET) in close_query
170
+ assert str(SECTOR) not in daily_query and str(MARKET) not in daily_query
171
assert all(c.technical_feature_snapshot.global_instrument_id == c.global_instrument_id for c in enriched)
172
# Private inputs are not part of the enrichment contract.
173
assert enriched == scanner.enrich_candidates(initial, sector_contexts={STOCK: context()})
ai/research-engine/tests/test_technical_features.py
+1
-1
@@ -180,6 +180,6 @@ def test_deterministic_pure_execution_and_configuration(monkeypatch):
180
first = engine.compute(STOCK, rows, as_of=NOW)
181
assert first == engine.compute(STOCK, reversed(rows), as_of=NOW)
182
assert original == [r.model_dump() for r in rows]
183
- assert first.feature_version == "TECHNICAL_FEATURES_V1"
183
+ assert first.feature_version == "TECHNICAL_FEATURES_V2"
184
with pytest.raises(ValueError): TechnicalConfig(momentum_full_scale_pct=0)
185
with pytest.raises(ValueError): TechnicalConfig(score_weights=(float("nan"), 1, 1))