feat: add technical and sector strength analysis

prakhar82 committed Sep 13, 2026 at 18:18 UTC e2223773c6e88f570e27960b5715421ba24f0c7c
5 files changed +1072
ai/research-engine/app/global_scanner.py
+74
@@ -17,6 +17,8 @@ import httpx
17 from app.models import ResearchBaseModel
18 from app.fact_precedence import SUPPORTED_FINANCIAL_SOURCE_TIERS
19 from app.persistence import ResearchPersistence
20 +from app.technical_features import TechnicalFeatureEngine, TechnicalFeatureSnapshot
21 +from app.sector_relative_strength import SectorContext, SectorRelativeStrengthEngine, SectorRelativeStrengthSnapshot
22
23
24 class EvidenceState(StrEnum):
@@ -72,6 +74,21 @@ class GlobalScanResult(ResearchBaseModel):
74 deep_analysis_candidate_ids: list[UUID]
75
76
77 +class StageBCandidate(ResearchBaseModel):
78 + global_instrument_id: UUID
79 + symbol: str | None
80 + pre_score: float | None
81 + feature_version: str = "GLOBAL_STAGE_B_V1"
82 + technical_feature_snapshot: TechnicalFeatureSnapshot
83 + sector_relative_strength_snapshot: SectorRelativeStrengthSnapshot
84 + technical_score: float | None
85 + sector_score: float | None
86 + stage_b_score: float | None
87 + confidence: float
88 + score_coverage: float
89 + score_weights: dict[str, float]
90 +
91 +
92 class EquityUniverse(Protocol):
93 async def active_global_equities(self, **kwargs) -> list[dict]: ...
94
@@ -273,6 +290,63 @@ class GlobalScanner:
290 self.universe, self.persistence = universe, persistence
291 self.pre_score, self.batch_size = pre_score or GlobalPreScore(), batch_size
292
293 + def enrich_candidates(self, scan: GlobalScanResult, *, sector_contexts: dict[UUID, SectorContext] | None = None,
294 + trusted_providers: dict[UUID, frozenset[str]] | None = None,
295 + technical_engine: TechnicalFeatureEngine | None = None,
296 + sector_engine: SectorRelativeStrengthEngine | None = None,
297 + technical_weight: float = 0.7, sector_weight: float = 0.3) -> list[StageBCandidate]:
298 + """Optional Stage B; no universe enumeration, providers, refresh or V1.
299 +
300 + Enrich all deep-eligible candidates (not the entire canonical universe).
301 + Prices for candidates and explicitly mapped benchmarks share bounded SQL
302 + batches. Default weights emphasize technical evidence; they are selection
303 + defaults, not calibrated investment weights. Missing legs renormalize the
304 + score while reducing reported coverage/confidence. Phase 1 is untouched.
305 + """
306 + from math import isfinite
307 +
308 + if (not all(isfinite(v) and v >= 0 for v in (technical_weight, sector_weight))
309 + or technical_weight + sector_weight == 0):
310 + raise ValueError("Stage-B weights must be finite, nonnegative and have positive sum")
311 + technical_engine = technical_engine or TechnicalFeatureEngine()
312 + sector_engine = sector_engine or SectorRelativeStrengthEngine()
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 + for candidate in candidates:
317 + context = contexts.get(candidate.global_instrument_id, SectorContext())
318 + for reference in (context.sector_benchmark, context.market_benchmark):
319 + if reference:
320 + ids.add(reference.instrument_id)
321 + histories = defaultdict(list)
322 + ordered = sorted(ids, key=str)
323 + for offset in range(0, len(ordered), self.batch_size):
324 + batch = set(ordered[offset:offset+self.batch_size])
325 + for row in self.persistence.load_market_price_observations(batch):
326 + if row.instrument_id in batch:
327 + histories[row.instrument_id].append(row)
328 + output = []
329 + total_weight = technical_weight + sector_weight
330 + for candidate in sorted(candidates, key=lambda c: str(c.global_instrument_id)):
331 + key = candidate.global_instrument_id
332 + technical = technical_engine.compute(key, histories[key], as_of=scan.as_of, currency=candidate.currency,
333 + trusted_providers=providers.get(key))
334 + sector = sector_engine.compute(key, histories[key], as_of=scan.as_of, currency=candidate.currency,
335 + context=contexts.get(key), benchmark_histories=histories, trusted_providers=providers.get(key))
336 + scores = [(technical.technical_score, technical_weight), (sector.relative_strength_score, sector_weight)]
337 + available = [(score, weight) for score, weight in scores if score is not None and weight > 0]
338 + available_weight = sum(weight for _, weight in available)
339 + score = sum(value * weight for value, weight in available) / available_weight if available_weight else None
340 + output.append(StageBCandidate(global_instrument_id=key, symbol=candidate.symbol, pre_score=candidate.pre_score,
341 + technical_feature_snapshot=technical, sector_relative_strength_snapshot=sector,
342 + technical_score=technical.technical_score, sector_score=sector.relative_strength_score,
343 + stage_b_score=round(score, 8) if score is not None else None,
344 + confidence=round((technical.confidence * technical_weight + sector.confidence * sector_weight) / total_weight, 6),
345 + score_coverage=round(available_weight / total_weight * 100, 6),
346 + score_weights={"technical": technical_weight, "sector": sector_weight}))
347 + output.sort(key=lambda c: (-(c.stage_b_score if c.stage_b_score is not None else -1), -c.confidence, str(c.global_instrument_id)))
348 + return output
349 +
350 async def scan(self, *, as_of: datetime, top_n: int, **universe_context) -> GlobalScanResult:
351 if top_n < 0:
352 raise ValueError("top_n must be nonnegative")
ai/research-engine/app/sector_relative_strength.py new
+212
@@ -0,0 +1,212 @@
1 +"""Pure, date-aligned stock/sector/market relative returns.
2 +
3 +Option B: missing durable benchmark mappings/history remain unavailable. A
4 +sector performer leaderboard is not a sector index and is never substituted.
5 +Comparisons are local-currency price returns (no invented FX conversion).
6 +"""
7 +from __future__ import annotations
8 +
9 +from dataclasses import asdict, dataclass
10 +from datetime import date, datetime, timedelta
11 +from math import isfinite
12 +from typing import Iterable, Literal, Mapping
13 +from uuid import UUID
14 +
15 +from pydantic import Field
16 +
17 +from app.models import MarketPriceObservation, ResearchBaseModel
18 +from app.sector_leaderboard import normalize_sector
19 +from app.technical_features import normalize_price_history, percentage, utc
20 +
21 +
22 +SECTOR_FEATURE_VERSION = "SECTOR_RELATIVE_STRENGTH_V1"
23 +
24 +
25 +@dataclass(frozen=True)
26 +class BenchmarkReference:
27 + instrument_id: UUID
28 + currency: str
29 + trusted_providers: frozenset[str] | None = None
30 +
31 +
32 +@dataclass(frozen=True)
33 +class SectorContext:
34 + """Authoritative classification and explicit canonical mappings, supplied by caller.
35 +
36 + India uses canonicalSector from the persisted NSE universe. Other markets
37 + may use their persisted canonical classification. No company-name inference.
38 + source/as_of retain classification provenance; future metadata is rejected.
39 + """
40 + sector: str | None = None
41 + source: str | None = None
42 + as_of: datetime | None = None
43 + region: str | None = None
44 + sector_benchmark: BenchmarkReference | None = None
45 + market_benchmark: BenchmarkReference | None = None
46 +
47 +
48 +@dataclass(frozen=True)
49 +class SectorRelativeStrengthConfig:
50 + return_lookbacks: tuple[int, ...] = (5, 21, 63, 126)
51 + period_weights: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0)
52 + max_age_days: int = 7
53 + # Normalize horizon lengths before classifying direction: 0.02 percentage
54 + # points/observed session is a configurable noise band, not a forecast.
55 + material_edge_per_observation_pct: float = 0.02
56 + improvement_per_observation_pct: float = 0.02
57 + # An average 0.2pp/session edge reaches score100, -0.2 reaches zero.
58 + full_scale_edge_per_observation_pct: float = 0.2
59 +
60 + def __post_init__(self):
61 + if (len(self.return_lookbacks) != 4 or any(n < 1 for n in self.return_lookbacks)
62 + or tuple(sorted(set(self.return_lookbacks))) != self.return_lookbacks
63 + or len(self.period_weights) != 4 or sum(self.period_weights) <= 0 or self.max_age_days < 1
64 + or self.full_scale_edge_per_observation_pct <= 0):
65 + raise ValueError("Invalid sector configuration")
66 + for value in asdict(self).values():
67 + if any(not isfinite(v) or v < 0 for v in (value if isinstance(value, tuple) else (value,))):
68 + raise ValueError("Sector thresholds/weights must be finite and nonnegative")
69 +
70 +
71 +class SectorRelativeStrengthSnapshot(ResearchBaseModel):
72 + global_instrument_id: UUID
73 + as_of: datetime
74 + feature_version: str = SECTOR_FEATURE_VERSION
75 + configuration: dict
76 + sector: str | None = None
77 + classification_source: str | None = None
78 + classification_as_of: datetime | None = None
79 + region: str | None = None
80 + sector_benchmark_id: UUID | None = None
81 + market_benchmark_id: UUID | None = None
82 + return_basis: str = "DATE_ALIGNED_LOCAL_CURRENCY_PRICE_RETURN_PCT"
83 + stock_return1_w: float | None = Field(default=None, alias="stockReturn1W")
84 + stock_return1_m: float | None = Field(default=None, alias="stockReturn1M")
85 + stock_return3_m: float | None = Field(default=None, alias="stockReturn3M")
86 + stock_return6_m: float | None = Field(default=None, alias="stockReturn6M")
87 + sector_return1_w: float | None = Field(default=None, alias="sectorReturn1W")
88 + sector_return1_m: float | None = Field(default=None, alias="sectorReturn1M")
89 + sector_return3_m: float | None = Field(default=None, alias="sectorReturn3M")
90 + sector_return6_m: float | None = Field(default=None, alias="sectorReturn6M")
91 + market_return1_w: float | None = Field(default=None, alias="marketReturn1W")
92 + market_return1_m: float | None = Field(default=None, alias="marketReturn1M")
93 + market_return3_m: float | None = Field(default=None, alias="marketReturn3M")
94 + market_return6_m: float | None = Field(default=None, alias="marketReturn6M")
95 + relative_vs_sector1_w: float | None = Field(default=None, alias="relativeVsSector1W")
96 + relative_vs_sector1_m: float | None = Field(default=None, alias="relativeVsSector1M")
97 + relative_vs_sector3_m: float | None = Field(default=None, alias="relativeVsSector3M")
98 + relative_vs_sector6_m: float | None = Field(default=None, alias="relativeVsSector6M")
99 + relative_vs_market1_w: float | None = Field(default=None, alias="relativeVsMarket1W")
100 + relative_vs_market1_m: float | None = Field(default=None, alias="relativeVsMarket1M")
101 + relative_vs_market3_m: float | None = Field(default=None, alias="relativeVsMarket3M")
102 + relative_vs_market6_m: float | None = Field(default=None, alias="relativeVsMarket6M")
103 + comparison_windows: dict[str, tuple[date, date]] = Field(default_factory=dict)
104 + feature_states: dict[str, str] = Field(default_factory=dict)
105 + sector_state: Literal["LEADING", "IMPROVING", "NEUTRAL", "WEAKENING", "LAGGING", "INSUFFICIENT_DATA"] = "INSUFFICIENT_DATA"
106 + relative_strength_score: float | None = None
107 + confidence: float = 0
108 + missing_inputs: list[str] = Field(default_factory=list)
109 + stale_inputs: list[str] = Field(default_factory=list)
110 +
111 +
112 +class SectorRelativeStrengthEngine:
113 + def __init__(self, config: SectorRelativeStrengthConfig | None = None):
114 + self.config = config or SectorRelativeStrengthConfig()
115 +
116 + def compute(self, instrument_id: UUID, stock_history: Iterable[MarketPriceObservation], *, as_of: datetime,
117 + context: SectorContext | None = None, currency: str | None = None,
118 + benchmark_histories: Mapping[UUID, Iterable[MarketPriceObservation]] | None = None,
119 + trusted_providers: frozenset[str] | None = None) -> SectorRelativeStrengthSnapshot:
120 + cfg, context = self.config, context or SectorContext()
121 + histories = benchmark_histories or {}
122 + classification_valid = bool(context.sector and context.source and context.as_of is not None and utc(context.as_of) <= utc(as_of))
123 + result = SectorRelativeStrengthSnapshot(global_instrument_id=instrument_id, as_of=utc(as_of), configuration=asdict(cfg),
124 + sector=normalize_sector(context.sector)[1] if classification_valid else None,
125 + classification_source=context.source if classification_valid else None,
126 + classification_as_of=utc(context.as_of) if classification_valid else None, region=context.region,
127 + sector_benchmark_id=context.sector_benchmark.instrument_id if context.sector_benchmark and classification_valid else None,
128 + market_benchmark_id=context.market_benchmark.instrument_id if context.market_benchmark else None)
129 + if not classification_valid:
130 + result.missing_inputs.append("AUTHORITATIVE_SECTOR_CLASSIFICATION")
131 + stock = normalize_price_history(instrument_id, stock_history, as_of=as_of, currency=currency,
132 + trusted_providers=trusted_providers)
133 + stock_stale = bool(stock.observations) and utc(as_of) - utc(stock.observations[-1].observed_at) > timedelta(days=cfg.max_age_days)
134 + if stock_stale:
135 + result.stale_inputs.append("STOCK_HISTORY")
136 + if stock.current_conflict:
137 + result.missing_inputs.append("CONFLICTING_STOCK_PRICE")
138 + benchmark_data = {}
139 + for name, reference in (("sector", context.sector_benchmark if classification_valid else None), ("market", context.market_benchmark)):
140 + if reference is None:
141 + result.missing_inputs.append(f"{name.upper()}_BENCHMARK_MAPPING")
142 + continue
143 + if reference.instrument_id == instrument_id or not reference.currency:
144 + result.missing_inputs.append(f"INVALID_{name.upper()}_BENCHMARK_MAPPING")
145 + continue
146 + history = normalize_price_history(reference.instrument_id, histories.get(reference.instrument_id, ()),
147 + as_of=as_of, currency=reference.currency, trusted_providers=reference.trusted_providers)
148 + if not history.observations or history.current_conflict:
149 + result.missing_inputs.append(f"{name.upper()}_HISTORY" if not history.current_conflict else f"CONFLICTING_{name.upper()}_PRICE")
150 + continue
151 + if utc(as_of) - utc(history.observations[-1].observed_at) > timedelta(days=cfg.max_age_days):
152 + result.stale_inputs.append(f"{name.upper()}_HISTORY")
153 + continue
154 + benchmark_data[name] = {utc(row.observed_at).date(): float(row.price) for row in history.observations}
155 + edges = {}
156 + for suffix, lookback, weight in zip(("1_w", "1_m", "3_m", "6_m"), cfg.return_lookbacks, cfg.period_weights):
157 + if not stock.current_conflict and len(stock.observations) > lookback:
158 + start, end = stock.observations[-lookback-1], stock.observations[-1]
159 + dates = utc(start.observed_at).date(), utc(end.observed_at).date()
160 + stock_return = percentage(float(end.price), float(start.price))
161 + setattr(result, f"stock_return{suffix}", stock_return)
162 + result.comparison_windows[suffix.replace("_", "").upper()] = dates
163 + period_edges = []
164 + for name in ("sector", "market"):
165 + rows = benchmark_data.get(name, {})
166 + if not stock_stale and dates[0] in rows and dates[1] in rows:
167 + value = percentage(rows[dates[1]], rows[dates[0]])
168 + setattr(result, f"{name}_return{suffix}", value)
169 + setattr(result, f"relative_vs_{name}{suffix}", stock_return - value)
170 + period_edges.append((stock_return - value) / lookback)
171 + elif rows and not stock_stale:
172 + result.missing_inputs.append(f"{name.upper()}_ALIGNED_DATES_{suffix.replace('_', '').upper()}")
173 + if period_edges:
174 + edges[suffix] = (sum(period_edges) / len(period_edges), weight)
175 + # Require at least two horizons to claim consistency. Missing benchmark
176 + # legs are omitted, never assigned a zero relative return.
177 + if len(edges) >= 2 and sum(weight for _, weight in edges.values()) > 0:
178 + average = sum(edge * weight for edge, weight in edges.values()) / sum(weight for _, weight in edges.values())
179 + result.relative_strength_score = min(100, max(0, 50 + 50 * average / cfg.full_scale_edge_per_observation_pct))
180 + # sectorState describes the stock's relative leadership with sector
181 + # context. Market-only evidence may score partially, but is not a sector state.
182 + sector_periods = sum(getattr(result, f"relative_vs_sector{s}") is not None for s in ("1_w", "1_m", "3_m", "6_m"))
183 + if sector_periods >= 2:
184 + values = [v for v, _ in edges.values()]
185 + if all(v > cfg.material_edge_per_observation_pct for v in values):
186 + result.sector_state = "LEADING"
187 + elif all(v < -cfg.material_edge_per_observation_pct for v in values):
188 + result.sector_state = "LAGGING"
189 + else:
190 + short = [edges[s][0] for s in ("1_w", "1_m") if s in edges]
191 + long = [edges[s][0] for s in ("3_m", "6_m") if s in edges]
192 + change = sum(short)/len(short) - sum(long)/len(long) if short and long else 0
193 + result.sector_state = ("IMPROVING" if change > cfg.improvement_per_observation_pct else
194 + "WEAKENING" if change < -cfg.improvement_per_observation_pct else "NEUTRAL")
195 + relative_fields = []
196 + for name, field in SectorRelativeStrengthSnapshot.model_fields.items():
197 + if name.startswith(("stock_return", "sector_return", "market_return", "relative_vs_")):
198 + value = getattr(result, name)
199 + alias = field.alias or name
200 + result.feature_states[alias] = "MISSING" if value is None else "STALE" if stock_stale else "AVAILABLE"
201 + if value is None:
202 + result.missing_inputs.append(alias)
203 + else:
204 + setattr(result, name, round(value, 8))
205 + if name.startswith("relative_vs_"):
206 + relative_fields.append(value)
207 + result.confidence = round(100 * sum(v is not None for v in relative_fields) / 8, 6)
208 + if result.relative_strength_score is not None:
209 + result.relative_strength_score = round(result.relative_strength_score, 8)
210 + result.missing_inputs = sorted(set(result.missing_inputs))
211 + result.stale_inputs = sorted(set(result.stale_inputs))
212 + return result
ai/research-engine/app/technical_features.py new
+411
@@ -0,0 +1,411 @@
1 +"""Pure close-based features over durable public observations.
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
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
12 +from decimal import Decimal, InvalidOperation
13 +from math import isfinite
14 +from typing import Iterable, Literal
15 +from uuid import UUID
16 +
17 +from pydantic import Field
18 +
19 +from app.models import MarketPriceObservation, ResearchBaseModel
20 +
21 +
22 +TECHNICAL_FEATURE_VERSION = "TECHNICAL_FEATURES_V1"
23 +TechnicalState = Literal["UPTREND", "DOWNTREND", "BASE_BUILDING", "BREAKOUT",
24 + "PULLBACK_IN_UPTREND", "REVERSAL_CANDIDATE", "RANGE_BOUND",
25 + "OVEREXTENDED", "INSUFFICIENT_DATA"]
26 +
27 +
28 +@dataclass(frozen=True)
29 +class TechnicalConfig:
30 + """Selection heuristics, not calibrated financial forecasts.
31 +
32 + 1% breakout buffers small price noise; 2% retreat/3% MA proximity defines
33 + a bounded pullback. 0.02% per observation and a 5% band define a flat base.
34 + 10% DMA20 distance plus RSI70 flags extension. These are explicit starting
35 + tolerances, configurable for later validation, not universal market rules.
36 + """
37 + max_age_days: int = 7
38 + return_lookbacks: tuple[int, ...] = (5, 21, 63, 126, 252)
39 + level_lookback: int = 20
40 + year_observations: int = 252
41 + slope_threshold_pct: float = 0.02
42 + breakout_buffer_pct: float = 1.0
43 + pullback_retreat_pct: float = 2.0
44 + pullback_proximity_pct: float = 3.0
45 + base_range_pct: float = 5.0
46 + extension_distance_pct: float = 10.0
47 + extension_rsi: float = 70.0
48 + volume_confirmation_ratio: float = 1.5
49 + score_weights: tuple[float, ...] = (50.0, 30.0, 20.0)
50 + momentum_full_scale_pct: float = 20.0
51 + extension_penalty: float = 15.0
52 + volume_breakout_bonus: float = 5.0
53 +
54 + def __post_init__(self):
55 + if (self.max_age_days < 1 or self.level_lookback < 2 or self.year_observations < 2
56 + or len(self.return_lookbacks) != 5 or any(n < 1 for n in self.return_lookbacks)
57 + or tuple(sorted(set(self.return_lookbacks))) != self.return_lookbacks
58 + or len(self.score_weights) != 3 or sum(self.score_weights) <= 0):
59 + raise ValueError("Invalid feature lookbacks or weights")
60 + for name, value in asdict(self).items():
61 + numbers = value if isinstance(value, tuple) else (value,)
62 + if any(not isfinite(v) or v < 0 for v in numbers):
63 + raise ValueError(f"Invalid technical configuration: {name}")
64 + if self.momentum_full_scale_pct == 0:
65 + raise ValueError("Momentum scale must be positive")
66 +
67 +
68 +class PersistedVolumeObservation(ResearchBaseModel):
69 + """Optional durable volume input; no volume is manufactured from quote counts."""
70 + instrument_id: UUID
71 + observed_at: datetime
72 + retrieved_at: datetime
73 + volume: Decimal | None
74 + provider: str
75 + source_url: str
76 +
77 +
78 +@dataclass(frozen=True)
79 +class PriceHistory:
80 + observations: tuple[MarketPriceObservation, ...]
81 + conflicting_dates: tuple[date, ...]
82 + current_conflict: bool
83 + rejected_count: int
84 + duplicate_count: int
85 + currency: str | None
86 +
87 +
88 +def utc(value: datetime) -> datetime:
89 + return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
90 +
91 +
92 +def finite_number(value) -> Decimal | None:
93 + if value is None or isinstance(value, bool):
94 + return None
95 + try:
96 + number = Decimal(str(value))
97 + return number if number.is_finite() and isfinite(float(number)) else None
98 + except (InvalidOperation, ValueError, OverflowError):
99 + return None
100 +
101 +
102 +def normalize_price_history(instrument_id: UUID, observations: Iterable[MarketPriceObservation], *,
103 + as_of: datetime, currency: str | None = None,
104 + trusted_providers: frozenset[str] | None = None) -> PriceHistory:
105 + """Latest timestamp per UTC date; identical ties collapse, unequal ties fail.
106 +
107 + This extends Phase 1's latest-timestamp conflict gate to each historical day.
108 + Conflicted historical dates are omitted with diagnostics. A conflicted latest
109 + date blocks current features instead of silently falling back to yesterday.
110 + Retrieval timestamps must also be known at as_of (no look-ahead).
111 + """
112 + grouped = defaultdict(list)
113 + rejected = 0
114 + currencies = set()
115 + for row in observations:
116 + price = finite_number(row.price)
117 + if (row.instrument_id != instrument_id or utc(row.observed_at) > utc(as_of)
118 + or utc(row.retrieved_at) > utc(as_of) or price is None or price <= 0
119 + or (trusted_providers is not None and row.provider not in trusted_providers)
120 + or (currency is not None and row.currency != currency)):
121 + rejected += 1
122 + continue
123 + currencies.add(row.currency)
124 + grouped[utc(row.observed_at).date()].append(row)
125 + if len(currencies) > 1:
126 + return PriceHistory((), tuple(sorted(grouped)), True, rejected, 0, currency)
127 + output, conflicts = [], []
128 + duplicates = 0
129 + for day, rows in sorted(grouped.items()):
130 + duplicates += len(rows) - 1
131 + latest = max(utc(row.observed_at) for row in rows)
132 + current = [row for row in rows if utc(row.observed_at) == latest]
133 + if len({row.price for row in current}) != 1:
134 + conflicts.append(day)
135 + continue
136 + # No provider is assumed more authoritative. Only identical-price ties
137 + # use provenance to select a stable representative.
138 + output.append(min(current, key=lambda row: (row.provider, row.source_url, utc(row.retrieved_at))))
139 + return PriceHistory(tuple(output), tuple(conflicts), bool(conflicts and max(grouped) in conflicts),
140 + rejected, duplicates, currency or next(iter(currencies), None))
141 +
142 +
143 +class TechnicalFeatureSnapshot(ResearchBaseModel):
144 + global_instrument_id: UUID
145 + as_of: datetime
146 + feature_version: str = TECHNICAL_FEATURE_VERSION
147 + configuration: dict
148 + price_basis: str = "CANONICAL_PERSISTED_PRICE_UNADJUSTED"
149 + extrema_basis: str = "ROLLING_CLOSE_EXTREMA"
150 + observation_count: int
151 + history_start: datetime | None = None
152 + history_end: datetime | None = None
153 + history_readiness: str
154 + currency: str | None = None
155 + latest_price: float | None = None
156 + dma20: float | None = None
157 + dma50: float | None = None
158 + dma100: float | None = None
159 + dma200: float | None = None
160 + distance_to_dma20_pct: float | None = None
161 + distance_to_dma50_pct: float | None = None
162 + distance_to_dma100_pct: float | None = None
163 + distance_to_dma200_pct: float | None = None
164 + rsi14: float | None = None
165 + macd: float | None = None
166 + macd_signal: float | None = None
167 + macd_histogram: float | None = None
168 + adx14: float | None = None
169 + atr14: float | None = None
170 + atr_pct: float | None = None
171 + return1_w: float | None = Field(default=None, alias="return1W")
172 + return1_m: float | None = Field(default=None, alias="return1M")
173 + return3_m: float | None = Field(default=None, alias="return3M")
174 + return6_m: float | None = Field(default=None, alias="return6M")
175 + return1_y: float | None = Field(default=None, alias="return1Y")
176 + trend_slope20: float | None = None
177 + trend_slope50: float | None = None
178 + volume_average20: float | None = None
179 + volume_ratio20: float | None = None
180 + distance_from52_week_high_pct: float | None = Field(default=None, alias="distanceFrom52WeekHighPct")
181 + distance_from52_week_low_pct: float | None = Field(default=None, alias="distanceFrom52WeekLowPct")
182 + support_level: float | None = None
183 + resistance_level: float | None = None
184 + distance_to_support_pct: float | None = None
185 + distance_to_resistance_pct: float | None = None
186 + higher_highs_higher_lows: bool | None = None
187 + lower_highs_lower_lows: bool | None = None
188 + breakout_state: str = "INSUFFICIENT_DATA"
189 + technical_state: TechnicalState = "INSUFFICIENT_DATA"
190 + technical_score: float | None = None
191 + score_components: dict[str, float] = Field(default_factory=dict)
192 + confidence: float = 0
193 + feature_states: dict[str, str] = Field(default_factory=dict)
194 + missing_inputs: list[str] = Field(default_factory=list)
195 + stale_inputs: list[str] = Field(default_factory=list)
196 + conflicting_dates: list[date] = Field(default_factory=list)
197 + rejected_observation_count: int = 0
198 + duplicate_observation_count: int = 0
199 +
200 +
201 +def percentage(value: float, reference: float) -> float:
202 + return (value / reference - 1) * 100
203 +
204 +
205 +def _ema(values: list[float], period: int) -> list[float]:
206 + """SMA seed, then alpha=2/(period+1); result begins at period-1."""
207 + if len(values) < period:
208 + return []
209 + output = [sum(values[:period]) / period]
210 + alpha = 2 / (period + 1)
211 + for value in values[period:]:
212 + output.append(alpha * value + (1 - alpha) * output[-1])
213 + return output
214 +
215 +
216 +def _rsi(values: list[float], period=14) -> float | None:
217 + if len(values) <= period:
218 + return None
219 + changes = [b - a for a, b in zip(values, values[1:])]
220 + gain = sum(max(v, 0) for v in changes[:period]) / period
221 + loss = sum(max(-v, 0) for v in changes[:period]) / period
222 + for change in changes[period:]:
223 + gain = (gain * (period - 1) + max(change, 0)) / period
224 + loss = (loss * (period - 1) + max(-change, 0)) / period
225 + return 50.0 if gain == loss == 0 else 100.0 if loss == 0 else 100 - 100 / (1 + gain / loss)
226 +
227 +
228 +def _slope(values: list[float]) -> float:
229 + """OLS slope as percent of window mean per observed session."""
230 + center = (len(values) - 1) / 2
231 + mean = sum(values) / len(values)
232 + slope = sum((i - center) * (v - mean) for i, v in enumerate(values)) / sum((i - center)**2 for i in range(len(values)))
233 + return slope / mean * 100
234 +
235 +
236 +def _clamp(value: float) -> float:
237 + return min(100.0, max(0.0, value))
238 +
239 +
240 +class TechnicalFeatureEngine:
241 + def __init__(self, config: TechnicalConfig | None = None):
242 + self.config = config or TechnicalConfig()
243 +
244 + def compute(self, instrument_id: UUID, observations: Iterable[MarketPriceObservation], *, as_of: datetime,
245 + currency: str | None = None, trusted_providers: frozenset[str] | None = None,
246 + volume_history: Iterable[PersistedVolumeObservation] = ()) -> TechnicalFeatureSnapshot:
247 + cfg = self.config
248 + history = normalize_price_history(instrument_id, observations, as_of=as_of, currency=currency,
249 + trusted_providers=trusted_providers)
250 + rows = history.observations
251 + prices = [float(row.price) for row in rows]
252 + n = len(prices)
253 + readiness = ("FULL_HISTORY" if n >= 200 else "EXTENDED_HISTORY" if n >= 100 else
254 + "MEDIUM_HISTORY" if n >= 50 else "SHORT_HISTORY" if n >= 20 else "INSUFFICIENT_HISTORY")
255 + result = TechnicalFeatureSnapshot(global_instrument_id=instrument_id, as_of=utc(as_of), configuration=asdict(cfg),
256 + 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,
258 + conflicting_dates=list(history.conflicting_dates), rejected_observation_count=history.rejected_count,
259 + duplicate_observation_count=history.duplicate_count)
260 + # A latest-price conflict invalidates current features, even with a long
261 + # historical tail. History metadata and conflict diagnostics are retained.
262 + 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)
264 + if stale:
265 + result.stale_inputs.append("PRICE_HISTORY")
266 + if usable:
267 + result.latest_price = prices[-1]
268 + for period in (20, 50, 100, 200):
269 + if n >= period:
270 + dma = sum(prices[-period:]) / period
271 + setattr(result, f"dma{period}", dma)
272 + setattr(result, f"distance_to_dma{period}_pct", percentage(prices[-1], dma))
273 + result.rsi14 = _rsi(prices)
274 + slow, fast = _ema(prices, 26), _ema(prices, 12)
275 + if slow:
276 + macd_series = [a - b for a, b in zip(fast[14:], slow)]
277 + result.macd = macd_series[-1]
278 + signal = _ema(macd_series, 9)
279 + if signal:
280 + result.macd_signal = signal[-1]
281 + result.macd_histogram = result.macd - result.macd_signal
282 + for field, lookback in zip(("return1_w", "return1_m", "return3_m", "return6_m", "return1_y"), cfg.return_lookbacks):
283 + if n > lookback:
284 + setattr(result, field, percentage(prices[-1], prices[-lookback-1]))
285 + for period in (20, 50):
286 + if n >= period:
287 + setattr(result, f"trend_slope{period}", _slope(prices[-period:]))
288 + if n >= cfg.year_observations:
289 + result.distance_from52_week_high_pct = percentage(prices[-1], max(prices[-cfg.year_observations:]))
290 + result.distance_from52_week_low_pct = percentage(prices[-1], min(prices[-cfg.year_observations:]))
291 + if n > cfg.level_lookback:
292 + window = prices[-cfg.level_lookback-1:-1]
293 + result.support_level, result.resistance_level = min(window), max(window)
294 + result.distance_to_support_pct = percentage(prices[-1], result.support_level)
295 + result.distance_to_resistance_pct = percentage(prices[-1], result.resistance_level)
296 + if n >= cfg.level_lookback * 2:
297 + before, after = prices[-2*cfg.level_lookback:-cfg.level_lookback], prices[-cfg.level_lookback:]
298 + result.higher_highs_higher_lows = max(after) > max(before) and min(after) > min(before)
299 + result.lower_highs_lower_lows = max(after) < max(before) and min(after) < min(before)
300 + self._volume(result, rows, volume_history, trusted_providers)
301 + if not stale and n >= 20:
302 + self._classify(result, prices)
303 + self._score(result)
304 + feature_names = ["latest_price", "dma20", "dma50", "dma100", "dma200", "rsi14", "macd", "macd_signal",
305 + "macd_histogram", "adx14", "atr14", "atr_pct", "trend_slope20", "trend_slope50",
306 + "return1_w", "return1_m", "return3_m", "return6_m", "return1_y",
307 + "volume_average20", "volume_ratio20", "support_level", "resistance_level",
308 + "distance_to_support_pct", "distance_to_resistance_pct", "higher_highs_higher_lows",
309 + "lower_highs_lower_lows", "distance_from52_week_high_pct", "distance_from52_week_low_pct",
310 + *(f"distance_to_dma{p}_pct" for p in (20, 50, 100, 200))]
311 + for name in feature_names:
312 + alias = TechnicalFeatureSnapshot.model_fields[name].alias or name
313 + value = getattr(result, name)
314 + result.feature_states[alias] = ("CONFLICTING" if history.current_conflict else
315 + "MISSING" if value is None else "STALE" if stale else "AVAILABLE")
316 + if value is None:
317 + result.missing_inputs.append(alias)
318 + result.missing_inputs.extend(["PERSISTED_OHLC"])
319 + if result.volume_average20 is None:
320 + result.missing_inputs.append("PERSISTED_VOLUME_HISTORY")
321 + core = ["dma20", "dma50", "dma100", "dma200", "rsi14", "macd_signal", "return1_m", "return3_m",
322 + "return6_m", "return1_y", "trend_slope20", "trend_slope50"]
323 + coverage = sum(getattr(result, field) is not None for field in core) / len(core)
324 + conflict_factor = n / (n + len(history.conflicting_dates)) if n else 0
325 + result.confidence = round(100 * coverage * conflict_factor * (0.5 if stale else 1), 6) if usable else 0
326 + result.missing_inputs = sorted(set(result.missing_inputs))
327 + # Rounding is only at the output boundary, after state/score decisions.
328 + for name in TechnicalFeatureSnapshot.model_fields:
329 + value = getattr(result, name)
330 + if isinstance(value, float):
331 + setattr(result, name, round(value, 8))
332 + return result
333 +
334 + def _volume(self, result, prices, volumes, trusted_providers):
335 + grouped = defaultdict(list)
336 + for row in volumes:
337 + number = finite_number(row.volume)
338 + if (row.instrument_id == result.global_instrument_id and number is not None and number >= 0
339 + and utc(row.observed_at) <= result.as_of and utc(row.retrieved_at) <= result.as_of
340 + and (trusted_providers is None or row.provider in trusted_providers)):
341 + grouped[utc(row.observed_at).date()].append(row)
342 + daily = {}
343 + for day, rows in sorted(grouped.items()):
344 + latest = max(utc(row.observed_at) for row in rows)
345 + values = {row.volume for row in rows if utc(row.observed_at) == latest}
346 + if len(values) == 1:
347 + daily[day] = float(next(iter(values)))
348 + else:
349 + result.missing_inputs.append(f"CONFLICTING_VOLUME:{day.isoformat()}")
350 + # Prior 20 completed observations; current volume never enters its own baseline.
351 + if len(prices) >= 21:
352 + days = [utc(row.observed_at).date() for row in prices[-21:-1]]
353 + if all(day in daily for day in days):
354 + result.volume_average20 = sum(daily[day] for day in days) / 20
355 + latest = daily.get(utc(prices[-1].observed_at).date())
356 + if latest is not None and result.volume_average20 > 0:
357 + result.volume_ratio20 = latest / result.volume_average20
358 + elif result.volume_average20 == 0:
359 + result.missing_inputs.append("NONZERO_VOLUME_BASELINE")
360 +
361 + def _classify(self, r, prices):
362 + cfg, price = self.config, prices[-1]
363 + broad_up = (r.dma50 is not None and r.trend_slope50 > cfg.slope_threshold_pct
364 + and (r.dma200 is None or r.dma50 > r.dma200))
365 + breakout = r.resistance_level is not None and percentage(price, r.resistance_level) > cfg.breakout_buffer_pct
366 + breakdown = r.support_level is not None and percentage(price, r.support_level) < -cfg.breakout_buffer_pct
367 + r.breakout_state = ("VOLUME_CONFIRMED" if breakout and r.volume_ratio20 is not None and r.volume_ratio20 >= cfg.volume_confirmation_ratio
368 + else "PRICE_BREAKOUT" if breakout else "PRICE_BREAKDOWN" if breakdown else "NONE")
369 + if r.distance_to_dma20_pct >= cfg.extension_distance_pct and r.rsi14 >= cfg.extension_rsi:
370 + r.technical_state = "OVEREXTENDED"
371 + elif breakout:
372 + r.technical_state = "BREAKOUT"
373 + elif (broad_up and price > r.dma50 and percentage(price, max(prices[-6:-1])) <= -cfg.pullback_retreat_pct
374 + and min(abs(r.distance_to_dma20_pct), abs(r.distance_to_dma50_pct)) <= cfg.pullback_proximity_pct):
375 + r.technical_state = "PULLBACK_IN_UPTREND"
376 + elif (r.trend_slope50 is not None and r.trend_slope50 < -cfg.slope_threshold_pct
377 + and r.trend_slope20 > cfg.slope_threshold_pct and price > r.dma20):
378 + r.technical_state = "REVERSAL_CANDIDATE"
379 + elif broad_up and price > r.dma50:
380 + r.technical_state = "UPTREND"
381 + elif r.dma50 is not None and price < r.dma50 and r.trend_slope50 < -cfg.slope_threshold_pct:
382 + r.technical_state = "DOWNTREND"
383 + elif abs(r.trend_slope20) <= cfg.slope_threshold_pct and percentage(max(prices[-20:]), min(prices[-20:])) <= cfg.base_range_pct:
384 + r.technical_state = "BASE_BUILDING"
385 + else:
386 + r.technical_state = "RANGE_BOUND"
387 +
388 + def _score(self, r):
389 + cfg = self.config
390 + alignments = [percentage(r.latest_price, d) for d in (r.dma20, r.dma50) if d is not None]
391 + if r.dma200 is not None:
392 + alignments.append(percentage(r.dma50, r.dma200))
393 + components = {"trendAlignment": sum(100 if v > 0 else 0 if v < 0 else 50 for v in alignments) / len(alignments)}
394 + momentum = [r.rsi14] if r.rsi14 is not None else []
395 + if r.return1_m is not None:
396 + momentum.append(_clamp(50 + 50 * r.return1_m / cfg.momentum_full_scale_pct))
397 + if momentum:
398 + components["momentum"] = sum(momentum) / len(momentum)
399 + if r.support_level is not None:
400 + span = r.resistance_level - r.support_level
401 + components["pricePosition"] = _clamp(100 * (r.latest_price - r.support_level) / span) if span else 50.0
402 + weights = dict(zip(("trendAlignment", "momentum", "pricePosition"), cfg.score_weights))
403 + total = sum(weights[key] for key in components)
404 + score = sum(value * weights[key] for key, value in components.items()) / total if total else None
405 + if score is not None:
406 + if r.technical_state == "OVEREXTENDED":
407 + score -= cfg.extension_penalty
408 + if r.breakout_state == "VOLUME_CONFIRMED":
409 + score += cfg.volume_breakout_bonus
410 + r.technical_score = round(_clamp(score), 8)
411 + r.score_components = {key: round(value, 8) for key, value in components.items()}
ai/research-engine/tests/test_sector_relative_strength.py new
+190
@@ -0,0 +1,190 @@
1 +from datetime import timedelta
2 +from uuid import UUID
3 +
4 +import pytest
5 +
6 +from app.sector_relative_strength import (
7 + BenchmarkReference, SectorContext, SectorRelativeStrengthConfig, SectorRelativeStrengthEngine,
8 +)
9 +from test_technical_features import NOW, STOCK, history
10 +
11 +
12 +SECTOR, MARKET = UUID(int=2), UUID(int=3)
13 +
14 +
15 +def context(currency="INR", region="INDIA"):
16 + return SectorContext("Financial Services", "CANONICAL_UNIVERSE", NOW-timedelta(days=1), region,
17 + BenchmarkReference(SECTOR, currency), BenchmarkReference(MARKET, currency))
18 +
19 +
20 +def rate_series(rate, count=160):
21 + return [100*(1+rate)**i for i in range(count)]
22 +
23 +
24 +def evaluate(stock, sector=None, market=None, mapping=None, currency="INR"):
25 + histories = {}
26 + if sector is not None: histories[SECTOR] = history(sector, SECTOR, currency)
27 + if market is not None: histories[MARKET] = history(market, MARKET, currency)
28 + return SectorRelativeStrengthEngine().compute(STOCK, history(stock, currency=currency), as_of=NOW,
29 + currency=currency, context=mapping or context(currency), benchmark_histories=histories)
30 +
31 +
32 +@pytest.mark.parametrize("rates,state", [((.002, .001, .0005), "LEADING"), ((-.002, 0, .001), "LAGGING"), ((0, 0, 0), "NEUTRAL")])
33 +def test_outperforming_lagging_and_flat_relative_series(rates, state):
34 + result = evaluate(*(rate_series(rate) for rate in rates))
35 + assert result.sector_state == state
36 + assert result.confidence == 100
37 + assert result.sector == "Financials"
38 + assert result.relative_vs_sector1_m == pytest.approx(result.stock_return1_m-result.sector_return1_m)
39 + assert result.relative_vs_market3_m == pytest.approx(result.stock_return3_m-result.market_return3_m)
40 + assert (result.relative_strength_score > 50 if state == "LEADING" else
41 + result.relative_strength_score < 50 if state == "LAGGING" else result.relative_strength_score == 50)
42 +
43 +
44 +def test_beating_sector_but_lagging_market_keeps_separate_legs():
45 + result = evaluate(rate_series(.001), rate_series(0), rate_series(.002))
46 + assert result.relative_vs_sector1_m > 0
47 + assert result.relative_vs_market1_m < 0
48 + assert result.sector_return1_m != result.market_return1_m
49 +
50 +
51 +@pytest.mark.parametrize("anchors,state", [((99, 98, 105, 110), "IMPROVING"), ((101, 102, 95, 90), "WEAKENING")])
52 +def test_relative_trend_changes_use_horizon_normalization(anchors, state):
53 + stock = [100]*160
54 + for offset, value in zip((5, 21, 63, 126), anchors): stock[-offset-1] = value
55 + result = evaluate(stock, [100]*160, [100]*160)
56 + assert result.sector_state == state
57 +
58 +
59 +def test_missing_sector_history_does_not_substitute_market():
60 + result = evaluate(rate_series(.001), market=rate_series(.0005))
61 + assert result.sector_return1_m is result.relative_vs_sector1_m is None
62 + assert "SECTOR_HISTORY" in result.missing_inputs
63 + assert result.market_return1_m is not None
64 + assert result.sector_state == "INSUFFICIENT_DATA"
65 + assert result.confidence == 50
66 +
67 +
68 +def test_missing_market_mapping_and_no_history_do_not_become_zero():
69 + mapping = SectorContext("Industrials", "CANONICAL", NOW, "INDIA", BenchmarkReference(SECTOR, "INR"))
70 + result = evaluate(rate_series(.001), sector=rate_series(.0005), mapping=mapping)
71 + assert result.market_return1_m is result.relative_vs_market1_m is None
72 + assert "MARKET_BENCHMARK_MAPPING" in result.missing_inputs
73 + missing = evaluate(rate_series(.001), mapping=SectorContext())
74 + assert missing.relative_strength_score is None and missing.confidence == 0
75 + assert missing.stock_return1_m is not None
76 +
77 +
78 +@pytest.mark.parametrize("count", [0, 5, 6, 21])
79 +def test_insufficient_lookback_cannot_claim_multi_period_consistency(count):
80 + result = evaluate([100]*count, [100]*count, [100]*count)
81 + assert result.sector_state == "INSUFFICIENT_DATA"
82 + assert result.relative_strength_score is None
83 + assert result.stock_return1_m is None
84 +
85 +
86 +@pytest.mark.parametrize("currency,region", [("INR", "INDIA"), ("USD", "USA"), ("EUR", "EUROPE")])
87 +def test_region_neutral_fixture(currency, region):
88 + result = evaluate(rate_series(.002), rate_series(.001), rate_series(.0005), mapping=context(currency, region), currency=currency)
89 + assert result.sector_state == "LEADING" and result.region == region
90 + assert result.market_benchmark_id == MARKET and result.sector_benchmark_id == SECTOR
91 +
92 +
93 +def test_exact_date_alignment_prevents_mismatched_period_comparison():
94 + stock = history(rate_series(.002))
95 + sector = history(rate_series(.001), SECTOR)
96 + sector.pop(-22) # Remove precisely the 1M stock reference date, not the latest.
97 + result = SectorRelativeStrengthEngine().compute(STOCK, stock, as_of=NOW, context=context(),
98 + benchmark_histories={SECTOR: sector, MARKET: history(rate_series(.001), MARKET)})
99 + assert result.relative_vs_sector1_m is None
100 + assert "SECTOR_ALIGNED_DATES_1M" in result.missing_inputs
101 + assert result.relative_vs_sector3_m is not None
102 + assert result.comparison_windows["1M"] == (stock[-22].observed_at.date(), stock[-1].observed_at.date())
103 +
104 +
105 +def test_stale_and_conflicting_benchmarks_are_explicit():
106 + stock = history(rate_series(.002))
107 + sector = history(rate_series(.001), SECTOR, end=NOW-timedelta(days=20))
108 + result = SectorRelativeStrengthEngine().compute(STOCK, stock, as_of=NOW, context=context(), benchmark_histories={SECTOR: sector})
109 + assert result.relative_vs_sector1_m is None
110 + assert result.stale_inputs == ["SECTOR_HISTORY"]
111 + sector = history(rate_series(.001), SECTOR)
112 + sector.append(sector[-1].model_copy(update={"price": 999, "provider": "OTHER"}))
113 + result = SectorRelativeStrengthEngine().compute(STOCK, stock, as_of=NOW, context=context(), benchmark_histories={SECTOR: sector})
114 + assert "CONFLICTING_SECTOR_PRICE" in result.missing_inputs
115 + assert result.relative_strength_score is None
116 +
117 +
118 +def test_determinism_no_network_and_future_classification_rejected(monkeypatch):
119 + import socket
120 + monkeypatch.setattr(socket, "create_connection", lambda *a, **k: pytest.fail("Network invoked"))
121 + engine = SectorRelativeStrengthEngine()
122 + stock, sector, market = history(rate_series(.002)), history(rate_series(.001), SECTOR), history(rate_series(.0005), MARKET)
123 + first = engine.compute(STOCK, stock, as_of=NOW, context=context(), benchmark_histories={SECTOR: sector, MARKET: market})
124 + second = engine.compute(STOCK, reversed(stock), as_of=NOW, context=context(), benchmark_histories={MARKET: list(reversed(market)), SECTOR: list(reversed(sector))})
125 + assert first == second
126 + future = SectorContext("Financials", "CANONICAL", NOW+timedelta(days=1), "INDIA", BenchmarkReference(SECTOR, "INR"))
127 + result = engine.compute(STOCK, stock, as_of=NOW, context=future, benchmark_histories={SECTOR: sector})
128 + assert result.sector is None and result.relative_vs_sector1_m is None
129 +
130 +
131 +def test_configuration_and_self_benchmark_rejected():
132 + with pytest.raises(ValueError): SectorRelativeStrengthConfig(period_weights=(0, 0, 0, 0))
133 + with pytest.raises(ValueError): SectorRelativeStrengthConfig(full_scale_edge_per_observation_pct=0)
134 + result = evaluate(rate_series(.001), mapping=SectorContext("Financials", "CANONICAL", NOW, "INDIA", BenchmarkReference(STOCK, "INR")))
135 + assert "INVALID_SECTOR_BENCHMARK_MAPPING" in result.missing_inputs
136 +
137 +
138 +@pytest.mark.asyncio
139 +async def test_stage_b_batches_only_deep_eligible_and_preserves_phase1(monkeypatch):
140 + from app.global_scanner import GlobalScanner
141 + from app.persistence import SqliteResearchPersistence
142 + from app.stock_rule_engine import StockRuleEngineService
143 + from test_global_scanner import instrument, persisted, scan
144 + def forbidden(*a, **k): pytest.fail("Provider/V1/universe invoked by Stage B")
145 + monkeypatch.setattr(StockRuleEngineService, "analyze", forbidden)
146 + store = SqliteResearchPersistence()
147 + items = [instrument(n) for n in (1, 4, 5)]
148 + for item in items: persisted(store, item)
149 + # Candidate5 lacks critical financial evidence, despite a complete price history.
150 + persisted(store, items[2], {})
151 + for key in (STOCK, UUID(int=4), UUID(int=5), SECTOR, MARKET):
152 + for row in history(rate_series(.001), key):
153 + store.upsert_market_price_observation(row.model_copy(update={"provider": "YAHOO_FINANCE"}))
154 + initial = await scan(items, store)
155 + before = initial.model_dump()
156 + queries = []
157 + store._connection.set_trace_callback(queries.append)
158 + class NoUniverse:
159 + active_global_equities = forbidden
160 + scanner = GlobalScanner(NoUniverse(), store)
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]
167 + assert all(c.technical_feature_snapshot.global_instrument_id == c.global_instrument_id for c in enriched)
168 + # Private inputs are not part of the enrichment contract.
169 + assert enriched == scanner.enrich_candidates(initial, sector_contexts={STOCK: context()})
170 +
171 +
172 +@pytest.mark.asyncio
173 +async def test_stage_b_missing_sector_renormalizes_and_order_is_deterministic():
174 + from app.global_scanner import GlobalScanner
175 + from app.persistence import SqliteResearchPersistence
176 + from test_global_scanner import instrument, persisted, scan
177 + store = SqliteResearchPersistence()
178 + items = [instrument(n) for n in (1, 4)]
179 + for item in items:
180 + persisted(store, item)
181 + for row in history(rate_series(.001), UUID(item["globalInstrumentId"])):
182 + store.upsert_market_price_observation(row.model_copy(update={"provider": "YAHOO_FINANCE"}))
183 + result = await scan(items, store)
184 + enriched = GlobalScanner(None, store).enrich_candidates(result)
185 + assert [c.global_instrument_id.int for c in enriched] == [1, 4]
186 + for candidate in enriched:
187 + assert candidate.sector_score is None
188 + assert candidate.stage_b_score == candidate.technical_score
189 + assert candidate.score_coverage == 70
190 + assert candidate.confidence == pytest.approx(candidate.technical_feature_snapshot.confidence*.7)
ai/research-engine/tests/test_technical_features.py new
+185
@@ -0,0 +1,185 @@
1 +from datetime import datetime, timedelta, timezone
2 +from decimal import Decimal
3 +from uuid import UUID
4 +
5 +import pytest
6 +
7 +from app.models import MarketPriceObservation
8 +from app.technical_features import TechnicalConfig, TechnicalFeatureEngine, PersistedVolumeObservation
9 +
10 +
11 +NOW = datetime(2026, 9, 13, 18, tzinfo=timezone.utc)
12 +STOCK = UUID(int=1)
13 +
14 +
15 +def history(values, instrument_id=STOCK, currency="INR", end=NOW):
16 + # Observed weekday sessions, with no calendar interpolation by the engine.
17 + dates, day = [], end
18 + while len(dates) < len(values):
19 + if day.weekday() < 5:
20 + dates.append(day)
21 + day -= timedelta(days=1)
22 + dates.reverse()
23 + return [MarketPriceObservation(instrument_id=instrument_id, price=Decimal(str(value)), currency=currency,
24 + observed_at=stamp, retrieved_at=stamp, provider="PERSISTED", source_url="https://evidence.test/history")
25 + for value, stamp in zip(values, dates)]
26 +
27 +
28 +def compute(values, **kwargs):
29 + return TechnicalFeatureEngine().compute(STOCK, history(values), as_of=NOW, currency="INR", **kwargs)
30 +
31 +
32 +@pytest.mark.parametrize("values,state", [
33 + ([100 + .2*i for i in range(260)], "UPTREND"),
34 + ([200 - .2*i for i in range(260)], "DOWNTREND"),
35 + ([100]*260, "BASE_BUILDING"),
36 + ([100 + 10*(i % 2) for i in range(260)], "RANGE_BOUND"),
37 + ([100]*60 + [104], "BREAKOUT"),
38 + ([100 + .2*i for i in range(255)] + [151, 150, 149, 148, 147], "PULLBACK_IN_UPTREND"),
39 + ([100 + .2*i for i in range(259)] + [200], "OVEREXTENDED"),
40 + ([200 - .6*i for i in range(240)] + [56.6 + .3*i for i in range(20)], "REVERSAL_CANDIDATE"),
41 + ([100]*19, "INSUFFICIENT_DATA"),
42 +])
43 +def test_explicit_technical_states(values, state):
44 + result = compute(values)
45 + assert result.technical_state == state
46 + assert (result.technical_score is None) == (state == "INSUFFICIENT_DATA")
47 +
48 +
49 +@pytest.mark.parametrize("count,readiness", [(19, "INSUFFICIENT_HISTORY"), (20, "SHORT_HISTORY"),
50 + (49, "SHORT_HISTORY"), (50, "MEDIUM_HISTORY"), (99, "MEDIUM_HISTORY"), (100, "EXTENDED_HISTORY"),
51 + (199, "EXTENDED_HISTORY"), (200, "FULL_HISTORY")])
52 +def test_history_boundaries_and_exact_dma_math(count, readiness):
53 + result = compute(list(range(1, count+1)))
54 + assert result.history_readiness == readiness
55 + for window in (20, 50, 100, 200):
56 + value = getattr(result, f"dma{window}")
57 + if count >= window:
58 + expected = (count + count-window+1) / 2
59 + assert value == expected
60 + assert getattr(result, f"distance_to_dma{window}_pct") == pytest.approx((count/expected-1)*100)
61 + else:
62 + assert value is None
63 + assert result.feature_states[f"dma{window}"] == "MISSING"
64 +
65 +
66 +def test_rsi_wilder_independent_known_worksheet():
67 + values = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28]
68 + # First 14 changes: total gains 3.34, losses 1.40. No EMA warmup ambiguity.
69 + assert compute(values).rsi14 == pytest.approx(100*3.34/(3.34+1.40), abs=1e-7)
70 + # One Wilder update: gain=(3.34/14*13)/14; loss=(1.40/14*13+.28)/14.
71 + gain, loss = Decimal("3.34")/14*13/14, (Decimal("1.40")/14*13+Decimal(".28"))/14
72 + assert compute(values + [46.0]).rsi14 == pytest.approx(float(100*gain/(gain+loss)), abs=1e-7)
73 + assert compute(values[:14]).rsi14 is None
74 + assert compute([100]*20).rsi14 == 50
75 + assert compute(list(range(1, 21))).rsi14 == 100
76 + assert compute(list(range(21, 1, -1))).rsi14 == 0
77 +
78 +
79 +def test_macd_linear_series_exact_and_independent_nonlinear_ema():
80 + result = compute(list(range(1, 61)))
81 + assert result.macd == pytest.approx(7)
82 + assert result.macd_signal == pytest.approx(7)
83 + assert result.macd_histogram == pytest.approx(0)
84 + values = [Decimal(100) + Decimal(i*i % 37) for i in range(70)]
85 + # Independent closed-form weighted sum, not the implementation's recursion.
86 + def ema_at(series, period, index):
87 + alpha = Decimal(2)/Decimal(period+1)
88 + decay = 1-alpha
89 + tail = index-period+1
90 + seed = sum(series[:period])/period
91 + return seed*decay**tail + sum(alpha*series[k]*decay**(index-k) for k in range(period, index+1))
92 + macds = [ema_at(values, 12, i)-ema_at(values, 26, i) for i in range(25, len(values))]
93 + expected_signal = ema_at(macds, 9, len(macds)-1)
94 + result = compute(values)
95 + assert result.macd == pytest.approx(float(macds[-1]), abs=1e-7)
96 + assert result.macd_signal == pytest.approx(float(expected_signal), abs=1e-7)
97 + assert result.macd_histogram == pytest.approx(float(macds[-1]-expected_signal), abs=1e-7)
98 + assert compute([100]*25).macd is None
99 + assert compute([100]*26).macd == 0
100 + assert compute([100]*33).macd_signal is None
101 + assert compute([100]*34).macd_signal == 0
102 +
103 +
104 +def test_missing_ohlc_volume_and_actual_zero_volume():
105 + rows = history([100]*30)
106 + engine = TechnicalFeatureEngine()
107 + result = engine.compute(STOCK, rows, as_of=NOW)
108 + assert result.adx14 is result.atr14 is result.atr_pct is None
109 + assert result.volume_average20 is result.volume_ratio20 is None
110 + volumes = [PersistedVolumeObservation(instrument_id=STOCK, observed_at=r.observed_at,
111 + retrieved_at=r.retrieved_at, volume=100 if i < 29 else 0, provider=r.provider, source_url=r.source_url) for i, r in enumerate(rows)]
112 + result = engine.compute(STOCK, rows, as_of=NOW, volume_history=volumes)
113 + assert result.volume_average20 == 100
114 + assert result.volume_ratio20 == 0
115 + assert result.feature_states["volumeRatio20"] == "AVAILABLE"
116 + zero = [r.model_copy(update={"volume": Decimal(0)}) for r in volumes]
117 + result = engine.compute(STOCK, rows, as_of=NOW, volume_history=zero)
118 + assert result.volume_average20 == 0 and result.volume_ratio20 is None
119 + assert "NONZERO_VOLUME_BASELINE" in result.missing_inputs
120 +
121 +
122 +def test_latest_conflict_is_not_averaged_or_replaced_by_prior_close():
123 + rows = history(range(100, 160))
124 + conflict = rows[-1].model_copy(update={"price": Decimal(999), "provider": "OTHER"})
125 + engine = TechnicalFeatureEngine()
126 + result = engine.compute(STOCK, rows+[conflict], as_of=NOW)
127 + assert result.latest_price is result.dma20 is result.technical_score is None
128 + assert result.technical_state == "INSUFFICIENT_DATA" and result.confidence == 0
129 + assert result.feature_states["latestPrice"] == "CONFLICTING"
130 + assert result == engine.compute(STOCK, list(reversed(rows+[conflict])), as_of=NOW)
131 +
132 +
133 +def test_same_date_deduplication_latest_timestamp_provenance_and_no_fill():
134 + rows = history(range(100, 160))
135 + engine = TechnicalFeatureEngine()
136 + first = engine.compute(STOCK, rows, as_of=NOW)
137 + duplicate = rows[-1].model_copy(update={"provider": "OTHER"})
138 + repeated = engine.compute(STOCK, [*reversed(rows), duplicate], as_of=NOW)
139 + assert repeated.observation_count == 60 and repeated.dma50 == first.dma50
140 + assert repeated.duplicate_observation_count == 1
141 + later = rows[-1].model_copy(update={"price": Decimal(160), "observed_at": rows[-1].observed_at+timedelta(minutes=1)})
142 + assert engine.compute(STOCK, rows+[later], as_of=NOW).latest_price == 160
143 + sparse = [r for i, r in enumerate(rows) if i % 2]
144 + assert engine.compute(STOCK, sparse, as_of=NOW).observation_count == 30
145 +
146 +
147 +def test_stale_future_currency_and_provider_filtering():
148 + engine = TechnicalFeatureEngine()
149 + rows = history(range(100, 160), end=NOW-timedelta(days=20))
150 + result = engine.compute(STOCK, rows, as_of=NOW)
151 + assert result.dma50 is not None and result.feature_states["dma50"] == "STALE"
152 + assert result.technical_state == "INSUFFICIENT_DATA" and result.technical_score is None
153 + assert result.stale_inputs == ["PRICE_HISTORY"]
154 + rows = history(range(100, 160))
155 + future = rows[-1].model_copy(update={"retrieved_at": NOW+timedelta(days=1), "price": Decimal(999)})
156 + result = engine.compute(STOCK, rows+[future], as_of=NOW)
157 + assert result.latest_price == 159 and result.rejected_observation_count == 1
158 + assert engine.compute(STOCK, rows, as_of=NOW, currency="USD").latest_price is None
159 + assert engine.compute(STOCK, rows, as_of=NOW, trusted_providers=frozenset()).latest_price is None
160 +
161 +
162 +def test_return_offsets_support_and_year_readiness():
163 + result = compute(list(range(1, 254)))
164 + for field, offset in [("return1_w", 5), ("return1_m", 21), ("return3_m", 63), ("return6_m", 126), ("return1_y", 252)]:
165 + assert getattr(result, field) == pytest.approx((253/(253-offset)-1)*100)
166 + assert result.support_level == 233 and result.resistance_level == 252
167 + assert result.distance_to_support_pct == pytest.approx((253/233-1)*100)
168 + assert result.higher_highs_higher_lows is True and result.lower_highs_lower_lows is False
169 + assert compute([100]*251).distance_from52_week_high_pct is None
170 + assert compute([100]*252).distance_from52_week_high_pct == 0
171 + assert compute([100]*252).return1_y is None
172 +
173 +
174 +def test_deterministic_pure_execution_and_configuration(monkeypatch):
175 + import socket
176 + monkeypatch.setattr(socket, "create_connection", lambda *a, **k: pytest.fail("Network invoked"))
177 + rows = history([100+.1*i for i in range(260)])
178 + original = [r.model_dump() for r in rows]
179 + engine = TechnicalFeatureEngine()
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"
184 + with pytest.raises(ValueError): TechnicalConfig(momentum_full_scale_pct=0)
185 + with pytest.raises(ValueError): TechnicalConfig(score_weights=(float("nan"), 1, 1))