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