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))