Integrate unused indicators into technical scoring; fix indicator dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 28s
Deploy / deploy (push) Successful in 22s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:17:07 +02:00
parent 9c6a0a72fa
commit d139dd0390
3 changed files with 96 additions and 27 deletions
+67 -13
View File
@@ -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,
}
@@ -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) {
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
<div className="mb-4">
<select
<Select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="w-full input-glass px-3 py-2.5 text-sm"
className="w-full !py-2.5"
>
<option value="">Select indicator</option>
{INDICATOR_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</Select>
</div>
{selectedType && indicatorQuery.isLoading && (
+25 -11
View File
@@ -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