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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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,16 +67,26 @@ 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
|
||||
if sub["name"] == "EMA_Cross":
|
||||
assert sub["raw_value"] in ("bullish", "neutral", "bearish")
|
||||
else:
|
||||
assert isinstance(sub["raw_value"], (int, float))
|
||||
assert sub["description"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user