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(
|
async def _compute_technical_score(
|
||||||
db: AsyncSession, symbol: str
|
db: AsyncSession, symbol: str
|
||||||
) -> tuple[float | None, dict | None]:
|
) -> 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
|
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
|
||||||
TypedDict shape: {sub_scores, formula, unavailable}.
|
TypedDict shape: {sub_scores, formula, unavailable}.
|
||||||
@@ -96,7 +97,10 @@ async def _compute_technical_score(
|
|||||||
from app.services.indicator_service import (
|
from app.services.indicator_service import (
|
||||||
compute_adx,
|
compute_adx,
|
||||||
compute_ema,
|
compute_ema,
|
||||||
|
compute_ema_cross,
|
||||||
|
compute_pivot_points,
|
||||||
compute_rsi,
|
compute_rsi,
|
||||||
|
compute_volume_profile,
|
||||||
_extract_ohlcv,
|
_extract_ohlcv,
|
||||||
)
|
)
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
@@ -105,27 +109,33 @@ async def _compute_technical_score(
|
|||||||
if not records:
|
if not records:
|
||||||
return None, None
|
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)
|
scores: list[tuple[float, float]] = [] # (weight, score)
|
||||||
sub_scores: list[dict] = []
|
sub_scores: list[dict] = []
|
||||||
unavailable: list[dict[str, str]] = []
|
unavailable: list[dict[str, str]] = []
|
||||||
|
|
||||||
# ADX (weight 0.4) — needs 28+ bars
|
# ADX (weight 0.30) — needs 28+ bars
|
||||||
try:
|
try:
|
||||||
adx_result = compute_adx(highs, lows, closes)
|
adx_result = compute_adx(highs, lows, closes)
|
||||||
scores.append((0.4, adx_result["score"]))
|
scores.append((0.30, adx_result["score"]))
|
||||||
sub_scores.append({
|
sub_scores.append({
|
||||||
"name": "ADX",
|
"name": "ADX",
|
||||||
"score": adx_result["score"],
|
"score": adx_result["score"],
|
||||||
"weight": 0.4,
|
"weight": 0.30,
|
||||||
"raw_value": adx_result["adx"],
|
"raw_value": adx_result["adx"],
|
||||||
"description": "ADX value (0-100). Higher = stronger trend.",
|
"description": "ADX value (0-100). Higher = stronger trend.",
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
unavailable.append({"name": "ADX", "reason": str(exc) or "Insufficient data for ADX"})
|
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:
|
try:
|
||||||
ema_result = compute_ema(closes)
|
ema_result = compute_ema(closes)
|
||||||
pct_diff = (
|
pct_diff = (
|
||||||
@@ -138,35 +148,79 @@ async def _compute_technical_score(
|
|||||||
if ema_result["ema"] != 0
|
if ema_result["ema"] != 0
|
||||||
else 0.0
|
else 0.0
|
||||||
)
|
)
|
||||||
scores.append((0.3, ema_result["score"]))
|
scores.append((0.20, ema_result["score"]))
|
||||||
sub_scores.append({
|
sub_scores.append({
|
||||||
"name": "EMA",
|
"name": "EMA",
|
||||||
"score": ema_result["score"],
|
"score": ema_result["score"],
|
||||||
"weight": 0.3,
|
"weight": 0.20,
|
||||||
"raw_value": pct_diff,
|
"raw_value": pct_diff,
|
||||||
"description": f"Price {pct_diff}% {'above' if pct_diff >= 0 else 'below'} EMA(20). Score: 50 + pct_diff * 10.",
|
"description": f"Price {pct_diff}% {'above' if pct_diff >= 0 else 'below'} EMA(20). Score: 50 + pct_diff * 10.",
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
unavailable.append({"name": "EMA", "reason": str(exc) or "Insufficient data for EMA"})
|
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:
|
try:
|
||||||
rsi_result = compute_rsi(closes)
|
rsi_result = compute_rsi(closes)
|
||||||
scores.append((0.3, rsi_result["score"]))
|
scores.append((0.20, rsi_result["score"]))
|
||||||
sub_scores.append({
|
sub_scores.append({
|
||||||
"name": "RSI",
|
"name": "RSI",
|
||||||
"score": rsi_result["score"],
|
"score": rsi_result["score"],
|
||||||
"weight": 0.3,
|
"weight": 0.20,
|
||||||
"raw_value": rsi_result["rsi"],
|
"raw_value": rsi_result["rsi"],
|
||||||
"description": "RSI(14) value. Score equals RSI.",
|
"description": "RSI(14) value. Score equals RSI.",
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
unavailable.append({"name": "RSI", "reason": str(exc) or "Insufficient data for RSI"})
|
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:
|
if not scores:
|
||||||
breakdown: dict = {
|
breakdown: dict = {
|
||||||
"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,
|
"unavailable": unavailable,
|
||||||
}
|
}
|
||||||
return None, breakdown
|
return None, breakdown
|
||||||
@@ -179,7 +233,7 @@ async def _compute_technical_score(
|
|||||||
|
|
||||||
breakdown = {
|
breakdown = {
|
||||||
"sub_scores": sub_scores,
|
"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,
|
"unavailable": unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getIndicator, getEMACross } from '../../api/indicators';
|
import { getIndicator, getEMACross } from '../../api/indicators';
|
||||||
|
import { Select } from '../ui/Field';
|
||||||
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
|
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
|
||||||
|
|
||||||
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
|
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>
|
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<select
|
<Select
|
||||||
value={selectedType}
|
value={selectedType}
|
||||||
onChange={(e) => setSelectedType(e.target.value)}
|
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>
|
<option value="">Select indicator…</option>
|
||||||
{INDICATOR_TYPES.map((type) => (
|
{INDICATOR_TYPES.map((type) => (
|
||||||
<option key={type} value={type}>{type}</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedType && indicatorQuery.isLoading && (
|
{selectedType && indicatorQuery.isLoading && (
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ async def test_returns_none_tuple_when_no_records(db_session):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_breakdown_with_all_sub_scores(db_session):
|
async def test_returns_breakdown_with_all_sub_scores(db_session):
|
||||||
"""With enough data, returns score and breakdown with ADX, EMA, RSI sub-scores."""
|
"""With enough data, returns score and breakdown with all six sub-scores."""
|
||||||
records = _make_ohlcv_records(50)
|
# 60 bars: enough for every indicator incl. EMA Cross (needs 51)
|
||||||
|
records = _make_ohlcv_records(60)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.price_service.query_ohlcv",
|
"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 "ADX" in names
|
||||||
assert "EMA" in names
|
assert "EMA" in names
|
||||||
assert "RSI" 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"]}
|
weight_map = {s["name"]: s["weight"] for s in breakdown["sub_scores"]}
|
||||||
assert weight_map["ADX"] == 0.4
|
assert weight_map["ADX"] == 0.30
|
||||||
assert weight_map["EMA"] == 0.3
|
assert weight_map["EMA"] == 0.20
|
||||||
assert weight_map["RSI"] == 0.3
|
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"]:
|
for sub in breakdown["sub_scores"]:
|
||||||
assert sub["raw_value"] is not None
|
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 sub["description"]
|
||||||
|
|
||||||
assert breakdown["unavailable"] == []
|
assert breakdown["unavailable"] == []
|
||||||
@@ -112,8 +123,8 @@ async def test_partial_sub_scores_with_insufficient_data(db_session):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_all_sub_scores_unavailable(db_session):
|
async def test_all_sub_scores_unavailable(db_session):
|
||||||
"""With very few bars (not enough for any indicator), returns None score with breakdown."""
|
"""With very few bars (not enough for any indicator), returns None score with breakdown."""
|
||||||
# 5 bars: not enough for any indicator
|
# 4 bars: not enough for any indicator (Pivot Points needs 5+)
|
||||||
records = _make_ohlcv_records(5)
|
records = _make_ohlcv_records(4)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.price_service.query_ohlcv",
|
"app.services.price_service.query_ohlcv",
|
||||||
@@ -125,12 +136,15 @@ async def test_all_sub_scores_unavailable(db_session):
|
|||||||
assert score is None
|
assert score is None
|
||||||
assert breakdown is not None
|
assert breakdown is not None
|
||||||
assert breakdown["sub_scores"] == []
|
assert breakdown["sub_scores"] == []
|
||||||
assert len(breakdown["unavailable"]) == 3
|
assert len(breakdown["unavailable"]) == 6
|
||||||
|
|
||||||
unavail_names = [u["name"] for u in breakdown["unavailable"]]
|
unavail_names = [u["name"] for u in breakdown["unavailable"]]
|
||||||
assert "ADX" in unavail_names
|
assert "ADX" in unavail_names
|
||||||
assert "EMA" in unavail_names
|
assert "EMA" in unavail_names
|
||||||
assert "RSI" 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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user