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
+8 -1
View File
@@ -1,5 +1,9 @@
# --- Database ---
DATABASE_URL=postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal
# Production alternative: put the password in a Docker secret file instead
# of embedding it in DATABASE_URL above (overrides DATABASE_URL's password
# at startup). Leave unset for local dev.
# DB_PASSWORD_FILE=/run/secrets/db_password.txt
# --- JWT (RS256) ---
JWT_PRIVATE_KEY_PATH=/run/secrets/jwt_private_key.pem
@@ -7,8 +11,11 @@ JWT_PUBLIC_KEY_PATH=/run/secrets/jwt_public_key.pem
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# --- Encryption (AES-256-CBC for API keys) ---
# --- Encryption (AES-256-GCM for API keys) ---
ENCRYPTION_KEY= # 32-byte hex, generate with: openssl rand -hex 32
# Production alternative: read the key from a Docker secret file instead
# (overrides ENCRYPTION_KEY above). Leave unset for local dev.
# ENCRYPTION_KEY_FILE=/run/secrets/encryption_key.txt
# --- Server ---
HOST=0.0.0.0
+8
View File
@@ -28,6 +28,14 @@ Thumbs.db
docker-data/
pgdata/
# Postgres backups (produced by scripts/backup_postgres.sh)
backups/
*.sql.gz
# Local dev tooling (machine-specific paths, not project code)
.claude/
frontend/dev.cmd
# Logs
*.log
+13
View File
@@ -36,6 +36,7 @@
| `backend-api` | FastAPI | 1G / 2 CPU | REST API, WebSocket, auth |
| `backend-scheduler` | Python async | 2G / 3 CPU | Fetch candle, phân tích tín hiệu, quản lý trade |
| `db` | PostgreSQL 16 | 2G | Lưu trữ toàn bộ dữ liệu |
| `redis` | redis:7-alpine | 192M | Cache dùng chung giữa backend-api và backend-scheduler (win-rate, PnL stats) — có fallback in-memory nếu Redis down |
| `nginx_proxy` | nginx:1.27-alpine | — | Reverse proxy SSL cho tất cả domain |
| `certbot_ssl` | certbot/certbot | — | Auto-renew Let's Encrypt mỗi 12h |
| `gitea` | gitea/gitea:latest | ~300MB | Git server tại git.dangloica.org |
@@ -323,6 +324,17 @@ Script `/opt/data/scripts/health_check.py`:
| `PROJECT_DIR` | `/opt/data/trading-portal` |
| `DATABASE_URL` | postgresql+asyncpg://trading:***@db:5432/trading_portal |
| `LOG_LEVEL` | INFO |
### Secrets (Docker, tất cả nằm trong `/opt/data/trading-portal/secrets/`, bind-mount `:ro` vào `/run/secrets` cho `db`/`backend-api`/`backend-scheduler`)
| File | Dùng bởi | Ghi chú |
|------|---------|---------|
| `jwt_private.pem` | backend-api, backend-scheduler | Ký JWT access/refresh token |
| `jwt_public_keys/*.pem` | backend-api, backend-scheduler | Verify JWT — hỗ trợ rotation nhiều key |
| `db_password.txt` | db, backend-api, backend-scheduler | Mật khẩu Postgres — `db` đọc qua `POSTGRES_PASSWORD_FILE`, backend đọc qua `DB_PASSWORD_FILE` (config.py tự ghép vào `DATABASE_URL`) |
| `encryption_key.txt` | backend-api, backend-scheduler | Khoá AES-256-GCM mã hoá API key sàn — đọc qua `ENCRYPTION_KEY_FILE` |
> Không còn secret nào truyền qua plain env var (`${DB_PASSWORD}`, `${ENCRYPTION_KEY}`) trong `docker-compose.yml` — toàn bộ đã chuyển sang file, đồng nhất với cách JWT key đã làm từ trước.
| Timezone display | UTC+7 (ICT) |
---
@@ -341,6 +353,7 @@ Script `/opt/data/scripts/health_check.py`:
- `compute_strategy_win_rates` có thể chạy 3 lần đồng thời khi cache hết hạn
- Không nghiêm trọng — chỉ xảy ra 1 lần mỗi 6 giờ
- Impact: redundant computation, không sai data
- (2026-07-03) `get_cached_rates()`/`get_pnl_stats()` giờ đọc qua Redis trước — chỉ `backend-scheduler` tính toán và ghi (`compute_strategy_win_rates` chạy trong scheduler), `backend-api` đọc chung kết quả thay vì mỗi process tự giữ cache riêng biệt. Race condition compute vẫn còn (nhiều lần gọi đồng thời trong cùng scheduler), nhưng không còn vấn đề "mỗi process thấy dữ liệu khác nhau" nữa.
---
+44
View File
@@ -366,3 +366,47 @@ psql -U trading -d trading -c "
| PostgreSQL | 5432 | TCP |
| Hermes Dashboard | 9119 | HTTP |
| Hermes Gateway | — | HTTP/WS |
## 9. Backup & Restore
### 9.0 ⚠️ Migration bắt buộc trước khi deploy bản có secrets mới
`docker-compose.yml` giờ yêu cầu 2 file secret mới — **service `db` sẽ không khởi động được nếu thiếu**:
```bash
mkdir -p /opt/data/trading-portal/secrets
echo -n "<mật khẩu Postgres hiện tại hoặc mật khẩu mới>" > /opt/data/trading-portal/secrets/db_password.txt
echo -n "<ENCRYPTION_KEY hiện tại, 64 ký tự hex>" > /opt/data/trading-portal/secrets/encryption_key.txt
chmod 600 /opt/data/trading-portal/secrets/db_password.txt /opt/data/trading-portal/secrets/encryption_key.txt
```
> Nếu container `db` **đã có dữ liệu** với mật khẩu Postgres cũ, `db_password.txt` phải chứa **đúng mật khẩu cũ đó** (không phải mật khẩu mới) — đổi mật khẩu Postgres thật sự là việc khác (`ALTER USER trading WITH PASSWORD ...`), không tự động xảy ra chỉ vì đổi cách truyền secret. `encryption_key.txt` bắt buộc phải là **đúng `ENCRYPTION_KEY` đang dùng** — dùng sai giá trị sẽ khiến toàn bộ API key sàn đã lưu (mã hoá AES-GCM) không giải mã được.
### 9.1 Backup định kỳ
Script `scripts/backup_postgres.sh` chạy `pg_dump` bên trong container `trading-db`, nén gzip, lưu vào `backups/` (đã gitignore — không commit dump vào git), và tự xoá backup cũ hơn N ngày (mặc định 14).
```bash
# Chạy tay
./scripts/backup_postgres.sh # giữ 14 ngày
./scripts/backup_postgres.sh 30 # giữ 30 ngày
# Cron (khuyến nghị: mỗi ngày 3h sáng)
0 3 * * * /opt/data/trading-portal/scripts/backup_postgres.sh >> /var/log/trading-portal-backup.log 2>&1
```
**Khuyến nghị**: đồng bộ thư mục `backups/` ra một nơi lưu trữ khác máy chủ (S3, rsync sang server khác...) — backup nằm cùng ổ đĩa với DB không chống được lỗi hỏng ổ cứng hay xoá nhầm cả server.
### 9.2 Restore
`scripts/restore_postgres.sh` — **thao tác phá huỷ**, luôn hỏi xác nhận trước khi chạy:
```bash
./scripts/restore_postgres.sh backups/trading_portal_20260703_030000.sql.gz
```
Quy trình: dừng `backend-api`/`backend-scheduler` → restore vào DB tạm `trading_portal_restoring` → đổi tên DB cũ thành `trading_portal_old_<timestamp>` (giữ lại, không xoá ngay) → đổi tên DB mới phục hồi thành `trading_portal` → khởi động lại backend. Sau khi xác nhận restore đúng, xoá thủ công DB `_old_...` để giải phóng dung lượng.
### 9.3 Test khôi phục định kỳ
Backup không có giá trị nếu chưa từng thử restore. Khuyến nghị test restore vào một DB tạm (không phải production) ít nhất mỗi quý một lần để chắc chắn quy trình còn hoạt động đúng.
+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,
+34 -5
View File
@@ -8,9 +8,10 @@ services:
environment:
POSTGRES_DB: trading_portal
POSTGRES_USER: trading
POSTGRES_PASSWORD: ${DB_PASSWORD:-trading_secret}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password.txt
volumes:
- pgdata:/var/lib/postgresql/data
- /opt/data/trading-portal/secrets:/run/secrets:ro
ports:
- "127.0.0.1:5432:5432"
restart: unless-stopped
@@ -28,6 +29,26 @@ services:
networks:
- trading-net
# ───────────────── Redis (shared cache: win-rate/PnL stats across api+scheduler) ─────────────────
redis:
image: redis:7-alpine
container_name: trading-redis
command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 192M
cpus: "0.5"
networks:
- trading-net
# ───────────────── FastAPI Backend (user-facing, no scheduler) ─────────────────
backend-api:
build:
@@ -36,10 +57,14 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal
DATABASE_URL: postgresql+asyncpg://trading:unused@db:5432/trading_portal
DB_PASSWORD_FILE: /run/secrets/db_password.txt
PORT: 8001
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
ENCRYPTION_KEY_FILE: /run/secrets/encryption_key.txt
REDIS_URL: redis://redis:6379/0
CORS_ORIGINS: http://localhost,http://localhost:5173,http://localhost:3000
LOG_LEVEL: INFO
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
@@ -75,9 +100,13 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
DATABASE_URL: postgresql+asyncpg://trading:unused@db:5432/trading_portal
DB_PASSWORD_FILE: /run/secrets/db_password.txt
ENCRYPTION_KEY_FILE: /run/secrets/encryption_key.txt
REDIS_URL: redis://redis:6379/0
LOG_LEVEL: INFO
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
JWT_PUBLIC_KEYS_DIR: /run/secrets/jwt_public_keys
+331
View File
@@ -18,11 +18,13 @@
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"oxlint": "^1.69.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}
@@ -2665,6 +2667,290 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "5.21.6",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-x64": "4.3.2",
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.2",
"@tailwindcss/oxide": "4.3.2",
"tailwindcss": "4.3.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@@ -3290,6 +3576,20 @@
"integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==",
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4341,6 +4641,16 @@
"node": ">=10"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5731,6 +6041,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+2
View File
@@ -20,11 +20,13 @@
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"oxlint": "^1.69.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}
+4 -11
View File
@@ -29,21 +29,14 @@ export class ErrorBoundary extends React.Component<Props, State> {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div style={{
padding: 40, textAlign: 'center', color: '#e74c3c',
background: '#1a1a2e', minHeight: '100vh',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}>
<h1 style={{ fontSize: 28, marginBottom: 12 }}>⚠️ Something went wrong</h1>
<p style={{ color: '#aaa', fontSize: 14, maxWidth: 500 }}>
<div className="min-h-screen flex flex-col items-center justify-center gap-3 bg-bg-primary p-10 text-center text-red">
<h1 className="text-2xl font-bold">⚠️ Something went wrong</h1>
<p className="max-w-[500px] text-sm text-text-secondary">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => { this.setState({ hasError: false, error: null }); window.location.href = '/'; }}
style={{
marginTop: 20, padding: '10px 24px', borderRadius: 6, border: 'none',
background: '#3498db', color: '#fff', cursor: 'pointer', fontSize: 14,
}}
className="mt-2 cursor-pointer rounded-md border-none bg-accent-blue px-6 py-2.5 text-sm text-white hover:opacity-90"
>
Reload App
</button>
+6 -18
View File
@@ -16,32 +16,20 @@ export const Skeleton: React.FC<SkeletonProps> = ({
count = 1,
style = {},
}) => {
const baseStyle: React.CSSProperties = {
// width/height/borderRadius are runtime-computed props (can't be static
// Tailwind classes), so they stay inline; the shimmer gradient/animation
// itself is a shared, static style defined once in index.css.
const dynamicStyle: React.CSSProperties = {
width,
height,
borderRadius,
background: 'linear-gradient(90deg, #2a2a3e 25%, #3a3a4e 50%, #2a2a3e 75%)',
backgroundSize: '200% 100%',
animation: 'skeleton-shimmer 1.5s ease-in-out infinite',
marginBottom: count > 1 ? 8 : 0,
};
if (typeof document !== 'undefined' && !document.getElementById('skeleton-keyframes')) {
const styleEl = document.createElement('style');
styleEl.id = 'skeleton-keyframes';
styleEl.textContent = `
@keyframes skeleton-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`;
document.head.appendChild(styleEl);
}
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} style={{ ...baseStyle, ...style }} />
<div key={i} className="skeleton-shimmer" style={{ ...dynamicStyle, ...style }} />
))}
</>
);
@@ -52,7 +40,7 @@ interface DashboardSkeletonProps {
}
export const DashboardSkeleton: React.FC<DashboardSkeletonProps> = ({ lines = 5 }) => (
<div style={{ padding: 20 }}>
<div className="p-5">
<Skeleton height={32} width="60%" style={{ marginBottom: 20 }} />
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
{Array.from({ length: lines }).map((_, i) => (
+187 -216
View File
@@ -30,6 +30,16 @@ interface ModalState {
targetUser: User | null;
}
const btnClass = 'min-h-9 touch-manipulation cursor-pointer whitespace-nowrap rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
const activeBtnClass = 'min-h-9 touch-manipulation cursor-pointer whitespace-nowrap rounded-md border border-accent-blue bg-accent-blue px-4 py-1.5 text-xs text-white';
const cellBtnClass = 'min-h-9 touch-manipulation cursor-pointer rounded border border-border-default bg-transparent px-2.5 py-[3px] text-[11px] text-accent';
const dangerBtnClass = 'min-h-9 touch-manipulation cursor-pointer rounded border border-red/20 bg-transparent px-2.5 py-[3px] text-[11px] text-red';
const inputClass = 'w-full rounded border border-border-default bg-bg-primary px-2 py-[5px] text-xs text-text-primary outline-none';
const labelClass = 'mb-1 text-[11px] font-medium text-text-secondary';
const thClass = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary';
const tdClass = 'px-3 py-2 align-middle';
const statCardClass = 'min-w-[100px] rounded-lg border border-border-default bg-bg-surface px-3.5 py-2 text-center';
export default function AdminPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -303,48 +313,25 @@ export default function AdminPage() {
if (!user?.is_admin) return null;
if (initializing || isLoading) {
return (
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading admin panel...</div>
<div className="flex min-h-screen items-center justify-center bg-bg-primary">
<div className="text-sm text-text-secondary">Loading admin panel...</div>
</div>
);
}
// ── Styles ──
const btnStyle: React.CSSProperties = {
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
touchAction: 'manipulation', minHeight: 36,
};
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
const cellBtn: React.CSSProperties = {
padding: '3px 10px', fontSize: 11, border: '1px solid #30363d',
borderRadius: 4, background: 'transparent', color: '#58a6ff', cursor: 'pointer',
touchAction: 'manipulation', minHeight: 36,
};
const dangerBtn: React.CSSProperties = { ...cellBtn, color: '#f85149', borderColor: '#f8514933' };
const badgeGreen: React.CSSProperties = { color: '#3fb950', fontSize: 12, fontWeight: 600 };
const badgeRed: React.CSSProperties = { color: '#f85149', fontSize: 12, fontWeight: 600 };
const inputStyle: React.CSSProperties = {
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
border: '1px solid #30363d', borderRadius: 4, outline: 'none', width: '100%', boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
};
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
{/* ═══ Navbar ═══ */}
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={{ fontSize: 18 }}>🛡️</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Admin Panel</span>
<nav className="flex flex-wrap items-center justify-between gap-2 overflow-x-auto border-b border-border-default bg-bg-surface px-3 py-2">
<div className="flex shrink-0 items-center gap-3">
<span className="text-lg">🛡️</span>
<span className="text-base font-bold text-text-heading">Admin Panel</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href="/profile" style={{ fontSize: 12, color: '#8b949e', textDecoration: 'none', whiteSpace: 'nowrap' }}>👤 {user.username}</a>
<button style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<div className="flex shrink-0 items-center gap-2">
<a href="/profile" className="whitespace-nowrap text-xs text-text-secondary no-underline">👤 {user.username}</a>
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button
style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }}
className={`${btnClass} border-red/20 text-red`}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
@@ -358,9 +345,9 @@ export default function AdminPage() {
</nav>
{/* ═══ Tabs ═══ */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px', borderBottom: '1px solid #21262d' }}>
<div className="flex gap-1 border-b border-border-muted px-4 py-3">
{(['users', 'exchanges', 'health'] as const).map((tab) => (
<button key={tab} style={activeTab === tab ? activeBtn : btnStyle} onClick={() => setActiveTab(tab)}>
<button key={tab} className={activeTab === tab ? activeBtnClass : btnClass} onClick={() => setActiveTab(tab)}>
{tab === 'users' ? '👥 Users' : tab === 'exchanges' ? '🔌 Exchanges' : '❤️ Health'}
</button>
))}
@@ -370,31 +357,31 @@ export default function AdminPage() {
{/* ═══ USERS TAB ═══ */}
{/* ════════════════════════════════════════════════════ */}
{activeTab === 'users' && (
<div style={{ padding: 16 }}>
<div className="p-4">
{/* Stats bar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>TOTAL</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{totalUsers}</div>
<div className="mb-3 flex flex-wrap gap-3">
<div className={statCardClass}>
<div className="mb-0.5 text-[11px] text-text-dim">TOTAL</div>
<div className="text-xl font-bold text-text-heading">{totalUsers}</div>
</div>
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ACTIVE</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{activeUsers}</div>
<div className={statCardClass}>
<div className="mb-0.5 text-[11px] text-text-dim">ACTIVE</div>
<div className="text-xl font-bold text-green">{activeUsers}</div>
</div>
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ADMINS</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#d29922' }}>{adminUsers}</div>
<div className={statCardClass}>
<div className="mb-0.5 text-[11px] text-text-dim">ADMINS</div>
<div className="text-xl font-bold text-yellow">{adminUsers}</div>
</div>
</div>
{/* ═══ Role Permission Legend ═══ */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 11, color: '#d29922', fontWeight: 700, marginBottom: 6 }}>🛡️ Admin</div>
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
<strong style={{ color: '#f0f6fc' }}>Toàn quyền</strong> — mọi tính năng.<br />
<div className="mb-4 flex flex-wrap gap-2">
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-yellow">🛡️ Admin</div>
<div className="text-[11px] leading-[1.7] text-text-secondary">
<strong className="text-text-heading">Toàn quyền</strong> — mọi tính năng.<br />
• Quản lý user (thêm/sửa/xóa, đặt role)<br />
Quản lý exchange & symbol (thêm, sửa, active/deactive)<br />
Quản lý exchange &amp; symbol (thêm, sửa, active/deactive)<br />
Xem audit log toàn bộ hệ thống<br />
Quản lý system config, health check<br />
Xem tất cả trades, signals, backtest của mọi user<br />
@@ -402,41 +389,41 @@ export default function AdminPage() {
Quản lý strategies, alerts, notification channels
</div>
</div>
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 11, color: '#8b949e', fontWeight: 700, marginBottom: 6 }}>👤 Trader</div>
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
• Xem dashboard, signals & multi-timeframe chart<br />
Chạy backtest & lưu kết quả<br />
Xem & quản lý hypothetical trades cá nhân<br />
<strong style={{ color: '#f0f6fc' }}>Đặt lệnh real trade</strong> (place order)<br />
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-text-secondary">👤 Trader</div>
<div className="text-[11px] leading-[1.7] text-text-secondary">
• Xem dashboard, signals &amp; multi-timeframe chart<br />
Chạy backtest &amp; lưu kết quả<br />
Xem &amp; quản lý hypothetical trades cá nhân<br />
<strong className="text-text-heading">Đặt lệnh real trade</strong> (place order)<br />
Quản lý watchlist, API credentials, personal alerts<br />
Quản lý strategies cá nhân<br />
Xem analytics & performance cá nhân
Xem analytics &amp; performance cá nhân
</div>
</div>
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 11, color: '#58a6ff', fontWeight: 700, marginBottom: 6 }}>👁️ Viewer</div>
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
<strong style={{ color: '#f85149' }}>Chỉ xem (read-only)</strong><br />
• Xem dashboard & real-time chart<br />
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-accent">👁️ Viewer</div>
<div className="text-[11px] leading-[1.7] text-text-secondary">
<strong className="text-red">Chỉ xem (read-only)</strong><br />
• Xem dashboard &amp; real-time chart<br />
Xem signals, trades, backtest history<br />
Xem analytics, alerts, watchlist<br />
<strong style={{ color: '#f85149' }}>Không thể:</strong> trade, đặt lệnh, tạo/sửa user,<br />
<strong className="text-red">Không thể:</strong> trade, đặt lệnh, tạo/sửa user,<br />
&nbsp;&nbsp;&nbsp;chạy backtest mới, chỉnh sửa credentials
</div>
</div>
</div>
{/* Search + Add bar */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
<div className="mb-3 flex items-center gap-2">
<input
style={{ ...inputStyle, maxWidth: 300 }}
className={`${inputClass} max-w-[300px]`}
placeholder="🔍 Search by username, email, display name..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
/>
<button
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', display: 'flex', alignItems: 'center', gap: 4 }}
className={`${btnClass} flex items-center gap-1 border-[#238636] bg-[#238636] text-white`}
onClick={() => openModal('add')}
>
+ Add User
@@ -444,60 +431,60 @@ export default function AdminPage() {
</div>
{/* User table */}
{usersLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading users...</p>}
{usersError && <p style={{ color: '#f85149', fontSize: 13 }}>{usersError}</p>}
{usersLoading && <p className="text-[13px] text-text-dim">Loading users...</p>}
{usersError && <p className="text-[13px] text-red">{usersError}</p>}
{!usersLoading && !usersError && (
<>
{filteredUsers.length === 0 ? (
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
<div className="p-6 text-center text-[13px] text-text-dim">
{userSearch ? 'No users match your search.' : 'No users yet.'}
</div>
) : (
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -12px', padding: '0 12px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 600 }}>
<div className="-mx-3 overflow-x-auto px-3">
<table className="w-full min-w-[600px] border-collapse text-xs">
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Username</th>
<th style={thStyle}>Display Name</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Role</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>Actions</th>
<tr className="border-b border-border-default">
<th className={thClass}>Username</th>
<th className={thClass}>Display Name</th>
<th className={thClass}>Email</th>
<th className={thClass}>Role</th>
<th className={thClass}>Status</th>
<th className={thClass}>Created</th>
<th className={thClass}>Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={String(u.id)} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ ...tdStyle, fontWeight: 600, color: '#f0f6fc' }}>{u.username}</td>
<td style={tdStyle}>{u.display_name || <span style={{ color: '#484f58' }}>—</span>}</td>
<td style={tdStyle}>{u.email}</td>
<td style={tdStyle}>
<tr key={String(u.id)} className="border-b border-border-muted">
<td className={`${tdClass} font-semibold text-text-heading`}>{u.username}</td>
<td className={tdClass}>{u.display_name || <span className="text-text-dim">—</span>}</td>
<td className={tdClass}>{u.email}</td>
<td className={tdClass}>
{(() => {
const role = u.role || (u.is_admin ? 'admin' : 'trader');
if (role === 'admin') return <span style={{ color: '#d29922', fontWeight: 600 }}>🛡️ Admin</span>;
if (role === 'viewer') return <span style={{ color: '#58a6ff', fontWeight: 500 }}>👁️ Viewer</span>;
return <span style={{ color: '#8b949e' }}>👤 Trader</span>;
if (role === 'admin') return <span className="font-semibold text-yellow">🛡️ Admin</span>;
if (role === 'viewer') return <span className="font-medium text-accent">👁️ Viewer</span>;
return <span className="text-text-secondary">👤 Trader</span>;
})()}
</td>
<td style={tdStyle}>
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_active')}>
{u.is_active ? <span style={badgeGreen}>● Active</span> : <span style={badgeRed}>● Inactive</span>}
<td className={tdClass}>
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_active')}>
{u.is_active ? <span className="text-xs font-semibold text-green">● Active</span> : <span className="text-xs font-semibold text-red">● Inactive</span>}
</button>
</td>
<td style={{ ...tdStyle, color: '#484f58' }}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_admin')}>
<td className={`${tdClass} text-text-dim`}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
<td className={tdClass}>
<div className="flex flex-wrap gap-1">
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_admin')}>
{u.is_admin ? 'Demote' : 'Promote'}
</button>
<button style={cellBtn} onClick={() => openModal('edit', u)}>
<button className={cellBtnClass} onClick={() => openModal('edit', u)}>
✏️ Edit
</button>
<button style={cellBtn} onClick={() => openModal('reset', u)}>
<button className={cellBtnClass} onClick={() => openModal('reset', u)}>
🔑 Reset
</button>
<button style={dangerBtn} onClick={() => openModal('delete', u)}>
<button className={dangerBtnClass} onClick={() => openModal('delete', u)}>
🗑️ Delete
</button>
</div>
@@ -508,7 +495,7 @@ export default function AdminPage() {
</table>
</div>
)}
<div style={{ fontSize: 11, color: '#484f58', marginTop: 8 }}>
<div className="mt-2 text-[11px] text-text-dim">
Showing {filteredUsers.length} of {totalUsers} users
</div>
</>
@@ -517,21 +504,12 @@ export default function AdminPage() {
{/* ═══ MODAL ═══ */}
{modal.show && (
<div
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
}}
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
>
<div
style={{
background: '#161b22', border: '1px solid #30363d', borderRadius: 12,
padding: 24, maxWidth: 440, width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
}}
>
<div className="w-[90%] max-w-[440px] rounded-xl border border-border-default bg-bg-surface p-6 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
{/* Title */}
<h3 style={{ fontSize: 15, color: '#f0f6fc', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
<h3 className="mb-4 flex items-center gap-2 text-[15px] text-text-heading">
{modal.type === 'add' && '➕ Add User'}
{modal.type === 'edit' && '✏️ Edit User'}
{modal.type === 'reset' && '🔑 Reset Password'}
@@ -540,30 +518,30 @@ export default function AdminPage() {
{/* Form fields */}
{modal.type === 'add' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="flex flex-col gap-3">
<div>
<div style={labelStyle}>Username</div>
<input style={inputStyle} value={formData.username}
<div className={labelClass}>Username</div>
<input className={inputClass} value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Email</div>
<input style={inputStyle} type="email" value={formData.email}
<div className={labelClass}>Email</div>
<input className={inputClass} type="email" value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Password (8+ chars, 1 number, 1 special)</div>
<input style={inputStyle} type="password" value={formData.password}
<div className={labelClass}>Password (8+ chars, 1 number, 1 special)</div>
<input className={inputClass} type="password" value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Display Name</div>
<input style={inputStyle} value={formData.display_name}
<div className={labelClass}>Display Name</div>
<input className={inputClass} value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Role</div>
<select style={{ ...inputStyle, cursor: 'pointer' }}
<div className={labelClass}>Role</div>
<select className={`${inputClass} cursor-pointer`}
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
<option value="admin">🛡️ Admin</option>
@@ -575,24 +553,24 @@ export default function AdminPage() {
)}
{modal.type === 'edit' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="flex flex-col gap-3">
<div>
<div style={labelStyle}>Username</div>
<input style={{ ...inputStyle, opacity: 0.5 }} value={formData.username} disabled />
<div className={labelClass}>Username</div>
<input className={`${inputClass} opacity-50`} value={formData.username} disabled />
</div>
<div>
<div style={labelStyle}>Email</div>
<input style={inputStyle} type="email" value={formData.email}
<div className={labelClass}>Email</div>
<input className={inputClass} type="email" value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Display Name</div>
<input style={inputStyle} value={formData.display_name}
<div className={labelClass}>Display Name</div>
<input className={inputClass} value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Role</div>
<select style={{ ...inputStyle, cursor: 'pointer' }}
<div className={labelClass}>Role</div>
<select className={`${inputClass} cursor-pointer`}
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
<option value="admin">🛡️ Admin</option>
@@ -605,13 +583,13 @@ export default function AdminPage() {
{modal.type === 'reset' && (
<div>
<p style={{ fontSize: 12, color: '#8b949e', margin: '0 0 12px 0' }}>
Reset password for <strong style={{ color: '#f0f6fc' }}>{modal.targetUser?.username}</strong>.
<p className="mb-3 text-xs text-text-secondary">
Reset password for <strong className="text-text-heading">{modal.targetUser?.username}</strong>.
All active sessions will be revoked.
</p>
<div>
<div style={labelStyle}>New Password (8+ chars, 1 number, 1 special)</div>
<input style={inputStyle} type="password" value={formData.password}
<div className={labelClass}>New Password (8+ chars, 1 number, 1 special)</div>
<input className={inputClass} type="password" value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
</div>
</div>
@@ -619,7 +597,7 @@ export default function AdminPage() {
{modal.type === 'delete' && (
<div>
<p style={{ fontSize: 12, color: '#f85149', margin: '0 0 12px 0', background: 'rgba(248,81,73,0.1)', padding: 10, borderRadius: 6, border: '1px solid rgba(248,81,73,0.2)' }}>
<p className="mb-3 rounded-md border border-red/20 bg-red/10 p-2.5 text-xs text-red">
⚠️ This will permanently delete user <strong>{modal.targetUser?.username}</strong>.
Watchlist, credentials, and sessions will be removed. This cannot be undone!
</p>
@@ -627,14 +605,14 @@ export default function AdminPage() {
)}
{/* Error */}
{formError && <p style={{ color: '#f85149', fontSize: 12, marginTop: 8 }}>{formError}</p>}
{formError && <p className="mt-2 text-xs text-red">{formError}</p>}
{/* Buttons */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
<button style={btnStyle} onClick={closeModal}>Cancel</button>
<div className="mt-4 flex justify-end gap-2">
<button className={btnClass} onClick={closeModal}>Cancel</button>
{modal.type === 'add' && (
<button
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636' }}
className={`${btnClass} border-[#238636] bg-[#238636] text-white`}
onClick={handleAddUser}
disabled={!formData.username || !formData.email || formData.password.length < 8}
>
@@ -643,7 +621,7 @@ export default function AdminPage() {
)}
{modal.type === 'edit' && (
<button
style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}
className={`${btnClass} border-accent-blue bg-accent-blue text-white`}
onClick={handleEditUser}
>
💾 Save Changes
@@ -651,7 +629,7 @@ export default function AdminPage() {
)}
{modal.type === 'reset' && (
<button
style={{ ...btnStyle, background: '#d29922', color: '#fff', borderColor: '#d29922' }}
className={`${btnClass} border-yellow bg-yellow text-white`}
onClick={handleResetPassword}
disabled={!formData.password || formData.password.length < 8}
>
@@ -660,7 +638,7 @@ export default function AdminPage() {
)}
{modal.type === 'delete' && (
<button
style={{ ...btnStyle, background: '#da3633', color: '#fff', borderColor: '#da3633' }}
className={`${btnClass} border-[#da3633] bg-[#da3633] text-white`}
onClick={handleDeleteUser}
>
🗑️ Delete Forever
@@ -677,48 +655,48 @@ export default function AdminPage() {
{/* ═══ EXCHANGES TAB ═══ */}
{/* ════════════════════════════════════════════════════ */}
{activeTab === 'exchanges' && (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: 0 }}>Exchange Management</h3>
<button style={btnStyle} onClick={() => setShowAddEx(!showAddEx)}>
<div className="p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="m-0 text-sm text-text-heading">Exchange Management</h3>
<button className={btnClass} onClick={() => setShowAddEx(!showAddEx)}>
{showAddEx ? '✕ Cancel' : '+ Add Exchange'}
</button>
</div>
{showAddEx && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 12, background: '#161b22', borderRadius: 8, marginBottom: 12, border: '1px solid #30363d' }}>
<input style={inputStyle} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
<input style={inputStyle} placeholder="Display Name (e.g. Binance)" value={newEx.display_name} onChange={(e) => setNewEx({ ...newEx, display_name: e.target.value })} />
<input style={inputStyle} placeholder="Base URL (e.g. https://api.binance.com)" value={newEx.base_url} onChange={(e) => setNewEx({ ...newEx, base_url: e.target.value })} />
<input style={inputStyle} placeholder="WS URL (e.g. wss://stream.binance.com:9443)" value={newEx.ws_url} onChange={(e) => setNewEx({ ...newEx, ws_url: e.target.value })} />
<button style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', marginTop: 4 }} onClick={handleAddExchange}>
<div className="mb-3 flex flex-col gap-2 rounded-lg border border-border-default bg-bg-surface p-3">
<input className={inputClass} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
<input className={inputClass} placeholder="Display Name (e.g. Binance)" value={newEx.display_name} onChange={(e) => setNewEx({ ...newEx, display_name: e.target.value })} />
<input className={inputClass} placeholder="Base URL (e.g. https://api.binance.com)" value={newEx.base_url} onChange={(e) => setNewEx({ ...newEx, base_url: e.target.value })} />
<input className={inputClass} placeholder="WS URL (e.g. wss://stream.binance.com:9443)" value={newEx.ws_url} onChange={(e) => setNewEx({ ...newEx, ws_url: e.target.value })} />
<button className={`${btnClass} mt-1 border-[#238636] bg-[#238636] text-white`} onClick={handleAddExchange}>
✅ Create Exchange
</button>
</div>
)}
{exLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading exchanges...</p>}
{exLoading && <p className="text-[13px] text-text-dim">Loading exchanges...</p>}
{!exLoading && (
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
<div className="overflow-x-auto">
<table className="w-full min-w-[400px] border-collapse text-xs">
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Name</th>
<th style={thStyle}>Display</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Action</th>
<tr className="border-b border-border-default">
<th className={thClass}>Name</th>
<th className={thClass}>Display</th>
<th className={thClass}>Status</th>
<th className={thClass}>Action</th>
</tr>
</thead>
<tbody>
{exchanges.map((ex) => (
<tr key={ex.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={tdStyle}>{ex.name}</td>
<td style={tdStyle}>{ex.display_name || ex.name}</td>
<td style={tdStyle}>
{ex.is_active ? <span style={badgeGreen}>Active</span> : <span style={badgeRed}>Inactive</span>}
<tr key={ex.id} className="border-b border-border-muted">
<td className={tdClass}>{ex.name}</td>
<td className={tdClass}>{ex.display_name || ex.name}</td>
<td className={tdClass}>
{ex.is_active ? <span className="text-xs font-semibold text-green">Active</span> : <span className="text-xs font-semibold text-red">Inactive</span>}
</td>
<td style={tdStyle}>
<button style={cellBtn} onClick={() => toggleExchange(ex)}>
<td className={tdClass}>
<button className={cellBtnClass} onClick={() => toggleExchange(ex)}>
{ex.is_active ? 'Deactivate' : 'Activate'}
</button>
</td>
@@ -733,72 +711,72 @@ export default function AdminPage() {
{/* ═══ HEALTH TAB ═══ */}
{activeTab === 'health' && (
<div style={{ padding: 16 }}>
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: '0 0 12px 0' }}>System Health</h3>
{healthLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading health data...</p>}
<div className="p-4">
<h3 className="mb-3 text-sm text-text-heading">System Health</h3>
{healthLoading && <p className="text-[13px] text-text-dim">Loading health data...</p>}
{!healthLoading && !health && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#484f58', fontSize: 13, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 24, marginBottom: 8 }}>⚠️</div>
<div className="rounded-lg border border-border-default bg-bg-surface px-4 py-6 text-center text-[13px] text-text-dim">
<div className="mb-2 text-2xl">⚠️</div>
Unable to fetch health data. The health endpoint may not be available.
</div>
)}
{health && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>STATUS</div>
<div style={{ fontSize: 18, fontWeight: 700, color: health.status === 'healthy' ? '#3fb950' : '#f85149' }}>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
<div className="mb-1 text-[11px] text-text-dim">STATUS</div>
<div className={`text-lg font-bold ${health.status === 'healthy' ? 'text-green' : 'text-red'}`}>
{health.status === 'healthy' ? '✅ Healthy' : '⚠️ Degraded'}
</div>
</div>
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>UPTIME</div>
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{formatUptime(health.uptime)}</div>
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
<div className="mb-1 text-[11px] text-text-dim">UPTIME</div>
<div className="text-lg font-bold text-text-heading">{formatUptime(health.uptime)}</div>
</div>
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>VERSION</div>
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{health.version}</div>
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
<div className="mb-1 text-[11px] text-text-dim">VERSION</div>
<div className="text-lg font-bold text-text-heading">{health.version}</div>
</div>
</div>
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Database</h4>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
<div className="rounded-lg border border-border-default bg-bg-surface p-3">
<h4 className="mb-2 text-[13px] text-text-heading">Database</h4>
<div className="overflow-x-auto">
<table className="w-full min-w-[400px] border-collapse text-xs">
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Connected</th>
<th style={thStyle}>Latency</th>
<th style={thStyle}>Pool Size</th>
<tr className="border-b border-border-default">
<th className={thClass}>Connected</th>
<th className={thClass}>Latency</th>
<th className={thClass}>Pool Size</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}>{health.db.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}</td>
<td style={tdStyle}>{health.db.latency_ms} ms</td>
<td style={tdStyle}>{health.db.pool_size} connections</td>
<td className={tdClass}>{health.db.connected ? <span className="text-xs font-semibold text-green">● Connected</span> : <span className="text-xs font-semibold text-red">● Disconnected</span>}</td>
<td className={tdClass}>{health.db.latency_ms} ms</td>
<td className={tdClass}>{health.db.pool_size} connections</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Exchange Connections</h4>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
<div className="rounded-lg border border-border-default bg-bg-surface p-3">
<h4 className="mb-2 text-[13px] text-text-heading">Exchange Connections</h4>
<div className="overflow-x-auto">
<table className="w-full min-w-[400px] border-collapse text-xs">
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Exchange</th>
<th style={thStyle}>Status</th>
<tr className="border-b border-border-default">
<th className={thClass}>Exchange</th>
<th className={thClass}>Status</th>
</tr>
</thead>
<tbody>
{health.exchange_connections.map((ec) => (
<tr key={ec.exchange} style={{ borderBottom: '1px solid #21262d' }}>
<td style={tdStyle}>{ec.exchange}</td>
<td style={tdStyle}>
{ec.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}
<tr key={ec.exchange} className="border-b border-border-muted">
<td className={tdClass}>{ec.exchange}</td>
<td className={tdClass}>
{ec.connected ? <span className="text-xs font-semibold text-green">● Connected</span> : <span className="text-xs font-semibold text-red">● Disconnected</span>}
</td>
</tr>
))}
@@ -813,10 +791,3 @@ export default function AdminPage() {
</div>
);
}
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
};
const tdStyle: React.CSSProperties = {
padding: '8px 12px', verticalAlign: 'middle',
};
+232 -280
View File
@@ -11,54 +11,12 @@ import {
type ConditionObject,
} from '../api/alertApi';
// ── Styles ──
const pageStyle: React.CSSProperties = {
minHeight: '100vh', background: '#0d1117', color: '#c9d1d9',
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
padding: '24px',
};
const headerStyle: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 24, flexWrap: 'wrap', gap: 12,
};
const cardStyle: React.CSSProperties = {
padding: 16, background: '#161b22', borderRadius: 8,
border: '1px solid #30363d', marginBottom: 12,
};
const btnStyle: React.CSSProperties = {
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
borderRadius: 6, background: '#21262d', color: '#c9d1d9',
cursor: 'pointer', touchAction: 'manipulation', minHeight: 32,
};
const primaryBtn: React.CSSProperties = {
...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb',
};
const dangerBtn: React.CSSProperties = {
...btnStyle, color: '#f85149', borderColor: '#f8514933',
};
const inputStyle: React.CSSProperties = {
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
border: '1px solid #30363d', borderRadius: 4, outline: 'none',
width: '100%', boxSizing: 'border-box',
};
const selectStyle: React.CSSProperties = { ...inputStyle, width: '100%' };
const labelStyle: React.CSSProperties = {
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
};
const modalOverlay: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
};
const modalContent: React.CSSProperties = {
background: '#161b22', borderRadius: 8, border: '1px solid #30363d',
padding: 24, width: '90%', maxWidth: 600, maxHeight: '80vh',
overflow: 'auto', color: '#c9d1d9',
};
const toggleStyle: React.CSSProperties = {
width: 36, height: 20, borderRadius: 10, border: 'none',
cursor: 'pointer', position: 'relative', transition: 'background 0.2s',
};
// ── Shared Tailwind class fragments ──
const btnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
const primaryBtnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-accent-blue bg-accent-blue px-4 py-1.5 text-xs text-white';
const dangerBtnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-red/20 bg-bg-hover px-4 py-1.5 text-xs text-red';
const inputClass = 'box-border w-full rounded border border-border-default bg-bg-primary px-2 py-1.5 text-xs text-text-primary outline-none';
const labelClass = 'mb-1 text-[11px] font-medium text-text-secondary';
const INDICATOR_OPTIONS = [
{ value: 'rsi', label: 'RSI' },
@@ -233,21 +191,25 @@ export default function AlertsPage() {
// ── Render ──
if (initializing) {
return <div style={pageStyle}><p style={{ textAlign: 'center', paddingTop: 80 }}>Loading...</p></div>;
return (
<div className="min-h-screen bg-bg-primary p-6 font-sans text-text-primary">
<p className="pt-20 text-center">Loading...</p>
</div>
);
}
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
{/* ─── NAV ─── */}
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 18 }}>🔔</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Smart Alerts</span>
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
<div className="flex items-center gap-3">
<span className="text-lg">🔔</span>
<span className="text-base font-bold text-text-heading">Smart Alerts</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<div className="flex items-center gap-2">
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button
style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }}
className={`${btnClass} border-red/20 text-red`}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
@@ -261,246 +223,236 @@ export default function AlertsPage() {
</nav>
{/* Content */}
<div style={{ padding: '16px 24px' }}>
{/* Header */}
<div style={headerStyle}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>🔔 Smart Alerts</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={primaryBtn} onClick={openCreateModal}>+ New Alert</button>
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
</div>
</div>
{/* Error */}
{error && (
<div style={{ padding: '8px 12px', background: 'rgba(248,81,73,0.15)', borderRadius: 6, marginBottom: 16, fontSize: 12, color: '#f85149' }}>
{error}
</div>
)}
{/* Loading */}
{loading && <p style={{ textAlign: 'center', color: '#8b949e' }}>Loading alerts...</p>}
{/* Empty state */}
{!loading && alerts.length === 0 && (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#8b949e' }}>
<p style={{ fontSize: 40, margin: 0 }}>🔔</p>
<p>No alerts configured yet.</p>
<button style={primaryBtn} onClick={openCreateModal}>Create your first alert</button>
</div>
)}
{/* Alert cards */}
{alerts.map((alert) => (
<div key={alert.id} style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{alert.name}</h3>
<span style={{
fontSize: 10, padding: '1px 6px', borderRadius: 4,
background: alert.is_active ? 'rgba(63,185,80,0.15)' : 'rgba(72,79,88,0.3)',
color: alert.is_active ? '#3fb950' : '#484f58',
}}>
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
</span>
<span style={{ fontSize: 10, color: '#8b949e' }}>via {alert.notify_platform}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{/* Toggle */}
<button
style={{
...toggleStyle,
background: alert.is_active ? '#1f6feb' : '#21262d',
}}
onClick={() => handleToggle(alert)}
title={alert.is_active ? 'Pause' : 'Activate'}
>
<span style={{
position: 'absolute', top: 2, width: 16, height: 16,
borderRadius: '50%', background: '#fff',
left: alert.is_active ? 18 : 2,
transition: 'left 0.2s',
}} />
</button>
<button style={btnStyle} onClick={() => openEditModal(alert)}>Edit</button>
<button style={dangerBtn} onClick={() => handleDelete(alert)}>Delete</button>
</div>
<div className="p-4 px-6">
{/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h2 className="m-0 text-xl font-semibold">🔔 Smart Alerts</h2>
<div className="flex items-center gap-2">
<button className={primaryBtnClass} onClick={openCreateModal}>+ New Alert</button>
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
</div>
{/* Conditions */}
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>Conditions:</div>
{alert.conditions.map((cond, idx) => (
<div key={idx} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', background: '#0d1117', borderRadius: 4,
margin: '2px 4px 2px 0', fontSize: 11, color: '#c9d1d9',
border: '1px solid #30363d',
}}>
<span style={{ color: '#58a6ff' }}>{cond.indicator}</span>
<span style={{ color: '#d29922' }}>{cond.operator}</span>
<span style={{ color: '#3fb950' }}>{cond.value}</span>
{cond.timeframe && <span style={{ color: '#8b949e' }}>({cond.timeframe})</span>}
</div>
))}
</div>
))}
{/* ── Create/Edit Modal ── */}
{showModal && (
<div style={modalOverlay} onClick={closeModal}>
<div style={modalContent} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
{editTarget ? 'Edit Alert' : 'New Alert'}
</h3>
{/* Error */}
{error && (
<div className="mb-4 rounded-md bg-red/15 px-3 py-2 text-xs text-red">
{error}
</div>
)}
{/* Name */}
<div style={{ marginBottom: 12 }}>
<div style={labelStyle}>Name</div>
<input
style={inputStyle}
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. RSI overbought + volume spike"
/>
</div>
{/* Loading */}
{loading && <p className="text-center text-text-secondary">Loading alerts...</p>}
{/* Platform */}
<div style={{ marginBottom: 12 }}>
<div style={labelStyle}>Notify via</div>
<select
style={selectStyle}
value={formPlatform}
onChange={(e) => setFormPlatform(e.target.value)}
>
{PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Empty state */}
{!loading && alerts.length === 0 && (
<div className="pt-16 text-center text-text-secondary">
<p className="m-0 text-4xl">🔔</p>
<p>No alerts configured yet.</p>
<button className={primaryBtnClass} onClick={openCreateModal}>Create your first alert</button>
</div>
)}
{/* Alert cards */}
{alerts.map((alert) => (
<div key={alert.id} className="mb-3 rounded-lg border border-border-default bg-bg-surface p-4">
<div className="mb-2 flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold">{alert.name}</h3>
<span className={`rounded px-1.5 py-px text-[10px] ${alert.is_active ? 'bg-green/15 text-green' : 'bg-text-dim/30 text-text-dim'}`}>
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
</span>
<span className="text-[10px] text-text-secondary">via {alert.notify_platform}</span>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* Toggle */}
<button
className={`relative h-5 w-9 cursor-pointer rounded-full border-none transition-colors ${alert.is_active ? 'bg-accent-blue' : 'bg-bg-hover'}`}
onClick={() => handleToggle(alert)}
title={alert.is_active ? 'Pause' : 'Activate'}
>
<span
className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-[left]"
style={{ left: alert.is_active ? 18 : 2 }}
/>
</button>
<button className={btnClass} onClick={() => openEditModal(alert)}>Edit</button>
<button className={dangerBtnClass} onClick={() => handleDelete(alert)}>Delete</button>
</div>
</div>
{/* Conditions */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={labelStyle}>Conditions (all must pass)</span>
<button style={btnStyle} onClick={addCondition}>+ Add Condition</button>
<div className="mb-1 text-[11px] text-text-secondary">Conditions:</div>
{alert.conditions.map((cond, idx) => (
<div key={idx} className="my-0.5 mr-1 inline-flex items-center gap-1 rounded border border-border-default bg-bg-primary px-2 py-0.5 text-[11px] text-text-primary">
<span className="text-accent">{cond.indicator}</span>
<span className="text-yellow">{cond.operator}</span>
<span className="text-green">{cond.value}</span>
{cond.timeframe && <span className="text-text-secondary">({cond.timeframe})</span>}
</div>
))}
</div>
))}
{/* ── Create/Edit Modal ── */}
{showModal && (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60" onClick={closeModal}>
<div
className="max-h-[80vh] w-[90%] max-w-[600px] overflow-auto rounded-lg border border-border-default bg-bg-surface p-6 text-text-primary"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-base font-semibold">
{editTarget ? 'Edit Alert' : 'New Alert'}
</h3>
{/* Name */}
<div className="mb-3">
<div className={labelClass}>Name</div>
<input
className={inputClass}
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. RSI overbought + volume spike"
/>
</div>
{formConditions.map((cond, idx) => (
<div key={idx} style={{
display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap',
padding: '8px 0', borderBottom: idx < formConditions.length - 1 ? '1px solid #21262d' : 'none',
}}>
{/* Indicator */}
<select
style={{ ...selectStyle, width: 110 }}
value={cond.indicator}
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
>
{INDICATOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Platform */}
<div className="mb-3">
<div className={labelClass}>Notify via</div>
<select
className={inputClass}
value={formPlatform}
onChange={(e) => setFormPlatform(e.target.value)}
>
{PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Operator */}
<select
style={{ ...selectStyle, width: 100 }}
value={cond.operator}
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
>
{OPERATOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Value */}
<input
style={{ ...inputStyle, width: 80 }}
type="number"
step="any"
value={cond.value}
onChange={(e) => updateCondition(idx, 'value', parseFloat(e.target.value) || 0)}
/>
{/* Timeframe */}
<select
style={{ ...selectStyle, width: 70 }}
value={cond.timeframe || ''}
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
>
{TIMEFRAME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Extra fields for volume */}
{cond.indicator === 'volume' && (
<>
<select
style={{ ...selectStyle, width: 110 }}
value={cond.type || 'absolute'}
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
>
<option value="absolute">Absolute</option>
<option value="avg_multiplier">Avg Multiplier</option>
</select>
{cond.type === 'avg_multiplier' && (
<input
style={{ ...inputStyle, width: 60 }}
type="number"
placeholder="Period"
value={cond.period || 20}
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
/>
)}
</>
)}
{/* Extra for sma/ema */}
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
<input
style={{ ...inputStyle, width: 60 }}
type="number"
placeholder="Period"
value={cond.period || 20}
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
/>
)}
{/* Remove */}
<button
style={{ ...dangerBtn, padding: '2px 8px', fontSize: 10 }}
onClick={() => removeCondition(idx)}
disabled={formConditions.length <= 1}
>
✕
</button>
{/* Conditions */}
<div className="mb-3">
<div className="mb-2 flex items-center justify-between">
<span className={labelClass}>Conditions (all must pass)</span>
<button className={btnClass} onClick={addCondition}>+ Add Condition</button>
</div>
))}
</div>
{/* Form error */}
{formError && (
<div style={{ padding: '6px 10px', background: 'rgba(248,81,73,0.15)', borderRadius: 4, marginBottom: 12, fontSize: 11, color: '#f85149' }}>
{formError}
{formConditions.map((cond, idx) => (
<div
key={idx}
className={`flex flex-wrap items-center gap-1.5 py-2 ${idx < formConditions.length - 1 ? 'border-b border-border-muted' : ''}`}
>
{/* Indicator */}
<select
className={`${inputClass} w-[110px]`}
value={cond.indicator}
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
>
{INDICATOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Operator */}
<select
className={`${inputClass} w-[100px]`}
value={cond.operator}
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
>
{OPERATOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Value */}
<input
className={`${inputClass} w-20`}
type="number"
step="any"
value={cond.value}
onChange={(e) => updateCondition(idx, 'value', parseFloat(e.target.value) || 0)}
/>
{/* Timeframe */}
<select
className={`${inputClass} w-[70px]`}
value={cond.timeframe || ''}
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
>
{TIMEFRAME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Extra fields for volume */}
{cond.indicator === 'volume' && (
<>
<select
className={`${inputClass} w-[110px]`}
value={cond.type || 'absolute'}
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
>
<option value="absolute">Absolute</option>
<option value="avg_multiplier">Avg Multiplier</option>
</select>
{cond.type === 'avg_multiplier' && (
<input
className={`${inputClass} w-[60px]`}
type="number"
placeholder="Period"
value={cond.period || 20}
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
/>
)}
</>
)}
{/* Extra for sma/ema */}
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
<input
className={`${inputClass} w-[60px]`}
type="number"
placeholder="Period"
value={cond.period || 20}
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
/>
)}
{/* Remove */}
<button
className={`${dangerBtnClass} px-2 py-0.5 text-[10px]`}
onClick={() => removeCondition(idx)}
disabled={formConditions.length <= 1}
>
✕
</button>
</div>
))}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button style={btnStyle} onClick={closeModal}>Cancel</button>
<button
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : editTarget ? 'Update' : 'Create'}
</button>
{/* Form error */}
{formError && (
<div className="mb-3 rounded bg-red/15 px-2.5 py-1.5 text-[11px] text-red">
{formError}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<button className={btnClass} onClick={closeModal}>Cancel</button>
<button
className={primaryBtnClass}
style={{ opacity: saving ? 0.6 : 1 }}
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : editTarget ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
);
+190 -276
View File
@@ -1,83 +1,53 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { fetchCurrentUser } from '../auth/authSlice';
import { logoutThunk } from '../auth/authSlice';
import { useT } from '../../translations';
import { apiFetch } from '../api/apiService';
// ── Mock/temporary analytics data ──
// These would be replaced by real API calls. For now, generate realistic demo data.
interface DailyPnL {
// ── Types matching backend GET /analytics/dashboard ──
interface PnlHistoryPoint {
date: string;
pnl: number;
isSignal: boolean;
cumulative: number;
}
interface AnalyticsStats {
totalPnL: number;
winRate: number;
bestTrade: number;
worstTrade: number;
profitFactor: number;
maxDrawdown: number;
totalTrades: number;
interface EquityPoint {
date: string;
equity: number;
}
interface WinRatePeriod {
trades: number;
wins: number;
losses: number;
win_rate: number;
pnl: number;
}
function generateDemoData(isSignal: boolean): { dailyPnL: DailyPnL[]; stats: AnalyticsStats } {
const dailyPnL: DailyPnL[] = [];
let equity = 10000;
let peak = 10000;
let maxDd = 0;
let totalWins = 0;
let totalLosses = 0;
let totalWinAmount = 0;
let totalLossAmount = 0;
let bestTrade = 0;
let worstTrade = 0;
for (let i = 29; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const basePnL = (Math.random() - 0.48) * (isSignal ? 300 : 200);
const pnl = Math.round(basePnL * 100) / 100;
dailyPnL.push({ date: date.toISOString().slice(0, 10), pnl, isSignal });
equity += pnl;
if (equity > peak) peak = equity;
const dd = ((peak - equity) / peak) * 100;
if (dd > maxDd) maxDd = dd;
if (pnl >= 0) {
totalWins++;
totalWinAmount += pnl;
if (pnl > bestTrade) bestTrade = pnl;
} else {
totalLosses++;
totalLossAmount += Math.abs(pnl);
if (pnl < worstTrade) worstTrade = pnl;
}
}
const totalTrades = totalWins + totalLosses;
const winRate = totalTrades > 0 ? (totalWins / totalTrades) * 100 : 0;
const profitFactor = totalLossAmount > 0 ? totalWinAmount / totalLossAmount : totalWinAmount > 0 ? Infinity : 0;
return {
dailyPnL,
stats: {
totalPnL: Math.round((equity - 10000) * 100) / 100,
winRate: Math.round(winRate * 10) / 10,
bestTrade: Math.round(bestTrade * 100) / 100,
worstTrade: Math.round(worstTrade * 100) / 100,
profitFactor: profitFactor === Infinity ? 999 : Math.round(profitFactor * 100) / 100,
maxDrawdown: Math.round(maxDd * 10) / 10,
totalTrades,
wins: totalWins,
losses: totalLosses,
},
};
interface BestWorstTrade {
symbol: string;
pnl: number;
date: string;
}
interface DashboardResponse {
pnl_history: PnlHistoryPoint[];
equity_curve: EquityPoint[];
drawdown: { max_drawdown_pct: number; current_drawdown_pct: number; max_drawdown_date: string };
win_rate_by_period: { daily: WinRatePeriod; weekly: WinRatePeriod; monthly: WinRatePeriod };
best_trade: BestWorstTrade;
worst_trade: BestWorstTrade;
total_trades: number;
total_pnl: number;
open_trades: number;
avg_trade_pnl: number;
profit_factor: number;
avg_hold_time_hours: number;
}
const DAY_RANGES = [7, 30, 90] as const;
// ── Simple SVG Bar Chart ──
function BarChart({
data,
@@ -85,7 +55,7 @@ function BarChart({
colorUp = '#3fb950',
colorDown = '#f85149',
}: {
data: DailyPnL[];
data: PnlHistoryPoint[];
height?: number;
colorUp?: string;
colorDown?: string;
@@ -129,21 +99,22 @@ function LineChart({
height = 200,
lineColor = '#58a6ff',
}: {
equityData: number[];
equityData: EquityPoint[];
height?: number;
lineColor?: string;
}) {
if (equityData.length < 2) return null;
const min = Math.min(...equityData);
const max = Math.max(...equityData);
const values = equityData.map((e) => e.equity);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const padding = 20;
const chartWidth = Math.max(800, equityData.length * 4);
const chartWidth = Math.max(800, values.length * 4);
const points = equityData
const points = values
.map((val, i) => {
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
const x = padding + (i / (values.length - 1)) * (chartWidth - padding * 2);
const y = height - padding - ((val - min) / range) * (height - padding * 2);
return `${x},${y}`;
})
@@ -165,13 +136,13 @@ function LineChart({
{/* Line */}
<polyline points={points} fill="none" stroke={lineColor} strokeWidth={2} strokeLinejoin="round" />
{/* Dots on line */}
{equityData.map((val, i) => {
if (i % 5 !== 0 && i !== equityData.length - 1) return null;
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
{values.map((val, i) => {
if (i % 5 !== 0 && i !== values.length - 1) return null;
const x = padding + (i / (values.length - 1)) * (chartWidth - padding * 2);
const y = height - padding - ((val - min) / range) * (height - padding * 2);
return (
<circle key={i} cx={x} cy={y} r={3} fill={lineColor} opacity={0.8}>
<title>{`Day ${i + 1}: $${val.toFixed(2)}`}</title>
<title>{`${equityData[i].date}: $${val.toFixed(2)}`}</title>
</circle>
);
})}
@@ -190,25 +161,11 @@ function StatCard({ label, value, color, isCurrency }: {
? `${Number(value) >= 0 ? '+' : ''}$${Number(value).toFixed(2)}`
: value;
return (
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 8,
padding: '12px 14px',
minWidth: 0,
}}>
<div style={{ fontSize: 11, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
<div className="min-w-0 rounded-lg border border-border-default bg-bg-surface px-3.5 py-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-text-secondary">
{label}
</div>
<div style={{
fontSize: 18,
fontWeight: 700,
fontFamily: "'SF Mono', 'Fira Code', monospace",
color: color || '#f0f6fc',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
}}>
<div className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-lg font-bold" style={{ color: color || '#f0f6fc' }}>
{displayValue}
</div>
</div>
@@ -223,15 +180,10 @@ export default function AnalyticsPage() {
const dispatch = useAppDispatch();
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const [initializing, setInitializing] = useState(true);
const [dataMode, setDataMode] = useState<'signal' | 'real'>('signal');
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// Responsive
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const [days, setDays] = useState<(typeof DAY_RANGES)[number]>(30);
const [data, setData] = useState<DashboardResponse | null>(null);
const [loadingData, setLoadingData] = useState(false);
const [error, setError] = useState<string | null>(null);
// Auth guard
useEffect(() => {
@@ -245,216 +197,178 @@ export default function AnalyticsPage() {
setInitializing(false);
}, [isAuthenticated, user, dispatch, navigate]);
// Generate demo data based on toggle
const demoData = useMemo(() => generateDemoData(dataMode === 'signal'), [dataMode]);
const equityCurve = useMemo(() => {
let eq = 10000;
return demoData.dailyPnL.map((d) => {
eq += d.pnl;
return Math.round(eq * 100) / 100;
});
}, [demoData]);
const loadDashboard = useCallback(async () => {
setLoadingData(true);
setError(null);
try {
const result = await apiFetch<DashboardResponse>(`/analytics/dashboard?days=${days}`);
setData(result);
} catch (err: any) {
setError(err?.message || 'Failed to load analytics');
} finally {
setLoadingData(false);
}
}, [days]);
useEffect(() => {
if (!initializing) loadDashboard();
}, [initializing, loadDashboard]);
const { t } = useT(); // eslint-disable-line
if (!isAuthenticated) return null;
if (initializing || isLoading) {
return (
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#8b949e' }}>
<div className="flex min-h-screen items-center justify-center bg-bg-primary text-text-secondary">
Loading analytics...
</div>
);
}
const navBtn: React.CSSProperties = {
padding: '6px 16px',
fontSize: 12,
border: '1px solid #30363d',
borderRadius: 6,
background: '#21262d',
color: '#c9d1d9',
cursor: 'pointer',
};
const navBtnClass = 'min-h-9 touch-manipulation whitespace-nowrap rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
const toggleBtnClass = (active: boolean) =>
`inline-flex min-h-9 touch-manipulation items-center rounded-md border px-4 py-1.5 text-xs transition-colors ${
active ? 'border-accent-blue bg-accent-blue font-semibold text-white' : 'border-border-default bg-bg-hover font-normal text-text-primary'
}`;
const toggleBtn = (active: boolean): React.CSSProperties => ({
padding: '6px 16px',
fontSize: 12,
border: '1px solid #30363d',
borderRadius: 6,
background: active ? '#1f6feb' : '#21262d',
color: active ? '#fff' : '#c9d1d9',
cursor: 'pointer',
fontWeight: active ? 600 : 400,
transition: 'all 0.15s ease',
minHeight: 36,
touchAction: 'manipulation',
display: 'inline-flex',
alignItems: 'center',
});
const s = demoData.stats;
const monthly = data?.win_rate_by_period?.monthly;
const winRate = monthly?.win_rate ?? 0;
const wins = monthly?.wins ?? 0;
const losses = Math.max((monthly?.trades ?? 0) - wins, 0);
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
{/* ─── NAV ─── */}
<nav style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
background: '#161b22',
borderBottom: '1px solid #30363d',
flexWrap: 'wrap',
gap: 8,
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', flexShrink: 0 }} onClick={() => navigate('/dashboard')}>
<span style={{ fontSize: 18 }}>📈</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Analytics</span>
<nav className="scrollable-x flex flex-wrap items-center justify-between gap-2 overflow-x-auto border-b border-border-default bg-bg-surface px-3 py-2">
<div className="flex shrink-0 cursor-pointer items-center gap-3" onClick={() => navigate('/dashboard')}>
<span className="text-lg">📈</span>
<span className="text-base font-bold text-text-heading">Analytics</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>👤 {user?.username}</span>
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/profile')}>Profile</button>
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }} onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xs text-text-secondary">👤 {user?.username}</span>
<button className={navBtnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button className={navBtnClass} onClick={() => navigate('/profile')}>Profile</button>
<button
className={`${navBtnClass} border-red/20 text-red`}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</div>
</nav>
{/* ─── PAGE CONTENT ─── */}
<div style={{ padding: isMobile ? 12 : 20, maxWidth: 960, margin: '0 auto' }}>
{/* Header + Toggle */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap' as const,
gap: 8,
marginBottom: 20,
}}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc', margin: 0 }}>
<div className="mx-auto max-w-[960px] p-3 md:p-5">
{/* Header + Range picker */}
<div className="mb-5 flex flex-wrap items-center justify-between gap-2">
<h2 className="m-0 text-lg font-bold text-text-heading">
📊 Performance Overview
</h2>
<div style={{ display: 'flex', gap: 4 }}>
<button
style={toggleBtn(dataMode === 'signal')}
onClick={() => setDataMode('signal')}
>
📈 Signal
</button>
<button
style={toggleBtn(dataMode === 'real')}
onClick={() => setDataMode('real')}
>
💹 Real
</button>
<div className="flex gap-1">
{DAY_RANGES.map((d) => (
<button key={d} className={toggleBtnClass(days === d)} onClick={() => setDays(d)}>
{d}d
</button>
))}
</div>
</div>
{/* ─── STATS CARDS GRID ─── */}
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(6, 1fr)',
gap: isMobile ? 8 : 10,
marginBottom: 24,
}}>
<StatCard label="Total P&L" value={s.totalPnL} color={s.totalPnL >= 0 ? '#3fb950' : '#f85149'} isCurrency />
<StatCard label="Win Rate" value={`${s.winRate}%`} color={s.winRate >= 50 ? '#3fb950' : '#f85149'} />
<StatCard label="Best Trade" value={s.bestTrade} color="#3fb950" isCurrency />
<StatCard label="Worst Trade" value={s.worstTrade} color="#f85149" isCurrency />
<StatCard label="Profit Factor" value={s.profitFactor} color={s.profitFactor >= 1 ? '#3fb950' : '#f85149'} />
<StatCard label="Max Drawdown" value={`${s.maxDrawdown}%`} color="#d29922" />
</div>
{/* ─── PnL Bar Chart ─── */}
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 10,
padding: '16px 12px',
marginBottom: 16,
overflow: 'hidden',
}}>
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
📊 Daily P&L (Last 30 Days)
</h3>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<BarChart data={demoData.dailyPnL} height={180} />
{error && (
<div className="mb-4 rounded-lg border border-red bg-[#2d0e0e] px-3.5 py-2.5 text-[13px] text-red">
⚠️ {error}
</div>
</div>
)}
{/* ─── Equity Curve ─── */}
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 10,
padding: '16px 12px',
marginBottom: 16,
overflow: 'hidden',
}}>
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
📈 Equity Curve
</h3>
<div style={{ display: 'flex', gap: 12, alignItems: 'baseline', marginBottom: 8, marginLeft: 4 }}>
<span style={{ fontSize: 12, color: '#8b949e' }}>Starting: <strong style={{ color: '#c9d1d9' }}>$10,000.00</strong></span>
<span style={{ fontSize: 14, fontWeight: 700, color: s.totalPnL >= 0 ? '#3fb950' : '#f85149' }}>
{s.totalPnL >= 0 ? '+' : ''}${s.totalPnL.toFixed(2)}
</span>
</div>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<LineChart equityData={equityCurve} height={200} />
</div>
</div>
{loadingData && !data && (
<div className="py-5 text-[13px] text-text-secondary">Loading performance data...</div>
)}
{/* ─── Summary Trades Table ─── */}
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 10,
padding: '16px',
marginBottom: 16,
}}>
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 0' }}>
📋 Trade Summary
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(4, 1fr)',
gap: 12,
}}>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Total Trades</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{s.totalTrades}</div>
{data && (
<>
{/* ─── STATS CARDS GRID ─── */}
<div className="mb-6 grid grid-cols-2 gap-2 md:grid-cols-6 md:gap-2.5">
<StatCard label="Total P&L" value={data.total_pnl} color={data.total_pnl >= 0 ? '#3fb950' : '#f85149'} isCurrency />
<StatCard label="Win Rate (30d)" value={`${winRate}%`} color={winRate >= 50 ? '#3fb950' : '#f85149'} />
<StatCard label="Best Trade" value={data.best_trade.pnl} color="#3fb950" isCurrency />
<StatCard label="Worst Trade" value={data.worst_trade.pnl} color="#f85149" isCurrency />
<StatCard label="Profit Factor" value={data.profit_factor} color={data.profit_factor >= 1 ? '#3fb950' : '#f85149'} />
<StatCard label="Max Drawdown" value={`${data.drawdown.max_drawdown_pct}%`} color="#d29922" />
</div>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Wins</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{s.wins}</div>
{/* ─── PnL Bar Chart ─── */}
<div className="mb-4 overflow-hidden rounded-[10px] border border-border-default bg-bg-surface px-3 py-4">
<h3 className="mb-3 ml-1 text-[13px] uppercase tracking-wide text-text-secondary">
📊 Daily P&L (Last {days} Days)
</h3>
{data.pnl_history.length === 0 ? (
<div className="py-5 text-center text-xs text-text-dim">No closed trades in this period yet.</div>
) : (
<div className="scrollable-x overflow-x-auto">
<BarChart data={data.pnl_history} height={180} />
</div>
)}
</div>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Losses</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#f85149' }}>{s.losses}</div>
{/* ─── Equity Curve ─── */}
<div className="mb-4 overflow-hidden rounded-[10px] border border-border-default bg-bg-surface px-3 py-4">
<h3 className="mb-3 ml-1 text-[13px] uppercase tracking-wide text-text-secondary">
📈 Equity Curve
</h3>
{data.equity_curve.length < 2 ? (
<div className="py-5 text-center text-xs text-text-dim">Not enough closed trades yet to plot an equity curve.</div>
) : (
<>
<div className="mb-2 ml-1 flex items-baseline gap-3">
<span className="text-xs text-text-secondary">Starting: <strong className="text-text-primary">${data.equity_curve[0].equity.toFixed(2)}</strong></span>
<span className={`text-sm font-bold ${data.total_pnl >= 0 ? 'text-green' : 'text-red'}`}>
{data.total_pnl >= 0 ? '+' : ''}${data.total_pnl.toFixed(2)}
</span>
</div>
<div className="scrollable-x overflow-x-auto">
<LineChart equityData={data.equity_curve} height={200} />
</div>
</>
)}
</div>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Data Source</div>
<div style={{ fontSize: 16, fontWeight: 600, color: '#58a6ff' }}>
{dataMode === 'signal' ? '📈 Signal' : '💹 Real'}
{/* ─── Summary Trades Table ─── */}
<div className="mb-4 rounded-[10px] border border-border-default bg-bg-surface p-4">
<h3 className="mb-3 text-[13px] uppercase tracking-wide text-text-secondary">
📋 Trade Summary
</h3>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div>
<div className="text-[11px] text-text-muted">Total Trades (all-time)</div>
<div className="text-xl font-bold text-text-heading">{data.total_trades}</div>
</div>
<div>
<div className="text-[11px] text-text-muted">Wins (30d)</div>
<div className="text-xl font-bold text-green">{wins}</div>
</div>
<div>
<div className="text-[11px] text-text-muted">Losses (30d)</div>
<div className="text-xl font-bold text-red">{losses}</div>
</div>
<div>
<div className="text-[11px] text-text-muted">Open Trades</div>
<div className="text-base font-semibold text-accent">
{data.open_trades}
</div>
</div>
</div>
</div>
</div>
</div>
{/* ─── Footer note ─── */}
<div style={{ fontSize: 11, color: '#484f58', textAlign: 'center' as const, padding: '16px 0' }}>
Analytics data is based on {dataMode === 'signal' ? 'simulated signal' : 'real exchange'} trades.
Charts update in real-time as new trades are executed.
</div>
{/* ─── Footer note ─── */}
<div className="px-0 py-4 text-center text-[11px] text-text-dim">
Combines signal (paper) trades and real exchange trades. Win/loss counts above are for the last 30 days; other stats use the selected {days}-day range.
</div>
</>
)}
</div>
</div>
);
+5 -33
View File
@@ -1,4 +1,5 @@
/** API functions for user-defined multi-condition alerts. */
import { apiFetch } from './apiService';
export interface ConditionObject {
indicator: string;
@@ -24,18 +25,8 @@ export interface AlertListResponse {
alerts: AlertItem[];
}
function getHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function fetchAlerts(): Promise<AlertItem[]> {
const res = await fetch('/api/v1/alerts', { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch alerts: ${res.status}`);
const data: AlertListResponse = await res.json();
const data = await apiFetch<AlertListResponse>('/alerts');
return data.alerts;
}
@@ -44,16 +35,10 @@ export async function createAlert(data: {
conditions: ConditionObject[];
notify_platform: string;
}): Promise<AlertItem> {
const res = await fetch('/api/v1/alerts', {
return apiFetch<AlertItem>('/alerts', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export async function updateAlert(
@@ -65,25 +50,12 @@ export async function updateAlert(
is_active?: boolean;
},
): Promise<AlertItem> {
const res = await fetch(`/api/v1/alerts/${id}`, {
return apiFetch<AlertItem>(`/alerts/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export async function deleteAlert(id: number): Promise<void> {
const res = await fetch(`/api/v1/alerts/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
await apiFetch<void>(`/alerts/${id}`, { method: 'DELETE' });
}
+3 -19
View File
@@ -1,4 +1,5 @@
/** API functions for real trades. */
import { apiFetch } from './apiService';
export interface RealTrade {
id: number;
@@ -46,26 +47,9 @@ export async function fetchRealTrades(
if (symbol) params.set('symbol', symbol);
if (status) params.set('status', status);
params.set('limit', String(limit));
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/v1/real-trades?${params}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) throw new Error(`Failed to fetch real trades: ${res.status}`);
return res.json();
return apiFetch<RealTradeListResponse>(`/real-trades?${params}`);
}
export async function fetchWinRate(): Promise<WinRateResponse> {
const token = localStorage.getItem('access_token');
const res = await fetch('/api/v1/real-trades/win-rate', {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) throw new Error(`Failed to fetch win rate: ${res.status}`);
return res.json();
return apiFetch<WinRateResponse>('/real-trades/win-rate');
}
+3 -16
View File
@@ -1,4 +1,5 @@
/** API functions for trading signals and hypothetical trades. */
import { apiFetch } from './apiService';
export interface Signal {
id: number;
@@ -48,22 +49,11 @@ export interface TradeListResponse {
win_rate: number | null;
}
function getHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function fetchSignals(symbol?: string, limit = 50): Promise<SignalListResponse> {
const params = new URLSearchParams();
if (symbol) params.set('symbol', symbol);
params.set('limit', String(limit));
const res = await fetch(`/api/v1/signals?${params}`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch signals: ${res.status}`);
return res.json();
return apiFetch<SignalListResponse>(`/signals?${params}`);
}
export async function fetchTrades(symbol?: string, status?: string, limit = 100): Promise<TradeListResponse> {
@@ -71,10 +61,7 @@ export async function fetchTrades(symbol?: string, status?: string, limit = 100)
if (symbol) params.set('symbol', symbol);
if (status) params.set('status', status);
params.set('limit', String(limit));
const res = await fetch(`/api/v1/signals/trades?${params}`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch trades: ${res.status}`);
return res.json();
return apiFetch<TradeListResponse>(`/signals/trades?${params}`);
}
/** Signal type display config */
+5 -26
View File
@@ -1,4 +1,5 @@
/** API functions for user watchlist management. */
import { apiFetch } from './apiService';
export interface WatchlistItem {
id: string;
@@ -18,45 +19,23 @@ export interface SymbolOption {
is_active: boolean;
}
function getHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function fetchWatchlist(): Promise<WatchlistItem[]> {
const res = await fetch('/api/v1/watchlist', { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch watchlist: ${res.status}`);
return res.json();
return apiFetch<WatchlistItem[]>('/watchlist');
}
export async function addToWatchlist(symbolId: number, label?: string): Promise<WatchlistItem> {
const res = await fetch('/api/v1/watchlist', {
return apiFetch<WatchlistItem>('/watchlist', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ symbol_id: symbolId, label: label || null }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Failed to add: ${res.status}`);
}
return res.json();
}
export async function removeFromWatchlist(id: string): Promise<void> {
const res = await fetch(`/api/v1/watchlist/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) throw new Error(`Failed to remove: ${res.status}`);
await apiFetch<void>(`/watchlist/${id}`, { method: 'DELETE' });
}
export async function fetchAllSymbols(exchange = 'mexc', q = ''): Promise<SymbolOption[]> {
const params = new URLSearchParams({ exchange });
if (q) params.set('q', q);
const res = await fetch(`/api/v1/watchlist/all-symbols?${params}`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch symbols: ${res.status}`);
return res.json();
return apiFetch<SymbolOption[]>(`/watchlist/all-symbols?${params}`);
}
+88 -112
View File
@@ -1,5 +1,6 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useT } from '../../translations';
import { apiFetch } from '../api/apiService';
interface SignalCounts {
[key: string]: number;
@@ -59,6 +60,11 @@ const SIGNAL_ICONS: Record<string, string> = {
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
const TIMEFRAMES = ['15m', '30m', '1h', '4h'];
const selectClass = 'rounded-md border border-border-default bg-bg-surface px-2.5 py-1.5 text-sm text-text-primary';
const thClass = 'px-3 py-2 text-left font-medium text-text-secondary';
const tdClass = 'px-3 py-1.5';
const cardClass = 'rounded-lg bg-bg-surface p-4';
export default function BacktestPage() {
const { t } = useT();
const [exchange, setExchange] = useState('binance');
@@ -76,12 +82,7 @@ export default function BacktestPage() {
let cancelled = false;
async function loadSymbols() {
try {
const res = await fetch(
`/api/v1/backtest/symbols?exchange=${encodeURIComponent(exchange)}`,
{ credentials: 'include' }
);
if (!res.ok) return;
const data = await res.json();
const data = await apiFetch<any>(`/backtest/symbols?exchange=${encodeURIComponent(exchange)}`);
if (cancelled) return;
const names = (data.symbols || []).map((s: any) => s.symbol);
setSymbols(names);
@@ -100,15 +101,9 @@ export default function BacktestPage() {
setError('');
setResult(null);
try {
const res = await fetch(
`/api/v1/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
{ credentials: 'include' }
const data = await apiFetch<BacktestResult>(
`/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
setResult(data);
} catch (e: any) {
setError(e.message);
@@ -118,92 +113,89 @@ export default function BacktestPage() {
}, [symbol, timeframe, days, tradeSize]);
return (
<div style={{ padding: '20px', maxWidth: '1000px', margin: '0 auto' }}>
<h1>📊 {t('Backtest')}</h1>
<div className="mx-auto max-w-[1000px] bg-bg-primary p-5 text-text-primary">
<h1 className="text-2xl font-bold text-text-heading">📊 {t('Backtest')}</h1>
{/* Controls */}
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '20px', alignItems: 'end' }}>
<div className="mb-5 flex flex-wrap items-end gap-3">
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Exchange</label><br />
<select value={exchange} onChange={e => setExchange(e.target.value)}
style={selectStyle}>
<label className="text-xs text-text-secondary">Exchange</label><br />
<select value={exchange} onChange={e => setExchange(e.target.value)} className={selectClass}>
{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Symbol ({symbols.length})</label><br />
<select value={symbol} onChange={e => setSymbol(e.target.value)}
style={{ ...selectStyle, minWidth: '140px' }}>
<label className="text-xs text-text-secondary">Symbol ({symbols.length})</label><br />
<select value={symbol} onChange={e => setSymbol(e.target.value)} className={`${selectClass} min-w-[140px]`}>
{symbols.length > 0
? symbols.map(s => <option key={s} value={s}>{s}</option>)
: <option value="">Loading...</option>}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Timeframe</label><br />
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} style={selectStyle}>
<label className="text-xs text-text-secondary">Timeframe</label><br />
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} className={selectClass}>
{TIMEFRAMES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Days</label><br />
<select value={days} onChange={e => setDays(Number(e.target.value))} style={selectStyle}>
<label className="text-xs text-text-secondary">Days</label><br />
<select value={days} onChange={e => setDays(Number(e.target.value))} className={selectClass}>
{[3, 7, 14, 30, 90, 180, 365].map(d => <option key={d} value={d}>{d}d</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Trade Size (USDT)</label><br />
<input type="number" value={tradeSize} onChange={e => setTradeSize(Number(e.target.value))}
style={{ ...selectStyle, width: '80px' }} min={1} />
<label className="text-xs text-text-secondary">Trade Size (USDT)</label><br />
<input
type="number"
value={tradeSize}
onChange={e => setTradeSize(Number(e.target.value))}
className={`${selectClass} w-20`}
min={1}
/>
</div>
<button onClick={runBacktest} disabled={loading}
style={{
padding: '8px 24px', background: '#2563eb', color: '#fff', border: 'none',
borderRadius: '6px', cursor: loading ? 'wait' : 'pointer', fontWeight: 600,
}}>
<button
onClick={runBacktest}
disabled={loading}
className={`rounded-md border-none bg-accent-blue px-6 py-2 font-semibold text-white ${loading ? 'cursor-wait' : 'cursor-pointer'}`}
>
{loading ? '⏳ Running...' : '▶ Run Backtest'}
</button>
</div>
{error && <div style={{ color: '#ef4444', marginBottom: '16px' }}>❌ {error}</div>}
{error && <div className="mb-4 text-red">❌ {error}</div>}
{result && (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
<div className="mb-5 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-3">
<SummaryCard label="Trades" value={result.trades.total.toString()} />
<SummaryCard label="Win Rate" value={`${result.trades.win_rate}%`}
color={result.trades.win_rate >= 50 ? '#22c55e' : '#ef4444'} />
color={result.trades.win_rate >= 50 ? 'text-green' : 'text-red'} />
<SummaryCard label="Total PnL" value={`$${result.trades.total_pnl.toFixed(2)}`}
color={result.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444'} />
color={result.trades.total_pnl >= 0 ? 'text-green' : 'text-red'} />
<SummaryCard label="Profit Factor" value={result.trades.profit_factor?.toFixed(2) ?? '∞'}
color={result.trades.profit_factor && result.trades.profit_factor >= 1 ? '#22c55e' : '#ef4444'} />
color={result.trades.profit_factor && result.trades.profit_factor >= 1 ? 'text-green' : 'text-red'} />
<SummaryCard label="Wins/Losses" value={`${result.trades.wins}W / ${result.trades.losses}L`} />
<SummaryCard label="Candles" value={result.candles_count.toString()} />
</div>
{/* Signal Breakdown */}
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
<h3 style={{ margin: '0 0 12px' }}>📋 Signal Breakdown</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<div className={`${cardClass} mb-4`}>
<h3 className="mb-3 text-text-heading">📋 Signal Breakdown</h3>
<div className="flex flex-wrap gap-4">
{Object.entries(result.signal_counts).map(([sig, count]) => {
const icon = SIGNAL_ICONS[sig] || '❓';
const sigTrades = result.trades.per_signal[sig] || { trades: 0, wins: 0, pnl: 0 };
return (
<div key={sig} style={{
padding: '8px 14px', background: '#16213e', borderRadius: '6px',
border: '1px solid #0f3460',
}}>
<div style={{ fontSize: '14px', fontWeight: 600 }}>
<div key={sig} className="rounded-md border border-border-default bg-bg-primary px-3.5 py-2">
<div className="text-sm font-semibold text-text-primary">
{icon} {sig.replace('_', ' ')}
</div>
<div style={{ fontSize: '12px', color: '#aaa' }}>
<div className="text-xs text-text-secondary">
{count} signals → {sigTrades.trades} trades
</div>
<div style={{
fontSize: '12px', fontWeight: 600,
color: sigTrades.pnl >= 0 ? '#22c55e' : '#ef4444'
}}>
<div className={`text-xs font-semibold ${sigTrades.pnl >= 0 ? 'text-green' : 'text-red'}`}>
${sigTrades.pnl.toFixed(2)} ({sigTrades.trades ? Math.round(sigTrades.wins / sigTrades.trades * 100) : 0}% WR)
</div>
</div>
@@ -213,43 +205,43 @@ export default function BacktestPage() {
</div>
{/* Best/Worst Trades */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
<div className="mb-4 grid grid-cols-2 gap-3">
{result.trades.best_trade && (
<div style={{ background: '#064e3b', borderRadius: '8px', padding: '12px' }}>
<div style={{ fontSize: '12px', color: '#6ee7b7' }}>🏆 Best Trade</div>
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</div>
<div style={{ color: '#22c55e', fontWeight: 700 }}>+${result.trades.best_trade.pnl.toFixed(2)}</div>
<div className="rounded-lg bg-green/10 p-3">
<div className="text-xs text-green">🏆 Best Trade</div>
<div className="text-sm font-semibold text-text-primary">{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</div>
<div className="font-bold text-green">+${result.trades.best_trade.pnl.toFixed(2)}</div>
</div>
)}
{result.trades.worst_trade && (
<div style={{ background: '#450a0a', borderRadius: '8px', padding: '12px' }}>
<div style={{ fontSize: '12px', color: '#fca5a5' }}>💸 Worst Trade</div>
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</div>
<div style={{ color: '#ef4444', fontWeight: 700 }}>{result.trades.worst_trade.pnl.toFixed(2)}$</div>
<div className="rounded-lg bg-red/10 p-3">
<div className="text-xs text-red">💸 Worst Trade</div>
<div className="text-sm font-semibold text-text-primary">{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</div>
<div className="font-bold text-red">{result.trades.worst_trade.pnl.toFixed(2)}$</div>
</div>
)}
</div>
{/* Recent Signals */}
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Signals (last 15)</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<div className={`${cardClass} mb-4`}>
<h3 className="mb-3 text-text-heading">📋 Recent Signals (last 15)</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr style={{ borderBottom: '1px solid #333' }}>
<th style={thStyle}>Time</th>
<th style={thStyle}>Signal</th>
<th style={thStyle}>Price</th>
<tr className="border-b border-border-default">
<th className={thClass}>Time</th>
<th className={thClass}>Signal</th>
<th className={thClass}>Price</th>
</tr>
</thead>
<tbody>
{result.recent_signals.map((s, i) => (
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
<td style={tdStyle}>{s.time.slice(5, 16)}</td>
<td style={tdStyle}>
<tr key={i} className="border-b border-border-muted">
<td className={tdClass}>{s.time.slice(5, 16)}</td>
<td className={tdClass}>
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
</td>
<td style={tdStyle}>${s.price.toFixed(2)}</td>
<td className={tdClass}>${s.price.toFixed(2)}</td>
</tr>
))}
</tbody>
@@ -259,31 +251,31 @@ export default function BacktestPage() {
{/* Recent Trades */}
{result.trades.recent.length > 0 && (
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px' }}>
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Trades (last 10)</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<div className={cardClass}>
<h3 className="mb-3 text-text-heading">📋 Recent Trades (last 10)</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr style={{ borderBottom: '1px solid #333' }}>
<th style={thStyle}>Time</th>
<th style={thStyle}>Dir</th>
<th style={thStyle}>Entry</th>
<th style={thStyle}>Exit</th>
<th style={thStyle}>PnL</th>
<th style={thStyle}>Reason</th>
<tr className="border-b border-border-default">
<th className={thClass}>Time</th>
<th className={thClass}>Dir</th>
<th className={thClass}>Entry</th>
<th className={thClass}>Exit</th>
<th className={thClass}>PnL</th>
<th className={thClass}>Reason</th>
</tr>
</thead>
<tbody>
{result.trades.recent.slice().reverse().map((t, i) => (
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
<td style={tdStyle}>{t.entry_time?.slice(5, 16) || '-'}</td>
<td style={tdStyle}>{t.direction}</td>
<td style={tdStyle}>${t.entry_price.toFixed(2)}</td>
<td style={tdStyle}>${t.exit_price?.toFixed(2) || '-'}</td>
<td style={{ ...tdStyle, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>
<tr key={i} className="border-b border-border-muted">
<td className={tdClass}>{t.entry_time?.slice(5, 16) || '-'}</td>
<td className={tdClass}>{t.direction}</td>
<td className={tdClass}>${t.entry_price.toFixed(2)}</td>
<td className={tdClass}>${t.exit_price?.toFixed(2) || '-'}</td>
<td className={`${tdClass} ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>
${t.pnl?.toFixed(2) || '0.00'}
</td>
<td style={tdStyle}>{t.exit_reason || '-'}</td>
<td className={tdClass}>{t.exit_reason || '-'}</td>
</tr>
))}
</tbody>
@@ -297,27 +289,11 @@ export default function BacktestPage() {
);
}
const selectStyle: React.CSSProperties = {
padding: '6px 10px', borderRadius: '6px', border: '1px solid #333',
background: '#1a1a2e', color: '#e0e0e0', fontSize: '14px',
};
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '8px 12px', color: '#888', fontWeight: 500,
};
const tdStyle: React.CSSProperties = {
padding: '6px 12px',
};
function SummaryCard({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div style={{
background: '#1a1a2e', borderRadius: '8px', padding: '14px',
border: '1px solid #0f3460',
}}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>{label}</div>
<div style={{ fontSize: '20px', fontWeight: 700, color: color || '#e0e0e0' }}>{value}</div>
<div className="rounded-lg border border-border-default bg-bg-surface p-3.5">
<div className="mb-1 text-xs text-text-secondary">{label}</div>
<div className={`text-xl font-bold ${color || 'text-text-primary'}`}>{value}</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { apiFetch } from '../api/apiService';
import {
createChart,
createSeriesMarkers,
@@ -297,18 +298,12 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
setError(null);
const token = localStorage.getItem('access_token');
const headers = { Authorization: `Bearer ${token}` };
async function fetchData() {
try {
// Fetch candles — use query params to avoid encoding issues
const candleRes = await fetch(
`/api/v1/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
{ headers }
const candleResponse = await apiFetch<any>(
`/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
);
if (!candleRes.ok) throw new Error(`Candle fetch error: ${candleRes.status}`);
const candleResponse = await candleRes.json();
// API returns {candles: [...], cursor: "...", has_more: bool} — unwrap it
const rawCandles: any[] = candleResponse.candles || candleResponse || [];
// Reverse so oldest candle comes first (lightweight-charts needs ASC order)
@@ -353,12 +348,9 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
}
// Fetch indicators
const indicatorsRes = await fetch(
`/api/v1/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
{ headers }
const indicatorData = await apiFetch<IndicatorResponse>(
`/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
);
if (!indicatorsRes.ok) throw new Error(`Indicator fetch error: ${indicatorsRes.status}`);
const indicatorData: IndicatorResponse = await indicatorsRes.json();
// Helper to build time-annotated line data from indicator array
function buildLineData(values: number[], times: Candle[]): LineData<UTCTimestamp>[] {
@@ -543,46 +535,18 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
}, [symbol, exchange, timeframe, onPriceUpdate]);
return (
<div style={styles.wrapper}>
{loading && <div style={styles.overlay}>Loading chart data...</div>}
{error && <div style={styles.errorOverlay}>Error: {error}</div>}
<div ref={chartContainerRef} style={styles.chartContainer} />
<div className="relative min-h-0 flex-1">
{loading && (
<div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-md bg-bg-primary/80 px-4 py-2 text-sm text-text-secondary">
Loading chart data...
</div>
)}
{error && (
<div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-md bg-bg-primary/80 px-4 py-2 text-sm text-red-alt">
Error: {error}
</div>
)}
<div ref={chartContainerRef} className="h-full w-full" />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
wrapper: {
position: 'relative',
flex: 1,
minHeight: 0,
},
chartContainer: {
width: '100%',
height: '100%',
},
overlay: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#8b949e',
fontSize: 14,
zIndex: 10,
background: '#0d1117cc',
padding: '8px 16px',
borderRadius: 6,
},
errorOverlay: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#ef5350',
fontSize: 14,
zIndex: 10,
background: '#0d1117cc',
padding: '8px 16px',
borderRadius: 6,
},
};
+21 -125
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { apiFetch } from '../api/apiService';
interface Exchange {
id: number;
@@ -27,6 +28,8 @@ interface Props {
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
const selectClass = 'h-6 w-full min-w-0 max-w-none rounded-md border border-border-default bg-bg-primary px-2 text-[11px] leading-4 text-text-primary outline-none cursor-pointer md:w-auto md:min-w-[100px] md:max-w-[140px]';
export default function ChartToolbar({
exchanges,
symbol,
@@ -39,14 +42,6 @@ export default function ChartToolbar({
const [symbols, setSymbols] = useState<SymbolData[]>([]);
const [loadingSymbols, setLoadingSymbols] = useState(false);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Fetch symbols when exchange changes
useEffect(() => {
if (exchange) {
@@ -57,12 +52,7 @@ export default function ChartToolbar({
async function fetchSymbols(exch: string) {
setLoadingSymbols(true);
try {
const res = await fetch(
`/api/v1/symbols?exchange=${exch}&active_only=true`,
{ headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` } }
);
if (!res.ok) throw new Error('Failed to fetch symbols');
const data: SymbolData[] = await res.json();
const data = await apiFetch<SymbolData[]>(`/symbols?exchange=${exch}&active_only=true`);
setSymbols(data);
} catch (err) {
console.error('fetchSymbols error:', err);
@@ -72,13 +62,13 @@ export default function ChartToolbar({
}
return (
<div style={{ ...styles.toolbar, gap: isMobile ? 8 : 16 }}>
<div className="flex flex-row flex-wrap items-center gap-2 border-b border-border-default bg-bg-surface px-4 py-1.5 md:gap-4">
{/* Exchange + Symbol row */}
<div style={{ ...styles.selectRow, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 6 : 12 }}>
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
<label style={styles.label}>Exchange</label>
<div className="flex flex-col flex-wrap items-center gap-1.5 md:flex-row md:gap-3">
<div className="flex w-full items-center gap-1.5 md:w-auto">
<label className="shrink-0 text-[11px] font-medium uppercase tracking-wide text-text-secondary">Exchange</label>
<select
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
className={selectClass}
value={exchange}
onChange={(e) => onExchangeChange(e.target.value)}
>
@@ -90,10 +80,10 @@ export default function ChartToolbar({
</select>
</div>
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
<label style={styles.label}>Symbol</label>
<div className="flex w-full items-center gap-1.5 md:w-auto">
<label className="shrink-0 text-[11px] font-medium uppercase tracking-wide text-text-secondary">Symbol</label>
<select
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
className={selectClass}
value={symbol}
onChange={(e) => onSymbolChange(e.target.value)}
disabled={loadingSymbols}
@@ -108,19 +98,18 @@ export default function ChartToolbar({
</div>
{/* Timeframe - horizontal scroll on mobile */}
<div style={{ ...styles.timeframeSection, flex: isMobile ? 'none' : 1 }}>
<label style={styles.label}>TF</label>
<div style={styles.timeframeScroll} className="scrollable-x">
<div style={styles.timeframeGroup}>
<div className="flex min-w-0 flex-none items-center gap-1.5 md:flex-1">
<label className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">TF</label>
<div className="scrollable-x min-w-0 flex-1 overflow-x-auto overflow-y-hidden pb-0.5">
<div className="flex shrink-0 items-center gap-0.5">
{TIMEFRAMES.map((tf) => (
<button
key={tf}
style={{
...styles.timeframeButton,
...(tf === timeframe ? styles.timeframeActive : {}),
padding: isMobile ? '2px 6px' : '3px 8px',
fontSize: isMobile ? 10 : 11,
}}
className={`h-6 min-w-8 shrink-0 whitespace-nowrap rounded border px-1.5 py-0.5 text-center align-middle text-[10px] font-medium leading-4 transition-colors md:px-2 md:text-[11px] ${
tf === timeframe
? 'border-accent-blue bg-accent-blue text-text-heading'
: 'border-transparent bg-transparent text-text-secondary'
}`}
onClick={() => onTimeframeChange(tf)}
>
{tf}
@@ -132,96 +121,3 @@ export default function ChartToolbar({
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
toolbar: {
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '6px 16px',
background: '#161b22',
borderBottom: '1px solid #30363d',
flexWrap: 'wrap',
// Responsive: on small screens, items stack
flexDirection: 'row' as const,
},
selectRow: {
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
},
group: {
display: 'flex',
alignItems: 'center',
gap: 6,
},
label: {
fontSize: 11,
fontWeight: 500,
color: '#8b949e',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
flexShrink: 0,
},
select: {
padding: '3px 8px',
fontSize: 11,
color: '#c9d1d9',
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
outline: 'none',
cursor: 'pointer',
minWidth: 100,
maxWidth: 140,
height: 24,
boxSizing: 'border-box' as const,
lineHeight: '16px',
},
timeframeSection: {
display: 'flex',
alignItems: 'center',
gap: 6,
flex: 1,
minWidth: 0,
},
timeframeScroll: {
overflowX: 'auto' as const,
overflowY: 'hidden',
WebkitOverflowScrolling: 'touch' as any,
scrollBehavior: 'smooth',
paddingBottom: 2,
flex: 1,
minWidth: 0,
},
timeframeGroup: {
display: 'flex',
gap: 2,
flexShrink: 0,
alignItems: 'center',
},
timeframeButton: {
padding: '3px 6px',
fontSize: 11,
fontWeight: 500,
color: '#8b949e',
background: 'transparent',
border: '1px solid transparent',
borderRadius: 4,
cursor: 'pointer',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap' as const,
minWidth: 32,
textAlign: 'center' as const,
height: 24,
lineHeight: '16px',
boxSizing: 'border-box' as const,
verticalAlign: 'middle',
},
timeframeActive: {
color: '#f0f6fc',
background: '#1f6feb',
borderColor: '#1f6feb',
},
};
@@ -32,17 +32,7 @@ interface BalanceResponse {
timestamp: string;
}
// ── Styles (kept from original) ──
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' },
nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 },
grid: { display: 'grid', gridTemplateColumns: '1fr 340px', gap: 0, height: 'calc(100vh - 44px)' },
sidePanel: { background: '#0d1117', borderLeft: '1px solid #30363d', display: 'flex', flexDirection: 'column', overflow: 'hidden' },
mainContent: { display: 'flex', flexDirection: 'column', overflow: 'hidden' },
btn: { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 },
activeBtn: { padding: '6px 16px', fontSize: 12, borderRadius: 6, background: '#1f6feb', color: '#fff', border: '1px solid #1f6feb', cursor: 'pointer' },
sectionTitle: { fontSize: 12, fontWeight: 600, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
};
const navBtnClass = 'min-h-9 touch-manipulation rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary cursor-pointer';
export default function DashboardPage() {
const navigate = useNavigate();
@@ -133,101 +123,65 @@ export default function DashboardPage() {
const { t } = useT();
return (
<div style={styles.page}>
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
{/* ─── NAV ─── */}
<nav style={styles.nav}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }} onClick={() => navigate('/')}>
<span style={{ fontSize: 18 }}>📊</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>{t('nav.tradingPortal')}</span>
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
<div className="flex cursor-pointer items-center gap-3" onClick={() => navigate('/')}>
<span className="text-lg">📊</span>
<span className="text-base font-bold text-text-heading">{t('nav.tradingPortal')}</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="flex items-center gap-2">
{isMobile ? (
<>
{/* Hamburger: toggle side panel */}
<button
onClick={() => { setShowSidePanel(!showSidePanel); setShowNavDropdown(false); }}
style={{
...styles.btn,
fontSize: 18,
padding: '8px 14px',
lineHeight: 1,
minHeight: 36,
minWidth: 36,
touchAction: 'manipulation',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
className="flex min-h-9 min-w-9 touch-manipulation items-center justify-center rounded-md border border-border-default bg-bg-hover text-lg leading-none text-text-primary"
title={t('nav.panelToggle')}
>
{showSidePanel ? '✕' : '☰'}
</button>
{/* Dropdown toggle for nav links */}
<div style={{ position: 'relative' }}>
<div className="relative">
<button
onClick={() => setShowNavDropdown(!showNavDropdown)}
style={{
...styles.btn,
fontSize: 16,
padding: '8px 14px',
lineHeight: 1,
minHeight: 36,
minWidth: 36,
touchAction: 'manipulation',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
background: showNavDropdown ? '#1f6feb' : '#21262d',
borderColor: showNavDropdown ? '#1f6feb' : '#30363d',
}}
className={`flex min-h-9 min-w-9 touch-manipulation items-center justify-center gap-1 rounded-md border px-3.5 py-2 text-base leading-none ${showNavDropdown ? 'border-accent-blue bg-accent-blue' : 'border-border-default bg-bg-hover'}`}
title="Menu"
>
<span style={{ fontSize: 14 }}>☰</span>
<span style={{ fontSize: 10 }}>▼</span>
<span className="text-sm">☰</span>
<span className="text-[10px]">▼</span>
</button>
{showNavDropdown && (
<>
{/* Backdrop to close on outside click */}
<div
onClick={() => setShowNavDropdown(false)}
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
zIndex: 90, background: 'transparent',
}}
className="fixed inset-0 z-[90] bg-transparent"
/>
<div style={{
position: 'absolute', top: '100%', right: 0,
marginTop: 4, zIndex: 100,
background: '#161b22', border: '1px solid #30363d',
borderRadius: 8, padding: 4,
display: 'flex', flexDirection: 'column', gap: 2,
minWidth: 160,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
}}>
<div className="absolute right-0 top-full z-[100] mt-1 flex min-w-[160px] flex-col gap-0.5 rounded-lg border border-border-default bg-bg-surface p-1 shadow-[0_8px_24px_rgba(0,0,0,0.5)]">
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-text-primary"
onClick={() => { navigate('/profile'); setShowNavDropdown(false); }}
>
{t('nav.profile', { name: userName })}
</button>
{(user?.role === 'admin' || user?.is_admin) && (
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-text-primary"
onClick={() => { navigate('/admin'); setShowNavDropdown(false); }}
>
🛡️ Admin
</button>
)}
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-text-primary"
onClick={() => { navigate('/analytics'); setShowNavDropdown(false); }}
>
📈 Analytics
</button>
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
<div className="my-0.5 h-px bg-border-default" />
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%', color: '#f85149' }}
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-red"
onClick={async () => {
setShowNavDropdown(false);
await dispatch(logoutThunk());
@@ -245,25 +199,19 @@ export default function DashboardPage() {
</>
) : (
<>
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/profile')}>
<button className={navBtnClass} onClick={() => navigate('/profile')}>
{t('nav.profile', { name: userName })}
</button>
{(user?.role === 'admin' || user?.is_admin) && (
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/admin')}>
<button className={navBtnClass} onClick={() => navigate('/admin')}>
🛡️ Admin
</button>
)}
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/analytics')}>
<button className={navBtnClass} onClick={() => navigate('/analytics')}>
📈 Analytics
</button>
<button
style={{
...styles.btn,
touchAction: 'manipulation',
minHeight: isMobile ? 36 : undefined,
color: '#f85149',
borderColor: '#f8514933',
}}
className={`${navBtnClass} border-red/20 text-red`}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
@@ -278,10 +226,10 @@ export default function DashboardPage() {
</div>
</nav>
{/* ─── GRID LAYOUT: 1fr (main) | 280px (side) ─── */}
<div style={{ ...styles.grid, gridTemplateColumns: isMobile ? '1fr' : '1fr 340px' }}>
{/* ─── GRID LAYOUT: 1fr (main) | 340px (side) ─── */}
<div className="grid h-[calc(100vh-44px)] grid-cols-1 gap-0 md:grid-cols-[1fr_340px]">
{/* ═══ MAIN CONTENT (LEFT) ═══ */}
<div style={styles.mainContent}>
<div className="flex flex-col overflow-hidden">
{/* Toolbar: exchange / symbol / timeframe selectors */}
<ChartToolbar
exchanges={exchanges}
@@ -294,7 +242,7 @@ export default function DashboardPage() {
/>
{/* Chart area: fills remaining vertical space */}
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div className="relative min-h-0 flex-1">
{symbol ? (
<ChartContainer
symbol={symbol}
@@ -303,14 +251,7 @@ export default function DashboardPage() {
onPriceUpdate={setLastPrice}
/>
) : (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#484f58',
fontSize: 14,
}}>
<div className="flex h-full items-center justify-center text-sm text-text-dim">
Select a symbol from watchlist
</div>
)}
@@ -318,12 +259,7 @@ export default function DashboardPage() {
{/* Signal panel: below chart, only when symbol selected */}
{symbol && (
<div style={{
borderTop: '1px solid #30363d',
height: isMobile ? 200 : 280,
minHeight: 0,
overflow: 'hidden',
}}>
<div className={`min-h-0 overflow-hidden border-t border-border-default ${isMobile ? 'h-[200px]' : 'h-[280px]'}`}>
<SignalPanel symbol={symbol} />
</div>
)}
@@ -331,7 +267,7 @@ export default function DashboardPage() {
{/* ═══ SIDE PANEL (RIGHT) ═══ */}
{!isMobile && (
<div style={styles.sidePanel}>
<div className="flex flex-col overflow-hidden border-l border-border-default bg-bg-primary">
{/* Watchlist at top of side panel */}
<WatchlistPanel
currentSymbol={symbol}
@@ -340,9 +276,9 @@ export default function DashboardPage() {
/>
{/* Order panel below watchlist */}
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
<div className="flex-1 overflow-y-auto border-t border-border-default">
{user?.role === 'viewer' ? (
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
<div className="p-3 px-4 text-center text-xs text-text-dim">
👁️ View-only mode — trading is disabled
</div>
) : (
@@ -368,9 +304,9 @@ export default function DashboardPage() {
onSymbolChange={(s) => { setSymbol(s); setShowSidePanel(false); }}
exchange={exchange}
/>
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
<div className="flex-1 overflow-y-auto border-t border-border-default">
{user?.role === 'viewer' ? (
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
<div className="p-3 px-4 text-center text-xs text-text-dim">
👁️ View-only mode — trading is disabled
</div>
) : (
+40 -274
View File
@@ -59,14 +59,6 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
}
}, [lastPrice]);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Auto-update clock every second
useEffect(() => {
const interval = setInterval(() => {
@@ -114,63 +106,54 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
}
};
const btnCursor = isConnected ? 'pointer' : 'not-allowed';
const btnOpacity = isConnected ? 1 : 0.5;
const disabledClass = isConnected ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-50';
return (
<div style={{ ...styles.panel, padding: isMobile ? '8px 12px' : '16px 20px' }}>
<div className="flex flex-col gap-5 px-3 py-2 md:px-5 md:py-4">
{/* Symbol info */}
<div style={styles.symbolSection}>
<h2 style={{ ...styles.symbolTitle, fontSize: isMobile ? 18 : 22 }}>{symbol}</h2>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 8 }}>
Ordering <strong style={{ color: '#8b949e' }}>{baseToken}</strong> with <strong style={{ color: '#8b949e' }}>{quoteToken}</strong>
<div className="border-b border-border-default pb-4">
<h2 className="mb-2 text-lg font-semibold text-text-heading md:text-[22px]">{symbol}</h2>
<div className="mb-2 text-[11px] text-text-dim">
Ordering <strong className="text-text-secondary">{baseToken}</strong> with <strong className="text-text-secondary">{quoteToken}</strong>
</div>
<div style={styles.priceRow}>
<span style={styles.priceLabel}>Last Price</span>
<div className="flex items-center justify-between">
<span className="text-[13px] text-text-secondary">Last Price</span>
{lastPrice !== null ? (
<span style={{
...styles.priceValue,
fontSize: isMobile ? 15 : 18,
color: priceDirection === 'up' ? '#26a69a' : priceDirection === 'down' ? '#ef5350' : '#26a69a',
}}>
<span className={`text-[15px] font-semibold md:text-lg ${priceDirection === 'down' ? 'text-red-alt' : 'text-green-alt'}`}>
{priceDirection === 'up' ? '▲ ' : priceDirection === 'down' ? '▼ ' : '● '}
{formatPrice(lastPrice)}
</span>
) : (
<span style={{ ...styles.priceLoading, fontSize: isMobile ? 13 : 14 }}>Loading price...</span>
<span className="text-[13px] italic text-text-secondary md:text-sm">Loading price...</span>
)}
</div>
</div>
{/* Time + Balance row (50/50) - stacks on mobile */}
<div style={{ ...styles.infoRow, flexDirection: isMobile ? 'column' : 'row' }}>
<div style={styles.infoBlock}>
<h3 style={styles.sectionTitle}>Time</h3>
<div style={{ ...styles.clock, fontSize: isMobile ? 14 : 15 }}>{currentTime.toLocaleTimeString()}</div>
<div style={styles.date}>
<div className="flex flex-col items-stretch overflow-hidden rounded-lg border border-border-default bg-bg-surface md:flex-row">
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Time</h3>
<div className="mb-1 font-mono text-sm text-text-primary md:text-[15px]">{currentTime.toLocaleTimeString()}</div>
<div className="text-[11px] text-text-secondary">
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
<div style={{ ...styles.infoDivider, width: isMobile ? 'auto' : 1, height: isMobile ? 1 : 'auto', margin: isMobile ? '6px 0' : 0 }} />
<div style={styles.infoBlock}>
<h3 style={styles.sectionTitle}>Account</h3>
<div style={{
...styles.balanceValue,
fontSize: isMobile ? 14 : 15,
color: (balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? '#3fb950' : '#8b949e',
}}>
<div className="mx-0 my-1.5 h-px w-auto shrink-0 bg-border-default md:mx-0 md:my-0 md:h-auto md:w-px" />
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Account</h3>
<div className={`mb-0.5 text-sm font-semibold md:text-[15px] ${(balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? 'text-green' : 'text-text-secondary'}`}>
${(balance?.find(b => b.asset === 'USDT')?.total ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div style={styles.balanceLabel}>Available</div>
<div className="text-xs text-text-secondary">Available</div>
</div>
</div>
{/* Quick Order */}
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Quick Order · {baseToken}/{quoteToken}</h3>
<div className="border-b border-border-default pb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Quick Order · {baseToken}/{quoteToken}</h3>
{/* Amount input */}
<div style={styles.inputRow}>
<div className="mb-2">
<input
type="number"
step="any"
@@ -178,25 +161,25 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
placeholder={`Amount in ${quoteToken}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ ...styles.amountInput, padding: isMobile ? '8px 10px' : '10px 12px', fontSize: isMobile ? 13 : 14 }}
className="box-border w-full rounded-md border border-border-default bg-bg-primary px-2.5 py-2 font-sans text-[13px] text-text-primary outline-none md:px-3 md:py-2.5 md:text-sm"
disabled={!isConnected}
/>
<div style={{ fontSize: 11, color: '#484f58', marginTop: 4, fontStyle: 'italic' }}>
<div className="mt-1 text-[11px] italic text-text-dim">
{quoteToken} amount · Buy = spend {quoteToken} · Sell = sell {baseToken}
</div>
</div>
{/* Buy / Sell buttons */}
<div style={styles.orderButtons}>
<div className="flex gap-2">
<button
style={{ ...styles.buyButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
className={`flex-1 rounded-md border-none py-2 text-[13px] font-semibold text-white md:py-2.5 md:text-sm ${disabledClass} bg-green-alt`}
disabled={!isConnected || orderLoading}
onClick={() => placeOrder('buy')}
>
{orderLoading ? 'Placing...' : `Buy ${baseToken}`}
</button>
<button
style={{ ...styles.sellButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
className={`flex-1 rounded-md border-none py-2 text-[13px] font-semibold text-white md:py-2.5 md:text-sm ${disabledClass} bg-red-alt`}
disabled={!isConnected || orderLoading}
onClick={() => placeOrder('sell')}
>
@@ -206,40 +189,40 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
{/* Connection hint */}
{!isConnected && (
<p style={styles.placeholder}>Connect exchange API in Profile → API Keys to enable trading</p>
<p className="mt-1.5 text-center text-[11px] italic text-text-dim">Connect exchange API in Profile → API Keys to enable trading</p>
)}
{/* Success message */}
{orderSuccess && (
<div style={styles.successBanner}>
<div className="mt-2 flex items-center justify-between rounded-md border border-green-alt bg-[#0a2e29] px-2.5 py-2 text-xs text-green-alt">
✅ {orderSuccess}
<button style={styles.dismissBtn} onClick={() => setOrderSuccess(null)}>✕</button>
<button className="ml-2 border-none bg-transparent p-0 text-sm leading-none text-inherit" onClick={() => setOrderSuccess(null)}>✕</button>
</div>
)}
{/* Error message */}
{orderError && (
<div style={styles.errorBanner}>
<div className="mt-2 flex items-center justify-between rounded-md border border-red bg-[#2d0e0e] px-2.5 py-2 text-xs text-red">
❌ {orderError}
<button style={styles.dismissBtn} onClick={() => setOrderError(null)}>✕</button>
<button className="ml-2 border-none bg-transparent p-0 text-sm leading-none text-inherit" onClick={() => setOrderError(null)}>✕</button>
</div>
)}
{/* P1-21: Confirmation dialog before placing real trade */}
{pendingOrder && (
<div style={styles.confirmOverlay}>
<div style={styles.confirmDialog}>
<div style={{ fontSize: 14, color: '#f0f6fc', marginBottom: 8 }}>
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60">
<div className="w-[90%] max-w-[360px] rounded-xl border border-border-default bg-bg-surface p-6 text-center">
<div className="mb-2 text-sm text-text-heading">
⚠️ Confirm {pendingOrder.side.toUpperCase()} order?
</div>
<div style={{ fontSize: 12, color: '#8b949e', marginBottom: 12 }}>
<div className="mb-3 text-xs text-text-secondary">
{pendingOrder.amount} {quoteToken} {pendingOrder.side === 'buy' ? '→' : '←'} {symbol}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={styles.confirmBtn} onClick={confirmOrder}>
<div className="flex gap-2">
<button className="cursor-pointer rounded-md border-none bg-[#238636] px-4 py-2 text-[13px] font-semibold text-white" onClick={confirmOrder}>
✅ {pendingOrder.side === 'buy' ? 'Buy' : 'Sell'}
</button>
<button style={styles.cancelBtn} onClick={() => setPendingOrder(null)}>
<button className="cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-2 text-[13px] text-text-primary" onClick={() => setPendingOrder(null)}>
Cancel
</button>
</div>
@@ -250,220 +233,3 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
panel: {
padding: '16px 20px',
display: 'flex',
flexDirection: 'column',
gap: 20,
},
symbolSection: {
paddingBottom: 16,
borderBottom: '1px solid #30363d',
},
symbolTitle: {
fontSize: 22,
fontWeight: 600,
color: '#f0f6fc',
margin: '0 0 8px',
},
priceRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
priceLabel: {
fontSize: 13,
color: '#8b949e',
},
priceValue: {
fontSize: 18,
fontWeight: 600,
color: '#26a69a',
},
priceLoading: {
fontSize: 13,
color: '#8b949e',
fontStyle: 'italic',
},
section: {
paddingBottom: 16,
borderBottom: '1px solid #30363d',
},
sectionTitle: {
fontSize: 12,
fontWeight: 500,
color: '#8b949e',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
margin: '0 0 8px',
},
infoRow: {
display: 'flex',
alignItems: 'stretch',
gap: 0,
background: '#161b22',
borderRadius: 8,
border: '1px solid #30363d',
overflow: 'hidden',
},
infoBlock: {
flex: 1,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column' as const,
justifyContent: 'center',
},
infoDivider: {
width: 1,
background: '#30363d',
flexShrink: 0,
},
clock: {
fontSize: 15,
fontWeight: 500,
color: '#c9d1d9',
fontFamily: 'monospace',
marginBottom: 4,
},
date: {
fontSize: 11,
color: '#8b949e',
},
balanceValue: {
fontSize: 15,
fontWeight: 600,
color: '#f0f6fc',
marginBottom: 2,
},
balanceLabel: {
fontSize: 12,
color: '#8b949e',
},
inputRow: {
marginBottom: 8,
},
amountInput: {
width: '100%',
padding: '10px 12px',
fontSize: 14,
color: '#c9d1d9',
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
outline: 'none',
boxSizing: 'border-box' as const,
fontFamily: 'inherit',
},
orderButtons: {
display: 'flex',
gap: 8,
},
buyButton: {
flex: 1,
padding: '10px 0',
fontSize: 14,
fontWeight: 600,
color: '#fff',
background: '#26a69a',
border: 'none',
borderRadius: 6,
cursor: 'not-allowed',
opacity: 0.5,
},
sellButton: {
flex: 1,
padding: '10px 0',
fontSize: 14,
fontWeight: 600,
color: '#fff',
background: '#ef5350',
border: 'none',
borderRadius: 6,
cursor: 'not-allowed',
opacity: 0.5,
},
placeholder: {
fontSize: 11,
color: '#484f58',
textAlign: 'center',
margin: '6px 0 0',
fontStyle: 'italic',
},
successBanner: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 12,
color: '#26a69a',
background: '#0a2e29',
border: '1px solid #26a69a',
borderRadius: 6,
padding: '8px 10px',
marginTop: 8,
},
errorBanner: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 12,
color: '#f85149',
background: '#2d0e0e',
border: '1px solid #f85149',
borderRadius: 6,
padding: '8px 10px',
marginTop: 8,
},
dismissBtn: {
background: 'transparent',
border: 'none',
color: 'inherit',
cursor: 'pointer',
fontSize: 14,
lineHeight: 1,
padding: 0,
marginLeft: 8,
},
// P1-21: Order confirmation dialog styles
confirmOverlay: {
position: 'fixed' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
},
confirmDialog: {
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 12,
padding: 24,
maxWidth: 360,
width: '90%',
textAlign: 'center' as const,
},
confirmBtn: {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 600,
fontSize: 13,
},
cancelBtn: {
background: '#21262d',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 6,
padding: '8px 16px',
cursor: 'pointer',
fontSize: 13,
},
};
+40 -204
View File
@@ -14,13 +14,6 @@ export default function SignalPanel({ symbol }: Props) {
const [loading, setLoading] = useState(false);
const [totalPnl, setTotalPnl] = useState<number | null>(null);
const [winRate, setWinRate] = useState<number | null>(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const loadData = useCallback(async () => {
setLoading(true);
@@ -48,24 +41,28 @@ export default function SignalPanel({ symbol }: Props) {
}, [loadData]);
return (
<div style={styles.panel}>
<div className="flex h-full flex-col overflow-hidden">
{/* Header + Summary */}
<div style={styles.header}>
<h3 style={styles.title}>📊 Signals & Trades</h3>
<button style={styles.refreshBtn} onClick={loadData} disabled={loading}>
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<h3 className="m-0 text-sm font-semibold text-text-heading">📊 Signals & Trades</h3>
<button
className="flex h-9 min-h-9 w-9 min-w-9 touch-manipulation items-center justify-center rounded-md border border-border-default bg-bg-surface text-sm text-text-secondary"
onClick={loadData}
disabled={loading}
>
{loading ? '…' : '↻'}
</button>
</div>
{/* PnL Summary */}
{totalPnl !== null && (
<div style={styles.summary}>
<span style={styles.summaryLabel}>Total P&L</span>
<span style={{ ...styles.summaryValue, color: totalPnl >= 0 ? '#3fb950' : '#f85149' }}>
<div className="flex flex-wrap items-center gap-2 border-b border-border-muted px-4 pb-2 pt-1">
<span className="text-xs text-text-secondary">Total P&L</span>
<span className={`font-mono text-[15px] font-bold ${totalPnl >= 0 ? 'text-green' : 'text-red'}`}>
{formatPnL(totalPnl)}
</span>
{winRate !== null && (
<span style={styles.winRate}>
<span className="ml-auto text-xs text-text-secondary">
Win Rate: {winRate.toFixed(1)}%
</span>
)}
@@ -73,15 +70,19 @@ export default function SignalPanel({ symbol }: Props) {
)}
{/* Tabs */}
<div style={styles.tabs}>
<div className="flex border-b border-border-muted">
<button
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'signals' ? styles.tabActive : {}) }}
className={`min-h-9 flex-1 touch-manipulation border-b-2 bg-transparent px-3 py-2 text-[10px] font-medium transition-colors md:text-xs ${
activeTab === 'signals' ? 'border-accent text-accent' : 'border-transparent text-text-secondary'
}`}
onClick={() => setActiveTab('signals')}
>
Signals ({signals.length})
</button>
<button
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'trades' ? styles.tabActive : {}) }}
className={`min-h-9 flex-1 touch-manipulation border-b-2 bg-transparent px-3 py-2 text-[10px] font-medium transition-colors md:text-xs ${
activeTab === 'trades' ? 'border-accent text-accent' : 'border-transparent text-text-secondary'
}`}
onClick={() => setActiveTab('trades')}
>
Trades ({trades.length})
@@ -89,27 +90,27 @@ export default function SignalPanel({ symbol }: Props) {
</div>
{/* Content */}
<div style={styles.content}>
<div className="flex-1 overflow-y-auto py-1">
{activeTab === 'signals' ? (
signals.length === 0 ? (
<div style={styles.empty}>No signals yet — waiting for candle data</div>
<div className="px-4 py-6 text-center text-[13px] text-text-dim">No signals yet — waiting for candle data</div>
) : (
signals.map((sig) => {
const cfg = SIGNAL_CONFIG[sig.signal_type] || { label: sig.signal_type, icon: '📊', color: '#8b949e', bg: 'transparent' };
return (
<div key={sig.id} style={styles.signalRow}>
<div style={styles.signalLeft}>
<span style={{ fontSize: 16 }}>{cfg.icon}</span>
<div key={sig.id} className="flex cursor-pointer items-center justify-between border-b border-border-muted px-3 py-2 transition-colors">
<div className="flex items-center gap-2">
<span className="text-base">{cfg.icon}</span>
<div>
<div style={{ ...styles.signalType, color: cfg.color }}>{cfg.label}</div>
<div style={styles.signalMeta}>
<div className="text-[13px] font-semibold" style={{ color: cfg.color }}>{cfg.label}</div>
<div className="mt-px text-[11px] text-text-muted">
{sig.symbol} · {sig.timeframe} · {sig.strength}
</div>
</div>
</div>
<div style={styles.signalRight}>
<div style={styles.signalPrice}>${formatPrice(sig.price)}</div>
<div style={styles.signalTime}>
<div className="text-right">
<div className="font-mono text-[13px] font-medium text-text-primary">${formatPrice(sig.price)}</div>
<div className="text-[11px] text-text-dim">
{new Date(sig.created_at).toLocaleTimeString()}
</div>
</div>
@@ -119,32 +120,31 @@ export default function SignalPanel({ symbol }: Props) {
)
) : (
trades.length === 0 ? (
<div style={styles.empty}>No trades yet</div>
<div className="px-4 py-6 text-center text-[13px] text-text-dim">No trades yet</div>
) : (
trades.map((trade) => (
<div key={trade.id} style={styles.tradeRow}>
<div style={styles.tradeHeader}>
<span style={{ ...styles.tradeDirection, color: trade.direction === 'LONG' ? '#3fb950' : '#f85149' }}>
<div key={trade.id} className="border-b border-border-muted px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${trade.direction === 'LONG' ? 'text-green' : 'text-red'}`}>
{trade.direction === 'LONG' ? '▲' : '▼'} {trade.direction}
</span>
<span style={styles.tradeSymbol}>{trade.symbol}</span>
<span style={{
...styles.tradeStatus,
color: trade.status === 'OPEN' ? '#58a6ff' : (trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149'),
}}>
<span className="text-xs text-text-primary">{trade.symbol}</span>
<span className={`ml-auto text-[11px] font-semibold ${
trade.status === 'OPEN' ? 'text-accent' : (trade.pnl && trade.pnl > 0 ? 'text-green' : 'text-red')
}`}>
{trade.status === 'OPEN' ? '🟢 OPEN' : `🔒 ${formatPnL(trade.pnl)}`}
</span>
</div>
<div style={styles.tradeDetails}>
<div className="mt-1 flex flex-wrap gap-1 font-mono text-[11px] text-text-secondary">
<span>Entry: ${formatPrice(trade.entry_price)}</span>
{trade.exit_price && <span> → Exit: ${formatPrice(trade.exit_price)}</span>}
{trade.pnl_percent !== null && (
<span style={{ color: trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149', fontWeight: 600 }}>
<span className={`font-semibold ${trade.pnl && trade.pnl > 0 ? 'text-green' : 'text-red'}`}>
{formatPnLPercent(trade.pnl_percent)}
</span>
)}
</div>
<div style={styles.tradeReason}>
<div className="mt-0.5 text-[10px] text-text-dim">
{trade.entry_reason && <span>📥 {trade.entry_reason}</span>}
{trade.exit_reason && <span> 📤 {trade.exit_reason}</span>}
</div>
@@ -156,167 +156,3 @@ export default function SignalPanel({ symbol }: Props) {
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
panel: {
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px 8px',
},
title: {
fontSize: 14,
fontWeight: 600,
color: '#f0f6fc',
margin: 0,
},
refreshBtn: {
width: 36,
height: 36,
border: '1px solid #30363d',
borderRadius: 6,
background: '#161b22',
color: '#8b949e',
cursor: 'pointer',
fontSize: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'manipulation',
minWidth: 36,
minHeight: 36,
},
summary: {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 16px 8px',
borderBottom: '1px solid #21262d',
flexWrap: 'wrap' as const,
},
summaryLabel: {
fontSize: 12,
color: '#8b949e',
},
summaryValue: {
fontSize: 15,
fontWeight: 700,
fontFamily: "'SF Mono', monospace",
},
winRate: {
fontSize: 12,
color: '#8b949e',
marginLeft: 'auto' as const,
},
tabs: {
display: 'flex',
borderBottom: '1px solid #21262d',
},
tab: {
flex: 1,
padding: '8px 12px',
fontSize: 12,
fontWeight: 500,
color: '#8b949e',
background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s ease',
minHeight: 36,
touchAction: 'manipulation',
},
tabActive: {
color: '#58a6ff',
borderBottomColor: '#58a6ff',
},
content: {
flex: 1,
overflowY: 'auto' as const,
padding: '4px 0',
},
empty: {
padding: '24px 16px',
textAlign: 'center' as const,
color: '#484f58',
fontSize: 13,
},
signalRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid #21262d',
cursor: 'pointer',
transition: 'background 0.15s ease',
},
signalLeft: {
display: 'flex',
alignItems: 'center',
gap: 8,
},
signalType: {
fontSize: 13,
fontWeight: 600,
},
signalMeta: {
fontSize: 11,
color: '#6e7681',
marginTop: 1,
},
signalRight: {
textAlign: 'right' as const,
},
signalPrice: {
fontSize: 13,
fontWeight: 500,
color: '#c9d1d9',
fontFamily: "'SF Mono', monospace",
},
signalTime: {
fontSize: 11,
color: '#484f58',
},
tradeRow: {
padding: '8px 12px',
borderBottom: '1px solid #21262d',
},
tradeHeader: {
display: 'flex',
alignItems: 'center',
gap: 8,
},
tradeDirection: {
fontSize: 12,
fontWeight: 700,
},
tradeSymbol: {
fontSize: 12,
color: '#c9d1d9',
},
tradeStatus: {
fontSize: 11,
fontWeight: 600,
marginLeft: 'auto' as const,
},
tradeDetails: {
fontSize: 11,
color: '#8b949e',
marginTop: 4,
fontFamily: "'SF Mono', monospace",
display: 'flex',
gap: 4,
flexWrap: 'wrap' as const,
},
tradeReason: {
fontSize: 10,
color: '#484f58',
marginTop: 2,
},
};
@@ -16,14 +16,6 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
const [loading, setLoading] = useState(true);
const [signals, setSignals] = useState<Signal[]>([]);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Add symbol state
const [showAdd, setShowAdd] = useState(false);
const [search, setSearch] = useState('');
@@ -111,25 +103,24 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
const availableSymbols = symbols.filter((s) => !watchlistSymbolIds.has(s.id));
return (
<div style={styles.panel}>
<style>{`
.wl-remove-btn:hover { opacity: 1 !important; color: #f85149 !important; background: rgba(248,81,73,0.1) !important; }
.wl-item:hover .wl-remove-btn { opacity: 0.65 !important; }
`}</style>
<div className="flex flex-col border-b border-border-muted">
{/* Header */}
<div style={styles.header}>
<h3 style={styles.title}>👁️ Watchlist</h3>
<button style={styles.addBtn} onClick={() => setShowAdd(!showAdd)}>
<div className="flex items-center justify-between px-3.5 pb-1.5 pt-2.5">
<h3 className="m-0 text-[13px] font-semibold text-text-heading">👁️ Watchlist</h3>
<button
className="flex h-[26px] w-[26px] items-center justify-center rounded-md border border-border-default bg-bg-surface text-base font-semibold leading-none text-accent"
onClick={() => setShowAdd(!showAdd)}
>
{showAdd ? '✕' : '+'}
</button>
</div>
{/* Add Symbol Panel */}
{showAdd && (
<div style={styles.addPanel}>
<div className="border-b border-border-muted px-3.5 pb-2.5 pt-1.5">
<input
ref={searchRef}
style={styles.searchInput}
className="mb-1.5 w-full rounded-md border border-border-default bg-bg-primary px-2.5 py-1.5 text-xs text-text-primary outline-none"
placeholder="Search symbols..."
value={search}
onChange={(e) => {
@@ -137,21 +128,21 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
searchSymbols(e.target.value);
}}
/>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.symbolList}>
{error && <div className="mb-1 text-[11px] text-red">{error}</div>}
<div className="max-h-[180px] overflow-y-auto">
{searching ? (
<div style={styles.loadingText}>Searching...</div>
<div className="px-3.5 py-3 text-center text-xs text-text-dim">Searching...</div>
) : availableSymbols.length === 0 ? (
<div style={styles.emptyText}>No symbols found</div>
<div className="px-3.5 py-3 text-center text-xs text-text-dim">No symbols found</div>
) : (
availableSymbols.slice(0, 30).map((sym) => (
<button
key={sym.id}
style={styles.symbolOption}
className="flex w-full items-center justify-between rounded border-none bg-transparent px-2 py-1.5 text-left text-xs text-text-primary"
onClick={() => handleAdd(sym)}
>
<span style={styles.symName}>{sym.symbol}</span>
<span style={styles.symExchange}>{exchange}</span>
<span className="font-medium">{sym.symbol}</span>
<span className="text-[10px] text-text-dim">{exchange}</span>
</button>
))
)}
@@ -160,11 +151,11 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
)}
{/* Watchlist Items */}
<div style={styles.list}>
<div className="max-h-[300px] overflow-y-auto">
{loading ? (
<div style={styles.loadingText}>Loading...</div>
<div className="px-3.5 py-3 text-center text-xs text-text-dim">Loading...</div>
) : items.length === 0 ? (
<div style={styles.emptyText}>
<div className="px-3.5 py-3 text-center text-xs text-text-dim">
No symbols watched yet. Click + to add.
</div>
) : (
@@ -175,30 +166,26 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
return (
<div
key={item.id}
className="wl-item"
style={{
...styles.item,
...(isActive ? styles.itemActive : {}),
padding: isMobile ? '5px 10px' : '7px 14px',
}}
className={`group flex cursor-pointer items-center justify-between border-l-[3px] px-3.5 py-1.5 transition-colors md:px-3.5 md:py-[7px] ${
isActive ? 'border-l-accent bg-accent/[0.08]' : 'border-l-transparent'
}`}
onClick={() => onSymbolChange(item.symbol)}
>
{/* Symbol info */}
<div style={styles.itemLeft}>
<div style={{ ...styles.itemSymbol, fontSize: isMobile ? 12 : 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{item.symbol}</div>
{item.label && <div style={styles.itemLabel}>{item.label}</div>}
<div className="flex min-w-0 flex-col gap-px">
<div className="max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-primary md:text-[13px]">{item.symbol}</div>
{item.label && <div className="text-[10px] text-text-muted">{item.label}</div>}
</div>
{/* Signal indicator */}
<div style={styles.itemRight}>
<div className="flex items-center gap-1">
{cfg && (
<span style={{ fontSize: 14, marginRight: 4 }} title={cfg.label}>
<span className="mr-1 text-sm" title={cfg.label}>
{cfg.icon}
</span>
)}
<button
className="wl-remove-btn"
style={styles.removeBtn}
className="z-[5] flex h-5 w-5 items-center justify-center rounded border-none bg-transparent text-xs text-text-dim opacity-40 transition-opacity group-hover:opacity-65 hover:!opacity-100 hover:!text-red hover:!bg-red/10"
onClick={(e) => {
e.stopPropagation();
handleRemove(item.id);
@@ -216,148 +203,3 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
panel: {
display: 'flex',
flexDirection: 'column',
borderBottom: '1px solid #21262d',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px 6px',
},
title: {
fontSize: 13,
fontWeight: 600,
color: '#f0f6fc',
margin: 0,
},
addBtn: {
width: 26,
height: 26,
border: '1px solid #30363d',
borderRadius: 6,
background: '#161b22',
color: '#58a6ff',
cursor: 'pointer',
fontSize: 16,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
},
addPanel: {
padding: '6px 14px 10px',
borderBottom: '1px solid #21262d',
},
searchInput: {
width: '100%',
padding: '6px 10px',
fontSize: 12,
color: '#c9d1d9',
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
outline: 'none',
marginBottom: 6,
},
error: {
fontSize: 11,
color: '#f85149',
marginBottom: 4,
},
symbolList: {
maxHeight: 180,
overflowY: 'auto' as const,
},
symbolOption: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '5px 8px',
border: 'none',
background: 'transparent',
color: '#c9d1d9',
fontSize: 12,
cursor: 'pointer',
borderRadius: 4,
textAlign: 'left' as const,
},
symName: {
fontWeight: 500,
},
symExchange: {
fontSize: 10,
color: '#484f58',
},
list: {
maxHeight: 300,
overflowY: 'auto' as const,
},
loadingText: {
padding: '12px 14px',
fontSize: 12,
color: '#484f58',
textAlign: 'center' as const,
},
emptyText: {
padding: '12px 14px',
fontSize: 12,
color: '#484f58',
textAlign: 'center' as const,
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '7px 14px',
cursor: 'pointer',
borderLeft: '3px solid transparent',
transition: 'all 0.15s ease',
},
itemActive: {
borderLeftColor: '#58a6ff',
background: 'rgba(88, 166, 255, 0.08)',
},
itemLeft: {
display: 'flex',
flexDirection: 'column' as const,
gap: 1,
minWidth: 0,
},
itemSymbol: {
fontSize: 13,
fontWeight: 500,
color: '#c9d1d9',
},
itemLabel: {
fontSize: 10,
color: '#6e7681',
},
itemRight: {
display: 'flex',
alignItems: 'center',
gap: 4,
},
removeBtn: {
width: 20,
height: 20,
border: 'none',
background: 'transparent',
color: '#484f58',
cursor: 'pointer',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
opacity: 0.4,
transition: 'opacity 0.15s ease, color 0.15s ease, background 0.15s ease',
zIndex: 5,
},
};
+298 -207
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useAppSelector } from '../../app/hooks';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { logoutThunk } from '../auth/authSlice';
import { apiFetch } from '../api/apiService';
import { useT } from '../../translations';
@@ -11,10 +13,10 @@ function authFetch<T = any>(url: string, options: RequestInit = {}): Promise<T>
return apiFetch<T>(apiUrl, options);
}
const btnStyle: React.CSSProperties = { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 };
const inputStyle: React.CSSProperties = { padding: '6px 10px', borderRadius: 6, border: '1px solid #30363d', background: '#0d1117', color: '#c9d1d9', fontSize: 13 };
const labelStyle: React.CSSProperties = { fontSize: 11, color: '#8b949e', marginBottom: 4 };
const cardStyle: React.CSSProperties = { background: '#161b22', borderRadius: 8, padding: 14, border: '1px solid #21262d' };
const btnClass = 'min-h-9 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
const inputClass = 'rounded-md border border-border-default bg-bg-primary px-2.5 py-1.5 text-[13px] text-text-primary';
const labelClass = 'mb-1 text-[11px] text-text-secondary';
const cardClass = 'rounded-lg border border-border-muted bg-bg-surface p-3.5';
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
const TFS_BT = ['15m', '30m', '1h', '4h'];
@@ -62,8 +64,14 @@ function formatTimeAgo(dateStr: string): string {
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return (
<div onClick={() => onChange(!value)} style={{ width: 40, height: 22, borderRadius: 11, cursor: 'pointer', position: 'relative', background: value ? '#238636' : '#21262d', transition: 'background 0.2s' }}>
<div style={{ width: 18, height: 18, borderRadius: '50%', background: '#fff', position: 'absolute', top: 2, left: value ? 20 : 2, transition: 'all 0.2s' }} />
<div
onClick={() => onChange(!value)}
className={`relative h-[22px] w-10 cursor-pointer rounded-full transition-colors ${value ? 'bg-[#238636]' : 'bg-bg-hover'}`}
>
<div
className="absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-all"
style={{ left: value ? 20 : 2 }}
/>
</div>
);
}
@@ -92,37 +100,37 @@ function InfoTab() {
try { await authFetch('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify({ old_password: pwForm.old_password, new_password: pwForm.new_password }) }); setPwMsg('✅ Password changed'); setPwModal(false); setPwForm({ old_password: '', new_password: '', confirm: '' }); } catch (e: any) { setPwMsg('❌ ' + e.message); }
};
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (!profile) return <div style={{ color: '#ef4444' }}>❌ Không tải được profile</div>;
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
if (!profile) return <div className="text-red-alt">❌ Không tải được profile</div>;
return (
<div>
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('✅') ? '#22c55e' : '#ef4444' }}>{msg}</div>}
<div style={{ display: 'grid', gap: 10, maxWidth: 400 }}>
<div><div style={labelStyle}>Username</div><input disabled value={profile.username} style={{ ...inputStyle, width: '100%', opacity: 0.6 }} /></div>
{msg && <div className={`mb-2.5 ${msg.startsWith('✅') ? 'text-green-alt' : 'text-red-alt'}`}>{msg}</div>}
<div className="grid max-w-[400px] gap-2.5">
<div><div className={labelClass}>Username</div><input disabled value={profile.username} className={`${inputClass} w-full opacity-60`} /></div>
{editing ? (<>
<div><div style={labelStyle}>Display Name</div><input value={form.display_name} onChange={e => setForm({ ...form, display_name: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div><div style={labelStyle}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div style={{ display: 'flex', gap: 8 }}><button onClick={save} style={btnStyle}>💾 Save</button><button onClick={() => setEditing(false)} style={btnStyle}>Cancel</button></div>
<div><div className={labelClass}>Display Name</div><input value={form.display_name} onChange={e => setForm({ ...form, display_name: e.target.value })} className={`${inputClass} w-full`} /></div>
<div><div className={labelClass}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} className={`${inputClass} w-full`} /></div>
<div className="flex gap-2"><button onClick={save} className={btnClass}>💾 Save</button><button onClick={() => setEditing(false)} className={btnClass}>Cancel</button></div>
</>) : (<>
<div><div style={labelStyle}>Display Name</div><div style={{ color: '#c9d1d9' }}>{profile.display_name || '-'}</div></div>
<div><div style={labelStyle}>Email</div><div style={{ color: '#c9d1d9' }}>{profile.email}</div></div>
<div><div style={labelStyle}>Role</div><div style={{ color: '#c9d1d9' }}>{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
<div><div style={labelStyle}>Member since</div><div style={{ color: '#c9d1d9' }}>{profile.created_at?.slice(0, 10)}</div></div>
<div style={{ display: 'flex', gap: 8 }}><button onClick={() => setEditing(true)} style={btnStyle}>✏️ Edit</button><button onClick={() => setPwModal(true)} style={{ ...btnStyle, color: '#d29922', borderColor: '#d29922' }}>🔑 Change Password</button></div>
<div><div className={labelClass}>Display Name</div><div className="text-text-primary">{profile.display_name || '-'}</div></div>
<div><div className={labelClass}>Email</div><div className="text-text-primary">{profile.email}</div></div>
<div><div className={labelClass}>Role</div><div className="text-text-primary">{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
<div><div className={labelClass}>Member since</div><div className="text-text-primary">{profile.created_at?.slice(0, 10)}</div></div>
<div className="flex gap-2"><button onClick={() => setEditing(true)} className={btnClass}>✏️ Edit</button><button onClick={() => setPwModal(true)} className={`${btnClass} border-yellow text-yellow`}>🔑 Change Password</button></div>
</>)}
</div>
{pwModal && (
<div onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#161b22', borderRadius: 12, padding: 24, border: '1px solid #30363d', maxWidth: 360, width: '90%' }}>
<h3 style={{ color: '#f0f6fc', margin: '0 0 16px' }}>🔑 Change Password</h3>
{pwMsg && <div style={{ marginBottom: 10, color: pwMsg.startsWith('✅') ? '#22c55e' : '#ef4444', fontSize: 12 }}>{pwMsg}</div>}
<div style={{ display: 'grid', gap: 10 }}>
<div><div style={labelStyle}>Old Password</div><input type="password" value={pwForm.old_password} onChange={e => setPwForm({ ...pwForm, old_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div><div style={labelStyle}>New Password</div><input type="password" value={pwForm.new_password} onChange={e => setPwForm({ ...pwForm, new_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div><div style={labelStyle}>Confirm New Password</div><input type="password" value={pwForm.confirm} onChange={e => setPwForm({ ...pwForm, confirm: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div style={{ display: 'flex', gap: 8 }}><button onClick={changePw} style={{ ...btnStyle, background: '#d29922', color: '#000', borderColor: '#d29922' }}>Change Password</button><button onClick={() => setPwModal(false)} style={btnStyle}>Cancel</button></div>
<div onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60">
<div className="w-[90%] max-w-[360px] rounded-xl border border-border-default bg-bg-surface p-6">
<h3 className="mb-4 text-text-heading">🔑 Change Password</h3>
{pwMsg && <div className={`mb-2.5 text-xs ${pwMsg.startsWith('✅') ? 'text-green-alt' : 'text-red-alt'}`}>{pwMsg}</div>}
<div className="grid gap-2.5">
<div><div className={labelClass}>Old Password</div><input type="password" value={pwForm.old_password} onChange={e => setPwForm({ ...pwForm, old_password: e.target.value })} className={`${inputClass} w-full`} /></div>
<div><div className={labelClass}>New Password</div><input type="password" value={pwForm.new_password} onChange={e => setPwForm({ ...pwForm, new_password: e.target.value })} className={`${inputClass} w-full`} /></div>
<div><div className={labelClass}>Confirm New Password</div><input type="password" value={pwForm.confirm} onChange={e => setPwForm({ ...pwForm, confirm: e.target.value })} className={`${inputClass} w-full`} /></div>
<div className="flex gap-2"><button onClick={changePw} className={`${btnClass} border-yellow bg-yellow text-black`}>Change Password</button><button onClick={() => setPwModal(false)} className={btnClass}>Cancel</button></div>
</div>
</div>
</div>
@@ -179,119 +187,119 @@ function KeysTab() {
const activeExchanges = keys.filter(k => k.is_active).map(k => k.exchange_name).filter((v, i, a) => a.indexOf(v) === i);
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
return (
<div>
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('❌') ? '#ef4444' : '#22c55e', fontSize: 12 }}>{msg}</div>}
<div style={{ marginBottom: 12, color: activeExchanges.length > 0 ? '#3fb950' : '#8b949e', fontWeight: 600 }}>
{msg && <div className={`mb-2.5 text-xs ${msg.startsWith('❌') ? 'text-red-alt' : 'text-green-alt'}`}>{msg}</div>}
<div className={`mb-3 font-semibold ${activeExchanges.length > 0 ? 'text-green' : 'text-text-secondary'}`}>
{activeExchanges.length > 0 ? `🔗 Connected to ${activeExchanges.map(e => e?.toUpperCase()).join(', ')}` : '🔌 No exchanges connected'}
</div>
<div style={{ marginBottom: 12 }}>
<button onClick={() => setShowAdd(!showAdd)} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
<div className="mb-3">
<button onClick={() => setShowAdd(!showAdd)} className={`${btnClass} border-accent-blue bg-accent-blue text-white`}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
</div>
{showAdd && (
<div style={{ ...cardStyle, marginBottom: 16, maxWidth: 400 }}>
<div style={{ display: 'grid', gap: 8 }}>
<div><div style={labelStyle}>Exchange</div><select value={form.exchange_name} onChange={e => setForm({ ...form, exchange_name: e.target.value })} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div><div style={labelStyle}>API Key</div><input value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API key" /></div>
<div><div style={labelStyle}>API Secret</div><input type="password" value={form.api_secret} onChange={e => setForm({ ...form, api_secret: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API secret" /></div>
<div><div style={labelStyle}>Passphrase (optional)</div><input type="password" value={form.passphrase} onChange={e => setForm({ ...form, passphrase: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Exchange passphrase" /></div>
<button onClick={addKey} style={{ ...btnStyle, background: '#22c55e', color: '#000', borderColor: '#22c55e' }}>💾 Save Key</button>
<div className={`${cardClass} mb-4 max-w-[400px]`}>
<div className="grid gap-2">
<div><div className={labelClass}>Exchange</div><select value={form.exchange_name} onChange={e => setForm({ ...form, exchange_name: e.target.value })} className={`${inputClass} w-full cursor-pointer`}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div><div className={labelClass}>API Key</div><input value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} className={`${inputClass} w-full`} placeholder="Enter API key" /></div>
<div><div className={labelClass}>API Secret</div><input type="password" value={form.api_secret} onChange={e => setForm({ ...form, api_secret: e.target.value })} className={`${inputClass} w-full`} placeholder="Enter API secret" /></div>
<div><div className={labelClass}>Passphrase (optional)</div><input type="password" value={form.passphrase} onChange={e => setForm({ ...form, passphrase: e.target.value })} className={`${inputClass} w-full`} placeholder="Exchange passphrase" /></div>
<button onClick={addKey} className={`${btnClass} border-green bg-green text-black`}>💾 Save Key</button>
</div>
</div>
)}
{keys.length === 0 ? (
<div style={{ color: '#8b949e' }}>Chưa có API key nào. Thêm key để bot có thể trade.</div>
<div className="text-text-secondary">Chưa có API key nào. Thêm key để bot có thể trade.</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
<div className="grid gap-2">
{keys.map((k: any) => (
<div key={k.id} style={{ ...cardStyle }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleExpand(k.id)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#8b949e', fontSize: 10, transition: 'transform 0.2s', transform: expandedId === k.id ? 'rotate(90deg)' : 'rotate(0deg)' }}>▶</span>
<span style={{ color: '#f0f6fc', fontWeight: 600 }}>{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
<div key={k.id} className={cardClass}>
<div className="flex items-center justify-between">
<div className="flex-1 cursor-pointer" onClick={() => toggleExpand(k.id)}>
<div className="flex items-center gap-1.5">
<span className={`text-[10px] text-text-secondary transition-transform ${expandedId === k.id ? 'rotate-90' : 'rotate-0'}`}>▶</span>
<span className="font-semibold text-text-heading">{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
</div>
<div style={{ color: '#8b949e', fontSize: 12, marginLeft: 16 }}>Key: {k.api_key}</div>
<div style={{ fontSize: 11, color: k.is_active ? '#3fb950' : '#484f58', marginTop: 2, marginLeft: 16 }}>
<div className="ml-4 text-xs text-text-secondary">Key: {k.api_key}</div>
<div className={`ml-4 mt-0.5 text-[11px] ${k.is_active ? 'text-green' : 'text-text-dim'}`}>
{k.is_active ? '● Connected' : '● Disabled'}
</div>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => testKey(k.id)} style={{ ...btnStyle, padding: '4px 10px', fontSize: 11 }}>🔍 Test</button>
<button onClick={() => deleteKey(k.id)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', padding: '4px 10px', fontSize: 11 }}>🗑</button>
<div className="flex gap-1">
<button onClick={() => testKey(k.id)} className={`${btnClass} px-2.5 py-1 text-[11px]`}>🔍 Test</button>
<button onClick={() => deleteKey(k.id)} className={`${btnClass} border-red-alt px-2.5 py-1 text-[11px] text-red-alt`}>🗑</button>
</div>
</div>
{testResults[k.id] && (
<div style={{ marginTop: 6, fontSize: 11, color: testResults[k.id].success ? '#3fb950' : '#ef4444' }}>
<div className={`mt-1.5 text-[11px] ${testResults[k.id].success ? 'text-green' : 'text-red-alt'}`}>
{testResults[k.id].success ? '✅' : '❌'} {testResults[k.id].message}
</div>
)}
{/* Expandable details: token balances + open orders */}
{expandedId === k.id && (
<div style={{ marginTop: 12, padding: '10px 14px', borderTop: '1px solid #21262d', background: '#0d1117', borderRadius: '0 0 8px 8px' }}>
<div className="mt-3 rounded-b-lg border-t border-border-muted bg-bg-primary px-3.5 py-2.5">
{keyDetails[k.id]?.loading ? (
<div style={{ color: '#8b949e', fontSize: 12 }}>⏳ Loading balances & orders...</div>
<div className="text-xs text-text-secondary">⏳ Loading balances & orders...</div>
) : keyDetails[k.id]?.error ? (
<div style={{ color: '#f85149', fontSize: 12 }}>❌ {keyDetails[k.id].error}</div>
<div className="text-xs text-red">❌ {keyDetails[k.id].error}</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
<div className="grid gap-3">
{/* Balances */}
<div>
<div style={{ color: '#58a6ff', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
<div className="mb-1.5 text-xs font-semibold text-accent">
💰 Balances — ≈ ${keyDetails[k.id]?.usdt_balance?.toFixed(2) || '0.00'} USDT
</div>
{keyDetails[k.id]?.balances?.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 150, overflowY: 'auto' }}>
<div className="flex max-h-[150px] flex-wrap gap-1 overflow-y-auto">
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).slice(0, 30).map((b: any) => (
<span key={b.asset} style={{ padding: '3px 8px', background: '#21262d', borderRadius: 6, fontSize: 11, color: '#c9d1d9', border: '1px solid #30363d', whiteSpace: 'nowrap' }}>
<span style={{ color: '#58a6ff' }}>{b.asset}</span>{' '}
<span style={{ color: '#8b949e' }}>{Number(b.total).toFixed(4)}</span>
<span key={b.asset} className="whitespace-nowrap rounded-md border border-border-default bg-bg-hover px-2 py-0.5 text-[11px] text-text-primary">
<span className="text-accent">{b.asset}</span>{' '}
<span className="text-text-secondary">{Number(b.total).toFixed(4)}</span>
</span>
))}
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).length === 0 && (
<span style={{ color: '#484f58', fontSize: 11 }}>No non-zero balances</span>
<span className="text-[11px] text-text-dim">No non-zero balances</span>
)}
</div>
) : (
<span style={{ color: '#484f58', fontSize: 11 }}>No balance data available</span>
<span className="text-[11px] text-text-dim">No balance data available</span>
)}
</div>
{/* Open Orders */}
<div>
<div style={{ color: '#d29922', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
<div className="mb-1.5 text-xs font-semibold text-yellow">
📋 Open Orders — {keyDetails[k.id]?.orders?.length || 0}
</div>
{keyDetails[k.id]?.orders?.length > 0 ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}>
<th style={{ textAlign: 'left', padding: 2 }}>Symbol</th>
<th style={{ textAlign: 'left', padding: 2 }}>Side</th>
<th style={{ textAlign: 'left', padding: 2 }}>Type</th>
<th style={{ textAlign: 'right', padding: 2 }}>Amount</th>
<th style={{ textAlign: 'right', padding: 2 }}>Price</th>
<th style={{ textAlign: 'left', padding: 2 }}>Status</th>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[11px]">
<thead><tr className="border-b border-border-default text-text-secondary">
<th className="p-0.5 text-left">Symbol</th>
<th className="p-0.5 text-left">Side</th>
<th className="p-0.5 text-left">Type</th>
<th className="p-0.5 text-right">Amount</th>
<th className="p-0.5 text-right">Price</th>
<th className="p-0.5 text-left">Status</th>
</tr></thead>
<tbody>
{keyDetails[k.id].orders.map((o: any, idx: number) => (
<tr key={o.order_id || idx} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: 2, color: '#c9d1d9' }}>{o.symbol}</td>
<td style={{ padding: 2, color: o.side === 'buy' ? '#3fb950' : '#f85149' }}>{o.side}</td>
<td style={{ padding: 2, color: '#8b949e' }}>{o.order_type}</td>
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.amount}</td>
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
<td style={{ padding: 2, color: o.status === 'open' ? '#d29922' : '#8b949e' }}>{o.status}</td>
<tr key={o.order_id || idx} className="border-b border-border-muted">
<td className="p-0.5 text-text-primary">{o.symbol}</td>
<td className={`p-0.5 ${o.side === 'buy' ? 'text-green' : 'text-red'}`}>{o.side}</td>
<td className="p-0.5 text-text-secondary">{o.order_type}</td>
<td className="p-0.5 text-right text-text-primary">{o.amount}</td>
<td className="p-0.5 text-right text-text-primary">{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
<td className={`p-0.5 ${o.status === 'open' ? 'text-yellow' : 'text-text-secondary'}`}>{o.status}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<span style={{ color: '#484f58', fontSize: 11 }}>No open orders</span>
<span className="text-[11px] text-text-dim">No open orders</span>
)}
</div>
</div>
@@ -314,27 +322,30 @@ function SessionsTab() {
useEffect(() => { load(); }, [load]);
const revoke = async (hash: string) => { try { await authFetch(`/api/v1/auth/sessions/${hash}`, { method: 'DELETE' }); load(); } catch {} };
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (sessions.length === 0) return <div style={{ color: '#8b949e', padding: 40, textAlign: 'center' }}>🔓 Không có session nào.</div>;
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
if (sessions.length === 0) return <div className="p-10 text-center text-text-secondary">🔓 Không có session nào.</div>;
return (
<div>
<div style={{ color: '#8b949e', fontSize: 12, marginBottom: 12 }}>🛡️ Active Sessions — {sessions.length}</div>
<div style={{ display: 'grid', gap: 10 }}>
<div className="mb-3 text-xs text-text-secondary">🛡️ Active Sessions — {sessions.length}</div>
<div className="grid gap-2.5">
{sessions.map((s: any) => {
const ua = parseUA(s.user_agent);
const icon = deviceIcon[ua.device];
const isCurrent = s.is_current;
return (
<div key={s.token_hash} style={{ ...cardStyle, border: isCurrent ? '1px solid #1f6feb44' : '1px solid #21262d', background: isCurrent ? 'linear-gradient(135deg, #161b22 0%, #0d1a28 100%)' : '#161b22', position: 'relative' }}>
{isCurrent && <span style={{ position: 'absolute', top: 8, right: 10, fontSize: 10, fontWeight: 700, color: '#1f6feb', background: '#1f6feb15', padding: '2px 8px', borderRadius: 4 }}>CURRENT</span>}
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, background: isCurrent ? '#1f6feb20' : '#21262d' }}>{icon}</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>{ua.browser} · {ua.os}{isCurrent && <span style={{ color: '#1f6feb', fontSize: 11, marginLeft: 8 }}>● Active now</span>}</div>
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
<div
key={s.token_hash}
className={`relative rounded-lg border p-3.5 ${isCurrent ? 'border-accent-blue/25 bg-gradient-to-br from-bg-surface to-[#0d1a28]' : 'border-border-muted bg-bg-surface'}`}
>
{isCurrent && <span className="absolute right-2.5 top-2 rounded bg-accent-blue/10 px-2 py-0.5 text-[10px] font-bold text-accent-blue">CURRENT</span>}
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] text-xl ${isCurrent ? 'bg-accent-blue/10' : 'bg-bg-hover'}`}>{icon}</div>
<div className="flex-1">
<div className="text-[13px] font-semibold text-text-heading">{ua.browser} · {ua.os}{isCurrent && <span className="ml-2 text-[11px] text-accent-blue">● Active now</span>}</div>
<div className="mt-0.5 text-[11px] text-text-secondary">🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
</div>
{!isCurrent && <button onClick={() => revoke(s.token_hash)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', background: '#ef444410', padding: '4px 12px', fontSize: 11 }}>🚫 Revoke</button>}
{!isCurrent && <button onClick={() => revoke(s.token_hash)} className="rounded-md border border-red-alt bg-red-alt/10 px-3 py-1 text-[11px] text-red-alt">🚫 Revoke</button>}
</div>
</div>
);
@@ -398,57 +409,79 @@ function SettingsTab() {
} catch (e: any) { setMsg('❌ ' + e.message); }
};
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
const filteredSymbols = allSymbols.filter(s => !tokenSearch || s.base.toLowerCase().includes(tokenSearch.toLowerCase()));
return (
<div>
{msg && <div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 9999, padding: '20px 36px', borderRadius: 12, fontSize: 15, fontWeight: 600, color: msg.startsWith('✅') ? '#3fb950' : '#f85149', background: msg.startsWith('✅') ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', border: `1px solid ${msg.startsWith('✅') ? '#3fb95044' : '#f8514944'}`, backdropFilter: 'blur(8px)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', textAlign: 'center' }}>{msg}</div>}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button onClick={save} style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', fontWeight: 600 }}>💾 Save Settings</button>
{msg && (
<div className={`fixed left-1/2 top-1/2 z-[9999] -translate-x-1/2 -translate-y-1/2 rounded-xl border px-9 py-5 text-center text-[15px] font-semibold shadow-[0_8px_32px_rgba(0,0,0,0.5)] backdrop-blur-md ${
msg.startsWith('✅') ? 'border-green/25 bg-green/15 text-green' : 'border-red/25 bg-red/15 text-red'
}`}>
{msg}
</div>
)}
<div className="mb-4 flex justify-end">
<button onClick={save} className={`${btnClass} border-[#238636] bg-[#238636] font-semibold text-white`}>💾 Save Settings</button>
</div>
{/* Row 1: Exchange + Timeframe */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🏛️ Exchange</div><select value={prefExchange} onChange={e => setPrefExchange(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>⏱️ Timeframe</div><select value={prefTimeframe} onChange={e => setPrefTimeframe(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{TIMEFRAMES.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className={cardClass}><div className="mb-1.5 text-[11px] text-text-secondary">🏛️ Exchange</div><select value={prefExchange} onChange={e => setPrefExchange(e.target.value)} className={`${inputClass} w-full cursor-pointer`}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div className={cardClass}><div className="mb-1.5 text-[11px] text-text-secondary">⏱️ Timeframe</div><select value={prefTimeframe} onChange={e => setPrefTimeframe(e.target.value)} className={`${inputClass} w-full cursor-pointer`}>{TIMEFRAMES.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
</div>
{/* Row 2: Theme + Language */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎨 Theme</div><div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setPrefTheme('dark')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'dark' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'dark' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'dark' ? 700 : 400 }}>🌙 Dark</button>
<button onClick={() => setPrefTheme('light')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'light' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'light' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'light' ? 700 : 400 }}>☀️ Light</button>
</div></div>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🌍 Language</div><select value={prefLanguage} onChange={e => setPrefLanguage(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{LANGUAGE_OPTIONS.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}</select></div>
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className={cardClass}>
<div className="mb-1.5 text-[11px] text-text-secondary">🎨 Theme</div>
<div className="flex gap-2">
<button onClick={() => setPrefTheme('dark')} className={`rounded-md px-4 py-1.5 text-xs ${prefTheme === 'dark' ? 'border-2 border-accent-blue bg-accent-blue/[0.13] font-bold' : 'border border-border-default bg-bg-hover font-normal'} text-text-primary`}>🌙 Dark</button>
<button onClick={() => setPrefTheme('light')} className={`rounded-md px-4 py-1.5 text-xs ${prefTheme === 'light' ? 'border-2 border-accent-blue bg-accent-blue/[0.13] font-bold' : 'border border-border-default bg-bg-hover font-normal'} text-text-primary`}>☀️ Light</button>
</div>
</div>
<div className={cardClass}><div className="mb-1.5 text-[11px] text-text-secondary">🌍 Language</div><select value={prefLanguage} onChange={e => setPrefLanguage(e.target.value)} className={`${inputClass} w-full cursor-pointer`}>{LANGUAGE_OPTIONS.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}</select></div>
</div>
{/* Chart Colors */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>📊 Chart Colors</div>
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefUpColor }} /><input type="color" value={prefUpColor} onChange={e => setPrefUpColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🟢 Up</span></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefDownColor }} /><input type="color" value={prefDownColor} onChange={e => setPrefDownColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🔴 Down</span></div>
<div className={`${cardClass} mb-3`}>
<div className="mb-1.5 text-[11px] text-text-secondary">📊 Chart Colors</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-1.5">
<div className="h-[18px] w-[18px] rounded" style={{ background: prefUpColor }} />
<input type="color" value={prefUpColor} onChange={e => setPrefUpColor(e.target.value)} className="h-7 w-7 cursor-pointer border-none bg-transparent" />
<span className="text-[11px] text-text-primary">🟢 Up</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-[18px] w-[18px] rounded" style={{ background: prefDownColor }} />
<input type="color" value={prefDownColor} onChange={e => setPrefDownColor(e.target.value)} className="h-7 w-7 cursor-pointer border-none bg-transparent" />
<span className="text-[11px] text-text-primary">🔴 Down</span>
</div>
</div>
</div>
{/* Notifications */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🔔 Notifications</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>📡 Signal alerts</span><ToggleSwitch value={prefNotifSignal} onChange={setPrefNotifSignal} /></label>
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>💹 Trade execution</span><ToggleSwitch value={prefNotifTrade} onChange={setPrefNotifTrade} /></label>
<div className={`${cardClass} mb-3`}>
<div className="mb-1.5 text-[11px] text-text-secondary">🔔 Notifications</div>
<div className="flex flex-col gap-2.5">
<label className="flex cursor-pointer items-center justify-between"><span className="text-[13px] text-text-primary">📡 Signal alerts</span><ToggleSwitch value={prefNotifSignal} onChange={setPrefNotifSignal} /></label>
<label className="flex cursor-pointer items-center justify-between"><span className="text-[13px] text-text-primary">💹 Trade execution</span><ToggleSwitch value={prefNotifTrade} onChange={setPrefNotifTrade} /></label>
</div>
</div>
{/* Strategy Toggles */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8 }}>📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
<div className={`${cardClass} mb-3`}>
<div className="mb-2 text-[11px] text-text-secondary">📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{strategyList.map(s => (
<label key={s.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', padding: '6px 10px', borderRadius: 6, background: enabledStrategies.includes(s.name) ? '#1f6feb10' : '#0d1117', border: `1px solid ${enabledStrategies.includes(s.name) ? '#1f6feb44' : '#21262d'}`, transition: 'all 0.2s' }}>
<span style={{ color: enabledStrategies.includes(s.name) ? '#c9d1d9' : '#484f58', fontSize: 12 }}>{s.display_name}</span>
<label
key={s.name}
className={`flex cursor-pointer items-center justify-between rounded-md border px-2.5 py-1.5 transition-colors ${
enabledStrategies.includes(s.name) ? 'border-accent-blue/25 bg-accent-blue/10' : 'border-border-muted bg-bg-primary'
}`}
>
<span className={`text-xs ${enabledStrategies.includes(s.name) ? 'text-text-primary' : 'text-text-dim'}`}>{s.display_name}</span>
<ToggleSwitch
value={enabledStrategies.includes(s.name)}
onChange={(on) => setEnabledStrategies(prev => on ? [...prev, s.name] : prev.filter(n => n !== s.name))}
@@ -459,35 +492,35 @@ function SettingsTab() {
</div>
{/* Trade Size */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>💰 Trade Size (USDT)</div>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8, fontStyle: 'italic' }}>Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="number" value={prefTradeSize} onChange={e => setPrefTradeSize(e.target.value)} min={1} step={1} style={{ ...inputStyle, width: 80 }} /><span style={{ color: '#8b949e', fontSize: 13 }}>USDT</span></div>
<div className={`${cardClass} mb-3`}>
<div className="mb-1 text-[13px] font-semibold text-text-heading">💰 Trade Size (USDT)</div>
<div className="mb-2 text-[11px] italic text-text-secondary">Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade</div>
<div className="flex items-center gap-2"><input type="number" value={prefTradeSize} onChange={e => setPrefTradeSize(e.target.value)} min={1} step={1} className={`${inputClass} w-20`} /><span className="text-[13px] text-text-secondary">USDT</span></div>
</div>
{/* Auto Trade */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={`${cardClass} mb-3`}>
<div className="flex items-center justify-between">
<div>
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>🤖 Auto Trade <span style={{ color: '#8b949e', fontSize: 10, background: '#21262d', padding: '1px 6px', borderRadius: 4 }}>SPOT</span></div>
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>Bot tự động trade tín hiệu cho các token đã chọn</div>
<div className="text-[13px] font-semibold text-text-heading">🤖 Auto Trade <span className="rounded bg-bg-hover px-1.5 py-px text-[10px] text-text-secondary">SPOT</span></div>
<div className="mt-0.5 text-[11px] text-text-secondary">Bot tự động trade tín hiệu cho các token đã chọn</div>
</div>
<ToggleSwitch value={prefAutoTrade} onChange={setPrefAutoTrade} />
</div>
{prefAutoTrade && <div style={{ marginTop: 8, color: '#d29922', fontSize: 11 }}>⚠️ Bot sẽ đặt lệnh thật. Chỉ bật khi đã backtest kỹ!</div>}
{prefAutoTrade && <div className="mt-2 text-[11px] text-yellow">⚠️ Bot sẽ đặt lệnh thật. Chỉ bật khi đã backtest kỹ!</div>}
{/* Token Summary */}
{prefAutoTradeTokens.length > 0 && (
<div style={{ marginTop: 12, borderLeft: `3px solid ${prefAutoTrade ? '#238636' : '#30363d'}`, padding: '8px 12px', borderRadius: 4, background: '#0d1117', opacity: prefAutoTrade ? 1 : 0.4, transition: 'all 0.3s' }}>
<div style={{ color: prefAutoTrade ? '#3fb950' : '#8b949e', fontSize: 12, fontWeight: 600 }}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>
<div className={`mt-3 rounded border-l-[3px] bg-bg-primary px-3 py-2 transition-opacity ${prefAutoTrade ? 'border-l-[#238636] opacity-100' : 'border-l-border-default opacity-40'}`}>
<div className={`text-xs font-semibold ${prefAutoTrade ? 'text-green' : 'text-text-secondary'}`}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
<div className="mt-0.5 text-[11px] text-text-secondary">
{prefAutoTrade ? `${prefAutoTradeTokens.length} cặp — click ✕ để bỏ theo dõi` : `Auto trade đang tắt — ${prefAutoTradeTokens.length} cặp đã lưu`}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
<div className="mt-1.5 flex flex-wrap gap-1">
{prefAutoTradeTokens.map(token => (
<span key={token} style={{ padding: '3px 8px 3px 10px', background: '#23863622', color: '#3fb950', border: '1px solid #23863644', borderRadius: 12, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<span key={token} className="inline-flex items-center gap-1 rounded-xl border border-[#23863644] bg-[#23863622] py-0.5 pl-2.5 pr-2 text-xs font-medium text-green">
{token}
{prefAutoTrade && <span onClick={() => setPrefAutoTradeTokens(prev => prev.filter(s => s !== token))} style={{ cursor: 'pointer', color: '#58a6ff', opacity: 0.6 }}>✕</span>}
{prefAutoTrade && <span onClick={() => setPrefAutoTradeTokens(prev => prev.filter(s => s !== token))} className="cursor-pointer text-accent opacity-60">✕</span>}
</span>
))}
</div>
@@ -495,13 +528,18 @@ function SettingsTab() {
)}
{/* Token Selector */}
<div style={{ marginTop: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎯 Chọn token cho Auto Trade</div>
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} style={{ ...inputStyle, width: '100%', marginBottom: 8 }} />
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<div className="mt-3">
<div className="mb-1.5 text-[11px] text-text-secondary">🎯 Chọn token cho Auto Trade</div>
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} className={`${inputClass} mb-2 w-full`} />
<div className="flex max-h-[200px] flex-wrap gap-1 overflow-y-auto">
{filteredSymbols.slice(0, 100).map(s => (
<button key={s.symbol} onClick={() => setPrefAutoTradeTokens(prev => prev.includes(s.symbol) ? prev.filter(t => t !== s.symbol) : [...prev, s.symbol])}
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6, border: `1px solid ${prefAutoTradeTokens.includes(s.symbol) ? '#238636' : '#30363d'}`, background: prefAutoTradeTokens.includes(s.symbol) ? '#23863622' : '#21262d', color: prefAutoTradeTokens.includes(s.symbol) ? '#3fb950' : '#8b949e', cursor: 'pointer' }}>
<button
key={s.symbol}
onClick={() => setPrefAutoTradeTokens(prev => prev.includes(s.symbol) ? prev.filter(t => t !== s.symbol) : [...prev, s.symbol])}
className={`rounded-md border px-2.5 py-1 text-[11px] ${
prefAutoTradeTokens.includes(s.symbol) ? 'border-[#238636] bg-[#23863622] text-green' : 'border-border-default bg-bg-hover text-text-secondary'
}`}
>
{s.base} {prefAutoTradeTokens.includes(s.symbol) && '✓'}
</button>
))}
@@ -545,46 +583,56 @@ function HistoryTab() {
useEffect(() => { load(); }, [load]);
const renderWinCard = (label: string, data: any) => (
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: (data?.win_rate ?? 0) >= 50 ? '#3fb950' : '#f85149' }}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
<div style={{ fontSize: 11, color: '#484f58' }}>{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
<div className="flex-1 rounded-lg border border-border-default bg-bg-surface px-4 py-3 text-center">
<div className="mb-1 text-[11px] text-text-secondary">{label}</div>
<div className={`text-2xl font-bold ${(data?.win_rate ?? 0) >= 50 ? 'text-green' : 'text-red'}`}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
<div className="text-[11px] text-text-dim">{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
</div>
);
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
return (
<div>
{/* Sub-tab bar */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
<button onClick={() => setSubTab('signals')} style={{ ...btnStyle, background: subTab === 'signals' ? '#1f6feb' : '#21262d', color: subTab === 'signals' ? '#fff' : '#8b949e', borderColor: subTab === 'signals' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'signals' ? 700 : 400 }}>📈 Signal Trades</button>
<button onClick={() => setSubTab('real')} style={{ ...btnStyle, background: subTab === 'real' ? '#1f6feb' : '#21262d', color: subTab === 'real' ? '#fff' : '#8b949e', borderColor: subTab === 'real' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'real' ? 700 : 400 }}>💹 Real Trades</button>
<div className="mb-4 flex gap-1">
<button
onClick={() => setSubTab('signals')}
className={`min-h-9 touch-manipulation rounded-md border px-4 py-1.5 text-xs ${
subTab === 'signals' ? 'border-accent-blue bg-accent-blue font-bold text-white' : 'border-border-default bg-bg-hover font-normal text-text-secondary'
}`}
>📈 Signal Trades</button>
<button
onClick={() => setSubTab('real')}
className={`min-h-9 touch-manipulation rounded-md border px-4 py-1.5 text-xs ${
subTab === 'real' ? 'border-accent-blue bg-accent-blue font-bold text-white' : 'border-border-default bg-bg-hover font-normal text-text-secondary'
}`}
>💹 Real Trades</button>
</div>
{subTab === 'signals' && (<>
{signalWinRate !== null && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<div className="mb-4 flex gap-3">
{renderWinCard('Win Rate', { win_rate: signalWinRate, wins: signalWinRate !== null ? Math.round((signalWinRate / 100) * signalTrades.length) : 0, trades: signalTrades.length })}
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>TOTAL P&L</div>
<div style={{ fontSize: 24, fontWeight: 700, color: (signalTotalPnl ?? 0) >= 0 ? '#3fb950' : '#f85149' }}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
<div className="flex-1 rounded-lg border border-border-default bg-bg-surface px-4 py-3 text-center">
<div className="mb-1 text-[11px] text-text-secondary">TOTAL P&L</div>
<div className={`text-2xl font-bold ${(signalTotalPnl ?? 0) >= 0 ? 'text-green' : 'text-red'}`}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
</div>
</div>
)}
{signalTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No signal trades yet.</div> : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
{signalTrades.length === 0 ? <div className="text-text-secondary">No signal trades yet.</div> : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Symbol</th><th className="p-1 text-left">Dir</th><th className="p-1 text-left">Entry</th><th className="p-1 text-left">Exit</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Status</th></tr></thead>
<tbody>
{signalTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: 4 }}>{t.symbol}</td>
<td style={{ padding: 4, color: t.direction === 'LONG' ? '#3fb950' : '#f85149' }}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
<td style={{ padding: 4 }}>${t.entry_price?.toFixed(2) || '-'}</td>
<td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td>
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
<td style={{ padding: 4, color: t.status === 'OPEN' ? '#d29922' : '#8b949e' }}>{t.status}</td>
<tr key={i} className="border-b border-border-muted">
<td className="p-1">{t.symbol}</td>
<td className={`p-1 ${t.direction === 'LONG' ? 'text-green' : 'text-red'}`}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
<td className="p-1">${t.entry_price?.toFixed(2) || '-'}</td>
<td className="p-1">${t.exit_price?.toFixed(2) || '-'}</td>
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>${t.pnl?.toFixed(2)}</td>
<td className={`p-1 ${t.status === 'OPEN' ? 'text-yellow' : 'text-text-secondary'}`}>{t.status}</td>
</tr>
))}
</tbody>
@@ -595,25 +643,25 @@ function HistoryTab() {
{subTab === 'real' && (<>
{winRate && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<div className="mb-4 flex gap-3">
{renderWinCard('Daily', winRate.daily)}
{renderWinCard('Weekly', winRate.weekly)}
{renderWinCard('Monthly', winRate.monthly)}
</div>
)}
{realTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No real trades yet. Place an order from the dashboard.</div> : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Side</th><th style={{ textAlign: 'left', padding: 4 }}>Amount</th><th style={{ textAlign: 'left', padding: 4 }}>Price</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
{realTrades.length === 0 ? <div className="text-text-secondary">No real trades yet. Place an order from the dashboard.</div> : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Symbol</th><th className="p-1 text-left">Side</th><th className="p-1 text-left">Amount</th><th className="p-1 text-left">Price</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Status</th></tr></thead>
<tbody>
{realTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: 4 }}>{t.symbol}</td>
<td style={{ padding: 4 }}><span style={{ color: t.side === 'buy' ? '#3fb950' : '#f85149', fontWeight: 600 }}>{t.side?.toUpperCase()}</span></td>
<td style={{ padding: 4 }}>{t.amount}</td>
<td style={{ padding: 4 }}>${t.price?.toFixed(2) || '-'}</td>
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
<td style={{ padding: 4, color: t.status === 'open' ? '#d29922' : '#8b949e' }}>{t.status}</td>
<tr key={i} className="border-b border-border-muted">
<td className="p-1">{t.symbol}</td>
<td className="p-1"><span className={`font-semibold ${t.side === 'buy' ? 'text-green' : 'text-red'}`}>{t.side?.toUpperCase()}</span></td>
<td className="p-1">{t.amount}</td>
<td className="p-1">${t.price?.toFixed(2) || '-'}</td>
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>${t.pnl?.toFixed(2)}</td>
<td className={`p-1 ${t.status === 'open' ? 'text-yellow' : 'text-text-secondary'}`}>{t.status}</td>
</tr>
))}
</tbody>
@@ -651,36 +699,50 @@ function BacktestTab() {
return (
<div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16, alignItems: 'end' }}>
<div><div style={labelStyle}>Exchange</div><select value={btExchange} onChange={e => setBtExchange(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 100 }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div><div style={labelStyle}>Symbol ({btSymbols.length})</div><select value={btSymbol} onChange={e => setBtSymbol(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 140 }}>{btSymbols.length > 0 ? btSymbols.map(s => <option key={s} value={s}>{s}</option>) : <option value="">Loading...</option>}</select></div>
<div><div style={labelStyle}>TF</div><select value={btTf} onChange={e => setBtTf(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 70 }}>{TFS_BT.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
<div><div style={labelStyle}>Days</div><select value={btDays} onChange={e => setBtDays(Number(e.target.value))} style={{ ...inputStyle, cursor: 'pointer', width: 65 }}>{DAYS.map(d => <option key={d} value={d}>{d}d</option>)}</select></div>
<div><div style={labelStyle}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} style={{ ...inputStyle, width: 70 }} min={1} /></div>
<button onClick={runBacktest} disabled={btLoading} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb', padding: '6px 20px', fontWeight: 600 }}>{btLoading ? '⏳...' : '▶ Run'}</button>
<div className="mb-4 flex flex-wrap items-end gap-2">
<div><div className={labelClass}>Exchange</div><select value={btExchange} onChange={e => setBtExchange(e.target.value)} className={`${inputClass} w-[100px] cursor-pointer`}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div><div className={labelClass}>Symbol ({btSymbols.length})</div><select value={btSymbol} onChange={e => setBtSymbol(e.target.value)} className={`${inputClass} w-[140px] cursor-pointer`}>{btSymbols.length > 0 ? btSymbols.map(s => <option key={s} value={s}>{s}</option>) : <option value="">Loading...</option>}</select></div>
<div><div className={labelClass}>TF</div><select value={btTf} onChange={e => setBtTf(e.target.value)} className={`${inputClass} w-[70px] cursor-pointer`}>{TFS_BT.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
<div><div className={labelClass}>Days</div><select value={btDays} onChange={e => setBtDays(Number(e.target.value))} className={`${inputClass} w-[65px] cursor-pointer`}>{DAYS.map(d => <option key={d} value={d}>{d}d</option>)}</select></div>
<div><div className={labelClass}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} className={`${inputClass} w-[70px]`} min={1} /></div>
<button onClick={runBacktest} disabled={btLoading} className={`${btnClass} border-accent-blue bg-accent-blue px-5 font-semibold text-white`}>{btLoading ? '⏳...' : '▶ Run'}</button>
</div>
{btError && <div style={{ color: '#ef4444', marginBottom: 12 }}>❌ {btError}</div>}
{btError && <div className="mb-3 text-red">❌ {btError}</div>}
{btResult && (<>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}><span style={{ color: btSaved ? '#22c55e' : '#8b949e', fontSize: 12 }}>{btSaved ? '✅ Saved to history' : '💾'}</span>{!btSaved && <button onClick={saveBacktest} style={{ ...btnStyle, fontSize: 11, padding: '3px 10px' }}>Save to History</button>}{btSaveError && <span style={{ color: '#ef4444', fontSize: 11 }}>❌ {btSaveError}</span>}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10, marginBottom: 16 }}>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>{btResult.symbol} @ {btResult.exchange}</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.timeframe} {btResult.days}d</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Candles</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.candles_count?.toLocaleString()}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Signals</div><div style={{ fontSize: 18, fontWeight: 700, color: '#58a6ff' }}>{btResult.total_signals}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Trades</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.trades.total}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Wins</div><div style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{btResult.trades.wins}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Losses</div><div style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{btResult.trades.losses}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Win Rate</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.win_rate || 0) >= 50 ? '#22c55e' : '#ef4444' }}>{btResult.trades.win_rate}%</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>PnL</div><div style={{ fontSize: 18, fontWeight: 700, color: btResult.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444' }}>${btResult.trades.total_pnl?.toFixed(2)}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Profit Factor</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.profit_factor || 0) >= 1 ? '#22c55e' : '#ef4444' }}>{btResult.trades.profit_factor?.toFixed(2) || 'N/A'}</div></div>
<div className="mb-4 flex items-center gap-2">
<span className={`text-xs ${btSaved ? 'text-green-alt' : 'text-text-secondary'}`}>{btSaved ? '✅ Saved to history' : '💾'}</span>
{!btSaved && <button onClick={saveBacktest} className={`${btnClass} px-2.5 py-1 text-[11px]`}>Save to History</button>}
{btSaveError && <span className="text-[11px] text-red">❌ {btSaveError}</span>}
</div>
<div className="mb-4 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2.5">
<div className={cardClass}><div className="text-[11px] text-text-secondary">{btResult.symbol} @ {btResult.exchange}</div><div className="text-lg font-bold text-text-heading">{btResult.timeframe} {btResult.days}d</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Candles</div><div className="text-lg font-bold text-text-heading">{btResult.candles_count?.toLocaleString()}</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Signals</div><div className="text-lg font-bold text-accent">{btResult.total_signals}</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Trades</div><div className="text-lg font-bold text-text-heading">{btResult.trades.total}</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Wins</div><div className="text-lg font-bold text-green-alt">{btResult.trades.wins}</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Losses</div><div className="text-lg font-bold text-red-alt">{btResult.trades.losses}</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Win Rate</div><div className={`text-lg font-bold ${(btResult.trades.win_rate || 0) >= 50 ? 'text-green-alt' : 'text-red-alt'}`}>{btResult.trades.win_rate}%</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">PnL</div><div className={`text-lg font-bold ${btResult.trades.total_pnl >= 0 ? 'text-green-alt' : 'text-red-alt'}`}>${btResult.trades.total_pnl?.toFixed(2)}</div></div>
<div className={cardClass}><div className="text-[11px] text-text-secondary">Profit Factor</div><div className={`text-lg font-bold ${(btResult.trades.profit_factor || 0) >= 1 ? 'text-green-alt' : 'text-red-alt'}`}>{btResult.trades.profit_factor?.toFixed(2) || 'N/A'}</div></div>
</div>
{btResult?.trades?.recent?.length > 0 && (
<div style={cardStyle}><h4 style={{ margin: '0 0 10px', color: '#f0f6fc', fontSize: 14 }}>📋 Recent Trades</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Time</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Reason</th></tr></thead>
<div className={cardClass}>
<h4 className="mb-2.5 text-sm text-text-heading">📋 Recent Trades</h4>
<div className="overflow-x-auto">
<table className="w-full min-w-[500px] border-collapse text-xs">
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Time</th><th className="p-1 text-left">Dir</th><th className="p-1 text-left">Entry</th><th className="p-1 text-left">Exit</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Reason</th></tr></thead>
<tbody>{btResult.trades.recent.slice(-10).reverse().map((t: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}><td style={{ padding: 4 }}>{t.entry_time?.slice(5, 16)}</td><td style={{ padding: 4, color: t.direction === 'LONG' ? '#22c55e' : '#ef4444' }}>{t.direction}</td><td style={{ padding: 4 }}>${t.entry_price?.toFixed(2)}</td><td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td><td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>${t.pnl?.toFixed(2)}</td><td style={{ padding: 4, color: '#8b949e' }}>{t.exit_reason}</td></tr>
<tr key={i} className="border-b border-border-muted">
<td className="p-1">{t.entry_time?.slice(5, 16)}</td>
<td className={`p-1 ${t.direction === 'LONG' ? 'text-green-alt' : 'text-red-alt'}`}>{t.direction}</td>
<td className="p-1">${t.entry_price?.toFixed(2)}</td>
<td className="p-1">${t.exit_price?.toFixed(2) || '-'}</td>
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green-alt' : 'text-red-alt'}`}>${t.pnl?.toFixed(2)}</td>
<td className="p-1 text-text-secondary">{t.exit_reason}</td>
</tr>
))}</tbody>
</table>
</div>
</div>
)}
</>)}
@@ -691,16 +753,44 @@ function BacktestTab() {
// ═══════════════ MAIN ═══════════════
export default function ProfilePage() {
const { t } = useT();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector(s => s.auth.user);
const tabs = ['info', 'keys', 'sessions', 'settings', 'history', 'backtest'];
const [tab, setTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'info');
return (
<div style={{ padding: 20, maxWidth: 1100, margin: '0 auto' }}>
<h2 style={{ color: '#f0f6fc', marginBottom: 16 }}>👤 {user?.username || 'Profile'}</h2>
<div style={{ display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap' }}>
<div className="min-h-screen bg-bg-primary">
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
<div className="flex cursor-pointer items-center gap-3" onClick={() => navigate('/dashboard')}>
<span className="text-lg">👤</span>
<span className="text-base font-bold text-text-heading">{user?.username || 'Profile'}</span>
</div>
<div className="flex items-center gap-2">
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button
className={`${btnClass} border-red/20 text-red`}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</div>
</nav>
<div className="mx-auto max-w-[1100px] p-5">
<div className="mb-5 flex flex-wrap gap-1">
{tabs.map(tb => (
<button key={tb} onClick={() => setTab(tb)} style={{ ...btnStyle, fontWeight: tab === tb ? 700 : 400, background: tab === tb ? '#1f6feb' : '#21262d', color: tab === tb ? '#fff' : '#8b949e', borderColor: tab === tb ? '#1f6feb' : '#30363d' }}>
<button
key={tb}
onClick={() => setTab(tb)}
className={`min-h-9 touch-manipulation rounded-md border px-4 py-1.5 text-xs ${
tab === tb ? 'border-accent-blue bg-accent-blue font-bold text-white' : 'border-border-default bg-bg-hover font-normal text-text-secondary'
}`}
>
{tb === 'info' ? 'ℹ️ Info' : tb === 'keys' ? '🔑 API Keys' : tb === 'sessions' ? '🔐 Sessions' : tb === 'settings' ? '⚙️ Settings' : tb === 'history' ? '📈 History' : '📊 Backtest'}
</button>
))}
@@ -711,6 +801,7 @@ export default function ProfilePage() {
{tab === 'settings' && <SettingsTab />}
{tab === 'history' && <HistoryTab />}
{tab === 'backtest' && <BacktestTab />}
</div>
</div>
);
}
+42
View File
@@ -1,5 +1,35 @@
/* ===== Trading Portal - Global Styles ===== */
@import "tailwindcss";
/* --- Tailwind theme tokens (single source of truth for the dark palette) ---
Mirrors the :root variables below 1:1 so `bg-surface`, `text-secondary`,
`border-default`, etc. are available as Tailwind utilities everywhere. */
@theme {
--color-bg-primary: #0d1117;
--color-bg-surface: #161b22;
--color-bg-elevated: #1c2128;
--color-bg-hover: #21262d;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-accent: #58a6ff;
--color-accent-hover: #79c0ff;
--color-accent-blue: #1f6feb;
--color-green: #3fb950;
--color-green-alt: #26a69a;
--color-red: #f85149;
--color-red-alt: #ef5350;
--color-yellow: #d29922;
--color-text-primary: #c9d1d9;
--color-text-heading: #f0f6fc;
--color-text-secondary: #8b949e;
--color-text-muted: #6e7681;
--color-text-dim: #484f58;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Consolas, monospace;
}
/* --- CSS Variables (Dark Theme) --- */
:root {
--bg-primary: #0d1117;
@@ -585,6 +615,18 @@ tr:hover td {
}
}
/* Skeleton shimmer (loading placeholders) */
.skeleton-shimmer {
background: linear-gradient(90deg, var(--bg-surface) 25%, var(--bg-hover) 50%, var(--bg-surface) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Pulse loading for placeholders */
.pulse {
animation: pulse 1.5s ease-in-out infinite;
+63 -71
View File
@@ -48,6 +48,10 @@ const ACTION_OPTIONS = [
const PAGE_SIZE = 50;
const btnClass = 'min-h-9 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
const thClass = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary';
const tdClass = 'px-3 py-2 align-middle';
export default function AuditLogPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -132,17 +136,17 @@ export default function AuditLogPage() {
permission_change: '#ff7b72',
};
const getActionBadge = (action: string) => {
const color = actionColors[action] || '#8b949e';
return { color, fontWeight: 600 };
};
const getActionBadge = (action: string): React.CSSProperties => ({
color: actionColors[action] || '#8b949e',
fontWeight: 600,
});
const formatDetails = (details: Record<string, unknown> | null) => {
if (!details) return <span style={{ color: '#484f58' }}>—</span>;
if (!details) return <span className="text-text-dim">—</span>;
const entries = Object.entries(details);
if (entries.length === 0) return <span style={{ color: '#484f58' }}>—</span>;
if (entries.length === 0) return <span className="text-text-dim">—</span>;
return (
<span style={{ fontSize: 11, color: '#8b949e', wordBreak: 'break-all' }}>
<span className="break-all text-[11px] text-text-secondary">
{entries.map(([k, v]) => `${k}: ${String(v ?? '')}`).join(', ')}
</span>
);
@@ -152,50 +156,40 @@ export default function AuditLogPage() {
if (!user?.is_admin) return null;
if (initializing || isLoading) {
return (
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading audit logs...</div>
<div className="flex min-h-screen items-center justify-center bg-bg-primary">
<div className="text-sm text-text-secondary">Loading audit logs...</div>
</div>
);
}
// ── Styles ──
const btnStyle: React.CSSProperties = {
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
minHeight: 36, touchAction: 'manipulation',
};
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
const selectStyle: React.CSSProperties = {
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
border: '1px solid #30363d', borderRadius: 4, outline: 'none', cursor: 'pointer',
minWidth: 160,
};
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
{/* ═══ Navbar ═══ */}
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 18 }}>📋</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Audit Log</span>
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
<div className="flex items-center gap-3">
<span className="text-lg">📋</span>
<span className="text-base font-bold text-text-heading">Audit Log</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button style={btnStyle} onClick={() => navigate('/admin')}>← Admin Panel</button>
<button style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }} onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}>
<div className="flex items-center gap-2">
<button className={btnClass} onClick={() => navigate('/admin')}>← Admin Panel</button>
<button
className={`${btnClass} border-red/20 text-red`}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</div>
</nav>
{/* ═══ Controls ═══ */}
<div style={{ display: 'flex', gap: 12, padding: '12px 16px', borderBottom: '1px solid #21262d', alignItems: 'center', flexWrap: 'wrap' }}>
<div className="flex flex-wrap items-center gap-3 border-b border-border-muted px-4 py-3">
<select
style={selectStyle}
className="min-w-[160px] cursor-pointer rounded border border-border-default bg-bg-primary px-2 py-1.5 text-xs text-text-primary outline-none"
value={actionFilter}
onChange={(e) => handleFilterChange(e.target.value)}
>
@@ -204,92 +198,98 @@ export default function AuditLogPage() {
))}
</select>
<span style={{ fontSize: 12, color: '#484f58' }}>
<span className="text-xs text-text-dim">
{total > 0 ? `${total} entries` : ''}
</span>
</div>
{/* ═══ Table ═══ */}
<div style={{ padding: 16, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading audit logs...</p>}
{error && <p style={{ color: '#f85149', fontSize: 13 }}>{error}</p>}
<div className="scrollable-x overflow-x-auto p-4">
{loading && <p className="text-[13px] text-text-dim">Loading audit logs...</p>}
{error && <p className="text-[13px] text-red">{error}</p>}
{!loading && !error && (
<>
{logs.length === 0 ? (
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
<div className="p-6 text-center text-[13px] text-text-dim">
No audit log entries found.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] border-collapse text-xs">
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Time</th>
<th style={thStyle}>User</th>
<th style={thStyle}>Action</th>
<th style={thStyle}>Resource</th>
<th style={thStyle}>Details</th>
<tr className="border-b border-border-default">
<th className={thClass}>Time</th>
<th className={thClass}>User</th>
<th className={thClass}>Action</th>
<th className={thClass}>Resource</th>
<th className={thClass}>Details</th>
</tr>
</thead>
<tbody>
{logs.map((entry) => (
<tr key={entry.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ ...tdStyle, color: '#484f58', whiteSpace: 'nowrap' }}>
<tr key={entry.id} className="border-b border-border-muted">
<td className={`${tdClass} whitespace-nowrap text-text-dim`}>
{formatTime(entry.created_at)}
</td>
<td style={tdStyle}>
<td className={tdClass}>
{entry.user_id ? (
<span style={{ color: '#8b949e', fontSize: 11, fontFamily: 'monospace' }}>
<span className="font-mono text-[11px] text-text-secondary">
{entry.user_id.substring(0, 8)}...
</span>
) : (
<span style={{ color: '#484f58' }}>System</span>
<span className="text-text-dim">System</span>
)}
</td>
<td style={tdStyle}>
<td className={tdClass}>
<span style={getActionBadge(entry.action)}>{entry.action}</span>
</td>
<td style={{ ...tdStyle, color: '#f0f6fc', fontFamily: 'monospace', fontSize: 11 }}>
<td className={`${tdClass} font-mono text-[11px] text-text-heading`}>
{entry.resource}
</td>
<td style={tdStyle}>
<td className={tdClass}>
{formatDetails(entry.details)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ═══ Pagination ═══ */}
{totalPages > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', alignItems: 'center', marginTop: 16 }}>
<div className="mt-4 flex items-center justify-center gap-1">
<button
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
className={btnClass}
style={{ opacity: currentPage <= 1 ? 0.4 : 1 }}
disabled={currentPage <= 1}
onClick={() => goToPage(1)}
>
««
</button>
<button
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
className={btnClass}
style={{ opacity: currentPage <= 1 ? 0.4 : 1 }}
disabled={currentPage <= 1}
onClick={() => goToPage(currentPage - 1)}
>
«
</button>
<span style={{ fontSize: 12, color: '#8b949e', padding: '0 8px' }}>
<span className="px-2 text-xs text-text-secondary">
Page {currentPage} of {totalPages}
</span>
<button
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
className={btnClass}
style={{ opacity: currentPage >= totalPages ? 0.4 : 1 }}
disabled={currentPage >= totalPages}
onClick={() => goToPage(currentPage + 1)}
>
»
</button>
<button
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
className={btnClass}
style={{ opacity: currentPage >= totalPages ? 0.4 : 1 }}
disabled={currentPage >= totalPages}
onClick={() => goToPage(totalPages)}
>
@@ -303,11 +303,3 @@ export default function AuditLogPage() {
</div>
);
}
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500,
fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
};
const tdStyle: React.CSSProperties = {
padding: '8px 12px', verticalAlign: 'middle',
};
+2
View File
@@ -1,10 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons.svg'],
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Backup the trading_portal Postgres database.
# Usage: ./backup_postgres.sh [retention_days]
#
# Intended to run via cron on the deploy host, e.g.:
# 0 3 * * * /opt/data/trading-portal/scripts/backup_postgres.sh >> /var/log/trading-portal-backup.log 2>&1
set -euo pipefail
PROJECT_DIR="${PROJECT_DIR:-/opt/data/trading-portal}"
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
RETENTION_DAYS="${1:-14}"
DB_CONTAINER="trading-db"
DB_USER="trading"
DB_NAME="trading_portal"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
OUT_FILE="$BACKUP_DIR/trading_portal_${TIMESTAMP}.sql.gz"
mkdir -p "$BACKUP_DIR"
echo "==> Backing up $DB_NAME from $DB_CONTAINER..."
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --no-owner --no-acl \
| gzip > "$OUT_FILE"
SIZE=$(du -h "$OUT_FILE" | cut -f1)
echo "==> Wrote $OUT_FILE ($SIZE)"
echo "==> Pruning backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name 'trading_portal_*.sql.gz' -mtime "+$RETENTION_DAYS" -print -delete
echo "✅ Backup complete."
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Restore the trading_portal Postgres database from a backup produced by
# backup_postgres.sh. DESTRUCTIVE: drops and recreates all data.
#
# Usage: ./restore_postgres.sh /path/to/trading_portal_YYYYMMDD_HHMMSS.sql.gz
set -euo pipefail
BACKUP_FILE="${1:?Usage: restore_postgres.sh <backup_file.sql.gz>}"
DB_CONTAINER="trading-db"
DB_USER="trading"
DB_NAME="trading_portal"
if [ ! -f "$BACKUP_FILE" ]; then
echo "ERROR: backup file not found: $BACKUP_FILE" >&2
exit 1
fi
echo "⚠️ This will REPLACE all data in '$DB_NAME' with the contents of:"
echo " $BACKUP_FILE"
read -r -p "Type 'yes' to continue: " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "Aborted."
exit 1
fi
echo "==> Stopping backend-api and backend-scheduler (avoid writes during restore)..."
docker compose -f "$(dirname "$0")/../docker-compose.yml" stop backend-api backend-scheduler
echo "==> Dropping and recreating $DB_NAME..."
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS ${DB_NAME}_restoring;"
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -c "CREATE DATABASE ${DB_NAME}_restoring;"
echo "==> Restoring dump into ${DB_NAME}_restoring..."
gunzip -c "$BACKUP_FILE" | docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -d "${DB_NAME}_restoring"
echo "==> Swapping databases..."
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -c "ALTER DATABASE ${DB_NAME} RENAME TO ${DB_NAME}_old_$(date +%s);"
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -c "ALTER DATABASE ${DB_NAME}_restoring RENAME TO ${DB_NAME};"
echo "==> Restarting backend-api and backend-scheduler..."
docker compose -f "$(dirname "$0")/../docker-compose.yml" start backend-api backend-scheduler
echo "✅ Restore complete. The pre-restore database was kept as '${DB_NAME}_old_<timestamp>' — drop it manually once you've verified the restore."
+114
View File
@@ -0,0 +1,114 @@
# Theo dõi đánh giá dự án Trading Portal — v6
> **Ngày đánh giá gốc:** 2026-07-03
> **Cập nhật v1-v5:** xem các file `theo_doi_trading-portal_v1.md`…`v5.md`
> **Cập nhật v6 (lần này):** 2026-07-04 — xử lý toàn bộ hạng mục còn lại **(h) → (r)** (trừ 2FA và (m) theo yêu cầu), thêm Redis, xây design system Tailwind cho toàn bộ frontend, và review UI end-to-end (coherence + responsive)
> **Người thực hiện:** Claude (Sonnet 5), theo yêu cầu của tien.a.le@accenture.com
> **Quy ước đặt tên:** Mỗi lần có thay đổi lớn → tạo bản mới `theo_doi_trading-portal_v7.md`, ... giữ nguyên các bản cũ làm lịch sử.
---
## 1. Tổng quan dự án
(Không đổi — xem [theo_doi_trading-portal_v2.md](theo_doi_trading-portal_v2.md) mục 1.)
---
## 2. Tóm tắt các hạng mục đã xử lý trong v6
### 2.1 Backend
| # | Vấn đề | Xử lý |
|---|---|---|
| h | God file `signal_service.py` | Tách ~500 dòng logic chấm điểm thuần (13-thuật-toán voting) sang `app/services/signal_scoring.py` — module không I/O, dễ test độc lập. `signal_service.py` chỉ còn phần orchestration + cooldown state, re-export các tên cũ để tương thích ngược. |
| l | Cache in-memory single-instance | Thêm service `redis` vào `docker-compose.yml` (redis:7-alpine, 128MB LRU, healthcheck). Viết `app/core/redis_client.py` — wrapper async có **cơ chế retry-cooldown 30s** (không fail vĩnh viễn nếu Redis tạm mất kết nối). `signal_booster.py` (win-rate cache, PnL stats cache) chuyển sang đọc/ghi Redis trước, fallback in-memory nếu Redis lỗi. |
| n | Không có backup/restore Postgres | Thêm `scripts/backup_postgres.sh` (pg_dump + gzip + xoá bản cũ theo retention) và `scripts/restore_postgres.sh` (restore an toàn kiểu swap-database, có xác nhận trước khi ghi đè). Tài liệu hoá đầy đủ trong `DEPLOYMENT.md` mục 9. |
| o | Quản lý secrets không nhất quán | Chuyển `DB_PASSWORD` và `ENCRYPTION_KEY` sang đọc từ file (Docker secrets pattern: `DB_PASSWORD_FILE`, `ENCRYPTION_KEY_FILE`), có `model_post_init` trong `config.py` để override, tương thích ngược với biến môi trường cũ. |
| q | RSI trả ~98 thay vì 50 khi giá đi ngang | Đã sửa trong `indicator_service.py::rsi()` — thêm điều kiện `avg_gain == 0 and avg_loss == 0` trả về 50.0 (trung tính) thay vì tính theo công thức RS gây sai số. |
| r | MFI wraparound index | Đã sửa trong `indicator_service.py::mfi()` — thêm guard `if j == 0: continue` để tránh Python đọc `typical_prices[-1]` (giá trị *cuối cùng*) khi tính candle đầu tiên. |
**Kết quả kiểm thử:** 134 test pass (tăng từ 122 ở v5 — thêm test cho Redis client, signal_booster cache, signal_scoring sau khi tách file, config secrets).
### 2.2 Frontend
| # | Vấn đề | Xử lý |
|---|---|---|
| i | Thiếu tầng data-fetching thống nhất | Toàn bộ `alertApi.ts`, `signalApi.ts`, `watchlistApi.ts`, `realTradeApi.ts` và các fetch rời rạc trong `ChartToolbar.tsx`, `ChartContainer.tsx`, `BacktestPage.tsx` chuyển sang dùng chung `apiFetch` (tự động refresh token, format lỗi nhất quán). |
| j | AnalyticsPage dùng data giả (`Math.random()`) | Viết lại hoàn toàn `AnalyticsPage.tsx` để gọi `GET /analytics/dashboard?days=N` — backend đã tính sẵn đầy đủ `pnl_history`, `equity_curve`, `drawdown`, `win_rate_by_period`, `best_trade`, `worst_trade`, `profit_factor`, v.v. Bỏ toggle Signal/Real không hoạt động, thay bằng bộ chọn khoảng ngày (7/30/90). |
| k | Inline CSS-in-JS không design system | Thêm Tailwind CSS v4 (`@tailwindcss/vite`, cấu hình `@theme` trong `index.css` ánh xạ đúng bảng màu GitHub-dark hiện có). Chuyển đổi **14 file** (toàn bộ trang + component dùng chung): `Skeleton`, `ErrorBoundary`, `DashboardPage` + các panel con, `AlertsPage`, `BacktestPage`, `AnalyticsPage`, `AuditLogPage`, `ProfilePage`, `AdminPage`. `LoginPage`/`RegisterPage` giữ nguyên (đã dùng class CSS nhất quán sẵn, không cần đổi). **Phát hiện thêm và sửa luôn 3 chỗ lệch bảng màu** trong lúc chuyển đổi: `ErrorBoundary` (tông tím `#1a1a2e` khác hẳn theme), `Skeleton` shimmer (tím `#2a2a3e`), `BacktestPage` (tông navy/xanh lá khác `#1a1a2e`/`#22c55e`). |
**Kết quả:** `npm run build` sạch, không lỗi TypeScript, sau toàn bộ 14 file.
---
## 3. Review UI end-to-end (frontend ↔ backend, coherence + responsive)
Theo yêu cầu review lại toàn bộ giao diện. Vì môi trường dev hiện không chạy backend, phần review tập trung vào: (1) đối chiếu request/response giữa frontend và API backend thực tế (đã làm khi sửa mục j), (2) kiểm tra bố cục & responsive bằng preview browser (desktop 1280px, mobile 375px) và audit code cho toàn bộ layout Tailwind mới, (3) kiểm tra tính nhất quán điều hướng giữa các trang.
**3 vấn đề thật đã tìm thấy và sửa:**
1. **Bảng dữ liệu thiếu cuộn ngang trên mobile** — `AuditLogPage.tsx` và tab Backtest trong `ProfilePage.tsx` có bảng `<table>` không được bọc trong `overflow-x-auto`, trong khi mọi bảng khác trong app đều có. Trên màn hình hẹp, bảng sẽ bị tràn/vỡ layout. → Đã bọc `overflow-x-auto` + `min-w` nhất quán với các bảng khác.
2. **Grid 2 cột cố định không tự xuống dòng trên mobile** — 3 chỗ trong tab Settings của `ProfilePage.tsx` (Exchange/Timeframe, Theme/Language, danh sách bật/tắt strategy) dùng `grid-cols-2` cứng, trong khi `AnalyticsPage.tsx` đã có sẵn pattern đúng (`grid-cols-2 md:grid-cols-6`, tự co về 1 cột trên mobile). → Đổi thành `grid-cols-1 sm:grid-cols-2` để nhất quán và dễ dùng trên điện thoại.
3. **`ProfilePage` là trang duy nhất không có thanh điều hướng** — Tất cả các trang chính khác (`DashboardPage`, `AdminPage`, `AnalyticsPage`, `AuditLogPage`) đều có nav bar với nút "← Dashboard" và "🚪 Logout". `ProfilePage` trước đó không có gì — người dùng chỉ có thể rời trang bằng nút Back của trình duyệt, không có cách logout tại chỗ. → Đã thêm nav bar đồng bộ với các trang còn lại.
**Ghi nhận nhưng chưa sửa (không nằm trong phạm vi yêu cầu lần này, cần bàn riêng nếu muốn xử lý):**
- `AdminPage.tsx` có 1 link `<a href="/profile">` (dùng thẻ `<a>` thường thay vì điều hướng SPA qua `navigate()`), gây reload toàn trang thay vì chuyển trang mượt. Không hỏng chức năng, chỉ là trải nghiệm chưa tối ưu.
- `apiService.ts::refreshAccessToken()` xoá token và ép logout người dùng khi refresh thất bại vì **bất kỳ lý do gì** — kể cả lỗi mạng/server tạm thời (vd. backend down trả 502), không chỉ khi refresh token thực sự không hợp lệ. Nghĩa là nếu backend gặp sự cố ngắn hạn đúng lúc access token hết hạn, người dùng sẽ bị đăng xuất oan. Đây là vấn đề kiến trúc auth, cần quyết định lại chiến lược retry — để riêng cho lần thảo luận sau, không tự ý sửa vì ảnh hưởng đến toàn bộ luồng xác thực.
---
## 4. Toàn bộ nhược điểm & rủi ro — trạng thái tổng hợp đến v6
| # | Vấn đề | Trạng thái |
|---|---|---|
| a | Mật khẩu Gitea lộ trong lịch sử git | ✅ Đóng hoàn toàn (v5) |
| b | RBAC thiếu ở `/orders/place` | ✅ Đã xử lý + có test |
| c | Hardcode sàn "mexc" trong order routing | ✅ Đã xử lý + có test |
| d | AES-CBC không xác thực toàn vẹn | ✅ Đã chuyển sang AES-GCM + có test |
| e | Thiếu test suite/CI | 🟢 134 test, CI workflow đã thêm (chưa xác nhận runner) |
| f | Mật khẩu DB không đồng bộ trong docker-compose | ✅ Đã xử lý |
| g | CORS fallback `*` | ✅ Đã fail-closed + có test |
| h | God files (`signal_service.py`) | ✅ Đã tách `signal_scoring.py` |
| i | Frontend thiếu tầng data-fetching thống nhất | ✅ Đã hợp nhất về `apiFetch` |
| j | AnalyticsPage dùng data giả | ✅ Đã nối vào `/analytics/dashboard` thật |
| k | Inline CSS-in-JS không design system | ✅ Đã có Tailwind design system, 14 file chuyển đổi |
| l | Cache in-memory single-instance | ✅ Đã thêm Redis + fallback graceful |
| m | Rủi ro overfitting hệ thống tín hiệu | ⏳ **Cố ý để lại** — cần bàn thiết kế riêng |
| n | Không có backup/restore Postgres | ✅ Đã có script backup/restore |
| o | Quản lý secrets không nhất quán | ✅ Đã chuyển sang Docker secrets pattern |
| p | Eviction dùng nhầm giá cross-symbol | ✅ Đã xử lý + có test |
| q | RSI sai giá trị khi giá đi ngang | ✅ Đã sửa + có test |
| r | MFI wraparound index | ✅ Đã sửa + có test |
| s | Sự cố quy trình: replace_all bỏ sót 1 vị trí | ✅ Đã vá, rút kinh nghiệm |
| t | (mới) Bảng thiếu overflow-x-auto trên mobile | ✅ Đã sửa (mục 3.1) |
| u | (mới) Grid 2 cột không responsive trong ProfilePage | ✅ Đã sửa (mục 3.2) |
| v | (mới) ProfilePage thiếu nav bar/logout | ✅ Đã sửa (mục 3.3) |
| w | (mới) Link `<a href>` thường gây full reload ở AdminPage | ⏳ Ghi nhận, không ưu tiên |
| x | (mới) refreshAccessToken() ép logout khi backend lỗi tạm thời | ⏳ Ghi nhận, cần bàn thiết kế riêng về chiến lược retry |
---
## 5. Đề xuất ưu tiên tiếp theo
| Ưu tiên | Việc cần làm |
|---|---|
| Ngắn hạn | Xác nhận Gitea Actions/runner đã bật để CI thực sự chạy |
| Ngắn hạn | Bật 2FA cho các tài khoản ghi trên Gitea (đã hoãn theo yêu cầu, chưa làm) |
| Trung hạn | Bàn về chiến lược retry khi refresh token thất bại do lỗi mạng/server tạm thời (x) |
| Trung hạn | Đổi link `<a href="/profile">` trong AdminPage sang điều hướng SPA (w) |
| Dài hạn | Backtest walk-forward trước khi tin vào win-rate để chỉnh Kelly sizing (m) — cần thiết kế riêng theo yêu cầu |
---
## 6. Lịch sử phiên bản
| Phiên bản | Ngày | Thay đổi |
|---|---|---|
| v0 | 2026-07-03 | Đánh giá tổng thể lần đầu |
| v1 | 2026-07-03 | Fix (b), (c), (f) |
| v2 | 2026-07-03 | Test cho (b)/(c), fix (d) AES-GCM, fix (g) CORS, hướng dẫn (a) |
| v3 | 2026-07-03 | Test risk_manager/trade_executor/signal_service (81 test), CI Gitea Actions, phát hiện (p) |
| v4 | 2026-07-03 | Rewrite lịch sử git (a) chuẩn bị xong, fix (p), test indicator_service + async signal_service (122 test), phát hiện (q)/(r), ghi nhận sự cố quy trình (s) |
| v5 | 2026-07-03 | Đóng hoàn toàn sự cố (a) — rotate xong 4/4 mật khẩu, force-push lịch sử đã rewrite thành công, verify sạch |
| v6 | 2026-07-04 | Xử lý (h), (i), (j), (k), (l), (n), (o), (q), (r) — 134 test pass; xây Tailwind design system cho 14 file frontend; thêm Redis; review UI end-to-end, phát hiện & sửa 3 vấn đề coherence/responsive (t, u, v), ghi nhận 2 vấn đề cần bàn riêng (w, x) |