Split signal scoring, add Redis cache, Tailwind design system, and fix UI coherence issues

Backend: extract pure scoring logic from signal_service.py into signal_scoring.py (h),
add Redis-backed win-rate/PnL caching with graceful degradation (l), add Postgres
backup/restore scripts (n), move DB/encryption secrets to Docker secrets pattern (o),
fix RSI flat-price bug and MFI wraparound index bug (q, r). 134 backend tests passing.

Frontend: consolidate all API calls onto shared apiFetch with auto token refresh (i),
wire AnalyticsPage to the real /analytics/dashboard endpoint instead of fake random
data (j), migrate all pages and shared components to a Tailwind CSS design system (k)
fixing 3 mismatched color palettes found along the way. UI review also found and fixed
missing mobile table scroll wrappers, non-stacking grids, and a missing nav/logout bar
on ProfilePage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 00:27:10 +07:00
parent 4a130363ec
commit 9de41ea92b
43 changed files with 2801 additions and 2800 deletions
+14 -4
View File
@@ -129,11 +129,15 @@ def rsi(prices: list[float], period: int = 14) -> list[Optional[float]]:
avg_gain = sum(d for d in deltas[:period] if d > 0) / period
avg_loss = abs(sum(d for d in deltas[:period] if d < 0)) / period
if avg_gain == 0 and avg_loss == 0:
# No price movement at all -- RSI is neutral by definition.
# (Previously this fell through to an `rs = 50.0` sentinel that
# was then run through the RSI formula, producing ~98.04
# instead of the intended neutral 50.)
result.append(50.0)
continue
if avg_loss == 0:
if avg_gain == 0:
rs = 50.0 # no movement
else:
rs = 100.0
rs = 100.0
else:
rs = avg_gain / avg_loss
result.append(100.0 - (100.0 / (1.0 + rs)))
@@ -1291,6 +1295,12 @@ def mfi(candles: list[dict], period: int = 14) -> list[Optional[float]]:
pos_flow = 0.0
neg_flow = 0.0
for j in range(i - period, i):
if j == 0:
# No prior candle to compare against -- Python's negative
# indexing would otherwise wrap `typical_prices[j - 1]`
# around to the LAST candle in the series, spuriously
# injecting a bogus flow-direction comparison.
continue
mf = raw_money_flow[j]
if typical_prices[j] > typical_prices[j - 1]:
pos_flow += mf
+32 -10
View File
@@ -15,14 +15,21 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import redis_client
from app.database import async_session_factory
logger = logging.getLogger(__name__)
# ── Global cache ──────────────────────────────────────────────────────────
# Backed by Redis so backend-api and backend-scheduler (separate processes/
# containers) share the same win rates instead of each computing/seeing
# their own. The in-memory dict below remains as a same-process fallback
# for whenever Redis is unreachable (e.g. local dev without a redis
# container) — see app/core/redis_client.py.
_win_rate_cache: dict[str, float] = {}
_last_cache_update: datetime | None = None
_CACHE_TTL_SECONDS = 21_600 # 6 hours
_REDIS_KEY_WIN_RATES = "signal_booster:win_rates"
# ── Strategy name normalisation ──────────────────────────────────────────
# Maps entry_reason values stored in hypothetical_trades to canonical names.
@@ -167,14 +174,15 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
_win_rate_cache = rates
_last_cache_update = datetime.now(timezone.utc)
await redis_client.set_json(_REDIS_KEY_WIN_RATES, rates, _CACHE_TTL_SECONDS)
logger.info(
"Computed win rates for %d strategies (decay=%.3f/day, min_trades=%d)",
len(rates), DECAY_LAMBDA, MIN_TRADES,
)
# Also refresh PnL stats for Kelly sizing
await _refresh_pnl_stats(db)
return rates
except Exception:
@@ -182,8 +190,13 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
return dict(_win_rate_cache) or {}
def get_cached_rates() -> dict[str, float]:
"""Return the current in-memory win-rate cache (may be stale or empty)."""
async def get_cached_rates() -> dict[str, float]:
"""Return cached win rates: Redis first (shared across processes), then
falls back to this process's own in-memory cache (may be stale or
empty) if Redis is unavailable or has no data yet."""
from_redis = await redis_client.get_json(_REDIS_KEY_WIN_RATES)
if from_redis:
return from_redis
return dict(_win_rate_cache)
@@ -258,14 +271,22 @@ def get_confidence(
_pnl_stats_cache: dict[str, float] = {}
_last_pnl_cache_update: float = 0.0
_PNL_CACHE_TTL = 3600 # 1 hour
_REDIS_KEY_PNL_STATS = "signal_booster:pnl_stats"
def get_pnl_stats() -> dict[str, float]:
"""Return cached avg_win_pct / avg_loss_pct (sync, safe for async context).
Cache is refreshed by the scheduler periodically via compute_strategy_win_rates.
Falls back to reasonable defaults (avg_win=3.0%, avg_loss=2.0%).
async def get_pnl_stats() -> dict[str, float]:
"""Return cached avg_win_pct / avg_loss_pct.
Cache is refreshed by the scheduler periodically via
compute_strategy_win_rates. Checks Redis first (shared across
backend-api/backend-scheduler processes), then this process's own
in-memory cache, then falls back to reasonable defaults
(avg_win=3.0%, avg_loss=2.0%).
"""
from_redis = await redis_client.get_json(_REDIS_KEY_PNL_STATS)
if from_redis:
return from_redis
import time as _time
now = _time.monotonic()
if now - _last_pnl_cache_update < _PNL_CACHE_TTL and _pnl_stats_cache:
@@ -294,7 +315,8 @@ async def _refresh_pnl_stats(db: AsyncSession) -> None:
if row and row[0] and row[1]:
_pnl_stats_cache = {"avg_win": float(row[0]), "avg_loss": float(row[1])}
_last_pnl_cache_update = _time.monotonic()
logger.debug("PnL stats refreshed: avg_win=%.2f%%, avg_loss=%.2f%%",
await redis_client.set_json(_REDIS_KEY_PNL_STATS, _pnl_stats_cache, _PNL_CACHE_TTL)
logger.debug("PnL stats refreshed: avg_win=%.2f%%, avg_loss=%.2f%%",
_pnl_stats_cache["avg_win"], _pnl_stats_cache["avg_loss"])
except Exception as e:
logger.debug("Failed to refresh PnL stats: %s", e)
+541
View File
@@ -0,0 +1,541 @@
"""Pure signal-scoring logic — the 13-algorithm voting system.
Extracted out of `signal_service.py` (which mixes this scoring logic with
async DB/notification/trade-trigger orchestration) so the classification
math can be read, tested, and reasoned about independently of any database
or network side effects. Nothing in this module touches I/O.
"""
from __future__ import annotations
import logging
import math
from decimal import Decimal
from typing import Optional
from app.services.signal_booster import (
boost_score,
get_confidence as compute_booster_confidence,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Signal type constants
# ---------------------------------------------------------------------------
STRONG_BUY = "STRONG_BUY"
BUY = "BUY"
STRONG_SELL = "STRONG_SELL"
SELL = "SELL"
CAUTION_LONG = "CAUTION_LONG"
CAUTION_SHORT = "CAUTION_SHORT"
SQUEEZE_ALERT = "SQUEEZE_ALERT"
# ---------------------------------------------------------------------------
# Signal detection configuration
# ---------------------------------------------------------------------------
# Minimum distance from BB bounds to filter noise
MIN_BB_DISTANCE_PCT = Decimal("0.001") # 0.1%
def _get_bb_values(indicators: dict) -> dict[str, list[float]] | None:
"""Extract Bollinger Band values from indicators dict."""
bb = indicators.get("bollinger_bands")
if not bb:
return None
# Ensure all required keys exist
required = ["upper", "middle", "lower"]
if not all(k in bb for k in required):
return None
return bb
def _get_rsi_values(indicators: dict) -> list[float] | None:
"""Extract RSI values."""
rsi = indicators.get("rsi_14")
if not rsi or not isinstance(rsi, list) or len(rsi) == 0:
return None
return rsi
def _get_sma_values(indicators: dict) -> list[float] | None:
"""Extract SMA values."""
sma = indicators.get("sma_20")
if not sma or not isinstance(sma, list) or len(sma) == 0:
return None
return sma
def _detect_squeeze(
bb: dict[str, list[float]],
lookback: int = 10,
) -> bool:
"""Detect Bollinger Band squeeze — narrowing of the band width.
A squeeze occurs when the current band width is at the lower end
of the recent range, indicating low volatility before a breakout.
"""
upper = bb.get("upper", [])
lower = bb.get("lower", [])
if not upper or not lower or len(upper) < lookback or len(lower) < lookback:
return False
# Band width = upper - lower
widths = []
for i in range(max(0, len(upper) - lookback), len(upper)):
try:
widths.append(upper[i] - lower[i])
except TypeError:
return False
if len(widths) < 3:
return False
current_width = widths[-1]
# Check if upper_1 and lower_1 exist for squeeze detection
upper_1 = bb.get("upper_1", [])
lower_1 = bb.get("lower_1", [])
if upper_1 and lower_1 and len(upper_1) >= 1 and len(lower_1) >= 1:
try:
inner_width = upper_1[-1] - lower_1[-1]
outer_width = upper[-1] - lower[-1]
# Squeeze when inner bands are VERY narrow relative to outer (≤20%)
if outer_width > 0 and inner_width / outer_width < 0.2:
return True
except TypeError:
pass
# Alternative: check if current width is at absolute minimum of longer lookback
min_width = min(widths)
if min_width > 0 and current_width <= min_width * 1.01:
return True
return False
def _classify_signal_bb(
close_price: float,
bb: dict[str, list[float]],
rsi: list[float] | None,
sma: list[float] | None,
) -> tuple[Optional[str], Optional[str]]:
"""Classify signal using Double BB + RSI only.
Returns (signal_type, strength) or (None, None) if no signal.
Used as one vote in the combined 4-algorithm system.
"""
if len(bb.get("upper", [])) == 0 or len(bb.get("lower", [])) == 0:
return None, None
upper_2 = bb["upper"][-1]
lower_2 = bb["lower"][-1]
upper_1 = bb.get("upper_1", [None])[-1]
lower_1 = bb.get("lower_1", [None])[-1]
middle = bb.get("middle", [None])[-1]
current_rsi = rsi[-1] if rsi and len(rsi) > 0 else 50
current_sma = sma[-1] if sma and len(sma) > 0 else None
# --- Price above BB 1σ Upper → Trend Long ---
if upper_1 is not None and close_price > upper_1:
if close_price > upper_2:
if current_rsi > 75:
return CAUTION_SHORT, "MODERATE"
# Only STRONG_BUY if RSI is firmly bullish AND SMA is above middle
if current_rsi > 60 and current_sma and middle and current_sma > middle:
return STRONG_BUY, "STRONG"
return BUY, "MODERATE"
else:
if current_rsi > 55:
return BUY, "MODERATE"
return BUY, "WEAK"
# --- Price below BB 1σ Lower → Trend Short ---
if lower_1 is not None and close_price < lower_1:
if close_price < lower_2:
if current_rsi < 25:
return CAUTION_LONG, "MODERATE"
# Only STRONG_SELL if RSI is firmly bearish AND SMA is below middle
if current_rsi < 40 and current_sma and middle and current_sma < middle:
return STRONG_SELL, "STRONG"
return SELL, "MODERATE"
else:
if current_rsi < 45:
return SELL, "MODERATE"
return SELL, "WEAK"
return None, None
def _classify_signal_combined(
close_price: float,
bb: dict[str, list[float]],
rsi: list[float] | None,
sma: list[float] | None,
macd_data: dict | None,
st_data: dict | None,
vol_data: list | None,
ichi_data: dict | None = None,
rsi_div: tuple = (None, None),
macd_div: tuple = (None, None),
smc_data: dict | None = None,
mtf_votes: list[tuple[Optional[str], Optional[str], float]] | None = None,
obv_data: list | None = None,
stoch_rsi_data: dict | None = None,
mfi_data: list | None = None,
fvg_data: dict | None = None,
candlestick_score: float | None = None,
rates: dict[str, float] | None = None,
enabled_strategies: list[str] | None = None,
) -> tuple[Optional[str], Optional[str], float, dict[str, float]]:
"""Classify market state using 13-algorithm voting with win-rate boosting.
Algorithms:
1. Double BB + RSI
2. MACD Crossover
3. SuperTrend
4. Volume Breakout
5. Ichimoku Cloud
6. Divergence Detection (RSI + MACD)
7. 🌤️ Market Structure (SMC) — BOS, CHoCH, OB
8. 🔄 Multi-Timeframe (15m + 1h + 4h)
9. 📊 OBV (On-Balance Volume) Crossover
10. 🔄 Stochastic RSI Crossover
11. 💰 MFI (Money Flow Index)
12. 🕯️ FVG (Fair Value Gap)
13. 🕯️ Candlestick Patterns (30+ patterns)
Each algorithm votes: BUY (+1/+2), SELL (-1/-2), or NEUTRAL (0).
If *rates* is provided, each strategy's raw score is boosted by its
historical win rate before the final classification.
Returns (signal_type, strength, confidence, raw_scores) where
confidence is a 0-1 float and raw_scores is a dict of all 9
algorithm scores for ML feature collection.
"""
# ── NaN/Inf guard: reject any invalid price before processing ──
if not math.isfinite(close_price) or close_price <= 0:
logger.warning("_classify_signal_combined: invalid close_price=%s, returning NEUTRAL", close_price)
return None, None, 0.0, {}
# ── Special signals (override) ──
squeeze = _detect_squeeze(bb)
if squeeze:
return SQUEEZE_ALERT, "MODERATE", 0.5, {}
# P2-2: Call _classify_signal_bb ONCE, reuse result for both
# early-return check AND the raw_scores vote
bb_type, bb_strength = _classify_signal_bb(close_price, bb, rsi, sma)
if bb_type in (CAUTION_LONG, CAUTION_SHORT):
return bb_type, "MODERATE", 0.5, {}
# ── Collect per-strategy raw scores ──
raw_scores: dict[str, float] = {
"double_bb_rsi": 0.0,
"macd_crossover": 0.0,
"supertrend": 0.0,
"volume_breakout": 0.0,
"ichimoku": 0.0,
"divergence": 0.0,
"smc": 0.0,
"mtf": 0.0,
"obv": 0.0,
"stoch_rsi": 0.0,
"mfi": 0.0,
"fvg": 0.0,
"candlestick": 0.0,
}
# 1. BB + RSI vote (reuse bb_type/bb_strength from above — P2-2)
if bb_type == STRONG_BUY:
raw_scores["double_bb_rsi"] = 2.0
elif bb_type == BUY:
raw_scores["double_bb_rsi"] = 1.0
elif bb_type == STRONG_SELL:
raw_scores["double_bb_rsi"] = -2.0
elif bb_type == SELL:
raw_scores["double_bb_rsi"] = -1.0
# 2. MACD Crossover vote
if macd_data:
macd_line = macd_data.get("macd_line", [])
signal_line = macd_data.get("signal_line", [])
if len(macd_line) >= 2 and len(signal_line) >= 2:
m_curr = macd_line[-1]
m_prev = macd_line[-2]
s_curr = signal_line[-1]
s_prev = signal_line[-2]
if m_curr is not None and s_curr is not None and m_prev is not None and s_prev is not None:
if m_prev <= s_prev and m_curr > s_curr and m_curr > 0:
raw_scores["macd_crossover"] = 1.0
elif m_prev >= s_prev and m_curr < s_curr and m_curr < 0:
raw_scores["macd_crossover"] = -1.0
# 3. SuperTrend vote
if st_data:
trend = st_data.get("trend", [])
if trend and len(trend) >= 1 and trend[-1] is not None:
raw_scores["supertrend"] = 1.0 if trend[-1] else -1.0
# 4. Volume Breakout vote (current bar only — avoid stale signals)
if vol_data and len(vol_data) >= 2:
vol_now = vol_data[-1]
if vol_now is True:
sma20 = sma[-1] if sma and len(sma) > 0 else None
if sma20 is not None and close_price > sma20:
raw_scores["volume_breakout"] = 1.0
elif sma20 is not None and close_price < sma20:
raw_scores["volume_breakout"] = -1.0
# 4b. OBV (On-Balance Volume) Crossover vote
if obv_data and len(obv_data) >= 2:
obv_now = obv_data[-1]
obv_prev = obv_data[-2]
if obv_now is True:
raw_scores["obv"] = 1.0
elif obv_now is False:
raw_scores["obv"] = -1.0
elif obv_prev is True:
# One bar ago still counts for momentum
raw_scores["obv"] = 0.5
elif obv_prev is False:
raw_scores["obv"] = -0.5
# 10. Stochastic RSI Crossover vote
if stoch_rsi_data:
k_line = stoch_rsi_data.get("k", [])
d_line = stoch_rsi_data.get("d", [])
if len(k_line) >= 2 and len(d_line) >= 2:
k_curr = k_line[-1]
k_prev = k_line[-2]
d_curr = d_line[-1]
if k_curr is not None and d_curr is not None and k_prev is not None:
# Oversold: %K < 20 and crossing above %D → BUY
if k_curr < 20 and k_prev <= d_curr and k_curr > d_curr:
raw_scores["stoch_rsi"] = 1.5
# Overbought: %K > 80 and crossing below %D → SELL
elif k_curr > 80 and k_prev >= d_curr and k_curr < d_curr:
raw_scores["stoch_rsi"] = -1.5
# 11. MFI (Money Flow Index) vote
if mfi_data and len(mfi_data) >= 2:
mfi_curr = mfi_data[-1]
mfi_prev = mfi_data[-2]
if mfi_curr is not None and mfi_prev is not None:
# Oversold: MFI < 20 and turning up → BUY
if mfi_curr < 20 and mfi_curr > mfi_prev:
raw_scores["mfi"] = 1.5
# Overbought: MFI > 80 and turning down → SELL
elif mfi_curr > 80 and mfi_curr < mfi_prev:
raw_scores["mfi"] = -1.5
# Neutral divergence: MFI trending in opposite direction to close
elif mfi_curr < 30:
raw_scores["mfi"] = 0.5
elif mfi_curr > 70:
raw_scores["mfi"] = -0.5
# 12. FVG (Fair Value Gap) vote — only when price is near the gap
if fvg_data:
fvg_type = fvg_data.get("type")
fvg_high = fvg_data.get("gap_high")
fvg_low = fvg_data.get("gap_low")
if fvg_type == "BULLISH" and fvg_high is not None and fvg_low is not None:
# Price near or inside bullish FVG → support → BUY
gap_mid = (fvg_high + fvg_low) / 2.0
dist_pct = abs(close_price - gap_mid) / close_price * 100
if dist_pct < 1.0:
raw_scores["fvg"] = 2.0
elif dist_pct < 2.0:
raw_scores["fvg"] = 1.0
# else: too far from gap → no vote (prevents noise)
elif fvg_type == "BEARISH" and fvg_high is not None and fvg_low is not None:
# Price near or inside bearish FVG → resistance → SELL
gap_mid = (fvg_high + fvg_low) / 2.0
dist_pct = abs(close_price - gap_mid) / close_price * 100
if dist_pct < 1.0:
raw_scores["fvg"] = -2.0
elif dist_pct < 2.0:
raw_scores["fvg"] = -1.0
# else: too far from gap → no vote
# 13. Candlestick Patterns vote
if candlestick_score is not None and candlestick_score != 0.0:
raw_scores["candlestick"] = candlestick_score
# 5. Ichimoku Cloud vote
if ichi_data:
tenkan = ichi_data.get("tenkan", [])
kijun = ichi_data.get("kijun", [])
senkou_a = ichi_data.get("senkou_a", [])
senkou_b = ichi_data.get("senkou_b", [])
if (tenkan and len(tenkan) >= 2 and kijun and len(kijun) >= 2
and senkou_a and len(senkou_a) >= 2 and senkou_b and len(senkou_b) >= 2):
t_now = tenkan[-1]
k_now = kijun[-1]
t_prev = tenkan[-2]
k_prev = kijun[-2]
sa_now = senkou_a[-1]
sb_now = senkou_b[-1]
ichi_score = 0.0
if t_now is not None and k_now is not None and t_prev is not None and k_prev is not None:
if t_prev <= k_prev and t_now > k_now:
ichi_score += 1.5
elif t_prev >= k_prev and t_now < k_now:
ichi_score -= 1.5
if sa_now is not None and sb_now is not None:
if sa_now > sb_now and close_price > sa_now:
ichi_score += 1.0
elif sa_now < sb_now and close_price < sb_now:
ichi_score -= 1.0
raw_scores["ichimoku"] = ichi_score
# 6. Divergence Detection vote
div_score = 0.0
for div_type, div_strength in [rsi_div, macd_div]:
if div_type == "BULLISH":
div_score += 2.0 if div_strength == "STRONG" else 1.0
elif div_type == "BEARISH":
div_score -= 2.0 if div_strength == "STRONG" else 1.0
raw_scores["divergence"] = div_score
# 7. 🌤️ Market Structure (SMC) vote
smc_score = 0.0
if smc_data:
bos = smc_data.get("bos")
choch = smc_data.get("choch")
trend = smc_data.get("trend", "NEUTRAL")
obs = smc_data.get("order_blocks", [])
if bos == "BULLISH":
smc_score += 1.5
elif bos == "BEARISH":
smc_score -= 1.5
if choch == "BULLISH":
smc_score += 1.5
elif choch == "BEARISH":
smc_score -= 1.5
if trend == "BULLISH":
smc_score += 1.0
elif trend == "BEARISH":
smc_score -= 1.0
if obs:
latest_close = close_price
bullish_obs = [ob for ob in obs if ob.get("type") == "BULLISH"]
bearish_obs = [ob for ob in obs if ob.get("type") == "BEARISH"]
for ob in bullish_obs:
ob_high = ob.get("price_high", 0)
if 0 < ob_high and ob_high * 0.995 <= latest_close <= ob_high * 1.005:
smc_score += 1.0
break
for ob in bearish_obs:
ob_low = ob.get("price_low", 0)
if 0 < ob_low and ob_low * 0.995 <= latest_close <= ob_low * 1.005:
smc_score -= 1.0
break
raw_scores["smc"] = smc_score
# 8. 🔄 Multi-Timeframe vote (weighted)
mtf_score = 0.0
if mtf_votes:
for sig_type, sig_strength, weight in mtf_votes:
if sig_type == STRONG_BUY:
mtf_score += 2.0 * weight
elif sig_type == BUY:
mtf_score += 1.0 * weight
elif sig_type == STRONG_SELL:
mtf_score -= 2.0 * weight
elif sig_type == SELL:
mtf_score -= 1.0 * weight
raw_scores["mtf"] = mtf_score
# ── Apply enabled_strategies filter (zero-out disabled strategies) ──
if enabled_strategies is not None:
disabled = [s for s in raw_scores if s not in enabled_strategies]
if disabled:
logger.debug("Disabled strategies: %s", disabled)
for s in disabled:
raw_scores[s] = 0.0
# ── P1-16: Correlation dampening ──
# Strategies in the same group are highly correlated; dampen when
# multiple group members agree (same sign) to avoid overconfidence.
CORRELATION_GROUPS: list[list[str]] = [
["double_bb_rsi", "stoch_rsi", "mfi"], # Oscillator group
["macd_crossover", "supertrend", "ichimoku"], # Trend group
["volume_breakout", "obv"], # Volume group
["divergence", "smc", "fvg", "candlestick"], # Pattern group
]
for group in CORRELATION_GROUPS:
active = [(s, raw_scores[s]) for s in group if raw_scores[s] != 0.0]
if len(active) >= 2:
signs = [1 if v > 0 else -1 for _, v in active]
pos_count = sum(1 for s in signs if s > 0)
neg_count = sum(1 for s in signs if s < 0)
# Dampen: scale each strategy's score by 1/sqrt(count)
if pos_count >= 2:
dampen = 1.0 / (pos_count ** 0.5)
for strat, val in active:
if val > 0:
raw_scores[strat] = val * dampen
if neg_count >= 2:
dampen = 1.0 / (neg_count ** 0.5)
for strat, val in active:
if val < 0:
raw_scores[strat] = val * dampen
# ── Apply win-rate boosting ──
boosted_scores: dict[str, float] = {}
for strategy, raw_score in raw_scores.items():
boosted_scores[strategy] = boost_score(raw_score, strategy, rates)
total_score = sum(boosted_scores.values())
confidence = compute_booster_confidence(raw_scores, rates)
# ── Dynamic threshold normalization ──
# Normalize total_score by sqrt(active_strategies) so that
# 3 strategies voting STRONG ≈ 10 strategies voting weak
active_count = sum(1 for v in boosted_scores.values() if v != 0.0)
if active_count > 1:
norm_factor = max(active_count ** 0.5, 1.0)
adjusted_score = total_score / norm_factor
else:
adjusted_score = total_score
# ── Final classification from boosted score ──
# 🔧 Dynamic thresholds: STRONG needs effective 4.0, BUY/SELL needs 1.0
if adjusted_score >= 4.0:
return STRONG_BUY, "STRONG", confidence, raw_scores
elif adjusted_score >= 1.0:
return BUY, "MODERATE", confidence, raw_scores
elif adjusted_score <= -4.0:
return STRONG_SELL, "STRONG", confidence, raw_scores
elif adjusted_score <= -1.0:
return SELL, "MODERATE", confidence, raw_scores
return None, None, confidence, raw_scores
def _calculate_pnl(
entry_price: Decimal,
exit_price: Decimal,
direction: str,
quantity: Decimal,
) -> tuple[Decimal, Decimal]:
"""Calculate absolute and percentage P&L."""
if entry_price == 0:
return Decimal("0"), Decimal("0")
# P1-15: Guard against divide-by-zero when quantity is 0
if quantity is None or quantity <= 0:
return Decimal("0"), Decimal("0")
if direction == "LONG":
pnl = (exit_price - entry_price) * quantity
else:
pnl = (entry_price - exit_price) * quantity
pnl_percent = (pnl / (entry_price * quantity)) * Decimal("100")
return pnl, pnl_percent
+23 -526
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import json
import logging
import math
import time as _time
from datetime import datetime, timedelta, timezone
from decimal import Decimal
@@ -29,12 +28,7 @@ from app.models.symbol import Symbol
from app.models.user import User
from app.services.candle_service import get_indicators
from app.services.indicator_service import atr as compute_atr
from app.services.signal_booster import (
boost_score,
compute_strategy_win_rates,
get_cached_rates,
get_confidence as compute_booster_confidence,
)
from app.services.signal_booster import compute_strategy_win_rates
from app.schemas.signal import SignalResponse, TradeResponse
from app.core.security import decrypt_api_key
from app.exchange.factory import factory as exchange_factory
@@ -83,530 +77,33 @@ async def _get_cached_enabled_strategies(db: AsyncSession) -> list[str] | None:
return _ENABLED_STRATEGIES_CACHE
# ---------------------------------------------------------------------------
# Signal type constants
# Signal scoring — extracted to signal_scoring.py (pure, no I/O). Re-exported
# here so existing call sites/imports of `app.services.signal_service`
# continue to work unchanged.
# ---------------------------------------------------------------------------
STRONG_BUY = "STRONG_BUY"
BUY = "BUY"
STRONG_SELL = "STRONG_SELL"
SELL = "SELL"
CAUTION_LONG = "CAUTION_LONG"
CAUTION_SHORT = "CAUTION_SHORT"
SQUEEZE_ALERT = "SQUEEZE_ALERT"
from app.services.signal_scoring import ( # noqa: E402
BUY,
CAUTION_LONG,
CAUTION_SHORT,
MIN_BB_DISTANCE_PCT,
SELL,
SQUEEZE_ALERT,
STRONG_BUY,
STRONG_SELL,
_calculate_pnl,
_classify_signal_bb,
_classify_signal_combined,
_detect_squeeze,
_get_bb_values,
_get_rsi_values,
_get_sma_values,
)
# P2-10: Cross-timeframe cooldown cache to prevent duplicate signals
# Key: f"{symbol}:{exchange}:{timeframe}" → last signal timestamp
_signal_cooldown: dict[str, float] = {}
_COOLDOWN_SECONDS = 300 # 5 minutes between same-direction signals
# ---------------------------------------------------------------------------
# Signal detection configuration
# ---------------------------------------------------------------------------
# Minimum distance from BB bounds to filter noise
MIN_BB_DISTANCE_PCT = Decimal("0.001") # 0.1%
def _get_bb_values(indicators: dict) -> dict[str, list[float]] | None:
"""Extract Bollinger Band values from indicators dict."""
bb = indicators.get("bollinger_bands")
if not bb:
return None
# Ensure all required keys exist
required = ["upper", "middle", "lower"]
if not all(k in bb for k in required):
return None
return bb
def _get_rsi_values(indicators: dict) -> list[float] | None:
"""Extract RSI values."""
rsi = indicators.get("rsi_14")
if not rsi or not isinstance(rsi, list) or len(rsi) == 0:
return None
return rsi
def _get_sma_values(indicators: dict) -> list[float] | None:
"""Extract SMA values."""
sma = indicators.get("sma_20")
if not sma or not isinstance(sma, list) or len(sma) == 0:
return None
return sma
def _detect_squeeze(
bb: dict[str, list[float]],
lookback: int = 10,
) -> bool:
"""Detect Bollinger Band squeeze — narrowing of the band width.
A squeeze occurs when the current band width is at the lower end
of the recent range, indicating low volatility before a breakout.
"""
upper = bb.get("upper", [])
lower = bb.get("lower", [])
if not upper or not lower or len(upper) < lookback or len(lower) < lookback:
return False
# Band width = upper - lower
widths = []
for i in range(max(0, len(upper) - lookback), len(upper)):
try:
widths.append(upper[i] - lower[i])
except TypeError:
return False
if len(widths) < 3:
return False
current_width = widths[-1]
# Check if upper_1 and lower_1 exist for squeeze detection
upper_1 = bb.get("upper_1", [])
lower_1 = bb.get("lower_1", [])
if upper_1 and lower_1 and len(upper_1) >= 1 and len(lower_1) >= 1:
try:
inner_width = upper_1[-1] - lower_1[-1]
outer_width = upper[-1] - lower[-1]
# Squeeze when inner bands are VERY narrow relative to outer (≤20%)
if outer_width > 0 and inner_width / outer_width < 0.2:
return True
except TypeError:
pass
# Alternative: check if current width is at absolute minimum of longer lookback
min_width = min(widths)
if min_width > 0 and current_width <= min_width * 1.01:
return True
return False
def _classify_signal_bb(
close_price: float,
bb: dict[str, list[float]],
rsi: list[float] | None,
sma: list[float] | None,
) -> tuple[Optional[str], Optional[str]]:
"""Classify signal using Double BB + RSI only.
Returns (signal_type, strength) or (None, None) if no signal.
Used as one vote in the combined 4-algorithm system.
"""
if len(bb.get("upper", [])) == 0 or len(bb.get("lower", [])) == 0:
return None, None
upper_2 = bb["upper"][-1]
lower_2 = bb["lower"][-1]
upper_1 = bb.get("upper_1", [None])[-1]
lower_1 = bb.get("lower_1", [None])[-1]
middle = bb.get("middle", [None])[-1]
current_rsi = rsi[-1] if rsi and len(rsi) > 0 else 50
current_sma = sma[-1] if sma and len(sma) > 0 else None
# --- Price above BB 1σ Upper → Trend Long ---
if upper_1 is not None and close_price > upper_1:
if close_price > upper_2:
if current_rsi > 75:
return CAUTION_SHORT, "MODERATE"
# Only STRONG_BUY if RSI is firmly bullish AND SMA is above middle
if current_rsi > 60 and current_sma and middle and current_sma > middle:
return STRONG_BUY, "STRONG"
return BUY, "MODERATE"
else:
if current_rsi > 55:
return BUY, "MODERATE"
return BUY, "WEAK"
# --- Price below BB 1σ Lower → Trend Short ---
if lower_1 is not None and close_price < lower_1:
if close_price < lower_2:
if current_rsi < 25:
return CAUTION_LONG, "MODERATE"
# Only STRONG_SELL if RSI is firmly bearish AND SMA is below middle
if current_rsi < 40 and current_sma and middle and current_sma < middle:
return STRONG_SELL, "STRONG"
return SELL, "MODERATE"
else:
if current_rsi < 45:
return SELL, "MODERATE"
return SELL, "WEAK"
return None, None
def _classify_signal_combined(
close_price: float,
bb: dict[str, list[float]],
rsi: list[float] | None,
sma: list[float] | None,
macd_data: dict | None,
st_data: dict | None,
vol_data: list | None,
ichi_data: dict | None = None,
rsi_div: tuple = (None, None),
macd_div: tuple = (None, None),
smc_data: dict | None = None,
mtf_votes: list[tuple[Optional[str], Optional[str], float]] | None = None,
obv_data: list | None = None,
stoch_rsi_data: dict | None = None,
mfi_data: list | None = None,
fvg_data: dict | None = None,
candlestick_score: float | None = None,
rates: dict[str, float] | None = None,
enabled_strategies: list[str] | None = None,
) -> tuple[Optional[str], Optional[str], float, dict[str, float]]:
"""Classify market state using 13-algorithm voting with win-rate boosting.
Algorithms:
1. Double BB + RSI
2. MACD Crossover
3. SuperTrend
4. Volume Breakout
5. Ichimoku Cloud
6. Divergence Detection (RSI + MACD)
7. 🌤️ Market Structure (SMC) — BOS, CHoCH, OB
8. 🔄 Multi-Timeframe (15m + 1h + 4h)
9. 📊 OBV (On-Balance Volume) Crossover
10. 🔄 Stochastic RSI Crossover
11. 💰 MFI (Money Flow Index)
12. 🕯️ FVG (Fair Value Gap)
13. 🕯️ Candlestick Patterns (30+ patterns)
Each algorithm votes: BUY (+1/+2), SELL (-1/-2), or NEUTRAL (0).
If *rates* is provided, each strategy's raw score is boosted by its
historical win rate before the final classification.
Returns (signal_type, strength, confidence, raw_scores) where
confidence is a 0-1 float and raw_scores is a dict of all 9
algorithm scores for ML feature collection.
"""
# ── NaN/Inf guard: reject any invalid price before processing ──
if not math.isfinite(close_price) or close_price <= 0:
logger.warning("_classify_signal_combined: invalid close_price=%s, returning NEUTRAL", close_price)
return None, None, 0.0, {}
# ── Special signals (override) ──
squeeze = _detect_squeeze(bb)
if squeeze:
return SQUEEZE_ALERT, "MODERATE", 0.5, {}
# P2-2: Call _classify_signal_bb ONCE, reuse result for both
# early-return check AND the raw_scores vote
bb_type, bb_strength = _classify_signal_bb(close_price, bb, rsi, sma)
if bb_type in (CAUTION_LONG, CAUTION_SHORT):
return bb_type, "MODERATE", 0.5, {}
# ── Collect per-strategy raw scores ──
raw_scores: dict[str, float] = {
"double_bb_rsi": 0.0,
"macd_crossover": 0.0,
"supertrend": 0.0,
"volume_breakout": 0.0,
"ichimoku": 0.0,
"divergence": 0.0,
"smc": 0.0,
"mtf": 0.0,
"obv": 0.0,
"stoch_rsi": 0.0,
"mfi": 0.0,
"fvg": 0.0,
"candlestick": 0.0,
}
# 1. BB + RSI vote (reuse bb_type/bb_strength from above — P2-2)
if bb_type == STRONG_BUY:
raw_scores["double_bb_rsi"] = 2.0
elif bb_type == BUY:
raw_scores["double_bb_rsi"] = 1.0
elif bb_type == STRONG_SELL:
raw_scores["double_bb_rsi"] = -2.0
elif bb_type == SELL:
raw_scores["double_bb_rsi"] = -1.0
# 2. MACD Crossover vote
if macd_data:
macd_line = macd_data.get("macd_line", [])
signal_line = macd_data.get("signal_line", [])
if len(macd_line) >= 2 and len(signal_line) >= 2:
m_curr = macd_line[-1]
m_prev = macd_line[-2]
s_curr = signal_line[-1]
s_prev = signal_line[-2]
if m_curr is not None and s_curr is not None and m_prev is not None and s_prev is not None:
if m_prev <= s_prev and m_curr > s_curr and m_curr > 0:
raw_scores["macd_crossover"] = 1.0
elif m_prev >= s_prev and m_curr < s_curr and m_curr < 0:
raw_scores["macd_crossover"] = -1.0
# 3. SuperTrend vote
if st_data:
trend = st_data.get("trend", [])
if trend and len(trend) >= 1 and trend[-1] is not None:
raw_scores["supertrend"] = 1.0 if trend[-1] else -1.0
# 4. Volume Breakout vote (current bar only — avoid stale signals)
if vol_data and len(vol_data) >= 2:
vol_now = vol_data[-1]
if vol_now is True:
sma20 = sma[-1] if sma and len(sma) > 0 else None
if sma20 is not None and close_price > sma20:
raw_scores["volume_breakout"] = 1.0
elif sma20 is not None and close_price < sma20:
raw_scores["volume_breakout"] = -1.0
# 4b. OBV (On-Balance Volume) Crossover vote
if obv_data and len(obv_data) >= 2:
obv_now = obv_data[-1]
obv_prev = obv_data[-2]
if obv_now is True:
raw_scores["obv"] = 1.0
elif obv_now is False:
raw_scores["obv"] = -1.0
elif obv_prev is True:
# One bar ago still counts for momentum
raw_scores["obv"] = 0.5
elif obv_prev is False:
raw_scores["obv"] = -0.5
# 10. Stochastic RSI Crossover vote
if stoch_rsi_data:
k_line = stoch_rsi_data.get("k", [])
d_line = stoch_rsi_data.get("d", [])
if len(k_line) >= 2 and len(d_line) >= 2:
k_curr = k_line[-1]
k_prev = k_line[-2]
d_curr = d_line[-1]
if k_curr is not None and d_curr is not None and k_prev is not None:
# Oversold: %K < 20 and crossing above %D → BUY
if k_curr < 20 and k_prev <= d_curr and k_curr > d_curr:
raw_scores["stoch_rsi"] = 1.5
# Overbought: %K > 80 and crossing below %D → SELL
elif k_curr > 80 and k_prev >= d_curr and k_curr < d_curr:
raw_scores["stoch_rsi"] = -1.5
# 11. MFI (Money Flow Index) vote
if mfi_data and len(mfi_data) >= 2:
mfi_curr = mfi_data[-1]
mfi_prev = mfi_data[-2]
if mfi_curr is not None and mfi_prev is not None:
# Oversold: MFI < 20 and turning up → BUY
if mfi_curr < 20 and mfi_curr > mfi_prev:
raw_scores["mfi"] = 1.5
# Overbought: MFI > 80 and turning down → SELL
elif mfi_curr > 80 and mfi_curr < mfi_prev:
raw_scores["mfi"] = -1.5
# Neutral divergence: MFI trending in opposite direction to close
elif mfi_curr < 30:
raw_scores["mfi"] = 0.5
elif mfi_curr > 70:
raw_scores["mfi"] = -0.5
# 12. FVG (Fair Value Gap) vote — only when price is near the gap
if fvg_data:
fvg_type = fvg_data.get("type")
fvg_high = fvg_data.get("gap_high")
fvg_low = fvg_data.get("gap_low")
if fvg_type == "BULLISH" and fvg_high is not None and fvg_low is not None:
# Price near or inside bullish FVG → support → BUY
gap_mid = (fvg_high + fvg_low) / 2.0
dist_pct = abs(close_price - gap_mid) / close_price * 100
if dist_pct < 1.0:
raw_scores["fvg"] = 2.0
elif dist_pct < 2.0:
raw_scores["fvg"] = 1.0
# else: too far from gap → no vote (prevents noise)
elif fvg_type == "BEARISH" and fvg_high is not None and fvg_low is not None:
# Price near or inside bearish FVG → resistance → SELL
gap_mid = (fvg_high + fvg_low) / 2.0
dist_pct = abs(close_price - gap_mid) / close_price * 100
if dist_pct < 1.0:
raw_scores["fvg"] = -2.0
elif dist_pct < 2.0:
raw_scores["fvg"] = -1.0
# else: too far from gap → no vote
# 13. Candlestick Patterns vote
if candlestick_score is not None and candlestick_score != 0.0:
raw_scores["candlestick"] = candlestick_score
# 5. Ichimoku Cloud vote
if ichi_data:
tenkan = ichi_data.get("tenkan", [])
kijun = ichi_data.get("kijun", [])
senkou_a = ichi_data.get("senkou_a", [])
senkou_b = ichi_data.get("senkou_b", [])
if (tenkan and len(tenkan) >= 2 and kijun and len(kijun) >= 2
and senkou_a and len(senkou_a) >= 2 and senkou_b and len(senkou_b) >= 2):
t_now = tenkan[-1]
k_now = kijun[-1]
t_prev = tenkan[-2]
k_prev = kijun[-2]
sa_now = senkou_a[-1]
sb_now = senkou_b[-1]
ichi_score = 0.0
if t_now is not None and k_now is not None and t_prev is not None and k_prev is not None:
if t_prev <= k_prev and t_now > k_now:
ichi_score += 1.5
elif t_prev >= k_prev and t_now < k_now:
ichi_score -= 1.5
if sa_now is not None and sb_now is not None:
if sa_now > sb_now and close_price > sa_now:
ichi_score += 1.0
elif sa_now < sb_now and close_price < sb_now:
ichi_score -= 1.0
raw_scores["ichimoku"] = ichi_score
# 6. Divergence Detection vote
div_score = 0.0
for div_type, div_strength in [rsi_div, macd_div]:
if div_type == "BULLISH":
div_score += 2.0 if div_strength == "STRONG" else 1.0
elif div_type == "BEARISH":
div_score -= 2.0 if div_strength == "STRONG" else 1.0
raw_scores["divergence"] = div_score
# 7. 🌤️ Market Structure (SMC) vote
smc_score = 0.0
if smc_data:
bos = smc_data.get("bos")
choch = smc_data.get("choch")
trend = smc_data.get("trend", "NEUTRAL")
obs = smc_data.get("order_blocks", [])
if bos == "BULLISH":
smc_score += 1.5
elif bos == "BEARISH":
smc_score -= 1.5
if choch == "BULLISH":
smc_score += 1.5
elif choch == "BEARISH":
smc_score -= 1.5
if trend == "BULLISH":
smc_score += 1.0
elif trend == "BEARISH":
smc_score -= 1.0
if obs:
latest_close = close_price
bullish_obs = [ob for ob in obs if ob.get("type") == "BULLISH"]
bearish_obs = [ob for ob in obs if ob.get("type") == "BEARISH"]
for ob in bullish_obs:
ob_high = ob.get("price_high", 0)
if 0 < ob_high and ob_high * 0.995 <= latest_close <= ob_high * 1.005:
smc_score += 1.0
break
for ob in bearish_obs:
ob_low = ob.get("price_low", 0)
if 0 < ob_low and ob_low * 0.995 <= latest_close <= ob_low * 1.005:
smc_score -= 1.0
break
raw_scores["smc"] = smc_score
# 8. 🔄 Multi-Timeframe vote (weighted)
mtf_score = 0.0
if mtf_votes:
for sig_type, sig_strength, weight in mtf_votes:
if sig_type == STRONG_BUY:
mtf_score += 2.0 * weight
elif sig_type == BUY:
mtf_score += 1.0 * weight
elif sig_type == STRONG_SELL:
mtf_score -= 2.0 * weight
elif sig_type == SELL:
mtf_score -= 1.0 * weight
raw_scores["mtf"] = mtf_score
# ── Apply enabled_strategies filter (zero-out disabled strategies) ──
if enabled_strategies is not None:
disabled = [s for s in raw_scores if s not in enabled_strategies]
if disabled:
logger.debug("Disabled strategies: %s", disabled)
for s in disabled:
raw_scores[s] = 0.0
# ── P1-16: Correlation dampening ──
# Strategies in the same group are highly correlated; dampen when
# multiple group members agree (same sign) to avoid overconfidence.
CORRELATION_GROUPS: list[list[str]] = [
["double_bb_rsi", "stoch_rsi", "mfi"], # Oscillator group
["macd_crossover", "supertrend", "ichimoku"], # Trend group
["volume_breakout", "obv"], # Volume group
["divergence", "smc", "fvg", "candlestick"], # Pattern group
]
for group in CORRELATION_GROUPS:
active = [(s, raw_scores[s]) for s in group if raw_scores[s] != 0.0]
if len(active) >= 2:
signs = [1 if v > 0 else -1 for _, v in active]
pos_count = sum(1 for s in signs if s > 0)
neg_count = sum(1 for s in signs if s < 0)
# Dampen: scale each strategy's score by 1/sqrt(count)
if pos_count >= 2:
dampen = 1.0 / (pos_count ** 0.5)
for strat, val in active:
if val > 0:
raw_scores[strat] = val * dampen
if neg_count >= 2:
dampen = 1.0 / (neg_count ** 0.5)
for strat, val in active:
if val < 0:
raw_scores[strat] = val * dampen
# ── Apply win-rate boosting ──
boosted_scores: dict[str, float] = {}
for strategy, raw_score in raw_scores.items():
boosted_scores[strategy] = boost_score(raw_score, strategy, rates)
total_score = sum(boosted_scores.values())
confidence = compute_booster_confidence(raw_scores, rates)
# ── Dynamic threshold normalization ──
# Normalize total_score by sqrt(active_strategies) so that
# 3 strategies voting STRONG ≈ 10 strategies voting weak
active_count = sum(1 for v in boosted_scores.values() if v != 0.0)
if active_count > 1:
norm_factor = max(active_count ** 0.5, 1.0)
adjusted_score = total_score / norm_factor
else:
adjusted_score = total_score
# ── Final classification from boosted score ──
# 🔧 Dynamic thresholds: STRONG needs effective 4.0, BUY/SELL needs 1.0
if adjusted_score >= 4.0:
return STRONG_BUY, "STRONG", confidence, raw_scores
elif adjusted_score >= 1.0:
return BUY, "MODERATE", confidence, raw_scores
elif adjusted_score <= -4.0:
return STRONG_SELL, "STRONG", confidence, raw_scores
elif adjusted_score <= -1.0:
return SELL, "MODERATE", confidence, raw_scores
return None, None, confidence, raw_scores
def _calculate_pnl(
entry_price: Decimal,
exit_price: Decimal,
direction: str,
quantity: Decimal,
) -> tuple[Decimal, Decimal]:
"""Calculate absolute and percentage P&L."""
if entry_price == 0:
return Decimal("0"), Decimal("0")
# P1-15: Guard against divide-by-zero when quantity is 0
if quantity is None or quantity <= 0:
return Decimal("0"), Decimal("0")
if direction == "LONG":
pnl = (exit_price - entry_price) * quantity
else:
pnl = (entry_price - exit_price) * quantity
pnl_percent = (pnl / (entry_price * quantity)) * Decimal("100")
return pnl, pnl_percent
# =========================================================================
# Public API
@@ -1319,8 +816,8 @@ async def _manage_trades(
try:
from app.services.risk_manager import DynamicKellySizer
from app.services.signal_booster import get_cached_rates, get_pnl_stats
rates = get_cached_rates()
pnl_stats = get_pnl_stats() # real avg_win / avg_loss from DB
rates = await get_cached_rates()
pnl_stats = await get_pnl_stats() # real avg_win / avg_loss from DB
kelly = DynamicKellySizer()
# Estimate win rate and avg win/loss from cached booster rates
overall_rate = rates.get("__all__", 0.5)
+2 -2
View File
@@ -266,8 +266,8 @@ async def execute_signal_trade(
try:
from app.services.risk_manager import DynamicKellySizer
from app.services.signal_booster import get_cached_rates, get_pnl_stats
rates = get_cached_rates()
pnl_stats = get_pnl_stats()
rates = await get_cached_rates()
pnl_stats = await get_pnl_stats()
kelly = DynamicKellySizer()
overall_rate = rates.get("__all__", 0.5)
dir_rate = rates.get(f"__all___{signal_direction}", overall_rate)