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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user