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
+29
View File
@@ -1,6 +1,12 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy.engine import make_url
def _read_secret_file(path: str) -> str:
with open(path, "r") as f:
return f.read().strip()
class Settings(BaseSettings):
@@ -8,6 +14,11 @@ class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal"
# If set, the password is read from this file (Docker secret) and used
# to override whatever password is embedded in DATABASE_URL. Keeps
# DB credentials out of plain env vars, consistent with how JWT keys
# are already handled.
DB_PASSWORD_FILE: str = ""
# JWT
JWT_PRIVATE_KEY_PATH: str = "/run/secrets/jwt_private.pem"
@@ -18,6 +29,13 @@ class Settings(BaseSettings):
# Encryption
ENCRYPTION_KEY: str = ""
# If set, ENCRYPTION_KEY is read from this file (Docker secret) instead.
ENCRYPTION_KEY_FILE: str = ""
# Redis (shared cache across backend-api / backend-scheduler processes).
# Optional: if unreachable, callers fall back to per-process in-memory
# caches (see app/core/redis_client.py).
REDIS_URL: str = "redis://redis:6379/0"
# Server
HOST: str = "0.0.0.0"
@@ -39,5 +57,16 @@ class Settings(BaseSettings):
extra="allow",
)
def model_post_init(self, __context) -> None:
if self.DB_PASSWORD_FILE:
password = _read_secret_file(self.DB_PASSWORD_FILE)
url = make_url(self.DATABASE_URL).set(password=password)
# SQLAlchemy's default str() masks the password with "***" —
# render_as_string(hide_password=False) is needed to get the
# real, usable connection string back.
self.DATABASE_URL = url.render_as_string(hide_password=False)
if self.ENCRYPTION_KEY_FILE:
self.ENCRYPTION_KEY = _read_secret_file(self.ENCRYPTION_KEY_FILE)
settings = Settings()
+74
View File
@@ -0,0 +1,74 @@
"""Shared async Redis client, used to cache state across the backend-api and
backend-scheduler processes (e.g. signal_booster win-rate/PnL stats), which
are separate containers and can't share Python module-level globals.
Every helper here degrades gracefully: if Redis is unreachable (not
configured for local dev, container down, network hiccup), callers get
``None``/a cache miss instead of an exception, and are expected to fall back
to a per-process in-memory cache.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Optional
from redis import asyncio as redis_asyncio
from app.config import settings
logger = logging.getLogger(__name__)
_client: Optional["redis_asyncio.Redis"] = None
_failed_until: float = 0.0 # monotonic timestamp; skip Redis until past this
_RETRY_COOLDOWN_SECONDS = 30
def _get_client() -> Optional["redis_asyncio.Redis"]:
"""Lazily create the Redis client. Returns None while within the retry
cooldown after a recent failure, so a Redis outage costs at most one
slow connect attempt per cooldown window instead of stalling every call."""
global _client
if time.monotonic() < _failed_until:
return None
if _client is None:
_client = redis_asyncio.from_url(
settings.REDIS_URL, decode_responses=True, socket_connect_timeout=2,
)
return _client
def _mark_failed(exc: Exception, operation: str, key: str) -> None:
global _failed_until
logger.warning(
"Redis %s(%s) failed, falling back to in-memory cache for %ds: %s",
operation, key, _RETRY_COOLDOWN_SECONDS, exc,
)
_failed_until = time.monotonic() + _RETRY_COOLDOWN_SECONDS
async def get_json(key: str) -> Any | None:
"""Return the JSON-decoded value for ``key``, or None on miss/error."""
client = _get_client()
if client is None:
return None
try:
raw = await client.get(key)
return json.loads(raw) if raw is not None else None
except Exception as exc:
_mark_failed(exc, "get_json", key)
return None
async def set_json(key: str, value: Any, ttl_seconds: int) -> None:
"""Store ``value`` as JSON under ``key`` with an expiry. Silently no-ops
on failure — callers should already be maintaining their own in-memory
fallback, so a failed cache write here is not fatal."""
client = _get_client()
if client is None:
return
try:
await client.set(key, json.dumps(value), ex=ttl_seconds)
except Exception as exc:
_mark_failed(exc, "set_json", key)
+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)
+1
View File
@@ -11,6 +11,7 @@ bcrypt==4.0.1
ccxt>=4.3
apscheduler==3.10.4
cachetools==5.5.0
redis==5.0.8
structlog==24.4.0
httpx==0.27.0
websockets==13.0
+38
View File
@@ -0,0 +1,38 @@
"""Tests for fix (o): DB_PASSWORD_FILE / ENCRYPTION_KEY_FILE support in
app/config.py, so DB password and encryption key can be sourced from Docker
secrets files (consistent with how JWT keys are already handled) instead of
plain env vars, while staying backward-compatible when the _FILE variants
are not set.
"""
from __future__ import annotations
from app.config import Settings
def test_db_password_file_overrides_database_url_password(tmp_path):
pw_file = tmp_path / "db_password.txt"
pw_file.write_text("s3cr3t-from-file\n")
settings = Settings(
DATABASE_URL="postgresql+asyncpg://trading:placeholder@db:5432/trading_portal",
DB_PASSWORD_FILE=str(pw_file),
)
assert "s3cr3t-from-file" in settings.DATABASE_URL
assert "placeholder" not in settings.DATABASE_URL
def test_encryption_key_file_overrides_encryption_key(tmp_path):
key_file = tmp_path / "encryption_key.txt"
key_file.write_text("00" * 32 + "\n")
settings = Settings(ENCRYPTION_KEY="", ENCRYPTION_KEY_FILE=str(key_file))
assert settings.ENCRYPTION_KEY == "00" * 32
def test_without_file_variants_plain_env_values_are_unchanged():
settings = Settings(
DATABASE_URL="postgresql+asyncpg://trading:plain@db:5432/trading_portal",
ENCRYPTION_KEY="plain-key",
)
assert settings.DATABASE_URL == "postgresql+asyncpg://trading:plain@db:5432/trading_portal"
assert settings.ENCRYPTION_KEY == "plain-key"
+14 -25
View File
@@ -66,18 +66,15 @@ class TestRsi:
result = rsi(prices, period=14)
assert result[14] == pytest.approx(0.0)
def test_flat_prices_do_not_yield_neutral_50(self):
"""Documents a discovered quirk (not fixed here — flagged for the
team to decide on): when there's truly zero price movement, the
code sets an internal `rs = 50.0` sentinel intending "neutral", but
that value is still run through the RSI formula
(100 - 100/(1+rs)), which maps rs=50 to RSI≈98.04, not the
conventionally-expected neutral RSI of 50. A perfectly flat run
(e.g. an illiquid pair or stablecoin) would misreport as
near-overbought instead of neutral."""
def test_flat_prices_yield_neutral_50(self):
"""Fix (q): zero price movement must report neutral RSI=50, not
~98.04. Previously the code set an internal `rs = 50.0` sentinel
intending "neutral" but still ran it through the RSI formula
(100 - 100/(1+rs)), which maps rs=50 to RSI≈98.04 — misreporting a
perfectly flat run (illiquid pair, stablecoin) as near-overbought."""
prices = [10.0] * 16
result = rsi(prices, period=14)
assert result[14] == pytest.approx(100.0 - 100.0 / 51.0)
assert result[14] == pytest.approx(50.0)
def test_insufficient_data_returns_all_none(self):
result = rsi([1, 2, 3], period=14)
@@ -191,27 +188,19 @@ class TestMfi:
def test_overbought_when_no_negative_flow(self):
# typical price strictly increasing -> every period contributes only
# positive flow -> neg_flow == 0 -> MFI defined as 100.0.
# Index 4 (not 3) is asserted because index 3 is the very first
# computed value and hits the negative-indexing quirk below.
candles = [candle(10 + i, 10 + i, 10 + i, volume=100) for i in range(5)]
result = mfi(candles, period=3)
assert result[4] == pytest.approx(100.0)
def test_first_computed_value_has_a_wraparound_indexing_quirk(self):
"""Documents a discovered quirk (not fixed here — flagged for the
team to decide on): for the first computed MFI value in a series,
the loop compares `typical_prices[j-1]` with `j=0`, which in Python
wraps around to `typical_prices[-1]` (the LAST candle in the whole
series) instead of having no prior candle to compare against. This
spuriously injects one bogus flow-direction comparison. In practice
this only taints the single oldest computed value in a long series
(never the latest, which is what signal_service.py actually reads),
so real-world impact is negligible — but it is objectively wrong."""
def test_first_computed_value_no_longer_wraps_around(self):
"""Fix (r): the first computed MFI value in a series must not
compare typical_prices[0] against typical_prices[-1] (the LAST
candle) via Python's negative-index wraparound. With strictly
increasing prices and no real negative flow, the first computed
value must also be 100.0, same as later ones."""
candles = [candle(10 + i, 10 + i, 10 + i, volume=100) for i in range(5)]
result = mfi(candles, period=3)
# Without the quirk this would also be 100.0 (strictly increasing,
# no real negative flow) — the quirk drags it down to ~69.7.
assert result[3] == pytest.approx(69.69696969696969)
assert result[3] == pytest.approx(100.0)
class TestDetectMarketRegime:
+56
View File
@@ -0,0 +1,56 @@
"""Tests for app/core/redis_client.py — the shared cache helper used to sync
state (win rates, PnL stats) between the backend-api and backend-scheduler
processes. Every path here must degrade gracefully when Redis is down,
since it's an optional cache, not a hard dependency.
"""
from __future__ import annotations
from app.core import redis_client
class FakeRedis:
def __init__(self):
self.store: dict[str, str] = {}
async def get(self, key):
return self.store.get(key)
async def set(self, key, value, ex=None):
self.store[key] = value
class BrokenRedis:
async def get(self, key):
raise ConnectionError("redis unreachable")
async def set(self, key, value, ex=None):
raise ConnectionError("redis unreachable")
async def test_set_then_get_json_roundtrip(monkeypatch):
fake = FakeRedis()
monkeypatch.setattr(redis_client, "_get_client", lambda: fake)
await redis_client.set_json("k", {"a": 1}, ttl_seconds=60)
assert await redis_client.get_json("k") == {"a": 1}
async def test_get_json_returns_none_when_key_missing(monkeypatch):
monkeypatch.setattr(redis_client, "_get_client", lambda: FakeRedis())
assert await redis_client.get_json("missing") is None
async def test_get_json_returns_none_when_client_unavailable(monkeypatch):
monkeypatch.setattr(redis_client, "_get_client", lambda: None)
assert await redis_client.get_json("k") is None
async def test_get_json_does_not_raise_when_redis_errors(monkeypatch):
monkeypatch.setattr(redis_client, "_get_client", lambda: BrokenRedis())
assert await redis_client.get_json("k") is None
async def test_set_json_does_not_raise_when_redis_errors(monkeypatch):
monkeypatch.setattr(redis_client, "_get_client", lambda: BrokenRedis())
# Must not raise -- callers treat cache writes as best-effort.
await redis_client.set_json("k", {"a": 1}, ttl_seconds=60)
@@ -0,0 +1,38 @@
"""Tests for fix (l): signal_booster's win-rate/PnL caches read from Redis
first (shared across the backend-api and backend-scheduler processes),
falling back to this process's own in-memory cache when Redis has no data
or is unavailable.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
from app.services import signal_booster
async def test_get_cached_rates_prefers_redis(monkeypatch):
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value={"__all__": 0.7}))
monkeypatch.setattr(signal_booster, "_win_rate_cache", {"__all__": 0.1})
assert await signal_booster.get_cached_rates() == {"__all__": 0.7}
async def test_get_cached_rates_falls_back_to_in_memory_when_redis_empty(monkeypatch):
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value=None))
monkeypatch.setattr(signal_booster, "_win_rate_cache", {"__all__": 0.42})
assert await signal_booster.get_cached_rates() == {"__all__": 0.42}
async def test_get_pnl_stats_prefers_redis(monkeypatch):
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value={"avg_win": 5.0, "avg_loss": 1.0}))
assert await signal_booster.get_pnl_stats() == {"avg_win": 5.0, "avg_loss": 1.0}
async def test_get_pnl_stats_falls_back_to_defaults_when_nothing_cached(monkeypatch):
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value=None))
monkeypatch.setattr(signal_booster, "_pnl_stats_cache", {})
monkeypatch.setattr(signal_booster, "_last_pnl_cache_update", 0.0)
assert await signal_booster.get_pnl_stats() == {"avg_win": 3.0, "avg_loss": 2.0}
@@ -1,5 +1,5 @@
"""Tests for the pure scoring/classification helpers in
app/services/signal_service.py — the 13-algorithm voting core that decides
app/services/signal_scoring.py — the 13-algorithm voting core that decides
BUY/SELL/STRONG signals. These functions take plain indicator dicts/lists
and return classifications; no DB or network I/O involved.
"""
@@ -8,7 +8,7 @@ from __future__ import annotations
import math
from decimal import Decimal
from app.services.signal_service import (
from app.services.signal_scoring import (
BUY,
CAUTION_LONG,
CAUTION_SHORT,