From d139dd0390b568c60317961827df71982786adda Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 12 Jun 2026 17:17:07 +0200 Subject: [PATCH] Integrate unused indicators into technical scoring; fix indicator dropdown - Technical dimension now uses all directional indicators: 0.30*ADX + 0.20*EMA + 0.20*RSI + 0.15*EMA_Cross (bullish=80 / neutral=50 / bearish=20) + 0.10*Volume_Profile (POC proximity) + 0.05*Pivot_Points (structure confluence); weights re-normalize when data is insufficient, as before - ATR stays out of scoring (volatility input for scanner stops, not a directional signal) - IndicatorSelector uses the shared Select so the option list is dark instead of the native white popup - Update technical scoring tests for the six-component breakdown Co-Authored-By: Claude Fable 5 --- app/services/scoring_service.py | 80 ++++++++++++++++--- .../components/ticker/IndicatorSelector.tsx | 7 +- tests/unit/test_scoring_service_technical.py | 36 ++++++--- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/app/services/scoring_service.py b/app/services/scoring_service.py index 99a2a71..9e28954 100644 --- a/app/services/scoring_service.py +++ b/app/services/scoring_service.py @@ -88,7 +88,8 @@ async def _save_weights(db: AsyncSession, weights: dict[str, float]) -> None: async def _compute_technical_score( db: AsyncSession, symbol: str ) -> tuple[float | None, dict | None]: - """Compute technical dimension score from ADX, EMA, RSI. + """Compute technical dimension score from ADX, EMA, RSI, EMA Cross, + Volume Profile and Pivot Points. Returns (score, breakdown) where breakdown follows the ScoreBreakdown TypedDict shape: {sub_scores, formula, unavailable}. @@ -96,7 +97,10 @@ async def _compute_technical_score( from app.services.indicator_service import ( compute_adx, compute_ema, + compute_ema_cross, + compute_pivot_points, compute_rsi, + compute_volume_profile, _extract_ohlcv, ) from app.services.price_service import query_ohlcv @@ -105,27 +109,33 @@ async def _compute_technical_score( if not records: return None, None - _, highs, lows, closes, _ = _extract_ohlcv(records) + _, highs, lows, closes, volumes = _extract_ohlcv(records) + + formula = ( + "Weighted average: 0.30*ADX + 0.20*EMA + 0.20*RSI + 0.15*EMA_Cross " + "+ 0.10*Volume_Profile + 0.05*Pivot_Points, re-normalized if any " + "sub-score unavailable." + ) scores: list[tuple[float, float]] = [] # (weight, score) sub_scores: list[dict] = [] unavailable: list[dict[str, str]] = [] - # ADX (weight 0.4) — needs 28+ bars + # ADX (weight 0.30) — needs 28+ bars try: adx_result = compute_adx(highs, lows, closes) - scores.append((0.4, adx_result["score"])) + scores.append((0.30, adx_result["score"])) sub_scores.append({ "name": "ADX", "score": adx_result["score"], - "weight": 0.4, + "weight": 0.30, "raw_value": adx_result["adx"], "description": "ADX value (0-100). Higher = stronger trend.", }) except Exception as exc: unavailable.append({"name": "ADX", "reason": str(exc) or "Insufficient data for ADX"}) - # EMA (weight 0.3) — needs period+1 bars + # EMA (weight 0.20) — needs period+1 bars try: ema_result = compute_ema(closes) pct_diff = ( @@ -138,35 +148,79 @@ async def _compute_technical_score( if ema_result["ema"] != 0 else 0.0 ) - scores.append((0.3, ema_result["score"])) + scores.append((0.20, ema_result["score"])) sub_scores.append({ "name": "EMA", "score": ema_result["score"], - "weight": 0.3, + "weight": 0.20, "raw_value": pct_diff, "description": f"Price {pct_diff}% {'above' if pct_diff >= 0 else 'below'} EMA(20). Score: 50 + pct_diff * 10.", }) except Exception as exc: unavailable.append({"name": "EMA", "reason": str(exc) or "Insufficient data for EMA"}) - # RSI (weight 0.3) — needs 15+ bars + # RSI (weight 0.20) — needs 15+ bars try: rsi_result = compute_rsi(closes) - scores.append((0.3, rsi_result["score"])) + scores.append((0.20, rsi_result["score"])) sub_scores.append({ "name": "RSI", "score": rsi_result["score"], - "weight": 0.3, + "weight": 0.20, "raw_value": rsi_result["rsi"], "description": "RSI(14) value. Score equals RSI.", }) except Exception as exc: unavailable.append({"name": "RSI", "reason": str(exc) or "Insufficient data for RSI"}) + # EMA Cross (weight 0.15) — needs 51+ bars. Directional trend signal. + try: + cross_result = compute_ema_cross(closes) + cross_score = {"bullish": 80.0, "neutral": 50.0, "bearish": 20.0}[cross_result["signal"]] + scores.append((0.15, cross_score)) + sub_scores.append({ + "name": "EMA_Cross", + "score": cross_score, + "weight": 0.15, + "raw_value": cross_result["signal"], + "description": "EMA(20) vs EMA(50): bullish=80, neutral=50, bearish=20.", + }) + except Exception as exc: + unavailable.append({"name": "EMA_Cross", "reason": str(exc) or "Insufficient data for EMA Cross"}) + + # Volume Profile (weight 0.10) — needs 20+ bars. Price near POC = trading + # at accepted value; far from POC = extended. + try: + vp_result = compute_volume_profile(highs, lows, closes, volumes) + scores.append((0.10, vp_result["score"])) + sub_scores.append({ + "name": "Volume_Profile", + "score": vp_result["score"], + "weight": 0.10, + "raw_value": vp_result["poc"], + "description": "Proximity of price to the Point of Control (volume-accepted value).", + }) + except Exception as exc: + unavailable.append({"name": "Volume_Profile", "reason": str(exc) or "Insufficient data for Volume Profile"}) + + # Pivot Points (weight 0.05) — needs 5+ bars. Price near swing structure. + try: + pivot_result = compute_pivot_points(highs, lows, closes) + scores.append((0.05, pivot_result["score"])) + sub_scores.append({ + "name": "Pivot_Points", + "score": pivot_result["score"], + "weight": 0.05, + "raw_value": pivot_result["pivot_count"], + "description": "Share of swing pivots within 2% of price (structure confluence).", + }) + except Exception as exc: + unavailable.append({"name": "Pivot_Points", "reason": str(exc) or "Insufficient data for Pivot Points"}) + if not scores: breakdown: dict = { "sub_scores": [], - "formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.", + "formula": formula, "unavailable": unavailable, } return None, breakdown @@ -179,7 +233,7 @@ async def _compute_technical_score( breakdown = { "sub_scores": sub_scores, - "formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.", + "formula": formula, "unavailable": unavailable, } diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx index 276b040..fd0e7bc 100644 --- a/frontend/src/components/ticker/IndicatorSelector.tsx +++ b/frontend/src/components/ticker/IndicatorSelector.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getIndicator, getEMACross } from '../../api/indicators'; +import { Select } from '../ui/Field'; import type { IndicatorResult, EMACrossResult } from '../../lib/types'; const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const; @@ -85,16 +86,16 @@ export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {

Indicators

- +
{selectedType && indicatorQuery.isLoading && ( diff --git a/tests/unit/test_scoring_service_technical.py b/tests/unit/test_scoring_service_technical.py index fad6c1e..3d15ed7 100644 --- a/tests/unit/test_scoring_service_technical.py +++ b/tests/unit/test_scoring_service_technical.py @@ -44,8 +44,9 @@ async def test_returns_none_tuple_when_no_records(db_session): @pytest.mark.asyncio async def test_returns_breakdown_with_all_sub_scores(db_session): - """With enough data, returns score and breakdown with ADX, EMA, RSI sub-scores.""" - records = _make_ohlcv_records(50) + """With enough data, returns score and breakdown with all six sub-scores.""" + # 60 bars: enough for every indicator incl. EMA Cross (needs 51) + records = _make_ohlcv_records(60) with patch( "app.services.price_service.query_ohlcv", @@ -66,17 +67,27 @@ async def test_returns_breakdown_with_all_sub_scores(db_session): assert "ADX" in names assert "EMA" in names assert "RSI" in names + assert "EMA_Cross" in names + assert "Volume_Profile" in names + assert "Pivot_Points" in names - # Verify weights + # Verify weights sum to 1 and match the formula weight_map = {s["name"]: s["weight"] for s in breakdown["sub_scores"]} - assert weight_map["ADX"] == 0.4 - assert weight_map["EMA"] == 0.3 - assert weight_map["RSI"] == 0.3 + assert weight_map["ADX"] == 0.30 + assert weight_map["EMA"] == 0.20 + assert weight_map["RSI"] == 0.20 + assert weight_map["EMA_Cross"] == 0.15 + assert weight_map["Volume_Profile"] == 0.10 + assert weight_map["Pivot_Points"] == 0.05 + assert sum(weight_map.values()) == pytest.approx(1.0) - # Verify raw_value is present and numeric + # Verify raw_value is present (numeric except EMA_Cross's signal string) for sub in breakdown["sub_scores"]: assert sub["raw_value"] is not None - assert isinstance(sub["raw_value"], (int, float)) + if sub["name"] == "EMA_Cross": + assert sub["raw_value"] in ("bullish", "neutral", "bearish") + else: + assert isinstance(sub["raw_value"], (int, float)) assert sub["description"] assert breakdown["unavailable"] == [] @@ -112,8 +123,8 @@ async def test_partial_sub_scores_with_insufficient_data(db_session): @pytest.mark.asyncio async def test_all_sub_scores_unavailable(db_session): """With very few bars (not enough for any indicator), returns None score with breakdown.""" - # 5 bars: not enough for any indicator - records = _make_ohlcv_records(5) + # 4 bars: not enough for any indicator (Pivot Points needs 5+) + records = _make_ohlcv_records(4) with patch( "app.services.price_service.query_ohlcv", @@ -125,12 +136,15 @@ async def test_all_sub_scores_unavailable(db_session): assert score is None assert breakdown is not None assert breakdown["sub_scores"] == [] - assert len(breakdown["unavailable"]) == 3 + assert len(breakdown["unavailable"]) == 6 unavail_names = [u["name"] for u in breakdown["unavailable"]] assert "ADX" in unavail_names assert "EMA" in unavail_names assert "RSI" in unavail_names + assert "EMA_Cross" in unavail_names + assert "Volume_Profile" in unavail_names + assert "Pivot_Points" in unavail_names @pytest.mark.asyncio