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:
+8
-1
@@ -1,5 +1,9 @@
|
|||||||
# --- Database ---
|
# --- Database ---
|
||||||
DATABASE_URL=postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal
|
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 (RS256) ---
|
||||||
JWT_PRIVATE_KEY_PATH=/run/secrets/jwt_private_key.pem
|
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_ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
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
|
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 ---
|
# --- Server ---
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ Thumbs.db
|
|||||||
docker-data/
|
docker-data/
|
||||||
pgdata/
|
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
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
| `backend-api` | FastAPI | 1G / 2 CPU | REST API, WebSocket, auth |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `certbot_ssl` | certbot/certbot | — | Auto-renew Let's Encrypt mỗi 12h |
|
||||||
| `gitea` | gitea/gitea:latest | ~300MB | Git server tại git.dangloica.org |
|
| `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` |
|
| `PROJECT_DIR` | `/opt/data/trading-portal` |
|
||||||
| `DATABASE_URL` | postgresql+asyncpg://trading:***@db:5432/trading_portal |
|
| `DATABASE_URL` | postgresql+asyncpg://trading:***@db:5432/trading_portal |
|
||||||
| `LOG_LEVEL` | INFO |
|
| `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) |
|
| 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
|
- `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ờ
|
- Không nghiêm trọng — chỉ xảy ra 1 lần mỗi 6 giờ
|
||||||
- Impact: redundant computation, không sai data
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -366,3 +366,47 @@ psql -U trading -d trading -c "
|
|||||||
| PostgreSQL | 5432 | TCP |
|
| PostgreSQL | 5432 | TCP |
|
||||||
| Hermes Dashboard | 9119 | HTTP |
|
| Hermes Dashboard | 9119 | HTTP |
|
||||||
| Hermes Gateway | — | HTTP/WS |
|
| 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.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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):
|
class Settings(BaseSettings):
|
||||||
@@ -8,6 +14,11 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal"
|
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
|
||||||
JWT_PRIVATE_KEY_PATH: str = "/run/secrets/jwt_private.pem"
|
JWT_PRIVATE_KEY_PATH: str = "/run/secrets/jwt_private.pem"
|
||||||
@@ -18,6 +29,13 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Encryption
|
# Encryption
|
||||||
ENCRYPTION_KEY: str = ""
|
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
|
# Server
|
||||||
HOST: str = "0.0.0.0"
|
HOST: str = "0.0.0.0"
|
||||||
@@ -39,5 +57,16 @@ class Settings(BaseSettings):
|
|||||||
extra="allow",
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Shared async Redis client, used to cache state across the backend-api and
|
||||||
|
backend-scheduler processes (e.g. signal_booster win-rate/PnL stats), which
|
||||||
|
are separate containers and can't share Python module-level globals.
|
||||||
|
|
||||||
|
Every helper here degrades gracefully: if Redis is unreachable (not
|
||||||
|
configured for local dev, container down, network hiccup), callers get
|
||||||
|
``None``/a cache miss instead of an exception, and are expected to fall back
|
||||||
|
to a per-process in-memory cache.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from redis import asyncio as redis_asyncio
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client: Optional["redis_asyncio.Redis"] = None
|
||||||
|
_failed_until: float = 0.0 # monotonic timestamp; skip Redis until past this
|
||||||
|
_RETRY_COOLDOWN_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> Optional["redis_asyncio.Redis"]:
|
||||||
|
"""Lazily create the Redis client. Returns None while within the retry
|
||||||
|
cooldown after a recent failure, so a Redis outage costs at most one
|
||||||
|
slow connect attempt per cooldown window instead of stalling every call."""
|
||||||
|
global _client
|
||||||
|
if time.monotonic() < _failed_until:
|
||||||
|
return None
|
||||||
|
if _client is None:
|
||||||
|
_client = redis_asyncio.from_url(
|
||||||
|
settings.REDIS_URL, decode_responses=True, socket_connect_timeout=2,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_failed(exc: Exception, operation: str, key: str) -> None:
|
||||||
|
global _failed_until
|
||||||
|
logger.warning(
|
||||||
|
"Redis %s(%s) failed, falling back to in-memory cache for %ds: %s",
|
||||||
|
operation, key, _RETRY_COOLDOWN_SECONDS, exc,
|
||||||
|
)
|
||||||
|
_failed_until = time.monotonic() + _RETRY_COOLDOWN_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
async def get_json(key: str) -> Any | None:
|
||||||
|
"""Return the JSON-decoded value for ``key``, or None on miss/error."""
|
||||||
|
client = _get_client()
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = await client.get(key)
|
||||||
|
return json.loads(raw) if raw is not None else None
|
||||||
|
except Exception as exc:
|
||||||
|
_mark_failed(exc, "get_json", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_json(key: str, value: Any, ttl_seconds: int) -> None:
|
||||||
|
"""Store ``value`` as JSON under ``key`` with an expiry. Silently no-ops
|
||||||
|
on failure — callers should already be maintaining their own in-memory
|
||||||
|
fallback, so a failed cache write here is not fatal."""
|
||||||
|
client = _get_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await client.set(key, json.dumps(value), ex=ttl_seconds)
|
||||||
|
except Exception as exc:
|
||||||
|
_mark_failed(exc, "set_json", key)
|
||||||
@@ -129,10 +129,14 @@ 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_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
|
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_loss == 0:
|
||||||
if avg_gain == 0:
|
|
||||||
rs = 50.0 # no movement
|
|
||||||
else:
|
|
||||||
rs = 100.0
|
rs = 100.0
|
||||||
else:
|
else:
|
||||||
rs = avg_gain / avg_loss
|
rs = avg_gain / avg_loss
|
||||||
@@ -1291,6 +1295,12 @@ def mfi(candles: list[dict], period: int = 14) -> list[Optional[float]]:
|
|||||||
pos_flow = 0.0
|
pos_flow = 0.0
|
||||||
neg_flow = 0.0
|
neg_flow = 0.0
|
||||||
for j in range(i - period, i):
|
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]
|
mf = raw_money_flow[j]
|
||||||
if typical_prices[j] > typical_prices[j - 1]:
|
if typical_prices[j] > typical_prices[j - 1]:
|
||||||
pos_flow += mf
|
pos_flow += mf
|
||||||
|
|||||||
@@ -15,14 +15,21 @@ from typing import Any
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core import redis_client
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Global cache ──────────────────────────────────────────────────────────
|
# ── 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] = {}
|
_win_rate_cache: dict[str, float] = {}
|
||||||
_last_cache_update: datetime | None = None
|
_last_cache_update: datetime | None = None
|
||||||
_CACHE_TTL_SECONDS = 21_600 # 6 hours
|
_CACHE_TTL_SECONDS = 21_600 # 6 hours
|
||||||
|
_REDIS_KEY_WIN_RATES = "signal_booster:win_rates"
|
||||||
|
|
||||||
# ── Strategy name normalisation ──────────────────────────────────────────
|
# ── Strategy name normalisation ──────────────────────────────────────────
|
||||||
# Maps entry_reason values stored in hypothetical_trades to canonical names.
|
# Maps entry_reason values stored in hypothetical_trades to canonical names.
|
||||||
@@ -167,6 +174,7 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
|
|||||||
|
|
||||||
_win_rate_cache = rates
|
_win_rate_cache = rates
|
||||||
_last_cache_update = datetime.now(timezone.utc)
|
_last_cache_update = datetime.now(timezone.utc)
|
||||||
|
await redis_client.set_json(_REDIS_KEY_WIN_RATES, rates, _CACHE_TTL_SECONDS)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Computed win rates for %d strategies (decay=%.3f/day, min_trades=%d)",
|
"Computed win rates for %d strategies (decay=%.3f/day, min_trades=%d)",
|
||||||
len(rates), DECAY_LAMBDA, MIN_TRADES,
|
len(rates), DECAY_LAMBDA, MIN_TRADES,
|
||||||
@@ -182,8 +190,13 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
|
|||||||
return dict(_win_rate_cache) or {}
|
return dict(_win_rate_cache) or {}
|
||||||
|
|
||||||
|
|
||||||
def get_cached_rates() -> dict[str, float]:
|
async def get_cached_rates() -> dict[str, float]:
|
||||||
"""Return the current in-memory win-rate cache (may be stale or empty)."""
|
"""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)
|
return dict(_win_rate_cache)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,14 +271,22 @@ def get_confidence(
|
|||||||
_pnl_stats_cache: dict[str, float] = {}
|
_pnl_stats_cache: dict[str, float] = {}
|
||||||
_last_pnl_cache_update: float = 0.0
|
_last_pnl_cache_update: float = 0.0
|
||||||
_PNL_CACHE_TTL = 3600 # 1 hour
|
_PNL_CACHE_TTL = 3600 # 1 hour
|
||||||
|
_REDIS_KEY_PNL_STATS = "signal_booster:pnl_stats"
|
||||||
|
|
||||||
|
|
||||||
def get_pnl_stats() -> dict[str, float]:
|
async def get_pnl_stats() -> dict[str, float]:
|
||||||
"""Return cached avg_win_pct / avg_loss_pct (sync, safe for async context).
|
"""Return cached avg_win_pct / avg_loss_pct.
|
||||||
|
|
||||||
Cache is refreshed by the scheduler periodically via compute_strategy_win_rates.
|
Cache is refreshed by the scheduler periodically via
|
||||||
Falls back to reasonable defaults (avg_win=3.0%, avg_loss=2.0%).
|
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
|
import time as _time
|
||||||
now = _time.monotonic()
|
now = _time.monotonic()
|
||||||
if now - _last_pnl_cache_update < _PNL_CACHE_TTL and _pnl_stats_cache:
|
if now - _last_pnl_cache_update < _PNL_CACHE_TTL and _pnl_stats_cache:
|
||||||
@@ -294,6 +315,7 @@ async def _refresh_pnl_stats(db: AsyncSession) -> None:
|
|||||||
if row and row[0] and row[1]:
|
if row and row[0] and row[1]:
|
||||||
_pnl_stats_cache = {"avg_win": float(row[0]), "avg_loss": float(row[1])}
|
_pnl_stats_cache = {"avg_win": float(row[0]), "avg_loss": float(row[1])}
|
||||||
_last_pnl_cache_update = _time.monotonic()
|
_last_pnl_cache_update = _time.monotonic()
|
||||||
|
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%%",
|
logger.debug("PnL stats refreshed: avg_win=%.2f%%, avg_loss=%.2f%%",
|
||||||
_pnl_stats_cache["avg_win"], _pnl_stats_cache["avg_loss"])
|
_pnl_stats_cache["avg_win"], _pnl_stats_cache["avg_loss"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
"""Pure signal-scoring logic — the 13-algorithm voting system.
|
||||||
|
|
||||||
|
Extracted out of `signal_service.py` (which mixes this scoring logic with
|
||||||
|
async DB/notification/trade-trigger orchestration) so the classification
|
||||||
|
math can be read, tested, and reasoned about independently of any database
|
||||||
|
or network side effects. Nothing in this module touches I/O.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.signal_booster import (
|
||||||
|
boost_score,
|
||||||
|
get_confidence as compute_booster_confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal type constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STRONG_BUY = "STRONG_BUY"
|
||||||
|
BUY = "BUY"
|
||||||
|
STRONG_SELL = "STRONG_SELL"
|
||||||
|
SELL = "SELL"
|
||||||
|
CAUTION_LONG = "CAUTION_LONG"
|
||||||
|
CAUTION_SHORT = "CAUTION_SHORT"
|
||||||
|
SQUEEZE_ALERT = "SQUEEZE_ALERT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal detection configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimum distance from BB bounds to filter noise
|
||||||
|
MIN_BB_DISTANCE_PCT = Decimal("0.001") # 0.1%
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bb_values(indicators: dict) -> dict[str, list[float]] | None:
|
||||||
|
"""Extract Bollinger Band values from indicators dict."""
|
||||||
|
bb = indicators.get("bollinger_bands")
|
||||||
|
if not bb:
|
||||||
|
return None
|
||||||
|
# Ensure all required keys exist
|
||||||
|
required = ["upper", "middle", "lower"]
|
||||||
|
if not all(k in bb for k in required):
|
||||||
|
return None
|
||||||
|
return bb
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rsi_values(indicators: dict) -> list[float] | None:
|
||||||
|
"""Extract RSI values."""
|
||||||
|
rsi = indicators.get("rsi_14")
|
||||||
|
if not rsi or not isinstance(rsi, list) or len(rsi) == 0:
|
||||||
|
return None
|
||||||
|
return rsi
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sma_values(indicators: dict) -> list[float] | None:
|
||||||
|
"""Extract SMA values."""
|
||||||
|
sma = indicators.get("sma_20")
|
||||||
|
if not sma or not isinstance(sma, list) or len(sma) == 0:
|
||||||
|
return None
|
||||||
|
return sma
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_squeeze(
|
||||||
|
bb: dict[str, list[float]],
|
||||||
|
lookback: int = 10,
|
||||||
|
) -> bool:
|
||||||
|
"""Detect Bollinger Band squeeze — narrowing of the band width.
|
||||||
|
|
||||||
|
A squeeze occurs when the current band width is at the lower end
|
||||||
|
of the recent range, indicating low volatility before a breakout.
|
||||||
|
"""
|
||||||
|
upper = bb.get("upper", [])
|
||||||
|
lower = bb.get("lower", [])
|
||||||
|
if not upper or not lower or len(upper) < lookback or len(lower) < lookback:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Band width = upper - lower
|
||||||
|
widths = []
|
||||||
|
for i in range(max(0, len(upper) - lookback), len(upper)):
|
||||||
|
try:
|
||||||
|
widths.append(upper[i] - lower[i])
|
||||||
|
except TypeError:
|
||||||
|
return False
|
||||||
|
if len(widths) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_width = widths[-1]
|
||||||
|
# Check if upper_1 and lower_1 exist for squeeze detection
|
||||||
|
upper_1 = bb.get("upper_1", [])
|
||||||
|
lower_1 = bb.get("lower_1", [])
|
||||||
|
if upper_1 and lower_1 and len(upper_1) >= 1 and len(lower_1) >= 1:
|
||||||
|
try:
|
||||||
|
inner_width = upper_1[-1] - lower_1[-1]
|
||||||
|
outer_width = upper[-1] - lower[-1]
|
||||||
|
# Squeeze when inner bands are VERY narrow relative to outer (≤20%)
|
||||||
|
if outer_width > 0 and inner_width / outer_width < 0.2:
|
||||||
|
return True
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Alternative: check if current width is at absolute minimum of longer lookback
|
||||||
|
min_width = min(widths)
|
||||||
|
if min_width > 0 and current_width <= min_width * 1.01:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_signal_bb(
|
||||||
|
close_price: float,
|
||||||
|
bb: dict[str, list[float]],
|
||||||
|
rsi: list[float] | None,
|
||||||
|
sma: list[float] | None,
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Classify signal using Double BB + RSI only.
|
||||||
|
|
||||||
|
Returns (signal_type, strength) or (None, None) if no signal.
|
||||||
|
Used as one vote in the combined 4-algorithm system.
|
||||||
|
"""
|
||||||
|
if len(bb.get("upper", [])) == 0 or len(bb.get("lower", [])) == 0:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
upper_2 = bb["upper"][-1]
|
||||||
|
lower_2 = bb["lower"][-1]
|
||||||
|
upper_1 = bb.get("upper_1", [None])[-1]
|
||||||
|
lower_1 = bb.get("lower_1", [None])[-1]
|
||||||
|
middle = bb.get("middle", [None])[-1]
|
||||||
|
current_rsi = rsi[-1] if rsi and len(rsi) > 0 else 50
|
||||||
|
current_sma = sma[-1] if sma and len(sma) > 0 else None
|
||||||
|
|
||||||
|
# --- Price above BB 1σ Upper → Trend Long ---
|
||||||
|
if upper_1 is not None and close_price > upper_1:
|
||||||
|
if close_price > upper_2:
|
||||||
|
if current_rsi > 75:
|
||||||
|
return CAUTION_SHORT, "MODERATE"
|
||||||
|
# Only STRONG_BUY if RSI is firmly bullish AND SMA is above middle
|
||||||
|
if current_rsi > 60 and current_sma and middle and current_sma > middle:
|
||||||
|
return STRONG_BUY, "STRONG"
|
||||||
|
return BUY, "MODERATE"
|
||||||
|
else:
|
||||||
|
if current_rsi > 55:
|
||||||
|
return BUY, "MODERATE"
|
||||||
|
return BUY, "WEAK"
|
||||||
|
|
||||||
|
# --- Price below BB 1σ Lower → Trend Short ---
|
||||||
|
if lower_1 is not None and close_price < lower_1:
|
||||||
|
if close_price < lower_2:
|
||||||
|
if current_rsi < 25:
|
||||||
|
return CAUTION_LONG, "MODERATE"
|
||||||
|
# Only STRONG_SELL if RSI is firmly bearish AND SMA is below middle
|
||||||
|
if current_rsi < 40 and current_sma and middle and current_sma < middle:
|
||||||
|
return STRONG_SELL, "STRONG"
|
||||||
|
return SELL, "MODERATE"
|
||||||
|
else:
|
||||||
|
if current_rsi < 45:
|
||||||
|
return SELL, "MODERATE"
|
||||||
|
return SELL, "WEAK"
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_signal_combined(
|
||||||
|
close_price: float,
|
||||||
|
bb: dict[str, list[float]],
|
||||||
|
rsi: list[float] | None,
|
||||||
|
sma: list[float] | None,
|
||||||
|
macd_data: dict | None,
|
||||||
|
st_data: dict | None,
|
||||||
|
vol_data: list | None,
|
||||||
|
ichi_data: dict | None = None,
|
||||||
|
rsi_div: tuple = (None, None),
|
||||||
|
macd_div: tuple = (None, None),
|
||||||
|
smc_data: dict | None = None,
|
||||||
|
mtf_votes: list[tuple[Optional[str], Optional[str], float]] | None = None,
|
||||||
|
obv_data: list | None = None,
|
||||||
|
stoch_rsi_data: dict | None = None,
|
||||||
|
mfi_data: list | None = None,
|
||||||
|
fvg_data: dict | None = None,
|
||||||
|
candlestick_score: float | None = None,
|
||||||
|
rates: dict[str, float] | None = None,
|
||||||
|
enabled_strategies: list[str] | None = None,
|
||||||
|
) -> tuple[Optional[str], Optional[str], float, dict[str, float]]:
|
||||||
|
"""Classify market state using 13-algorithm voting with win-rate boosting.
|
||||||
|
|
||||||
|
Algorithms:
|
||||||
|
1. Double BB + RSI
|
||||||
|
2. MACD Crossover
|
||||||
|
3. SuperTrend
|
||||||
|
4. Volume Breakout
|
||||||
|
5. Ichimoku Cloud
|
||||||
|
6. Divergence Detection (RSI + MACD)
|
||||||
|
7. 🌤️ Market Structure (SMC) — BOS, CHoCH, OB
|
||||||
|
8. 🔄 Multi-Timeframe (15m + 1h + 4h)
|
||||||
|
9. 📊 OBV (On-Balance Volume) Crossover
|
||||||
|
10. 🔄 Stochastic RSI Crossover
|
||||||
|
11. 💰 MFI (Money Flow Index)
|
||||||
|
12. 🕯️ FVG (Fair Value Gap)
|
||||||
|
13. 🕯️ Candlestick Patterns (30+ patterns)
|
||||||
|
|
||||||
|
Each algorithm votes: BUY (+1/+2), SELL (-1/-2), or NEUTRAL (0).
|
||||||
|
If *rates* is provided, each strategy's raw score is boosted by its
|
||||||
|
historical win rate before the final classification.
|
||||||
|
|
||||||
|
Returns (signal_type, strength, confidence, raw_scores) where
|
||||||
|
confidence is a 0-1 float and raw_scores is a dict of all 9
|
||||||
|
algorithm scores for ML feature collection.
|
||||||
|
"""
|
||||||
|
# ── NaN/Inf guard: reject any invalid price before processing ──
|
||||||
|
if not math.isfinite(close_price) or close_price <= 0:
|
||||||
|
logger.warning("_classify_signal_combined: invalid close_price=%s, returning NEUTRAL", close_price)
|
||||||
|
return None, None, 0.0, {}
|
||||||
|
|
||||||
|
# ── Special signals (override) ──
|
||||||
|
squeeze = _detect_squeeze(bb)
|
||||||
|
if squeeze:
|
||||||
|
return SQUEEZE_ALERT, "MODERATE", 0.5, {}
|
||||||
|
|
||||||
|
# P2-2: Call _classify_signal_bb ONCE, reuse result for both
|
||||||
|
# early-return check AND the raw_scores vote
|
||||||
|
bb_type, bb_strength = _classify_signal_bb(close_price, bb, rsi, sma)
|
||||||
|
if bb_type in (CAUTION_LONG, CAUTION_SHORT):
|
||||||
|
return bb_type, "MODERATE", 0.5, {}
|
||||||
|
|
||||||
|
# ── Collect per-strategy raw scores ──
|
||||||
|
raw_scores: dict[str, float] = {
|
||||||
|
"double_bb_rsi": 0.0,
|
||||||
|
"macd_crossover": 0.0,
|
||||||
|
"supertrend": 0.0,
|
||||||
|
"volume_breakout": 0.0,
|
||||||
|
"ichimoku": 0.0,
|
||||||
|
"divergence": 0.0,
|
||||||
|
"smc": 0.0,
|
||||||
|
"mtf": 0.0,
|
||||||
|
"obv": 0.0,
|
||||||
|
"stoch_rsi": 0.0,
|
||||||
|
"mfi": 0.0,
|
||||||
|
"fvg": 0.0,
|
||||||
|
"candlestick": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. BB + RSI vote (reuse bb_type/bb_strength from above — P2-2)
|
||||||
|
if bb_type == STRONG_BUY:
|
||||||
|
raw_scores["double_bb_rsi"] = 2.0
|
||||||
|
elif bb_type == BUY:
|
||||||
|
raw_scores["double_bb_rsi"] = 1.0
|
||||||
|
elif bb_type == STRONG_SELL:
|
||||||
|
raw_scores["double_bb_rsi"] = -2.0
|
||||||
|
elif bb_type == SELL:
|
||||||
|
raw_scores["double_bb_rsi"] = -1.0
|
||||||
|
|
||||||
|
# 2. MACD Crossover vote
|
||||||
|
if macd_data:
|
||||||
|
macd_line = macd_data.get("macd_line", [])
|
||||||
|
signal_line = macd_data.get("signal_line", [])
|
||||||
|
if len(macd_line) >= 2 and len(signal_line) >= 2:
|
||||||
|
m_curr = macd_line[-1]
|
||||||
|
m_prev = macd_line[-2]
|
||||||
|
s_curr = signal_line[-1]
|
||||||
|
s_prev = signal_line[-2]
|
||||||
|
if m_curr is not None and s_curr is not None and m_prev is not None and s_prev is not None:
|
||||||
|
if m_prev <= s_prev and m_curr > s_curr and m_curr > 0:
|
||||||
|
raw_scores["macd_crossover"] = 1.0
|
||||||
|
elif m_prev >= s_prev and m_curr < s_curr and m_curr < 0:
|
||||||
|
raw_scores["macd_crossover"] = -1.0
|
||||||
|
|
||||||
|
# 3. SuperTrend vote
|
||||||
|
if st_data:
|
||||||
|
trend = st_data.get("trend", [])
|
||||||
|
if trend and len(trend) >= 1 and trend[-1] is not None:
|
||||||
|
raw_scores["supertrend"] = 1.0 if trend[-1] else -1.0
|
||||||
|
|
||||||
|
# 4. Volume Breakout vote (current bar only — avoid stale signals)
|
||||||
|
if vol_data and len(vol_data) >= 2:
|
||||||
|
vol_now = vol_data[-1]
|
||||||
|
if vol_now is True:
|
||||||
|
sma20 = sma[-1] if sma and len(sma) > 0 else None
|
||||||
|
if sma20 is not None and close_price > sma20:
|
||||||
|
raw_scores["volume_breakout"] = 1.0
|
||||||
|
elif sma20 is not None and close_price < sma20:
|
||||||
|
raw_scores["volume_breakout"] = -1.0
|
||||||
|
|
||||||
|
# 4b. OBV (On-Balance Volume) Crossover vote
|
||||||
|
if obv_data and len(obv_data) >= 2:
|
||||||
|
obv_now = obv_data[-1]
|
||||||
|
obv_prev = obv_data[-2]
|
||||||
|
if obv_now is True:
|
||||||
|
raw_scores["obv"] = 1.0
|
||||||
|
elif obv_now is False:
|
||||||
|
raw_scores["obv"] = -1.0
|
||||||
|
elif obv_prev is True:
|
||||||
|
# One bar ago still counts for momentum
|
||||||
|
raw_scores["obv"] = 0.5
|
||||||
|
elif obv_prev is False:
|
||||||
|
raw_scores["obv"] = -0.5
|
||||||
|
|
||||||
|
# 10. Stochastic RSI Crossover vote
|
||||||
|
if stoch_rsi_data:
|
||||||
|
k_line = stoch_rsi_data.get("k", [])
|
||||||
|
d_line = stoch_rsi_data.get("d", [])
|
||||||
|
if len(k_line) >= 2 and len(d_line) >= 2:
|
||||||
|
k_curr = k_line[-1]
|
||||||
|
k_prev = k_line[-2]
|
||||||
|
d_curr = d_line[-1]
|
||||||
|
if k_curr is not None and d_curr is not None and k_prev is not None:
|
||||||
|
# Oversold: %K < 20 and crossing above %D → BUY
|
||||||
|
if k_curr < 20 and k_prev <= d_curr and k_curr > d_curr:
|
||||||
|
raw_scores["stoch_rsi"] = 1.5
|
||||||
|
# Overbought: %K > 80 and crossing below %D → SELL
|
||||||
|
elif k_curr > 80 and k_prev >= d_curr and k_curr < d_curr:
|
||||||
|
raw_scores["stoch_rsi"] = -1.5
|
||||||
|
|
||||||
|
# 11. MFI (Money Flow Index) vote
|
||||||
|
if mfi_data and len(mfi_data) >= 2:
|
||||||
|
mfi_curr = mfi_data[-1]
|
||||||
|
mfi_prev = mfi_data[-2]
|
||||||
|
if mfi_curr is not None and mfi_prev is not None:
|
||||||
|
# Oversold: MFI < 20 and turning up → BUY
|
||||||
|
if mfi_curr < 20 and mfi_curr > mfi_prev:
|
||||||
|
raw_scores["mfi"] = 1.5
|
||||||
|
# Overbought: MFI > 80 and turning down → SELL
|
||||||
|
elif mfi_curr > 80 and mfi_curr < mfi_prev:
|
||||||
|
raw_scores["mfi"] = -1.5
|
||||||
|
# Neutral divergence: MFI trending in opposite direction to close
|
||||||
|
elif mfi_curr < 30:
|
||||||
|
raw_scores["mfi"] = 0.5
|
||||||
|
elif mfi_curr > 70:
|
||||||
|
raw_scores["mfi"] = -0.5
|
||||||
|
|
||||||
|
# 12. FVG (Fair Value Gap) vote — only when price is near the gap
|
||||||
|
if fvg_data:
|
||||||
|
fvg_type = fvg_data.get("type")
|
||||||
|
fvg_high = fvg_data.get("gap_high")
|
||||||
|
fvg_low = fvg_data.get("gap_low")
|
||||||
|
if fvg_type == "BULLISH" and fvg_high is not None and fvg_low is not None:
|
||||||
|
# Price near or inside bullish FVG → support → BUY
|
||||||
|
gap_mid = (fvg_high + fvg_low) / 2.0
|
||||||
|
dist_pct = abs(close_price - gap_mid) / close_price * 100
|
||||||
|
if dist_pct < 1.0:
|
||||||
|
raw_scores["fvg"] = 2.0
|
||||||
|
elif dist_pct < 2.0:
|
||||||
|
raw_scores["fvg"] = 1.0
|
||||||
|
# else: too far from gap → no vote (prevents noise)
|
||||||
|
elif fvg_type == "BEARISH" and fvg_high is not None and fvg_low is not None:
|
||||||
|
# Price near or inside bearish FVG → resistance → SELL
|
||||||
|
gap_mid = (fvg_high + fvg_low) / 2.0
|
||||||
|
dist_pct = abs(close_price - gap_mid) / close_price * 100
|
||||||
|
if dist_pct < 1.0:
|
||||||
|
raw_scores["fvg"] = -2.0
|
||||||
|
elif dist_pct < 2.0:
|
||||||
|
raw_scores["fvg"] = -1.0
|
||||||
|
# else: too far from gap → no vote
|
||||||
|
|
||||||
|
# 13. Candlestick Patterns vote
|
||||||
|
if candlestick_score is not None and candlestick_score != 0.0:
|
||||||
|
raw_scores["candlestick"] = candlestick_score
|
||||||
|
|
||||||
|
# 5. Ichimoku Cloud vote
|
||||||
|
if ichi_data:
|
||||||
|
tenkan = ichi_data.get("tenkan", [])
|
||||||
|
kijun = ichi_data.get("kijun", [])
|
||||||
|
senkou_a = ichi_data.get("senkou_a", [])
|
||||||
|
senkou_b = ichi_data.get("senkou_b", [])
|
||||||
|
if (tenkan and len(tenkan) >= 2 and kijun and len(kijun) >= 2
|
||||||
|
and senkou_a and len(senkou_a) >= 2 and senkou_b and len(senkou_b) >= 2):
|
||||||
|
t_now = tenkan[-1]
|
||||||
|
k_now = kijun[-1]
|
||||||
|
t_prev = tenkan[-2]
|
||||||
|
k_prev = kijun[-2]
|
||||||
|
sa_now = senkou_a[-1]
|
||||||
|
sb_now = senkou_b[-1]
|
||||||
|
ichi_score = 0.0
|
||||||
|
if t_now is not None and k_now is not None and t_prev is not None and k_prev is not None:
|
||||||
|
if t_prev <= k_prev and t_now > k_now:
|
||||||
|
ichi_score += 1.5
|
||||||
|
elif t_prev >= k_prev and t_now < k_now:
|
||||||
|
ichi_score -= 1.5
|
||||||
|
if sa_now is not None and sb_now is not None:
|
||||||
|
if sa_now > sb_now and close_price > sa_now:
|
||||||
|
ichi_score += 1.0
|
||||||
|
elif sa_now < sb_now and close_price < sb_now:
|
||||||
|
ichi_score -= 1.0
|
||||||
|
raw_scores["ichimoku"] = ichi_score
|
||||||
|
|
||||||
|
# 6. Divergence Detection vote
|
||||||
|
div_score = 0.0
|
||||||
|
for div_type, div_strength in [rsi_div, macd_div]:
|
||||||
|
if div_type == "BULLISH":
|
||||||
|
div_score += 2.0 if div_strength == "STRONG" else 1.0
|
||||||
|
elif div_type == "BEARISH":
|
||||||
|
div_score -= 2.0 if div_strength == "STRONG" else 1.0
|
||||||
|
raw_scores["divergence"] = div_score
|
||||||
|
|
||||||
|
# 7. 🌤️ Market Structure (SMC) vote
|
||||||
|
smc_score = 0.0
|
||||||
|
if smc_data:
|
||||||
|
bos = smc_data.get("bos")
|
||||||
|
choch = smc_data.get("choch")
|
||||||
|
trend = smc_data.get("trend", "NEUTRAL")
|
||||||
|
obs = smc_data.get("order_blocks", [])
|
||||||
|
|
||||||
|
if bos == "BULLISH":
|
||||||
|
smc_score += 1.5
|
||||||
|
elif bos == "BEARISH":
|
||||||
|
smc_score -= 1.5
|
||||||
|
|
||||||
|
if choch == "BULLISH":
|
||||||
|
smc_score += 1.5
|
||||||
|
elif choch == "BEARISH":
|
||||||
|
smc_score -= 1.5
|
||||||
|
|
||||||
|
if trend == "BULLISH":
|
||||||
|
smc_score += 1.0
|
||||||
|
elif trend == "BEARISH":
|
||||||
|
smc_score -= 1.0
|
||||||
|
|
||||||
|
if obs:
|
||||||
|
latest_close = close_price
|
||||||
|
bullish_obs = [ob for ob in obs if ob.get("type") == "BULLISH"]
|
||||||
|
bearish_obs = [ob for ob in obs if ob.get("type") == "BEARISH"]
|
||||||
|
|
||||||
|
for ob in bullish_obs:
|
||||||
|
ob_high = ob.get("price_high", 0)
|
||||||
|
if 0 < ob_high and ob_high * 0.995 <= latest_close <= ob_high * 1.005:
|
||||||
|
smc_score += 1.0
|
||||||
|
break
|
||||||
|
|
||||||
|
for ob in bearish_obs:
|
||||||
|
ob_low = ob.get("price_low", 0)
|
||||||
|
if 0 < ob_low and ob_low * 0.995 <= latest_close <= ob_low * 1.005:
|
||||||
|
smc_score -= 1.0
|
||||||
|
break
|
||||||
|
raw_scores["smc"] = smc_score
|
||||||
|
|
||||||
|
# 8. 🔄 Multi-Timeframe vote (weighted)
|
||||||
|
mtf_score = 0.0
|
||||||
|
if mtf_votes:
|
||||||
|
for sig_type, sig_strength, weight in mtf_votes:
|
||||||
|
if sig_type == STRONG_BUY:
|
||||||
|
mtf_score += 2.0 * weight
|
||||||
|
elif sig_type == BUY:
|
||||||
|
mtf_score += 1.0 * weight
|
||||||
|
elif sig_type == STRONG_SELL:
|
||||||
|
mtf_score -= 2.0 * weight
|
||||||
|
elif sig_type == SELL:
|
||||||
|
mtf_score -= 1.0 * weight
|
||||||
|
raw_scores["mtf"] = mtf_score
|
||||||
|
|
||||||
|
# ── Apply enabled_strategies filter (zero-out disabled strategies) ──
|
||||||
|
if enabled_strategies is not None:
|
||||||
|
disabled = [s for s in raw_scores if s not in enabled_strategies]
|
||||||
|
if disabled:
|
||||||
|
logger.debug("Disabled strategies: %s", disabled)
|
||||||
|
for s in disabled:
|
||||||
|
raw_scores[s] = 0.0
|
||||||
|
|
||||||
|
# ── P1-16: Correlation dampening ──
|
||||||
|
# Strategies in the same group are highly correlated; dampen when
|
||||||
|
# multiple group members agree (same sign) to avoid overconfidence.
|
||||||
|
CORRELATION_GROUPS: list[list[str]] = [
|
||||||
|
["double_bb_rsi", "stoch_rsi", "mfi"], # Oscillator group
|
||||||
|
["macd_crossover", "supertrend", "ichimoku"], # Trend group
|
||||||
|
["volume_breakout", "obv"], # Volume group
|
||||||
|
["divergence", "smc", "fvg", "candlestick"], # Pattern group
|
||||||
|
]
|
||||||
|
for group in CORRELATION_GROUPS:
|
||||||
|
active = [(s, raw_scores[s]) for s in group if raw_scores[s] != 0.0]
|
||||||
|
if len(active) >= 2:
|
||||||
|
signs = [1 if v > 0 else -1 for _, v in active]
|
||||||
|
pos_count = sum(1 for s in signs if s > 0)
|
||||||
|
neg_count = sum(1 for s in signs if s < 0)
|
||||||
|
# Dampen: scale each strategy's score by 1/sqrt(count)
|
||||||
|
if pos_count >= 2:
|
||||||
|
dampen = 1.0 / (pos_count ** 0.5)
|
||||||
|
for strat, val in active:
|
||||||
|
if val > 0:
|
||||||
|
raw_scores[strat] = val * dampen
|
||||||
|
if neg_count >= 2:
|
||||||
|
dampen = 1.0 / (neg_count ** 0.5)
|
||||||
|
for strat, val in active:
|
||||||
|
if val < 0:
|
||||||
|
raw_scores[strat] = val * dampen
|
||||||
|
|
||||||
|
# ── Apply win-rate boosting ──
|
||||||
|
boosted_scores: dict[str, float] = {}
|
||||||
|
for strategy, raw_score in raw_scores.items():
|
||||||
|
boosted_scores[strategy] = boost_score(raw_score, strategy, rates)
|
||||||
|
|
||||||
|
total_score = sum(boosted_scores.values())
|
||||||
|
confidence = compute_booster_confidence(raw_scores, rates)
|
||||||
|
|
||||||
|
# ── Dynamic threshold normalization ──
|
||||||
|
# Normalize total_score by sqrt(active_strategies) so that
|
||||||
|
# 3 strategies voting STRONG ≈ 10 strategies voting weak
|
||||||
|
active_count = sum(1 for v in boosted_scores.values() if v != 0.0)
|
||||||
|
if active_count > 1:
|
||||||
|
norm_factor = max(active_count ** 0.5, 1.0)
|
||||||
|
adjusted_score = total_score / norm_factor
|
||||||
|
else:
|
||||||
|
adjusted_score = total_score
|
||||||
|
|
||||||
|
# ── Final classification from boosted score ──
|
||||||
|
# 🔧 Dynamic thresholds: STRONG needs effective 4.0, BUY/SELL needs 1.0
|
||||||
|
if adjusted_score >= 4.0:
|
||||||
|
return STRONG_BUY, "STRONG", confidence, raw_scores
|
||||||
|
elif adjusted_score >= 1.0:
|
||||||
|
return BUY, "MODERATE", confidence, raw_scores
|
||||||
|
elif adjusted_score <= -4.0:
|
||||||
|
return STRONG_SELL, "STRONG", confidence, raw_scores
|
||||||
|
elif adjusted_score <= -1.0:
|
||||||
|
return SELL, "MODERATE", confidence, raw_scores
|
||||||
|
|
||||||
|
return None, None, confidence, raw_scores
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_pnl(
|
||||||
|
entry_price: Decimal,
|
||||||
|
exit_price: Decimal,
|
||||||
|
direction: str,
|
||||||
|
quantity: Decimal,
|
||||||
|
) -> tuple[Decimal, Decimal]:
|
||||||
|
"""Calculate absolute and percentage P&L."""
|
||||||
|
if entry_price == 0:
|
||||||
|
return Decimal("0"), Decimal("0")
|
||||||
|
|
||||||
|
# P1-15: Guard against divide-by-zero when quantity is 0
|
||||||
|
if quantity is None or quantity <= 0:
|
||||||
|
return Decimal("0"), Decimal("0")
|
||||||
|
|
||||||
|
if direction == "LONG":
|
||||||
|
pnl = (exit_price - entry_price) * quantity
|
||||||
|
else:
|
||||||
|
pnl = (entry_price - exit_price) * quantity
|
||||||
|
|
||||||
|
pnl_percent = (pnl / (entry_price * quantity)) * Decimal("100")
|
||||||
|
return pnl, pnl_percent
|
||||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import time as _time
|
import time as _time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -29,12 +28,7 @@ from app.models.symbol import Symbol
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.candle_service import get_indicators
|
from app.services.candle_service import get_indicators
|
||||||
from app.services.indicator_service import atr as compute_atr
|
from app.services.indicator_service import atr as compute_atr
|
||||||
from app.services.signal_booster import (
|
from app.services.signal_booster import compute_strategy_win_rates
|
||||||
boost_score,
|
|
||||||
compute_strategy_win_rates,
|
|
||||||
get_cached_rates,
|
|
||||||
get_confidence as compute_booster_confidence,
|
|
||||||
)
|
|
||||||
from app.schemas.signal import SignalResponse, TradeResponse
|
from app.schemas.signal import SignalResponse, TradeResponse
|
||||||
from app.core.security import decrypt_api_key
|
from app.core.security import decrypt_api_key
|
||||||
from app.exchange.factory import factory as exchange_factory
|
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
|
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"
|
from app.services.signal_scoring import ( # noqa: E402
|
||||||
BUY = "BUY"
|
BUY,
|
||||||
STRONG_SELL = "STRONG_SELL"
|
CAUTION_LONG,
|
||||||
SELL = "SELL"
|
CAUTION_SHORT,
|
||||||
CAUTION_LONG = "CAUTION_LONG"
|
MIN_BB_DISTANCE_PCT,
|
||||||
CAUTION_SHORT = "CAUTION_SHORT"
|
SELL,
|
||||||
SQUEEZE_ALERT = "SQUEEZE_ALERT"
|
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
|
# P2-10: Cross-timeframe cooldown cache to prevent duplicate signals
|
||||||
# Key: f"{symbol}:{exchange}:{timeframe}" → last signal timestamp
|
# Key: f"{symbol}:{exchange}:{timeframe}" → last signal timestamp
|
||||||
_signal_cooldown: dict[str, float] = {}
|
_signal_cooldown: dict[str, float] = {}
|
||||||
_COOLDOWN_SECONDS = 300 # 5 minutes between same-direction signals
|
_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
|
# Public API
|
||||||
@@ -1319,8 +816,8 @@ async def _manage_trades(
|
|||||||
try:
|
try:
|
||||||
from app.services.risk_manager import DynamicKellySizer
|
from app.services.risk_manager import DynamicKellySizer
|
||||||
from app.services.signal_booster import get_cached_rates, get_pnl_stats
|
from app.services.signal_booster import get_cached_rates, get_pnl_stats
|
||||||
rates = get_cached_rates()
|
rates = await get_cached_rates()
|
||||||
pnl_stats = get_pnl_stats() # real avg_win / avg_loss from DB
|
pnl_stats = await get_pnl_stats() # real avg_win / avg_loss from DB
|
||||||
kelly = DynamicKellySizer()
|
kelly = DynamicKellySizer()
|
||||||
# Estimate win rate and avg win/loss from cached booster rates
|
# Estimate win rate and avg win/loss from cached booster rates
|
||||||
overall_rate = rates.get("__all__", 0.5)
|
overall_rate = rates.get("__all__", 0.5)
|
||||||
|
|||||||
@@ -266,8 +266,8 @@ async def execute_signal_trade(
|
|||||||
try:
|
try:
|
||||||
from app.services.risk_manager import DynamicKellySizer
|
from app.services.risk_manager import DynamicKellySizer
|
||||||
from app.services.signal_booster import get_cached_rates, get_pnl_stats
|
from app.services.signal_booster import get_cached_rates, get_pnl_stats
|
||||||
rates = get_cached_rates()
|
rates = await get_cached_rates()
|
||||||
pnl_stats = get_pnl_stats()
|
pnl_stats = await get_pnl_stats()
|
||||||
kelly = DynamicKellySizer()
|
kelly = DynamicKellySizer()
|
||||||
overall_rate = rates.get("__all__", 0.5)
|
overall_rate = rates.get("__all__", 0.5)
|
||||||
dir_rate = rates.get(f"__all___{signal_direction}", overall_rate)
|
dir_rate = rates.get(f"__all___{signal_direction}", overall_rate)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ bcrypt==4.0.1
|
|||||||
ccxt>=4.3
|
ccxt>=4.3
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
cachetools==5.5.0
|
cachetools==5.5.0
|
||||||
|
redis==5.0.8
|
||||||
structlog==24.4.0
|
structlog==24.4.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
websockets==13.0
|
websockets==13.0
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests for fix (o): DB_PASSWORD_FILE / ENCRYPTION_KEY_FILE support in
|
||||||
|
app/config.py, so DB password and encryption key can be sourced from Docker
|
||||||
|
secrets files (consistent with how JWT keys are already handled) instead of
|
||||||
|
plain env vars, while staying backward-compatible when the _FILE variants
|
||||||
|
are not set.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_password_file_overrides_database_url_password(tmp_path):
|
||||||
|
pw_file = tmp_path / "db_password.txt"
|
||||||
|
pw_file.write_text("s3cr3t-from-file\n")
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
DATABASE_URL="postgresql+asyncpg://trading:placeholder@db:5432/trading_portal",
|
||||||
|
DB_PASSWORD_FILE=str(pw_file),
|
||||||
|
)
|
||||||
|
assert "s3cr3t-from-file" in settings.DATABASE_URL
|
||||||
|
assert "placeholder" not in settings.DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_encryption_key_file_overrides_encryption_key(tmp_path):
|
||||||
|
key_file = tmp_path / "encryption_key.txt"
|
||||||
|
key_file.write_text("00" * 32 + "\n")
|
||||||
|
|
||||||
|
settings = Settings(ENCRYPTION_KEY="", ENCRYPTION_KEY_FILE=str(key_file))
|
||||||
|
assert settings.ENCRYPTION_KEY == "00" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_file_variants_plain_env_values_are_unchanged():
|
||||||
|
settings = Settings(
|
||||||
|
DATABASE_URL="postgresql+asyncpg://trading:plain@db:5432/trading_portal",
|
||||||
|
ENCRYPTION_KEY="plain-key",
|
||||||
|
)
|
||||||
|
assert settings.DATABASE_URL == "postgresql+asyncpg://trading:plain@db:5432/trading_portal"
|
||||||
|
assert settings.ENCRYPTION_KEY == "plain-key"
|
||||||
@@ -66,18 +66,15 @@ class TestRsi:
|
|||||||
result = rsi(prices, period=14)
|
result = rsi(prices, period=14)
|
||||||
assert result[14] == pytest.approx(0.0)
|
assert result[14] == pytest.approx(0.0)
|
||||||
|
|
||||||
def test_flat_prices_do_not_yield_neutral_50(self):
|
def test_flat_prices_yield_neutral_50(self):
|
||||||
"""Documents a discovered quirk (not fixed here — flagged for the
|
"""Fix (q): zero price movement must report neutral RSI=50, not
|
||||||
team to decide on): when there's truly zero price movement, the
|
~98.04. Previously the code set an internal `rs = 50.0` sentinel
|
||||||
code sets an internal `rs = 50.0` sentinel intending "neutral", but
|
intending "neutral" but still ran it through the RSI formula
|
||||||
that value is still run through the RSI formula
|
(100 - 100/(1+rs)), which maps rs=50 to RSI≈98.04 — misreporting a
|
||||||
(100 - 100/(1+rs)), which maps rs=50 to RSI≈98.04, not the
|
perfectly flat run (illiquid pair, stablecoin) as near-overbought."""
|
||||||
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."""
|
|
||||||
prices = [10.0] * 16
|
prices = [10.0] * 16
|
||||||
result = rsi(prices, period=14)
|
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):
|
def test_insufficient_data_returns_all_none(self):
|
||||||
result = rsi([1, 2, 3], period=14)
|
result = rsi([1, 2, 3], period=14)
|
||||||
@@ -191,27 +188,19 @@ class TestMfi:
|
|||||||
def test_overbought_when_no_negative_flow(self):
|
def test_overbought_when_no_negative_flow(self):
|
||||||
# typical price strictly increasing -> every period contributes only
|
# typical price strictly increasing -> every period contributes only
|
||||||
# positive flow -> neg_flow == 0 -> MFI defined as 100.0.
|
# 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)]
|
candles = [candle(10 + i, 10 + i, 10 + i, volume=100) for i in range(5)]
|
||||||
result = mfi(candles, period=3)
|
result = mfi(candles, period=3)
|
||||||
assert result[4] == pytest.approx(100.0)
|
assert result[4] == pytest.approx(100.0)
|
||||||
|
|
||||||
def test_first_computed_value_has_a_wraparound_indexing_quirk(self):
|
def test_first_computed_value_no_longer_wraps_around(self):
|
||||||
"""Documents a discovered quirk (not fixed here — flagged for the
|
"""Fix (r): the first computed MFI value in a series must not
|
||||||
team to decide on): for the first computed MFI value in a series,
|
compare typical_prices[0] against typical_prices[-1] (the LAST
|
||||||
the loop compares `typical_prices[j-1]` with `j=0`, which in Python
|
candle) via Python's negative-index wraparound. With strictly
|
||||||
wraps around to `typical_prices[-1]` (the LAST candle in the whole
|
increasing prices and no real negative flow, the first computed
|
||||||
series) instead of having no prior candle to compare against. This
|
value must also be 100.0, same as later ones."""
|
||||||
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."""
|
|
||||||
candles = [candle(10 + i, 10 + i, 10 + i, volume=100) for i in range(5)]
|
candles = [candle(10 + i, 10 + i, 10 + i, volume=100) for i in range(5)]
|
||||||
result = mfi(candles, period=3)
|
result = mfi(candles, period=3)
|
||||||
# Without the quirk this would also be 100.0 (strictly increasing,
|
assert result[3] == pytest.approx(100.0)
|
||||||
# no real negative flow) — the quirk drags it down to ~69.7.
|
|
||||||
assert result[3] == pytest.approx(69.69696969696969)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDetectMarketRegime:
|
class TestDetectMarketRegime:
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for app/core/redis_client.py — the shared cache helper used to sync
|
||||||
|
state (win rates, PnL stats) between the backend-api and backend-scheduler
|
||||||
|
processes. Every path here must degrade gracefully when Redis is down,
|
||||||
|
since it's an optional cache, not a hard dependency.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core import redis_client
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
def __init__(self):
|
||||||
|
self.store: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return self.store.get(key)
|
||||||
|
|
||||||
|
async def set(self, key, value, ex=None):
|
||||||
|
self.store[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class BrokenRedis:
|
||||||
|
async def get(self, key):
|
||||||
|
raise ConnectionError("redis unreachable")
|
||||||
|
|
||||||
|
async def set(self, key, value, ex=None):
|
||||||
|
raise ConnectionError("redis unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_then_get_json_roundtrip(monkeypatch):
|
||||||
|
fake = FakeRedis()
|
||||||
|
monkeypatch.setattr(redis_client, "_get_client", lambda: fake)
|
||||||
|
|
||||||
|
await redis_client.set_json("k", {"a": 1}, ttl_seconds=60)
|
||||||
|
assert await redis_client.get_json("k") == {"a": 1}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_json_returns_none_when_key_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(redis_client, "_get_client", lambda: FakeRedis())
|
||||||
|
assert await redis_client.get_json("missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_json_returns_none_when_client_unavailable(monkeypatch):
|
||||||
|
monkeypatch.setattr(redis_client, "_get_client", lambda: None)
|
||||||
|
assert await redis_client.get_json("k") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_json_does_not_raise_when_redis_errors(monkeypatch):
|
||||||
|
monkeypatch.setattr(redis_client, "_get_client", lambda: BrokenRedis())
|
||||||
|
assert await redis_client.get_json("k") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_json_does_not_raise_when_redis_errors(monkeypatch):
|
||||||
|
monkeypatch.setattr(redis_client, "_get_client", lambda: BrokenRedis())
|
||||||
|
# Must not raise -- callers treat cache writes as best-effort.
|
||||||
|
await redis_client.set_json("k", {"a": 1}, ttl_seconds=60)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests for fix (l): signal_booster's win-rate/PnL caches read from Redis
|
||||||
|
first (shared across the backend-api and backend-scheduler processes),
|
||||||
|
falling back to this process's own in-memory cache when Redis has no data
|
||||||
|
or is unavailable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from app.services import signal_booster
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_cached_rates_prefers_redis(monkeypatch):
|
||||||
|
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value={"__all__": 0.7}))
|
||||||
|
monkeypatch.setattr(signal_booster, "_win_rate_cache", {"__all__": 0.1})
|
||||||
|
|
||||||
|
assert await signal_booster.get_cached_rates() == {"__all__": 0.7}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_cached_rates_falls_back_to_in_memory_when_redis_empty(monkeypatch):
|
||||||
|
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value=None))
|
||||||
|
monkeypatch.setattr(signal_booster, "_win_rate_cache", {"__all__": 0.42})
|
||||||
|
|
||||||
|
assert await signal_booster.get_cached_rates() == {"__all__": 0.42}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_pnl_stats_prefers_redis(monkeypatch):
|
||||||
|
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value={"avg_win": 5.0, "avg_loss": 1.0}))
|
||||||
|
|
||||||
|
assert await signal_booster.get_pnl_stats() == {"avg_win": 5.0, "avg_loss": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_pnl_stats_falls_back_to_defaults_when_nothing_cached(monkeypatch):
|
||||||
|
monkeypatch.setattr(signal_booster.redis_client, "get_json", AsyncMock(return_value=None))
|
||||||
|
monkeypatch.setattr(signal_booster, "_pnl_stats_cache", {})
|
||||||
|
monkeypatch.setattr(signal_booster, "_last_pnl_cache_update", 0.0)
|
||||||
|
|
||||||
|
assert await signal_booster.get_pnl_stats() == {"avg_win": 3.0, "avg_loss": 2.0}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Tests for the pure scoring/classification helpers in
|
"""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
|
BUY/SELL/STRONG signals. These functions take plain indicator dicts/lists
|
||||||
and return classifications; no DB or network I/O involved.
|
and return classifications; no DB or network I/O involved.
|
||||||
"""
|
"""
|
||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.services.signal_service import (
|
from app.services.signal_scoring import (
|
||||||
BUY,
|
BUY,
|
||||||
CAUTION_LONG,
|
CAUTION_LONG,
|
||||||
CAUTION_SHORT,
|
CAUTION_SHORT,
|
||||||
+34
-5
@@ -8,9 +8,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: trading_portal
|
POSTGRES_DB: trading_portal
|
||||||
POSTGRES_USER: trading
|
POSTGRES_USER: trading
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-trading_secret}
|
POSTGRES_PASSWORD_FILE: /run/secrets/db_password.txt
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- /opt/data/trading-portal/secrets:/run/secrets:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -28,6 +29,26 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- trading-net
|
- 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) ─────────────────
|
# ───────────────── FastAPI Backend (user-facing, no scheduler) ─────────────────
|
||||||
backend-api:
|
backend-api:
|
||||||
build:
|
build:
|
||||||
@@ -36,10 +57,14 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
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
|
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
|
CORS_ORIGINS: http://localhost,http://localhost:5173,http://localhost:3000
|
||||||
LOG_LEVEL: INFO
|
LOG_LEVEL: INFO
|
||||||
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
|
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
|
||||||
@@ -75,9 +100,13 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal
|
DATABASE_URL: postgresql+asyncpg://trading:unused@db:5432/trading_portal
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
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
|
LOG_LEVEL: INFO
|
||||||
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
|
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
|
||||||
JWT_PUBLIC_KEYS_DIR: /run/secrets/jwt_public_keys
|
JWT_PUBLIC_KEYS_DIR: /run/secrets/jwt_public_keys
|
||||||
|
|||||||
Generated
+331
@@ -18,11 +18,13 @@
|
|||||||
"vite-plugin-pwa": "^1.3.0"
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"oxlint": "^1.69.0",
|
"oxlint": "^1.69.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.0"
|
"vite": "^8.1.0"
|
||||||
}
|
}
|
||||||
@@ -2665,6 +2667,290 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||||
"version": "3.0.0-pre1",
|
"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",
|
"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==",
|
"integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.2",
|
"version": "1.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||||
@@ -4341,6 +4641,16 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5731,6 +6041,27 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
"vite-plugin-pwa": "^1.3.0"
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"oxlint": "^1.69.0",
|
"oxlint": "^1.69.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.0"
|
"vite": "^8.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,21 +29,14 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
if (this.props.fallback) return this.props.fallback;
|
if (this.props.fallback) return this.props.fallback;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="min-h-screen flex flex-col items-center justify-center gap-3 bg-bg-primary p-10 text-center text-red">
|
||||||
padding: 40, textAlign: 'center', color: '#e74c3c',
|
<h1 className="text-2xl font-bold">⚠️ Something went wrong</h1>
|
||||||
background: '#1a1a2e', minHeight: '100vh',
|
<p className="max-w-[500px] text-sm text-text-secondary">
|
||||||
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 }}>
|
|
||||||
{this.state.error?.message || 'An unexpected error occurred'}
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => { this.setState({ hasError: false, error: null }); window.location.href = '/'; }}
|
onClick={() => { this.setState({ hasError: false, error: null }); window.location.href = '/'; }}
|
||||||
style={{
|
className="mt-2 cursor-pointer rounded-md border-none bg-accent-blue px-6 py-2.5 text-sm text-white hover:opacity-90"
|
||||||
marginTop: 20, padding: '10px 24px', borderRadius: 6, border: 'none',
|
|
||||||
background: '#3498db', color: '#fff', cursor: 'pointer', fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Reload App
|
Reload App
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,32 +16,20 @@ export const Skeleton: React.FC<SkeletonProps> = ({
|
|||||||
count = 1,
|
count = 1,
|
||||||
style = {},
|
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,
|
width,
|
||||||
height,
|
height,
|
||||||
borderRadius,
|
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,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{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 }) => (
|
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={32} width="60%" style={{ marginBottom: 20 }} />
|
||||||
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
|
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
|
||||||
{Array.from({ length: lines }).map((_, i) => (
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ interface ModalState {
|
|||||||
targetUser: User | null;
|
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() {
|
export default function AdminPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -303,48 +313,25 @@ export default function AdminPage() {
|
|||||||
if (!user?.is_admin) return null;
|
if (!user?.is_admin) return null;
|
||||||
if (initializing || isLoading) {
|
if (initializing || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="flex min-h-screen items-center justify-center bg-bg-primary">
|
||||||
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading admin panel...</div>
|
<div className="text-sm text-text-secondary">Loading admin panel...</div>
|
||||||
</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 (
|
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 ═══ */}
|
{/* ═══ 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' }}>
|
<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 style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
<span style={{ fontSize: 18 }}>🛡️</span>
|
<span className="text-lg">🛡️</span>
|
||||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Admin Panel</span>
|
<span className="text-base font-bold text-text-heading">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<a href="/profile" style={{ fontSize: 12, color: '#8b949e', textDecoration: 'none', whiteSpace: 'nowrap' }}>👤 {user.username}</a>
|
<a href="/profile" className="whitespace-nowrap text-xs text-text-secondary no-underline">👤 {user.username}</a>
|
||||||
<button style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }}
|
className={`${btnClass} border-red/20 text-red`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await dispatch(logoutThunk());
|
await dispatch(logoutThunk());
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
@@ -358,9 +345,9 @@ export default function AdminPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ═══ Tabs ═══ */}
|
{/* ═══ 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) => (
|
{(['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'}
|
{tab === 'users' ? '👥 Users' : tab === 'exchanges' ? '🔌 Exchanges' : '❤️ Health'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -370,31 +357,31 @@ export default function AdminPage() {
|
|||||||
{/* ═══ USERS TAB ═══ */}
|
{/* ═══ USERS TAB ═══ */}
|
||||||
{/* ════════════════════════════════════════════════════ */}
|
{/* ════════════════════════════════════════════════════ */}
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div style={{ padding: 16 }}>
|
<div className="p-4">
|
||||||
{/* Stats bar */}
|
{/* Stats bar */}
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
<div className="mb-3 flex flex-wrap gap-3">
|
||||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
<div className={statCardClass}>
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>TOTAL</div>
|
<div className="mb-0.5 text-[11px] text-text-dim">TOTAL</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{totalUsers}</div>
|
<div className="text-xl font-bold text-text-heading">{totalUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
<div className={statCardClass}>
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ACTIVE</div>
|
<div className="mb-0.5 text-[11px] text-text-dim">ACTIVE</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{activeUsers}</div>
|
<div className="text-xl font-bold text-green">{activeUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
<div className={statCardClass}>
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ADMINS</div>
|
<div className="mb-0.5 text-[11px] text-text-dim">ADMINS</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#d29922' }}>{adminUsers}</div>
|
<div className="text-xl font-bold text-yellow">{adminUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ Role Permission Legend ═══ */}
|
{/* ═══ Role Permission Legend ═══ */}
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
|
||||||
<div style={{ fontSize: 11, color: '#d29922', fontWeight: 700, marginBottom: 6 }}>🛡️ Admin</div>
|
<div className="mb-1.5 text-[11px] font-bold text-yellow">🛡️ Admin</div>
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
<div className="text-[11px] leading-[1.7] text-text-secondary">
|
||||||
<strong style={{ color: '#f0f6fc' }}>Toàn quyền</strong> — mọi tính năng.<br />
|
<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ý 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 & symbol (thêm, sửa, active/deactive)<br />
|
||||||
Xem audit log toàn bộ hệ thống<br />
|
Xem audit log toàn bộ hệ thống<br />
|
||||||
Quản lý system config, health check<br />
|
Quản lý system config, health check<br />
|
||||||
Xem tất cả trades, signals, backtest của mọi user<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
|
Quản lý strategies, alerts, notification channels
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', fontWeight: 700, marginBottom: 6 }}>👤 Trader</div>
|
<div className="mb-1.5 text-[11px] font-bold text-text-secondary">👤 Trader</div>
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
<div className="text-[11px] leading-[1.7] text-text-secondary">
|
||||||
• Xem dashboard, signals & multi-timeframe chart<br />
|
• Xem dashboard, signals & multi-timeframe chart<br />
|
||||||
Chạy backtest & lưu kết quả<br />
|
Chạy backtest & lưu kết quả<br />
|
||||||
Xem & quản lý hypothetical trades cá nhân<br />
|
Xem & quản lý hypothetical trades cá nhân<br />
|
||||||
<strong style={{ color: '#f0f6fc' }}>Đặt lệnh real trade</strong> (place order)<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ý watchlist, API credentials, personal alerts<br />
|
||||||
Quản lý strategies cá nhân<br />
|
Quản lý strategies cá nhân<br />
|
||||||
Xem analytics & performance cá nhân
|
Xem analytics & performance cá nhân
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
|
||||||
<div style={{ fontSize: 11, color: '#58a6ff', fontWeight: 700, marginBottom: 6 }}>👁️ Viewer</div>
|
<div className="mb-1.5 text-[11px] font-bold text-accent">👁️ Viewer</div>
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
<div className="text-[11px] leading-[1.7] text-text-secondary">
|
||||||
<strong style={{ color: '#f85149' }}>Chỉ xem (read-only)</strong><br />
|
<strong className="text-red">Chỉ xem (read-only)</strong><br />
|
||||||
• Xem dashboard & real-time chart<br />
|
• Xem dashboard & real-time chart<br />
|
||||||
Xem signals, trades, backtest history<br />
|
Xem signals, trades, backtest history<br />
|
||||||
Xem analytics, alerts, watchlist<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 />
|
||||||
chạy backtest mới, chỉnh sửa credentials
|
chạy backtest mới, chỉnh sửa credentials
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Add bar */}
|
{/* Search + Add bar */}
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
style={{ ...inputStyle, maxWidth: 300 }}
|
className={`${inputClass} max-w-[300px]`}
|
||||||
placeholder="🔍 Search by username, email, display name..."
|
placeholder="🔍 Search by username, email, display name..."
|
||||||
value={userSearch}
|
value={userSearch}
|
||||||
onChange={(e) => setUserSearch(e.target.value)}
|
onChange={(e) => setUserSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<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')}
|
onClick={() => openModal('add')}
|
||||||
>
|
>
|
||||||
+ Add User
|
+ Add User
|
||||||
@@ -444,60 +431,60 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User table */}
|
{/* User table */}
|
||||||
{usersLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading users...</p>}
|
{usersLoading && <p className="text-[13px] text-text-dim">Loading users...</p>}
|
||||||
{usersError && <p style={{ color: '#f85149', fontSize: 13 }}>{usersError}</p>}
|
{usersError && <p className="text-[13px] text-red">{usersError}</p>}
|
||||||
{!usersLoading && !usersError && (
|
{!usersLoading && !usersError && (
|
||||||
<>
|
<>
|
||||||
{filteredUsers.length === 0 ? (
|
{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.'}
|
{userSearch ? 'No users match your search.' : 'No users yet.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -12px', padding: '0 12px' }}>
|
<div className="-mx-3 overflow-x-auto px-3">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 600 }}>
|
<table className="w-full min-w-[600px] border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Username</th>
|
<th className={thClass}>Username</th>
|
||||||
<th style={thStyle}>Display Name</th>
|
<th className={thClass}>Display Name</th>
|
||||||
<th style={thStyle}>Email</th>
|
<th className={thClass}>Email</th>
|
||||||
<th style={thStyle}>Role</th>
|
<th className={thClass}>Role</th>
|
||||||
<th style={thStyle}>Status</th>
|
<th className={thClass}>Status</th>
|
||||||
<th style={thStyle}>Created</th>
|
<th className={thClass}>Created</th>
|
||||||
<th style={thStyle}>Actions</th>
|
<th className={thClass}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredUsers.map((u) => (
|
{filteredUsers.map((u) => (
|
||||||
<tr key={String(u.id)} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={String(u.id)} className="border-b border-border-muted">
|
||||||
<td style={{ ...tdStyle, fontWeight: 600, color: '#f0f6fc' }}>{u.username}</td>
|
<td className={`${tdClass} font-semibold text-text-heading`}>{u.username}</td>
|
||||||
<td style={tdStyle}>{u.display_name || <span style={{ color: '#484f58' }}>—</span>}</td>
|
<td className={tdClass}>{u.display_name || <span className="text-text-dim">—</span>}</td>
|
||||||
<td style={tdStyle}>{u.email}</td>
|
<td className={tdClass}>{u.email}</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const role = u.role || (u.is_admin ? 'admin' : 'trader');
|
const role = u.role || (u.is_admin ? 'admin' : 'trader');
|
||||||
if (role === 'admin') return <span style={{ color: '#d29922', fontWeight: 600 }}>🛡️ Admin</span>;
|
if (role === 'admin') return <span className="font-semibold text-yellow">🛡️ Admin</span>;
|
||||||
if (role === 'viewer') return <span style={{ color: '#58a6ff', fontWeight: 500 }}>👁️ Viewer</span>;
|
if (role === 'viewer') return <span className="font-medium text-accent">👁️ Viewer</span>;
|
||||||
return <span style={{ color: '#8b949e' }}>👤 Trader</span>;
|
return <span className="text-text-secondary">👤 Trader</span>;
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_active')}>
|
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_active')}>
|
||||||
{u.is_active ? <span style={badgeGreen}>● Active</span> : <span style={badgeRed}>● Inactive</span>}
|
{u.is_active ? <span className="text-xs font-semibold text-green">● Active</span> : <span className="text-xs font-semibold text-red">● Inactive</span>}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...tdStyle, color: '#484f58' }}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
|
<td className={`${tdClass} text-text-dim`}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div className="flex flex-wrap gap-1">
|
||||||
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_admin')}>
|
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_admin')}>
|
||||||
{u.is_admin ? 'Demote' : 'Promote'}
|
{u.is_admin ? 'Demote' : 'Promote'}
|
||||||
</button>
|
</button>
|
||||||
<button style={cellBtn} onClick={() => openModal('edit', u)}>
|
<button className={cellBtnClass} onClick={() => openModal('edit', u)}>
|
||||||
✏️ Edit
|
✏️ Edit
|
||||||
</button>
|
</button>
|
||||||
<button style={cellBtn} onClick={() => openModal('reset', u)}>
|
<button className={cellBtnClass} onClick={() => openModal('reset', u)}>
|
||||||
🔑 Reset
|
🔑 Reset
|
||||||
</button>
|
</button>
|
||||||
<button style={dangerBtn} onClick={() => openModal('delete', u)}>
|
<button className={dangerBtnClass} onClick={() => openModal('delete', u)}>
|
||||||
🗑️ Delete
|
🗑️ Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,7 +495,7 @@ export default function AdminPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginTop: 8 }}>
|
<div className="mt-2 text-[11px] text-text-dim">
|
||||||
Showing {filteredUsers.length} of {totalUsers} users
|
Showing {filteredUsers.length} of {totalUsers} users
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -517,21 +504,12 @@ export default function AdminPage() {
|
|||||||
{/* ═══ MODAL ═══ */}
|
{/* ═══ MODAL ═══ */}
|
||||||
{modal.show && (
|
{modal.show && (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
|
||||||
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,
|
|
||||||
}}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
|
||||||
>
|
>
|
||||||
<div
|
<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)]">
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title */}
|
{/* 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 === 'add' && '➕ Add User'}
|
||||||
{modal.type === 'edit' && '✏️ Edit User'}
|
{modal.type === 'edit' && '✏️ Edit User'}
|
||||||
{modal.type === 'reset' && '🔑 Reset Password'}
|
{modal.type === 'reset' && '🔑 Reset Password'}
|
||||||
@@ -540,30 +518,30 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
{modal.type === 'add' && (
|
{modal.type === 'add' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Username</div>
|
<div className={labelClass}>Username</div>
|
||||||
<input style={inputStyle} value={formData.username}
|
<input className={inputClass} value={formData.username}
|
||||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Email</div>
|
<div className={labelClass}>Email</div>
|
||||||
<input style={inputStyle} type="email" value={formData.email}
|
<input className={inputClass} type="email" value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Password (8+ chars, 1 number, 1 special)</div>
|
<div className={labelClass}>Password (8+ chars, 1 number, 1 special)</div>
|
||||||
<input style={inputStyle} type="password" value={formData.password}
|
<input className={inputClass} type="password" value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Display Name</div>
|
<div className={labelClass}>Display Name</div>
|
||||||
<input style={inputStyle} value={formData.display_name}
|
<input className={inputClass} value={formData.display_name}
|
||||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Role</div>
|
<div className={labelClass}>Role</div>
|
||||||
<select style={{ ...inputStyle, cursor: 'pointer' }}
|
<select className={`${inputClass} cursor-pointer`}
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
||||||
<option value="admin">🛡️ Admin</option>
|
<option value="admin">🛡️ Admin</option>
|
||||||
@@ -575,24 +553,24 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{modal.type === 'edit' && (
|
{modal.type === 'edit' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Username</div>
|
<div className={labelClass}>Username</div>
|
||||||
<input style={{ ...inputStyle, opacity: 0.5 }} value={formData.username} disabled />
|
<input className={`${inputClass} opacity-50`} value={formData.username} disabled />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Email</div>
|
<div className={labelClass}>Email</div>
|
||||||
<input style={inputStyle} type="email" value={formData.email}
|
<input className={inputClass} type="email" value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Display Name</div>
|
<div className={labelClass}>Display Name</div>
|
||||||
<input style={inputStyle} value={formData.display_name}
|
<input className={inputClass} value={formData.display_name}
|
||||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>Role</div>
|
<div className={labelClass}>Role</div>
|
||||||
<select style={{ ...inputStyle, cursor: 'pointer' }}
|
<select className={`${inputClass} cursor-pointer`}
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
||||||
<option value="admin">🛡️ Admin</option>
|
<option value="admin">🛡️ Admin</option>
|
||||||
@@ -605,13 +583,13 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{modal.type === 'reset' && (
|
{modal.type === 'reset' && (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 12, color: '#8b949e', margin: '0 0 12px 0' }}>
|
<p className="mb-3 text-xs text-text-secondary">
|
||||||
Reset password for <strong style={{ color: '#f0f6fc' }}>{modal.targetUser?.username}</strong>.
|
Reset password for <strong className="text-text-heading">{modal.targetUser?.username}</strong>.
|
||||||
All active sessions will be revoked.
|
All active sessions will be revoked.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<div style={labelStyle}>New Password (8+ chars, 1 number, 1 special)</div>
|
<div className={labelClass}>New Password (8+ chars, 1 number, 1 special)</div>
|
||||||
<input style={inputStyle} type="password" value={formData.password}
|
<input className={inputClass} type="password" value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,7 +597,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{modal.type === 'delete' && (
|
{modal.type === 'delete' && (
|
||||||
<div>
|
<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>.
|
⚠️ This will permanently delete user <strong>{modal.targetUser?.username}</strong>.
|
||||||
Watchlist, credentials, and sessions will be removed. This cannot be undone!
|
Watchlist, credentials, and sessions will be removed. This cannot be undone!
|
||||||
</p>
|
</p>
|
||||||
@@ -627,14 +605,14 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{formError && <p style={{ color: '#f85149', fontSize: 12, marginTop: 8 }}>{formError}</p>}
|
{formError && <p className="mt-2 text-xs text-red">{formError}</p>}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button style={btnStyle} onClick={closeModal}>Cancel</button>
|
<button className={btnClass} onClick={closeModal}>Cancel</button>
|
||||||
{modal.type === 'add' && (
|
{modal.type === 'add' && (
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636' }}
|
className={`${btnClass} border-[#238636] bg-[#238636] text-white`}
|
||||||
onClick={handleAddUser}
|
onClick={handleAddUser}
|
||||||
disabled={!formData.username || !formData.email || formData.password.length < 8}
|
disabled={!formData.username || !formData.email || formData.password.length < 8}
|
||||||
>
|
>
|
||||||
@@ -643,7 +621,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
{modal.type === 'edit' && (
|
{modal.type === 'edit' && (
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}
|
className={`${btnClass} border-accent-blue bg-accent-blue text-white`}
|
||||||
onClick={handleEditUser}
|
onClick={handleEditUser}
|
||||||
>
|
>
|
||||||
💾 Save Changes
|
💾 Save Changes
|
||||||
@@ -651,7 +629,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
{modal.type === 'reset' && (
|
{modal.type === 'reset' && (
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, background: '#d29922', color: '#fff', borderColor: '#d29922' }}
|
className={`${btnClass} border-yellow bg-yellow text-white`}
|
||||||
onClick={handleResetPassword}
|
onClick={handleResetPassword}
|
||||||
disabled={!formData.password || formData.password.length < 8}
|
disabled={!formData.password || formData.password.length < 8}
|
||||||
>
|
>
|
||||||
@@ -660,7 +638,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
{modal.type === 'delete' && (
|
{modal.type === 'delete' && (
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, background: '#da3633', color: '#fff', borderColor: '#da3633' }}
|
className={`${btnClass} border-[#da3633] bg-[#da3633] text-white`}
|
||||||
onClick={handleDeleteUser}
|
onClick={handleDeleteUser}
|
||||||
>
|
>
|
||||||
🗑️ Delete Forever
|
🗑️ Delete Forever
|
||||||
@@ -677,48 +655,48 @@ export default function AdminPage() {
|
|||||||
{/* ═══ EXCHANGES TAB ═══ */}
|
{/* ═══ EXCHANGES TAB ═══ */}
|
||||||
{/* ════════════════════════════════════════════════════ */}
|
{/* ════════════════════════════════════════════════════ */}
|
||||||
{activeTab === 'exchanges' && (
|
{activeTab === 'exchanges' && (
|
||||||
<div style={{ padding: 16 }}>
|
<div className="p-4">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: 0 }}>Exchange Management</h3>
|
<h3 className="m-0 text-sm text-text-heading">Exchange Management</h3>
|
||||||
<button style={btnStyle} onClick={() => setShowAddEx(!showAddEx)}>
|
<button className={btnClass} onClick={() => setShowAddEx(!showAddEx)}>
|
||||||
{showAddEx ? '✕ Cancel' : '+ Add Exchange'}
|
{showAddEx ? '✕ Cancel' : '+ Add Exchange'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddEx && (
|
{showAddEx && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 12, background: '#161b22', borderRadius: 8, marginBottom: 12, border: '1px solid #30363d' }}>
|
<div className="mb-3 flex flex-col gap-2 rounded-lg border border-border-default bg-bg-surface p-3">
|
||||||
<input style={inputStyle} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
|
<input className={inputClass} 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 className={inputClass} 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 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 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 })} />
|
<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 style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', marginTop: 4 }} onClick={handleAddExchange}>
|
<button className={`${btnClass} mt-1 border-[#238636] bg-[#238636] text-white`} onClick={handleAddExchange}>
|
||||||
✅ Create Exchange
|
✅ Create Exchange
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{exLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading exchanges...</p>}
|
{exLoading && <p className="text-[13px] text-text-dim">Loading exchanges...</p>}
|
||||||
{!exLoading && (
|
{!exLoading && (
|
||||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Name</th>
|
<th className={thClass}>Name</th>
|
||||||
<th style={thStyle}>Display</th>
|
<th className={thClass}>Display</th>
|
||||||
<th style={thStyle}>Status</th>
|
<th className={thClass}>Status</th>
|
||||||
<th style={thStyle}>Action</th>
|
<th className={thClass}>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{exchanges.map((ex) => (
|
{exchanges.map((ex) => (
|
||||||
<tr key={ex.id} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={ex.id} className="border-b border-border-muted">
|
||||||
<td style={tdStyle}>{ex.name}</td>
|
<td className={tdClass}>{ex.name}</td>
|
||||||
<td style={tdStyle}>{ex.display_name || ex.name}</td>
|
<td className={tdClass}>{ex.display_name || ex.name}</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
{ex.is_active ? <span style={badgeGreen}>Active</span> : <span style={badgeRed}>Inactive</span>}
|
{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>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
<button style={cellBtn} onClick={() => toggleExchange(ex)}>
|
<button className={cellBtnClass} onClick={() => toggleExchange(ex)}>
|
||||||
{ex.is_active ? 'Deactivate' : 'Activate'}
|
{ex.is_active ? 'Deactivate' : 'Activate'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -733,72 +711,72 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* ═══ HEALTH TAB ═══ */}
|
{/* ═══ HEALTH TAB ═══ */}
|
||||||
{activeTab === 'health' && (
|
{activeTab === 'health' && (
|
||||||
<div style={{ padding: 16 }}>
|
<div className="p-4">
|
||||||
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: '0 0 12px 0' }}>System Health</h3>
|
<h3 className="mb-3 text-sm text-text-heading">System Health</h3>
|
||||||
{healthLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading health data...</p>}
|
{healthLoading && <p className="text-[13px] text-text-dim">Loading health data...</p>}
|
||||||
{!healthLoading && !health && (
|
{!healthLoading && !health && (
|
||||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#484f58', fontSize: 13, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
<div className="rounded-lg border border-border-default bg-bg-surface px-4 py-6 text-center text-[13px] text-text-dim">
|
||||||
<div style={{ fontSize: 24, marginBottom: 8 }}>⚠️</div>
|
<div className="mb-2 text-2xl">⚠️</div>
|
||||||
Unable to fetch health data. The health endpoint may not be available.
|
Unable to fetch health data. The health endpoint may not be available.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{health && (
|
{health && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div className="flex flex-col gap-4">
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
<div className="flex flex-wrap gap-4">
|
||||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>STATUS</div>
|
<div className="mb-1 text-[11px] text-text-dim">STATUS</div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: health.status === 'healthy' ? '#3fb950' : '#f85149' }}>
|
<div className={`text-lg font-bold ${health.status === 'healthy' ? 'text-green' : 'text-red'}`}>
|
||||||
{health.status === 'healthy' ? '✅ Healthy' : '⚠️ Degraded'}
|
{health.status === 'healthy' ? '✅ Healthy' : '⚠️ Degraded'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>UPTIME</div>
|
<div className="mb-1 text-[11px] text-text-dim">UPTIME</div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{formatUptime(health.uptime)}</div>
|
<div className="text-lg font-bold text-text-heading">{formatUptime(health.uptime)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>VERSION</div>
|
<div className="mb-1 text-[11px] text-text-dim">VERSION</div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{health.version}</div>
|
<div className="text-lg font-bold text-text-heading">{health.version}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
<div className="rounded-lg border border-border-default bg-bg-surface p-3">
|
||||||
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Database</h4>
|
<h4 className="mb-2 text-[13px] text-text-heading">Database</h4>
|
||||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Connected</th>
|
<th className={thClass}>Connected</th>
|
||||||
<th style={thStyle}>Latency</th>
|
<th className={thClass}>Latency</th>
|
||||||
<th style={thStyle}>Pool Size</th>
|
<th className={thClass}>Pool Size</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={tdStyle}>{health.db.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}</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 style={tdStyle}>{health.db.latency_ms} ms</td>
|
<td className={tdClass}>{health.db.latency_ms} ms</td>
|
||||||
<td style={tdStyle}>{health.db.pool_size} connections</td>
|
<td className={tdClass}>{health.db.pool_size} connections</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
<div className="rounded-lg border border-border-default bg-bg-surface p-3">
|
||||||
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Exchange Connections</h4>
|
<h4 className="mb-2 text-[13px] text-text-heading">Exchange Connections</h4>
|
||||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Exchange</th>
|
<th className={thClass}>Exchange</th>
|
||||||
<th style={thStyle}>Status</th>
|
<th className={thClass}>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{health.exchange_connections.map((ec) => (
|
{health.exchange_connections.map((ec) => (
|
||||||
<tr key={ec.exchange} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={ec.exchange} className="border-b border-border-muted">
|
||||||
<td style={tdStyle}>{ec.exchange}</td>
|
<td className={tdClass}>{ec.exchange}</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
{ec.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}
|
{ec.connected ? <span className="text-xs font-semibold text-green">● Connected</span> : <span className="text-xs font-semibold text-red">● Disconnected</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -813,10 +791,3 @@ export default function AdminPage() {
|
|||||||
</div>
|
</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',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -11,54 +11,12 @@ import {
|
|||||||
type ConditionObject,
|
type ConditionObject,
|
||||||
} from '../api/alertApi';
|
} from '../api/alertApi';
|
||||||
|
|
||||||
// ── Styles ──
|
// ── Shared Tailwind class fragments ──
|
||||||
const pageStyle: React.CSSProperties = {
|
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';
|
||||||
minHeight: '100vh', background: '#0d1117', color: '#c9d1d9',
|
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';
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
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';
|
||||||
padding: '24px',
|
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 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',
|
|
||||||
};
|
|
||||||
|
|
||||||
const INDICATOR_OPTIONS = [
|
const INDICATOR_OPTIONS = [
|
||||||
{ value: 'rsi', label: 'RSI' },
|
{ value: 'rsi', label: 'RSI' },
|
||||||
@@ -233,21 +191,25 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
if (initializing) {
|
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 (
|
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 ─── */}
|
||||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
|
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div className="flex items-center gap-3">
|
||||||
<span style={{ fontSize: 18 }}>🔔</span>
|
<span className="text-lg">🔔</span>
|
||||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Smart Alerts</span>
|
<span className="text-base font-bold text-text-heading">Smart Alerts</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div className="flex items-center gap-2">
|
||||||
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }}
|
className={`${btnClass} border-red/20 text-red`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await dispatch(logoutThunk());
|
await dispatch(logoutThunk());
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
@@ -261,87 +223,73 @@ export default function AlertsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ padding: '16px 24px' }}>
|
<div className="p-4 px-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={headerStyle}>
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>🔔 Smart Alerts</h2>
|
<h2 className="m-0 text-xl font-semibold">🔔 Smart Alerts</h2>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div className="flex items-center gap-2">
|
||||||
<button style={primaryBtn} onClick={openCreateModal}>+ New Alert</button>
|
<button className={primaryBtnClass} onClick={openCreateModal}>+ New Alert</button>
|
||||||
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ padding: '8px 12px', background: 'rgba(248,81,73,0.15)', borderRadius: 6, marginBottom: 16, fontSize: 12, color: '#f85149' }}>
|
<div className="mb-4 rounded-md bg-red/15 px-3 py-2 text-xs text-red">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{loading && <p style={{ textAlign: 'center', color: '#8b949e' }}>Loading alerts...</p>}
|
{loading && <p className="text-center text-text-secondary">Loading alerts...</p>}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!loading && alerts.length === 0 && (
|
{!loading && alerts.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#8b949e' }}>
|
<div className="pt-16 text-center text-text-secondary">
|
||||||
<p style={{ fontSize: 40, margin: 0 }}>🔔</p>
|
<p className="m-0 text-4xl">🔔</p>
|
||||||
<p>No alerts configured yet.</p>
|
<p>No alerts configured yet.</p>
|
||||||
<button style={primaryBtn} onClick={openCreateModal}>Create your first alert</button>
|
<button className={primaryBtnClass} onClick={openCreateModal}>Create your first alert</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert cards */}
|
{/* Alert cards */}
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div key={alert.id} style={cardStyle}>
|
<div key={alert.id} className="mb-3 rounded-lg border border-border-default bg-bg-surface p-4">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="flex items-center gap-2">
|
||||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{alert.name}</h3>
|
<h3 className="m-0 text-sm font-semibold">{alert.name}</h3>
|
||||||
<span style={{
|
<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'}`}>
|
||||||
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'}
|
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 10, color: '#8b949e' }}>via {alert.notify_platform}</span>
|
<span className="text-[10px] text-text-secondary">via {alert.notify_platform}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<button
|
<button
|
||||||
style={{
|
className={`relative h-5 w-9 cursor-pointer rounded-full border-none transition-colors ${alert.is_active ? 'bg-accent-blue' : 'bg-bg-hover'}`}
|
||||||
...toggleStyle,
|
|
||||||
background: alert.is_active ? '#1f6feb' : '#21262d',
|
|
||||||
}}
|
|
||||||
onClick={() => handleToggle(alert)}
|
onClick={() => handleToggle(alert)}
|
||||||
title={alert.is_active ? 'Pause' : 'Activate'}
|
title={alert.is_active ? 'Pause' : 'Activate'}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span
|
||||||
position: 'absolute', top: 2, width: 16, height: 16,
|
className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-[left]"
|
||||||
borderRadius: '50%', background: '#fff',
|
style={{ left: alert.is_active ? 18 : 2 }}
|
||||||
left: alert.is_active ? 18 : 2,
|
/>
|
||||||
transition: 'left 0.2s',
|
|
||||||
}} />
|
|
||||||
</button>
|
</button>
|
||||||
<button style={btnStyle} onClick={() => openEditModal(alert)}>Edit</button>
|
<button className={btnClass} onClick={() => openEditModal(alert)}>Edit</button>
|
||||||
<button style={dangerBtn} onClick={() => handleDelete(alert)}>Delete</button>
|
<button className={dangerBtnClass} onClick={() => handleDelete(alert)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>Conditions:</div>
|
<div className="mb-1 text-[11px] text-text-secondary">Conditions:</div>
|
||||||
{alert.conditions.map((cond, idx) => (
|
{alert.conditions.map((cond, idx) => (
|
||||||
<div key={idx} style={{
|
<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">
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
<span className="text-accent">{cond.indicator}</span>
|
||||||
padding: '2px 8px', background: '#0d1117', borderRadius: 4,
|
<span className="text-yellow">{cond.operator}</span>
|
||||||
margin: '2px 4px 2px 0', fontSize: 11, color: '#c9d1d9',
|
<span className="text-green">{cond.value}</span>
|
||||||
border: '1px solid #30363d',
|
{cond.timeframe && <span className="text-text-secondary">({cond.timeframe})</span>}
|
||||||
}}>
|
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -349,17 +297,20 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
{/* ── Create/Edit Modal ── */}
|
{/* ── Create/Edit Modal ── */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div style={modalOverlay} onClick={closeModal}>
|
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60" onClick={closeModal}>
|
||||||
<div style={modalContent} onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
|
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'}
|
{editTarget ? 'Edit Alert' : 'New Alert'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div className="mb-3">
|
||||||
<div style={labelStyle}>Name</div>
|
<div className={labelClass}>Name</div>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
className={inputClass}
|
||||||
value={formName}
|
value={formName}
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
placeholder="e.g. RSI overbought + volume spike"
|
placeholder="e.g. RSI overbought + volume spike"
|
||||||
@@ -367,10 +318,10 @@ export default function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform */}
|
{/* Platform */}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div className="mb-3">
|
||||||
<div style={labelStyle}>Notify via</div>
|
<div className={labelClass}>Notify via</div>
|
||||||
<select
|
<select
|
||||||
style={selectStyle}
|
className={inputClass}
|
||||||
value={formPlatform}
|
value={formPlatform}
|
||||||
onChange={(e) => setFormPlatform(e.target.value)}
|
onChange={(e) => setFormPlatform(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -381,20 +332,20 @@ export default function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div className="mb-3">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span style={labelStyle}>Conditions (all must pass)</span>
|
<span className={labelClass}>Conditions (all must pass)</span>
|
||||||
<button style={btnStyle} onClick={addCondition}>+ Add Condition</button>
|
<button className={btnClass} onClick={addCondition}>+ Add Condition</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formConditions.map((cond, idx) => (
|
{formConditions.map((cond, idx) => (
|
||||||
<div key={idx} style={{
|
<div
|
||||||
display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap',
|
key={idx}
|
||||||
padding: '8px 0', borderBottom: idx < formConditions.length - 1 ? '1px solid #21262d' : 'none',
|
className={`flex flex-wrap items-center gap-1.5 py-2 ${idx < formConditions.length - 1 ? 'border-b border-border-muted' : ''}`}
|
||||||
}}>
|
>
|
||||||
{/* Indicator */}
|
{/* Indicator */}
|
||||||
<select
|
<select
|
||||||
style={{ ...selectStyle, width: 110 }}
|
className={`${inputClass} w-[110px]`}
|
||||||
value={cond.indicator}
|
value={cond.indicator}
|
||||||
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
|
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -405,7 +356,7 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
{/* Operator */}
|
{/* Operator */}
|
||||||
<select
|
<select
|
||||||
style={{ ...selectStyle, width: 100 }}
|
className={`${inputClass} w-[100px]`}
|
||||||
value={cond.operator}
|
value={cond.operator}
|
||||||
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
|
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -416,7 +367,7 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
{/* Value */}
|
{/* Value */}
|
||||||
<input
|
<input
|
||||||
style={{ ...inputStyle, width: 80 }}
|
className={`${inputClass} w-20`}
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={cond.value}
|
value={cond.value}
|
||||||
@@ -425,7 +376,7 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
{/* Timeframe */}
|
{/* Timeframe */}
|
||||||
<select
|
<select
|
||||||
style={{ ...selectStyle, width: 70 }}
|
className={`${inputClass} w-[70px]`}
|
||||||
value={cond.timeframe || ''}
|
value={cond.timeframe || ''}
|
||||||
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
|
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
|
||||||
>
|
>
|
||||||
@@ -438,7 +389,7 @@ export default function AlertsPage() {
|
|||||||
{cond.indicator === 'volume' && (
|
{cond.indicator === 'volume' && (
|
||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
style={{ ...selectStyle, width: 110 }}
|
className={`${inputClass} w-[110px]`}
|
||||||
value={cond.type || 'absolute'}
|
value={cond.type || 'absolute'}
|
||||||
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
|
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -447,7 +398,7 @@ export default function AlertsPage() {
|
|||||||
</select>
|
</select>
|
||||||
{cond.type === 'avg_multiplier' && (
|
{cond.type === 'avg_multiplier' && (
|
||||||
<input
|
<input
|
||||||
style={{ ...inputStyle, width: 60 }}
|
className={`${inputClass} w-[60px]`}
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Period"
|
placeholder="Period"
|
||||||
value={cond.period || 20}
|
value={cond.period || 20}
|
||||||
@@ -460,7 +411,7 @@ export default function AlertsPage() {
|
|||||||
{/* Extra for sma/ema */}
|
{/* Extra for sma/ema */}
|
||||||
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
|
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
|
||||||
<input
|
<input
|
||||||
style={{ ...inputStyle, width: 60 }}
|
className={`${inputClass} w-[60px]`}
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Period"
|
placeholder="Period"
|
||||||
value={cond.period || 20}
|
value={cond.period || 20}
|
||||||
@@ -470,7 +421,7 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
{/* Remove */}
|
{/* Remove */}
|
||||||
<button
|
<button
|
||||||
style={{ ...dangerBtn, padding: '2px 8px', fontSize: 10 }}
|
className={`${dangerBtnClass} px-2 py-0.5 text-[10px]`}
|
||||||
onClick={() => removeCondition(idx)}
|
onClick={() => removeCondition(idx)}
|
||||||
disabled={formConditions.length <= 1}
|
disabled={formConditions.length <= 1}
|
||||||
>
|
>
|
||||||
@@ -482,16 +433,17 @@ export default function AlertsPage() {
|
|||||||
|
|
||||||
{/* Form error */}
|
{/* Form error */}
|
||||||
{formError && (
|
{formError && (
|
||||||
<div style={{ padding: '6px 10px', background: 'rgba(248,81,73,0.15)', borderRadius: 4, marginBottom: 12, fontSize: 11, color: '#f85149' }}>
|
<div className="mb-3 rounded bg-red/15 px-2.5 py-1.5 text-[11px] text-red">
|
||||||
{formError}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div className="flex justify-end gap-2">
|
||||||
<button style={btnStyle} onClick={closeModal}>Cancel</button>
|
<button className={btnClass} onClick={closeModal}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}
|
className={primaryBtnClass}
|
||||||
|
style={{ opacity: saving ? 0.6 : 1 }}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,83 +1,53 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||||
import { fetchCurrentUser } from '../auth/authSlice';
|
import { fetchCurrentUser } from '../auth/authSlice';
|
||||||
import { logoutThunk } from '../auth/authSlice';
|
import { logoutThunk } from '../auth/authSlice';
|
||||||
import { useT } from '../../translations';
|
import { useT } from '../../translations';
|
||||||
|
import { apiFetch } from '../api/apiService';
|
||||||
|
|
||||||
// ── Mock/temporary analytics data ──
|
// ── Types matching backend GET /analytics/dashboard ──
|
||||||
// These would be replaced by real API calls. For now, generate realistic demo data.
|
interface PnlHistoryPoint {
|
||||||
interface DailyPnL {
|
|
||||||
date: string;
|
date: string;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
isSignal: boolean;
|
cumulative: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnalyticsStats {
|
interface EquityPoint {
|
||||||
totalPnL: number;
|
date: string;
|
||||||
winRate: number;
|
equity: number;
|
||||||
bestTrade: number;
|
}
|
||||||
worstTrade: number;
|
|
||||||
profitFactor: number;
|
interface WinRatePeriod {
|
||||||
maxDrawdown: number;
|
trades: number;
|
||||||
totalTrades: number;
|
|
||||||
wins: number;
|
wins: number;
|
||||||
losses: number;
|
win_rate: number;
|
||||||
|
pnl: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateDemoData(isSignal: boolean): { dailyPnL: DailyPnL[]; stats: AnalyticsStats } {
|
interface BestWorstTrade {
|
||||||
const dailyPnL: DailyPnL[] = [];
|
symbol: string;
|
||||||
let equity = 10000;
|
pnl: number;
|
||||||
let peak = 10000;
|
date: string;
|
||||||
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 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 ──
|
// ── Simple SVG Bar Chart ──
|
||||||
function BarChart({
|
function BarChart({
|
||||||
data,
|
data,
|
||||||
@@ -85,7 +55,7 @@ function BarChart({
|
|||||||
colorUp = '#3fb950',
|
colorUp = '#3fb950',
|
||||||
colorDown = '#f85149',
|
colorDown = '#f85149',
|
||||||
}: {
|
}: {
|
||||||
data: DailyPnL[];
|
data: PnlHistoryPoint[];
|
||||||
height?: number;
|
height?: number;
|
||||||
colorUp?: string;
|
colorUp?: string;
|
||||||
colorDown?: string;
|
colorDown?: string;
|
||||||
@@ -129,21 +99,22 @@ function LineChart({
|
|||||||
height = 200,
|
height = 200,
|
||||||
lineColor = '#58a6ff',
|
lineColor = '#58a6ff',
|
||||||
}: {
|
}: {
|
||||||
equityData: number[];
|
equityData: EquityPoint[];
|
||||||
height?: number;
|
height?: number;
|
||||||
lineColor?: string;
|
lineColor?: string;
|
||||||
}) {
|
}) {
|
||||||
if (equityData.length < 2) return null;
|
if (equityData.length < 2) return null;
|
||||||
|
|
||||||
const min = Math.min(...equityData);
|
const values = equityData.map((e) => e.equity);
|
||||||
const max = Math.max(...equityData);
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
const range = max - min || 1;
|
const range = max - min || 1;
|
||||||
const padding = 20;
|
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) => {
|
.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);
|
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
})
|
})
|
||||||
@@ -165,13 +136,13 @@ function LineChart({
|
|||||||
{/* Line */}
|
{/* Line */}
|
||||||
<polyline points={points} fill="none" stroke={lineColor} strokeWidth={2} strokeLinejoin="round" />
|
<polyline points={points} fill="none" stroke={lineColor} strokeWidth={2} strokeLinejoin="round" />
|
||||||
{/* Dots on line */}
|
{/* Dots on line */}
|
||||||
{equityData.map((val, i) => {
|
{values.map((val, i) => {
|
||||||
if (i % 5 !== 0 && i !== equityData.length - 1) return null;
|
if (i % 5 !== 0 && i !== values.length - 1) return null;
|
||||||
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);
|
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||||
return (
|
return (
|
||||||
<circle key={i} cx={x} cy={y} r={3} fill={lineColor} opacity={0.8}>
|
<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>
|
</circle>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -190,25 +161,11 @@ function StatCard({ label, value, color, isCurrency }: {
|
|||||||
? `${Number(value) >= 0 ? '+' : ''}$${Number(value).toFixed(2)}`
|
? `${Number(value) >= 0 ? '+' : ''}$${Number(value).toFixed(2)}`
|
||||||
: value;
|
: value;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="min-w-0 rounded-lg border border-border-default bg-bg-surface px-3.5 py-3">
|
||||||
background: '#161b22',
|
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-text-secondary">
|
||||||
border: '1px solid #30363d',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '12px 14px',
|
|
||||||
minWidth: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-lg font-bold" style={{ color: color || '#f0f6fc' }}>
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
|
||||||
color: color || '#f0f6fc',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap' as const,
|
|
||||||
}}>
|
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,15 +180,10 @@ export default function AnalyticsPage() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
const [dataMode, setDataMode] = useState<'signal' | 'real'>('signal');
|
const [days, setDays] = useState<(typeof DAY_RANGES)[number]>(30);
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||||
|
const [loadingData, setLoadingData] = useState(false);
|
||||||
// Responsive
|
const [error, setError] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auth guard
|
// Auth guard
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -245,216 +197,178 @@ export default function AnalyticsPage() {
|
|||||||
setInitializing(false);
|
setInitializing(false);
|
||||||
}, [isAuthenticated, user, dispatch, navigate]);
|
}, [isAuthenticated, user, dispatch, navigate]);
|
||||||
|
|
||||||
// Generate demo data based on toggle
|
const loadDashboard = useCallback(async () => {
|
||||||
const demoData = useMemo(() => generateDemoData(dataMode === 'signal'), [dataMode]);
|
setLoadingData(true);
|
||||||
const equityCurve = useMemo(() => {
|
setError(null);
|
||||||
let eq = 10000;
|
try {
|
||||||
return demoData.dailyPnL.map((d) => {
|
const result = await apiFetch<DashboardResponse>(`/analytics/dashboard?days=${days}`);
|
||||||
eq += d.pnl;
|
setData(result);
|
||||||
return Math.round(eq * 100) / 100;
|
} catch (err: any) {
|
||||||
});
|
setError(err?.message || 'Failed to load analytics');
|
||||||
}, [demoData]);
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializing) loadDashboard();
|
||||||
|
}, [initializing, loadDashboard]);
|
||||||
|
|
||||||
const { t } = useT(); // eslint-disable-line
|
const { t } = useT(); // eslint-disable-line
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
if (initializing || isLoading) {
|
if (initializing || isLoading) {
|
||||||
return (
|
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...
|
Loading analytics...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navBtn: React.CSSProperties = {
|
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';
|
||||||
padding: '6px 16px',
|
const toggleBtnClass = (active: boolean) =>
|
||||||
fontSize: 12,
|
`inline-flex min-h-9 touch-manipulation items-center rounded-md border px-4 py-1.5 text-xs transition-colors ${
|
||||||
border: '1px solid #30363d',
|
active ? 'border-accent-blue bg-accent-blue font-semibold text-white' : 'border-border-default bg-bg-hover font-normal text-text-primary'
|
||||||
borderRadius: 6,
|
}`;
|
||||||
background: '#21262d',
|
|
||||||
color: '#c9d1d9',
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBtn = (active: boolean): React.CSSProperties => ({
|
const monthly = data?.win_rate_by_period?.monthly;
|
||||||
padding: '6px 16px',
|
const winRate = monthly?.win_rate ?? 0;
|
||||||
fontSize: 12,
|
const wins = monthly?.wins ?? 0;
|
||||||
border: '1px solid #30363d',
|
const losses = Math.max((monthly?.trades ?? 0) - wins, 0);
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
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 ─── */}
|
||||||
<nav style={{
|
<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">
|
||||||
display: 'flex',
|
<div className="flex shrink-0 cursor-pointer items-center gap-3" onClick={() => navigate('/dashboard')}>
|
||||||
alignItems: 'center',
|
<span className="text-lg">📈</span>
|
||||||
justifyContent: 'space-between',
|
<span className="text-base font-bold text-text-heading">Analytics</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>👤 {user?.username}</span>
|
<span className="whitespace-nowrap text-xs text-text-secondary">👤 {user?.username}</span>
|
||||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
<button className={navBtnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/profile')}>Profile</button>
|
<button className={navBtnClass} onClick={() => navigate('/profile')}>Profile</button>
|
||||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }} onClick={async () => {
|
<button
|
||||||
|
className={`${navBtnClass} border-red/20 text-red`}
|
||||||
|
onClick={async () => {
|
||||||
await dispatch(logoutThunk());
|
await dispatch(logoutThunk());
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
🚪 Logout
|
🚪 Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ─── PAGE CONTENT ─── */}
|
{/* ─── PAGE CONTENT ─── */}
|
||||||
<div style={{ padding: isMobile ? 12 : 20, maxWidth: 960, margin: '0 auto' }}>
|
<div className="mx-auto max-w-[960px] p-3 md:p-5">
|
||||||
{/* Header + Toggle */}
|
{/* Header + Range picker */}
|
||||||
<div style={{
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-2">
|
||||||
display: 'flex',
|
<h2 className="m-0 text-lg font-bold text-text-heading">
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
flexWrap: 'wrap' as const,
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 20,
|
|
||||||
}}>
|
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc', margin: 0 }}>
|
|
||||||
📊 Performance Overview
|
📊 Performance Overview
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div className="flex gap-1">
|
||||||
<button
|
{DAY_RANGES.map((d) => (
|
||||||
style={toggleBtn(dataMode === 'signal')}
|
<button key={d} className={toggleBtnClass(days === d)} onClick={() => setDays(d)}>
|
||||||
onClick={() => setDataMode('signal')}
|
{d}d
|
||||||
>
|
|
||||||
📈 Signal
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={toggleBtn(dataMode === 'real')}
|
|
||||||
onClick={() => setDataMode('real')}
|
|
||||||
>
|
|
||||||
💹 Real
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red bg-[#2d0e0e] px-3.5 py-2.5 text-[13px] text-red">
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingData && !data && (
|
||||||
|
<div className="py-5 text-[13px] text-text-secondary">Loading performance data...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
{/* ─── STATS CARDS GRID ─── */}
|
{/* ─── STATS CARDS GRID ─── */}
|
||||||
<div style={{
|
<div className="mb-6 grid grid-cols-2 gap-2 md:grid-cols-6 md:gap-2.5">
|
||||||
display: 'grid',
|
<StatCard label="Total P&L" value={data.total_pnl} color={data.total_pnl >= 0 ? '#3fb950' : '#f85149'} isCurrency />
|
||||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(6, 1fr)',
|
<StatCard label="Win Rate (30d)" value={`${winRate}%`} color={winRate >= 50 ? '#3fb950' : '#f85149'} />
|
||||||
gap: isMobile ? 8 : 10,
|
<StatCard label="Best Trade" value={data.best_trade.pnl} color="#3fb950" isCurrency />
|
||||||
marginBottom: 24,
|
<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="Total P&L" value={s.totalPnL} color={s.totalPnL >= 0 ? '#3fb950' : '#f85149'} isCurrency />
|
<StatCard label="Max Drawdown" value={`${data.drawdown.max_drawdown_pct}%`} color="#d29922" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* ─── PnL Bar Chart ─── */}
|
{/* ─── PnL Bar Chart ─── */}
|
||||||
<div style={{
|
<div className="mb-4 overflow-hidden rounded-[10px] border border-border-default bg-bg-surface px-3 py-4">
|
||||||
background: '#161b22',
|
<h3 className="mb-3 ml-1 text-[13px] uppercase tracking-wide text-text-secondary">
|
||||||
border: '1px solid #30363d',
|
📊 Daily P&L (Last {days} Days)
|
||||||
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>
|
</h3>
|
||||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
{data.pnl_history.length === 0 ? (
|
||||||
<BarChart data={demoData.dailyPnL} height={180} />
|
<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>
|
||||||
|
|
||||||
{/* ─── Equity Curve ─── */}
|
{/* ─── Equity Curve ─── */}
|
||||||
<div style={{
|
<div className="mb-4 overflow-hidden rounded-[10px] border border-border-default bg-bg-surface px-3 py-4">
|
||||||
background: '#161b22',
|
<h3 className="mb-3 ml-1 text-[13px] uppercase tracking-wide text-text-secondary">
|
||||||
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
|
📈 Equity Curve
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'baseline', marginBottom: 8, marginLeft: 4 }}>
|
{data.equity_curve.length < 2 ? (
|
||||||
<span style={{ fontSize: 12, color: '#8b949e' }}>Starting: <strong style={{ color: '#c9d1d9' }}>$10,000.00</strong></span>
|
<div className="py-5 text-center text-xs text-text-dim">Not enough closed trades yet to plot an equity curve.</div>
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, color: s.totalPnL >= 0 ? '#3fb950' : '#f85149' }}>
|
) : (
|
||||||
{s.totalPnL >= 0 ? '+' : ''}${s.totalPnL.toFixed(2)}
|
<>
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
<div className="scrollable-x overflow-x-auto">
|
||||||
<LineChart equityData={equityCurve} height={200} />
|
<LineChart equityData={data.equity_curve} height={200} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Summary Trades Table ─── */}
|
{/* ─── Summary Trades Table ─── */}
|
||||||
<div style={{
|
<div className="mb-4 rounded-[10px] border border-border-default bg-bg-surface p-4">
|
||||||
background: '#161b22',
|
<h3 className="mb-3 text-[13px] uppercase tracking-wide text-text-secondary">
|
||||||
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
|
📋 Trade Summary
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(4, 1fr)',
|
|
||||||
gap: 12,
|
|
||||||
}}>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Total Trades</div>
|
<div className="text-[11px] text-text-muted">Total Trades (all-time)</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{s.totalTrades}</div>
|
<div className="text-xl font-bold text-text-heading">{data.total_trades}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Wins</div>
|
<div className="text-[11px] text-text-muted">Wins (30d)</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{s.wins}</div>
|
<div className="text-xl font-bold text-green">{wins}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Losses</div>
|
<div className="text-[11px] text-text-muted">Losses (30d)</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f85149' }}>{s.losses}</div>
|
<div className="text-xl font-bold text-red">{losses}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Data Source</div>
|
<div className="text-[11px] text-text-muted">Open Trades</div>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#58a6ff' }}>
|
<div className="text-base font-semibold text-accent">
|
||||||
{dataMode === 'signal' ? '📈 Signal' : '💹 Real'}
|
{data.open_trades}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Footer note ─── */}
|
{/* ─── Footer note ─── */}
|
||||||
<div style={{ fontSize: 11, color: '#484f58', textAlign: 'center' as const, padding: '16px 0' }}>
|
<div className="px-0 py-4 text-center text-[11px] text-text-dim">
|
||||||
Analytics data is based on {dataMode === 'signal' ? 'simulated signal' : 'real exchange'} trades.
|
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.
|
||||||
Charts update in real-time as new trades are executed.
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** API functions for user-defined multi-condition alerts. */
|
/** API functions for user-defined multi-condition alerts. */
|
||||||
|
import { apiFetch } from './apiService';
|
||||||
|
|
||||||
export interface ConditionObject {
|
export interface ConditionObject {
|
||||||
indicator: string;
|
indicator: string;
|
||||||
@@ -24,18 +25,8 @@ export interface AlertListResponse {
|
|||||||
alerts: AlertItem[];
|
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[]> {
|
export async function fetchAlerts(): Promise<AlertItem[]> {
|
||||||
const res = await fetch('/api/v1/alerts', { headers: getHeaders() });
|
const data = await apiFetch<AlertListResponse>('/alerts');
|
||||||
if (!res.ok) throw new Error(`Failed to fetch alerts: ${res.status}`);
|
|
||||||
const data: AlertListResponse = await res.json();
|
|
||||||
return data.alerts;
|
return data.alerts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +35,10 @@ export async function createAlert(data: {
|
|||||||
conditions: ConditionObject[];
|
conditions: ConditionObject[];
|
||||||
notify_platform: string;
|
notify_platform: string;
|
||||||
}): Promise<AlertItem> {
|
}): Promise<AlertItem> {
|
||||||
const res = await fetch('/api/v1/alerts', {
|
return apiFetch<AlertItem>('/alerts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data),
|
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(
|
export async function updateAlert(
|
||||||
@@ -65,25 +50,12 @@ export async function updateAlert(
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<AlertItem> {
|
): Promise<AlertItem> {
|
||||||
const res = await fetch(`/api/v1/alerts/${id}`, {
|
return apiFetch<AlertItem>(`/alerts/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data),
|
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> {
|
export async function deleteAlert(id: number): Promise<void> {
|
||||||
const res = await fetch(`/api/v1/alerts/${id}`, {
|
await apiFetch<void>(`/alerts/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** API functions for real trades. */
|
/** API functions for real trades. */
|
||||||
|
import { apiFetch } from './apiService';
|
||||||
|
|
||||||
export interface RealTrade {
|
export interface RealTrade {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -46,26 +47,9 @@ export async function fetchRealTrades(
|
|||||||
if (symbol) params.set('symbol', symbol);
|
if (symbol) params.set('symbol', symbol);
|
||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
|
return apiFetch<RealTradeListResponse>(`/real-trades?${params}`);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWinRate(): Promise<WinRateResponse> {
|
export async function fetchWinRate(): Promise<WinRateResponse> {
|
||||||
const token = localStorage.getItem('access_token');
|
return apiFetch<WinRateResponse>('/real-trades/win-rate');
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** API functions for trading signals and hypothetical trades. */
|
/** API functions for trading signals and hypothetical trades. */
|
||||||
|
import { apiFetch } from './apiService';
|
||||||
|
|
||||||
export interface Signal {
|
export interface Signal {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -48,22 +49,11 @@ export interface TradeListResponse {
|
|||||||
win_rate: number | null;
|
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> {
|
export async function fetchSignals(symbol?: string, limit = 50): Promise<SignalListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (symbol) params.set('symbol', symbol);
|
if (symbol) params.set('symbol', symbol);
|
||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
|
return apiFetch<SignalListResponse>(`/signals?${params}`);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTrades(symbol?: string, status?: string, limit = 100): Promise<TradeListResponse> {
|
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 (symbol) params.set('symbol', symbol);
|
||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
|
return apiFetch<TradeListResponse>(`/signals/trades?${params}`);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signal type display config */
|
/** Signal type display config */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** API functions for user watchlist management. */
|
/** API functions for user watchlist management. */
|
||||||
|
import { apiFetch } from './apiService';
|
||||||
|
|
||||||
export interface WatchlistItem {
|
export interface WatchlistItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,45 +19,23 @@ export interface SymbolOption {
|
|||||||
is_active: boolean;
|
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[]> {
|
export async function fetchWatchlist(): Promise<WatchlistItem[]> {
|
||||||
const res = await fetch('/api/v1/watchlist', { headers: getHeaders() });
|
return apiFetch<WatchlistItem[]>('/watchlist');
|
||||||
if (!res.ok) throw new Error(`Failed to fetch watchlist: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToWatchlist(symbolId: number, label?: string): Promise<WatchlistItem> {
|
export async function addToWatchlist(symbolId: number, label?: string): Promise<WatchlistItem> {
|
||||||
const res = await fetch('/api/v1/watchlist', {
|
return apiFetch<WatchlistItem>('/watchlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify({ symbol_id: symbolId, label: label || null }),
|
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> {
|
export async function removeFromWatchlist(id: string): Promise<void> {
|
||||||
const res = await fetch(`/api/v1/watchlist/${id}`, {
|
await apiFetch<void>(`/watchlist/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Failed to remove: ${res.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAllSymbols(exchange = 'mexc', q = ''): Promise<SymbolOption[]> {
|
export async function fetchAllSymbols(exchange = 'mexc', q = ''): Promise<SymbolOption[]> {
|
||||||
const params = new URLSearchParams({ exchange });
|
const params = new URLSearchParams({ exchange });
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
const res = await fetch(`/api/v1/watchlist/all-symbols?${params}`, { headers: getHeaders() });
|
return apiFetch<SymbolOption[]>(`/watchlist/all-symbols?${params}`);
|
||||||
if (!res.ok) throw new Error(`Failed to fetch symbols: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useT } from '../../translations';
|
import { useT } from '../../translations';
|
||||||
|
import { apiFetch } from '../api/apiService';
|
||||||
|
|
||||||
interface SignalCounts {
|
interface SignalCounts {
|
||||||
[key: string]: number;
|
[key: string]: number;
|
||||||
@@ -59,6 +60,11 @@ const SIGNAL_ICONS: Record<string, string> = {
|
|||||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h'];
|
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() {
|
export default function BacktestPage() {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [exchange, setExchange] = useState('binance');
|
const [exchange, setExchange] = useState('binance');
|
||||||
@@ -76,12 +82,7 @@ export default function BacktestPage() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadSymbols() {
|
async function loadSymbols() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const data = await apiFetch<any>(`/backtest/symbols?exchange=${encodeURIComponent(exchange)}`);
|
||||||
`/api/v1/backtest/symbols?exchange=${encodeURIComponent(exchange)}`,
|
|
||||||
{ credentials: 'include' }
|
|
||||||
);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const names = (data.symbols || []).map((s: any) => s.symbol);
|
const names = (data.symbols || []).map((s: any) => s.symbol);
|
||||||
setSymbols(names);
|
setSymbols(names);
|
||||||
@@ -100,15 +101,9 @@ export default function BacktestPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const data = await apiFetch<BacktestResult>(
|
||||||
`/api/v1/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
|
`/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
|
||||||
{ credentials: 'include' }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setResult(data);
|
setResult(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -118,92 +113,89 @@ export default function BacktestPage() {
|
|||||||
}, [symbol, timeframe, days, tradeSize]);
|
}, [symbol, timeframe, days, tradeSize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px', maxWidth: '1000px', margin: '0 auto' }}>
|
<div className="mx-auto max-w-[1000px] bg-bg-primary p-5 text-text-primary">
|
||||||
<h1>📊 {t('Backtest')}</h1>
|
<h1 className="text-2xl font-bold text-text-heading">📊 {t('Backtest')}</h1>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* 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>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: '#888' }}>Exchange</label><br />
|
<label className="text-xs text-text-secondary">Exchange</label><br />
|
||||||
<select value={exchange} onChange={e => setExchange(e.target.value)}
|
<select value={exchange} onChange={e => setExchange(e.target.value)} className={selectClass}>
|
||||||
style={selectStyle}>
|
|
||||||
{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}
|
{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: '#888' }}>Symbol ({symbols.length})</label><br />
|
<label className="text-xs text-text-secondary">Symbol ({symbols.length})</label><br />
|
||||||
<select value={symbol} onChange={e => setSymbol(e.target.value)}
|
<select value={symbol} onChange={e => setSymbol(e.target.value)} className={`${selectClass} min-w-[140px]`}>
|
||||||
style={{ ...selectStyle, minWidth: '140px' }}>
|
|
||||||
{symbols.length > 0
|
{symbols.length > 0
|
||||||
? symbols.map(s => <option key={s} value={s}>{s}</option>)
|
? symbols.map(s => <option key={s} value={s}>{s}</option>)
|
||||||
: <option value="">Loading...</option>}
|
: <option value="">Loading...</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: '#888' }}>Timeframe</label><br />
|
<label className="text-xs text-text-secondary">Timeframe</label><br />
|
||||||
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} style={selectStyle}>
|
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} className={selectClass}>
|
||||||
{TIMEFRAMES.map(t => <option key={t} value={t}>{t}</option>)}
|
{TIMEFRAMES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: '#888' }}>Days</label><br />
|
<label className="text-xs text-text-secondary">Days</label><br />
|
||||||
<select value={days} onChange={e => setDays(Number(e.target.value))} style={selectStyle}>
|
<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>)}
|
{[3, 7, 14, 30, 90, 180, 365].map(d => <option key={d} value={d}>{d}d</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '12px', color: '#888' }}>Trade Size (USDT)</label><br />
|
<label className="text-xs text-text-secondary">Trade Size (USDT)</label><br />
|
||||||
<input type="number" value={tradeSize} onChange={e => setTradeSize(Number(e.target.value))}
|
<input
|
||||||
style={{ ...selectStyle, width: '80px' }} min={1} />
|
type="number"
|
||||||
|
value={tradeSize}
|
||||||
|
onChange={e => setTradeSize(Number(e.target.value))}
|
||||||
|
className={`${selectClass} w-20`}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={runBacktest} disabled={loading}
|
<button
|
||||||
style={{
|
onClick={runBacktest}
|
||||||
padding: '8px 24px', background: '#2563eb', color: '#fff', border: 'none',
|
disabled={loading}
|
||||||
borderRadius: '6px', cursor: loading ? 'wait' : 'pointer', fontWeight: 600,
|
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'}
|
{loading ? '⏳ Running...' : '▶ Run Backtest'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ color: '#ef4444', marginBottom: '16px' }}>❌ {error}</div>}
|
{error && <div className="mb-4 text-red">❌ {error}</div>}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* 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="Trades" value={result.trades.total.toString()} />
|
||||||
<SummaryCard label="Win Rate" value={`${result.trades.win_rate}%`}
|
<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)}`}
|
<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) ?? '∞'}
|
<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="Wins/Losses" value={`${result.trades.wins}W / ${result.trades.losses}L`} />
|
||||||
<SummaryCard label="Candles" value={result.candles_count.toString()} />
|
<SummaryCard label="Candles" value={result.candles_count.toString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signal Breakdown */}
|
{/* Signal Breakdown */}
|
||||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
<div className={`${cardClass} mb-4`}>
|
||||||
<h3 style={{ margin: '0 0 12px' }}>📋 Signal Breakdown</h3>
|
<h3 className="mb-3 text-text-heading">📋 Signal Breakdown</h3>
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
<div className="flex flex-wrap gap-4">
|
||||||
{Object.entries(result.signal_counts).map(([sig, count]) => {
|
{Object.entries(result.signal_counts).map(([sig, count]) => {
|
||||||
const icon = SIGNAL_ICONS[sig] || '❓';
|
const icon = SIGNAL_ICONS[sig] || '❓';
|
||||||
const sigTrades = result.trades.per_signal[sig] || { trades: 0, wins: 0, pnl: 0 };
|
const sigTrades = result.trades.per_signal[sig] || { trades: 0, wins: 0, pnl: 0 };
|
||||||
return (
|
return (
|
||||||
<div key={sig} style={{
|
<div key={sig} className="rounded-md border border-border-default bg-bg-primary px-3.5 py-2">
|
||||||
padding: '8px 14px', background: '#16213e', borderRadius: '6px',
|
<div className="text-sm font-semibold text-text-primary">
|
||||||
border: '1px solid #0f3460',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>
|
|
||||||
{icon} {sig.replace('_', ' ')}
|
{icon} {sig.replace('_', ' ')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#aaa' }}>
|
<div className="text-xs text-text-secondary">
|
||||||
{count} signals → {sigTrades.trades} trades
|
{count} signals → {sigTrades.trades} trades
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div className={`text-xs font-semibold ${sigTrades.pnl >= 0 ? 'text-green' : 'text-red'}`}>
|
||||||
fontSize: '12px', fontWeight: 600,
|
|
||||||
color: sigTrades.pnl >= 0 ? '#22c55e' : '#ef4444'
|
|
||||||
}}>
|
|
||||||
${sigTrades.pnl.toFixed(2)} ({sigTrades.trades ? Math.round(sigTrades.wins / sigTrades.trades * 100) : 0}% WR)
|
${sigTrades.pnl.toFixed(2)} ({sigTrades.trades ? Math.round(sigTrades.wins / sigTrades.trades * 100) : 0}% WR)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,43 +205,43 @@ export default function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Best/Worst Trades */}
|
{/* 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 && (
|
{result.trades.best_trade && (
|
||||||
<div style={{ background: '#064e3b', borderRadius: '8px', padding: '12px' }}>
|
<div className="rounded-lg bg-green/10 p-3">
|
||||||
<div style={{ fontSize: '12px', color: '#6ee7b7' }}>🏆 Best Trade</div>
|
<div className="text-xs text-green">🏆 Best Trade</div>
|
||||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</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 style={{ color: '#22c55e', fontWeight: 700 }}>+${result.trades.best_trade.pnl.toFixed(2)}</div>
|
<div className="font-bold text-green">+${result.trades.best_trade.pnl.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result.trades.worst_trade && (
|
{result.trades.worst_trade && (
|
||||||
<div style={{ background: '#450a0a', borderRadius: '8px', padding: '12px' }}>
|
<div className="rounded-lg bg-red/10 p-3">
|
||||||
<div style={{ fontSize: '12px', color: '#fca5a5' }}>💸 Worst Trade</div>
|
<div className="text-xs text-red">💸 Worst Trade</div>
|
||||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</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 style={{ color: '#ef4444', fontWeight: 700 }}>{result.trades.worst_trade.pnl.toFixed(2)}$</div>
|
<div className="font-bold text-red">{result.trades.worst_trade.pnl.toFixed(2)}$</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Signals */}
|
{/* Recent Signals */}
|
||||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
<div className={`${cardClass} mb-4`}>
|
||||||
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Signals (last 15)</h3>
|
<h3 className="mb-3 text-text-heading">📋 Recent Signals (last 15)</h3>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
<table className="w-full border-collapse text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #333' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Time</th>
|
<th className={thClass}>Time</th>
|
||||||
<th style={thStyle}>Signal</th>
|
<th className={thClass}>Signal</th>
|
||||||
<th style={thStyle}>Price</th>
|
<th className={thClass}>Price</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.recent_signals.map((s, i) => (
|
{result.recent_signals.map((s, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
|
<tr key={i} className="border-b border-border-muted">
|
||||||
<td style={tdStyle}>{s.time.slice(5, 16)}</td>
|
<td className={tdClass}>{s.time.slice(5, 16)}</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
|
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>${s.price.toFixed(2)}</td>
|
<td className={tdClass}>${s.price.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -259,31 +251,31 @@ export default function BacktestPage() {
|
|||||||
|
|
||||||
{/* Recent Trades */}
|
{/* Recent Trades */}
|
||||||
{result.trades.recent.length > 0 && (
|
{result.trades.recent.length > 0 && (
|
||||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px' }}>
|
<div className={cardClass}>
|
||||||
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Trades (last 10)</h3>
|
<h3 className="mb-3 text-text-heading">📋 Recent Trades (last 10)</h3>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
<table className="w-full border-collapse text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #333' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Time</th>
|
<th className={thClass}>Time</th>
|
||||||
<th style={thStyle}>Dir</th>
|
<th className={thClass}>Dir</th>
|
||||||
<th style={thStyle}>Entry</th>
|
<th className={thClass}>Entry</th>
|
||||||
<th style={thStyle}>Exit</th>
|
<th className={thClass}>Exit</th>
|
||||||
<th style={thStyle}>PnL</th>
|
<th className={thClass}>PnL</th>
|
||||||
<th style={thStyle}>Reason</th>
|
<th className={thClass}>Reason</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.trades.recent.slice().reverse().map((t, i) => (
|
{result.trades.recent.slice().reverse().map((t, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
|
<tr key={i} className="border-b border-border-muted">
|
||||||
<td style={tdStyle}>{t.entry_time?.slice(5, 16) || '-'}</td>
|
<td className={tdClass}>{t.entry_time?.slice(5, 16) || '-'}</td>
|
||||||
<td style={tdStyle}>{t.direction}</td>
|
<td className={tdClass}>{t.direction}</td>
|
||||||
<td style={tdStyle}>${t.entry_price.toFixed(2)}</td>
|
<td className={tdClass}>${t.entry_price.toFixed(2)}</td>
|
||||||
<td style={tdStyle}>${t.exit_price?.toFixed(2) || '-'}</td>
|
<td className={tdClass}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||||
<td style={{ ...tdStyle, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>
|
<td className={`${tdClass} ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>
|
||||||
${t.pnl?.toFixed(2) || '0.00'}
|
${t.pnl?.toFixed(2) || '0.00'}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>{t.exit_reason || '-'}</td>
|
<td className={tdClass}>{t.exit_reason || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 }) {
|
function SummaryCard({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="rounded-lg border border-border-default bg-bg-surface p-3.5">
|
||||||
background: '#1a1a2e', borderRadius: '8px', padding: '14px',
|
<div className="mb-1 text-xs text-text-secondary">{label}</div>
|
||||||
border: '1px solid #0f3460',
|
<div className={`text-xl font-bold ${color || 'text-text-primary'}`}>{value}</div>
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>{label}</div>
|
|
||||||
<div style={{ fontSize: '20px', fontWeight: 700, color: color || '#e0e0e0' }}>{value}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { apiFetch } from '../api/apiService';
|
||||||
import {
|
import {
|
||||||
createChart,
|
createChart,
|
||||||
createSeriesMarkers,
|
createSeriesMarkers,
|
||||||
@@ -297,18 +298,12 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
// Fetch candles — use query params to avoid encoding issues
|
// Fetch candles — use query params to avoid encoding issues
|
||||||
const candleRes = await fetch(
|
const candleResponse = await apiFetch<any>(
|
||||||
`/api/v1/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
|
`/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
|
||||||
{ headers }
|
|
||||||
);
|
);
|
||||||
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
|
// API returns {candles: [...], cursor: "...", has_more: bool} — unwrap it
|
||||||
const rawCandles: any[] = candleResponse.candles || candleResponse || [];
|
const rawCandles: any[] = candleResponse.candles || candleResponse || [];
|
||||||
// Reverse so oldest candle comes first (lightweight-charts needs ASC order)
|
// 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
|
// Fetch indicators
|
||||||
const indicatorsRes = await fetch(
|
const indicatorData = await apiFetch<IndicatorResponse>(
|
||||||
`/api/v1/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
|
`/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
|
||||||
{ headers }
|
|
||||||
);
|
);
|
||||||
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
|
// Helper to build time-annotated line data from indicator array
|
||||||
function buildLineData(values: number[], times: Candle[]): LineData<UTCTimestamp>[] {
|
function buildLineData(values: number[], times: Candle[]): LineData<UTCTimestamp>[] {
|
||||||
@@ -543,46 +535,18 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
|
|||||||
}, [symbol, exchange, timeframe, onPriceUpdate]);
|
}, [symbol, exchange, timeframe, onPriceUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.wrapper}>
|
<div className="relative min-h-0 flex-1">
|
||||||
{loading && <div style={styles.overlay}>Loading chart data...</div>}
|
{loading && (
|
||||||
{error && <div style={styles.errorOverlay}>Error: {error}</div>}
|
<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">
|
||||||
<div ref={chartContainerRef} style={styles.chartContainer} />
|
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>
|
</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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiFetch } from '../api/apiService';
|
||||||
|
|
||||||
interface Exchange {
|
interface Exchange {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,6 +28,8 @@ interface Props {
|
|||||||
|
|
||||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
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({
|
export default function ChartToolbar({
|
||||||
exchanges,
|
exchanges,
|
||||||
symbol,
|
symbol,
|
||||||
@@ -39,14 +42,6 @@ export default function ChartToolbar({
|
|||||||
const [symbols, setSymbols] = useState<SymbolData[]>([]);
|
const [symbols, setSymbols] = useState<SymbolData[]>([]);
|
||||||
const [loadingSymbols, setLoadingSymbols] = useState(false);
|
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
|
// Fetch symbols when exchange changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exchange) {
|
if (exchange) {
|
||||||
@@ -57,12 +52,7 @@ export default function ChartToolbar({
|
|||||||
async function fetchSymbols(exch: string) {
|
async function fetchSymbols(exch: string) {
|
||||||
setLoadingSymbols(true);
|
setLoadingSymbols(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const data = await apiFetch<SymbolData[]>(`/symbols?exchange=${exch}&active_only=true`);
|
||||||
`/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();
|
|
||||||
setSymbols(data);
|
setSymbols(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('fetchSymbols error:', err);
|
console.error('fetchSymbols error:', err);
|
||||||
@@ -72,13 +62,13 @@ export default function ChartToolbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Exchange + Symbol row */}
|
||||||
<div style={{ ...styles.selectRow, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 6 : 12 }}>
|
<div className="flex flex-col flex-wrap items-center gap-1.5 md:flex-row md:gap-3">
|
||||||
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
|
<div className="flex w-full items-center gap-1.5 md:w-auto">
|
||||||
<label style={styles.label}>Exchange</label>
|
<label className="shrink-0 text-[11px] font-medium uppercase tracking-wide text-text-secondary">Exchange</label>
|
||||||
<select
|
<select
|
||||||
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
|
className={selectClass}
|
||||||
value={exchange}
|
value={exchange}
|
||||||
onChange={(e) => onExchangeChange(e.target.value)}
|
onChange={(e) => onExchangeChange(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -90,10 +80,10 @@ export default function ChartToolbar({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
|
<div className="flex w-full items-center gap-1.5 md:w-auto">
|
||||||
<label style={styles.label}>Symbol</label>
|
<label className="shrink-0 text-[11px] font-medium uppercase tracking-wide text-text-secondary">Symbol</label>
|
||||||
<select
|
<select
|
||||||
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
|
className={selectClass}
|
||||||
value={symbol}
|
value={symbol}
|
||||||
onChange={(e) => onSymbolChange(e.target.value)}
|
onChange={(e) => onSymbolChange(e.target.value)}
|
||||||
disabled={loadingSymbols}
|
disabled={loadingSymbols}
|
||||||
@@ -108,19 +98,18 @@ export default function ChartToolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeframe - horizontal scroll on mobile */}
|
{/* Timeframe - horizontal scroll on mobile */}
|
||||||
<div style={{ ...styles.timeframeSection, flex: isMobile ? 'none' : 1 }}>
|
<div className="flex min-w-0 flex-none items-center gap-1.5 md:flex-1">
|
||||||
<label style={styles.label}>TF</label>
|
<label className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">TF</label>
|
||||||
<div style={styles.timeframeScroll} className="scrollable-x">
|
<div className="scrollable-x min-w-0 flex-1 overflow-x-auto overflow-y-hidden pb-0.5">
|
||||||
<div style={styles.timeframeGroup}>
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
{TIMEFRAMES.map((tf) => (
|
{TIMEFRAMES.map((tf) => (
|
||||||
<button
|
<button
|
||||||
key={tf}
|
key={tf}
|
||||||
style={{
|
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] ${
|
||||||
...styles.timeframeButton,
|
tf === timeframe
|
||||||
...(tf === timeframe ? styles.timeframeActive : {}),
|
? 'border-accent-blue bg-accent-blue text-text-heading'
|
||||||
padding: isMobile ? '2px 6px' : '3px 8px',
|
: 'border-transparent bg-transparent text-text-secondary'
|
||||||
fontSize: isMobile ? 10 : 11,
|
}`}
|
||||||
}}
|
|
||||||
onClick={() => onTimeframeChange(tf)}
|
onClick={() => onTimeframeChange(tf)}
|
||||||
>
|
>
|
||||||
{tf}
|
{tf}
|
||||||
@@ -132,96 +121,3 @@ export default function ChartToolbar({
|
|||||||
</div>
|
</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;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styles (kept from original) ──
|
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';
|
||||||
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' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -133,101 +123,65 @@ export default function DashboardPage() {
|
|||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
|
||||||
{/* ─── NAV ─── */}
|
{/* ─── NAV ─── */}
|
||||||
<nav style={styles.nav}>
|
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }} onClick={() => navigate('/')}>
|
<div className="flex cursor-pointer items-center gap-3" onClick={() => navigate('/')}>
|
||||||
<span style={{ fontSize: 18 }}>📊</span>
|
<span className="text-lg">📊</span>
|
||||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>{t('nav.tradingPortal')}</span>
|
<span className="text-base font-bold text-text-heading">{t('nav.tradingPortal')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div className="flex items-center gap-2">
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<>
|
<>
|
||||||
{/* Hamburger: toggle side panel */}
|
{/* Hamburger: toggle side panel */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowSidePanel(!showSidePanel); setShowNavDropdown(false); }}
|
onClick={() => { setShowSidePanel(!showSidePanel); setShowNavDropdown(false); }}
|
||||||
style={{
|
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"
|
||||||
...styles.btn,
|
|
||||||
fontSize: 18,
|
|
||||||
padding: '8px 14px',
|
|
||||||
lineHeight: 1,
|
|
||||||
minHeight: 36,
|
|
||||||
minWidth: 36,
|
|
||||||
touchAction: 'manipulation',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
title={t('nav.panelToggle')}
|
title={t('nav.panelToggle')}
|
||||||
>
|
>
|
||||||
{showSidePanel ? '✕' : '☰'}
|
{showSidePanel ? '✕' : '☰'}
|
||||||
</button>
|
</button>
|
||||||
{/* Dropdown toggle for nav links */}
|
{/* Dropdown toggle for nav links */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNavDropdown(!showNavDropdown)}
|
onClick={() => setShowNavDropdown(!showNavDropdown)}
|
||||||
style={{
|
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'}`}
|
||||||
...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',
|
|
||||||
}}
|
|
||||||
title="Menu"
|
title="Menu"
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 14 }}>☰</span>
|
<span className="text-sm">☰</span>
|
||||||
<span style={{ fontSize: 10 }}>▼</span>
|
<span className="text-[10px]">▼</span>
|
||||||
</button>
|
</button>
|
||||||
{showNavDropdown && (
|
{showNavDropdown && (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop to close on outside click */}
|
{/* Backdrop to close on outside click */}
|
||||||
<div
|
<div
|
||||||
onClick={() => setShowNavDropdown(false)}
|
onClick={() => setShowNavDropdown(false)}
|
||||||
style={{
|
className="fixed inset-0 z-[90] bg-transparent"
|
||||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
zIndex: 90, background: 'transparent',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{
|
<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)]">
|
||||||
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)',
|
|
||||||
}}>
|
|
||||||
<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('/profile'); setShowNavDropdown(false); }}
|
onClick={() => { navigate('/profile'); setShowNavDropdown(false); }}
|
||||||
>
|
>
|
||||||
{t('nav.profile', { name: userName })}
|
{t('nav.profile', { name: userName })}
|
||||||
</button>
|
</button>
|
||||||
{(user?.role === 'admin' || user?.is_admin) && (
|
{(user?.role === 'admin' || user?.is_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('/admin'); setShowNavDropdown(false); }}
|
onClick={() => { navigate('/admin'); setShowNavDropdown(false); }}
|
||||||
>
|
>
|
||||||
🛡️ Admin
|
🛡️ Admin
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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); }}
|
onClick={() => { navigate('/analytics'); setShowNavDropdown(false); }}
|
||||||
>
|
>
|
||||||
📈 Analytics
|
📈 Analytics
|
||||||
</button>
|
</button>
|
||||||
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
<div className="my-0.5 h-px bg-border-default" />
|
||||||
<button
|
<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 () => {
|
onClick={async () => {
|
||||||
setShowNavDropdown(false);
|
setShowNavDropdown(false);
|
||||||
await dispatch(logoutThunk());
|
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 })}
|
{t('nav.profile', { name: userName })}
|
||||||
</button>
|
</button>
|
||||||
{(user?.role === 'admin' || user?.is_admin) && (
|
{(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
|
🛡️ Admin
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/analytics')}>
|
<button className={navBtnClass} onClick={() => navigate('/analytics')}>
|
||||||
📈 Analytics
|
📈 Analytics
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style={{
|
className={`${navBtnClass} border-red/20 text-red`}
|
||||||
...styles.btn,
|
|
||||||
touchAction: 'manipulation',
|
|
||||||
minHeight: isMobile ? 36 : undefined,
|
|
||||||
color: '#f85149',
|
|
||||||
borderColor: '#f8514933',
|
|
||||||
}}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await dispatch(logoutThunk());
|
await dispatch(logoutThunk());
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
@@ -278,10 +226,10 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ─── GRID LAYOUT: 1fr (main) | 280px (side) ─── */}
|
{/* ─── GRID LAYOUT: 1fr (main) | 340px (side) ─── */}
|
||||||
<div style={{ ...styles.grid, gridTemplateColumns: isMobile ? '1fr' : '1fr 340px' }}>
|
<div className="grid h-[calc(100vh-44px)] grid-cols-1 gap-0 md:grid-cols-[1fr_340px]">
|
||||||
{/* ═══ MAIN CONTENT (LEFT) ═══ */}
|
{/* ═══ MAIN CONTENT (LEFT) ═══ */}
|
||||||
<div style={styles.mainContent}>
|
<div className="flex flex-col overflow-hidden">
|
||||||
{/* Toolbar: exchange / symbol / timeframe selectors */}
|
{/* Toolbar: exchange / symbol / timeframe selectors */}
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
exchanges={exchanges}
|
exchanges={exchanges}
|
||||||
@@ -294,7 +242,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Chart area: fills remaining vertical space */}
|
{/* Chart area: fills remaining vertical space */}
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div className="relative min-h-0 flex-1">
|
||||||
{symbol ? (
|
{symbol ? (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
symbol={symbol}
|
symbol={symbol}
|
||||||
@@ -303,14 +251,7 @@ export default function DashboardPage() {
|
|||||||
onPriceUpdate={setLastPrice}
|
onPriceUpdate={setLastPrice}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div className="flex h-full items-center justify-center text-sm text-text-dim">
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
color: '#484f58',
|
|
||||||
fontSize: 14,
|
|
||||||
}}>
|
|
||||||
Select a symbol from watchlist
|
Select a symbol from watchlist
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -318,12 +259,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Signal panel: below chart, only when symbol selected */}
|
{/* Signal panel: below chart, only when symbol selected */}
|
||||||
{symbol && (
|
{symbol && (
|
||||||
<div style={{
|
<div className={`min-h-0 overflow-hidden border-t border-border-default ${isMobile ? 'h-[200px]' : 'h-[280px]'}`}>
|
||||||
borderTop: '1px solid #30363d',
|
|
||||||
height: isMobile ? 200 : 280,
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
<SignalPanel symbol={symbol} />
|
<SignalPanel symbol={symbol} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -331,7 +267,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* ═══ SIDE PANEL (RIGHT) ═══ */}
|
{/* ═══ SIDE PANEL (RIGHT) ═══ */}
|
||||||
{!isMobile && (
|
{!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 */}
|
{/* Watchlist at top of side panel */}
|
||||||
<WatchlistPanel
|
<WatchlistPanel
|
||||||
currentSymbol={symbol}
|
currentSymbol={symbol}
|
||||||
@@ -340,9 +276,9 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Order panel below watchlist */}
|
{/* 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' ? (
|
{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
|
👁️ View-only mode — trading is disabled
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -368,9 +304,9 @@ export default function DashboardPage() {
|
|||||||
onSymbolChange={(s) => { setSymbol(s); setShowSidePanel(false); }}
|
onSymbolChange={(s) => { setSymbol(s); setShowSidePanel(false); }}
|
||||||
exchange={exchange}
|
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' ? (
|
{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
|
👁️ View-only mode — trading is disabled
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -59,14 +59,6 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
|||||||
}
|
}
|
||||||
}, [lastPrice]);
|
}, [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
|
// Auto-update clock every second
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -114,63 +106,54 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnCursor = isConnected ? 'pointer' : 'not-allowed';
|
const disabledClass = isConnected ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-50';
|
||||||
const btnOpacity = isConnected ? 1 : 0.5;
|
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Symbol info */}
|
||||||
<div style={styles.symbolSection}>
|
<div className="border-b border-border-default pb-4">
|
||||||
<h2 style={{ ...styles.symbolTitle, fontSize: isMobile ? 18 : 22 }}>{symbol}</h2>
|
<h2 className="mb-2 text-lg font-semibold text-text-heading md:text-[22px]">{symbol}</h2>
|
||||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 8 }}>
|
<div className="mb-2 text-[11px] text-text-dim">
|
||||||
Ordering <strong style={{ color: '#8b949e' }}>{baseToken}</strong> with <strong style={{ color: '#8b949e' }}>{quoteToken}</strong>
|
Ordering <strong className="text-text-secondary">{baseToken}</strong> with <strong className="text-text-secondary">{quoteToken}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.priceRow}>
|
<div className="flex items-center justify-between">
|
||||||
<span style={styles.priceLabel}>Last Price</span>
|
<span className="text-[13px] text-text-secondary">Last Price</span>
|
||||||
{lastPrice !== null ? (
|
{lastPrice !== null ? (
|
||||||
<span style={{
|
<span className={`text-[15px] font-semibold md:text-lg ${priceDirection === 'down' ? 'text-red-alt' : 'text-green-alt'}`}>
|
||||||
...styles.priceValue,
|
|
||||||
fontSize: isMobile ? 15 : 18,
|
|
||||||
color: priceDirection === 'up' ? '#26a69a' : priceDirection === 'down' ? '#ef5350' : '#26a69a',
|
|
||||||
}}>
|
|
||||||
{priceDirection === 'up' ? '▲ ' : priceDirection === 'down' ? '▼ ' : '● '}
|
{priceDirection === 'up' ? '▲ ' : priceDirection === 'down' ? '▼ ' : '● '}
|
||||||
{formatPrice(lastPrice)}
|
{formatPrice(lastPrice)}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time + Balance row (50/50) - stacks on mobile */}
|
{/* Time + Balance row (50/50) - stacks on mobile */}
|
||||||
<div style={{ ...styles.infoRow, flexDirection: isMobile ? 'column' : 'row' }}>
|
<div className="flex flex-col items-stretch overflow-hidden rounded-lg border border-border-default bg-bg-surface md:flex-row">
|
||||||
<div style={styles.infoBlock}>
|
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
|
||||||
<h3 style={styles.sectionTitle}>Time</h3>
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Time</h3>
|
||||||
<div style={{ ...styles.clock, fontSize: isMobile ? 14 : 15 }}>{currentTime.toLocaleTimeString()}</div>
|
<div className="mb-1 font-mono text-sm text-text-primary md:text-[15px]">{currentTime.toLocaleTimeString()}</div>
|
||||||
<div style={styles.date}>
|
<div className="text-[11px] text-text-secondary">
|
||||||
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...styles.infoDivider, width: isMobile ? 'auto' : 1, height: isMobile ? 1 : 'auto', margin: isMobile ? '6px 0' : 0 }} />
|
<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 style={styles.infoBlock}>
|
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
|
||||||
<h3 style={styles.sectionTitle}>Account</h3>
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Account</h3>
|
||||||
<div style={{
|
<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'}`}>
|
||||||
...styles.balanceValue,
|
|
||||||
fontSize: isMobile ? 14 : 15,
|
|
||||||
color: (balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? '#3fb950' : '#8b949e',
|
|
||||||
}}>
|
|
||||||
${(balance?.find(b => b.asset === 'USDT')?.total ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
${(balance?.find(b => b.asset === 'USDT')?.total ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.balanceLabel}>Available</div>
|
<div className="text-xs text-text-secondary">Available</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Order */}
|
{/* Quick Order */}
|
||||||
<div style={styles.section}>
|
<div className="border-b border-border-default pb-4">
|
||||||
<h3 style={styles.sectionTitle}>Quick Order · {baseToken}/{quoteToken}</h3>
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Quick Order · {baseToken}/{quoteToken}</h3>
|
||||||
|
|
||||||
{/* Amount input */}
|
{/* Amount input */}
|
||||||
<div style={styles.inputRow}>
|
<div className="mb-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -178,25 +161,25 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
|||||||
placeholder={`Amount in ${quoteToken}`}
|
placeholder={`Amount in ${quoteToken}`}
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
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}
|
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}
|
{quoteToken} amount · Buy = spend {quoteToken} · Sell = sell {baseToken}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buy / Sell buttons */}
|
{/* Buy / Sell buttons */}
|
||||||
<div style={styles.orderButtons}>
|
<div className="flex gap-2">
|
||||||
<button
|
<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}
|
disabled={!isConnected || orderLoading}
|
||||||
onClick={() => placeOrder('buy')}
|
onClick={() => placeOrder('buy')}
|
||||||
>
|
>
|
||||||
{orderLoading ? 'Placing...' : `Buy ${baseToken}`}
|
{orderLoading ? 'Placing...' : `Buy ${baseToken}`}
|
||||||
</button>
|
</button>
|
||||||
<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}
|
disabled={!isConnected || orderLoading}
|
||||||
onClick={() => placeOrder('sell')}
|
onClick={() => placeOrder('sell')}
|
||||||
>
|
>
|
||||||
@@ -206,40 +189,40 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
|||||||
|
|
||||||
{/* Connection hint */}
|
{/* Connection hint */}
|
||||||
{!isConnected && (
|
{!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 */}
|
{/* Success message */}
|
||||||
{orderSuccess && (
|
{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}
|
✅ {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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{orderError && (
|
{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}
|
❌ {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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* P1-21: Confirmation dialog before placing real trade */}
|
{/* P1-21: Confirmation dialog before placing real trade */}
|
||||||
{pendingOrder && (
|
{pendingOrder && (
|
||||||
<div style={styles.confirmOverlay}>
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60">
|
||||||
<div style={styles.confirmDialog}>
|
<div className="w-[90%] max-w-[360px] rounded-xl border border-border-default bg-bg-surface p-6 text-center">
|
||||||
<div style={{ fontSize: 14, color: '#f0f6fc', marginBottom: 8 }}>
|
<div className="mb-2 text-sm text-text-heading">
|
||||||
⚠️ Confirm {pendingOrder.side.toUpperCase()} order?
|
⚠️ Confirm {pendingOrder.side.toUpperCase()} order?
|
||||||
</div>
|
</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}
|
{pendingOrder.amount} {quoteToken} {pendingOrder.side === 'buy' ? '→' : '←'} {symbol}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="flex gap-2">
|
||||||
<button style={styles.confirmBtn} onClick={confirmOrder}>
|
<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'}
|
✅ {pendingOrder.side === 'buy' ? 'Buy' : 'Sell'}
|
||||||
</button>
|
</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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,220 +233,3 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
|||||||
</div>
|
</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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -14,13 +14,6 @@ export default function SignalPanel({ symbol }: Props) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [totalPnl, setTotalPnl] = useState<number | null>(null);
|
const [totalPnl, setTotalPnl] = useState<number | null>(null);
|
||||||
const [winRate, setWinRate] = 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 () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -48,24 +41,28 @@ export default function SignalPanel({ symbol }: Props) {
|
|||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.panel}>
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{/* Header + Summary */}
|
{/* Header + Summary */}
|
||||||
<div style={styles.header}>
|
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||||
<h3 style={styles.title}>📊 Signals & Trades</h3>
|
<h3 className="m-0 text-sm font-semibold text-text-heading">📊 Signals & Trades</h3>
|
||||||
<button style={styles.refreshBtn} onClick={loadData} disabled={loading}>
|
<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 ? '…' : '↻'}
|
{loading ? '…' : '↻'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PnL Summary */}
|
{/* PnL Summary */}
|
||||||
{totalPnl !== null && (
|
{totalPnl !== null && (
|
||||||
<div style={styles.summary}>
|
<div className="flex flex-wrap items-center gap-2 border-b border-border-muted px-4 pb-2 pt-1">
|
||||||
<span style={styles.summaryLabel}>Total P&L</span>
|
<span className="text-xs text-text-secondary">Total P&L</span>
|
||||||
<span style={{ ...styles.summaryValue, color: totalPnl >= 0 ? '#3fb950' : '#f85149' }}>
|
<span className={`font-mono text-[15px] font-bold ${totalPnl >= 0 ? 'text-green' : 'text-red'}`}>
|
||||||
{formatPnL(totalPnl)}
|
{formatPnL(totalPnl)}
|
||||||
</span>
|
</span>
|
||||||
{winRate !== null && (
|
{winRate !== null && (
|
||||||
<span style={styles.winRate}>
|
<span className="ml-auto text-xs text-text-secondary">
|
||||||
Win Rate: {winRate.toFixed(1)}%
|
Win Rate: {winRate.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -73,15 +70,19 @@ export default function SignalPanel({ symbol }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={styles.tabs}>
|
<div className="flex border-b border-border-muted">
|
||||||
<button
|
<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')}
|
onClick={() => setActiveTab('signals')}
|
||||||
>
|
>
|
||||||
Signals ({signals.length})
|
Signals ({signals.length})
|
||||||
</button>
|
</button>
|
||||||
<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')}
|
onClick={() => setActiveTab('trades')}
|
||||||
>
|
>
|
||||||
Trades ({trades.length})
|
Trades ({trades.length})
|
||||||
@@ -89,27 +90,27 @@ export default function SignalPanel({ symbol }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={styles.content}>
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{activeTab === 'signals' ? (
|
{activeTab === 'signals' ? (
|
||||||
signals.length === 0 ? (
|
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) => {
|
signals.map((sig) => {
|
||||||
const cfg = SIGNAL_CONFIG[sig.signal_type] || { label: sig.signal_type, icon: '📊', color: '#8b949e', bg: 'transparent' };
|
const cfg = SIGNAL_CONFIG[sig.signal_type] || { label: sig.signal_type, icon: '📊', color: '#8b949e', bg: 'transparent' };
|
||||||
return (
|
return (
|
||||||
<div key={sig.id} style={styles.signalRow}>
|
<div key={sig.id} className="flex cursor-pointer items-center justify-between border-b border-border-muted px-3 py-2 transition-colors">
|
||||||
<div style={styles.signalLeft}>
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ fontSize: 16 }}>{cfg.icon}</span>
|
<span className="text-base">{cfg.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ ...styles.signalType, color: cfg.color }}>{cfg.label}</div>
|
<div className="text-[13px] font-semibold" style={{ color: cfg.color }}>{cfg.label}</div>
|
||||||
<div style={styles.signalMeta}>
|
<div className="mt-px text-[11px] text-text-muted">
|
||||||
{sig.symbol} · {sig.timeframe} · {sig.strength}
|
{sig.symbol} · {sig.timeframe} · {sig.strength}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.signalRight}>
|
<div className="text-right">
|
||||||
<div style={styles.signalPrice}>${formatPrice(sig.price)}</div>
|
<div className="font-mono text-[13px] font-medium text-text-primary">${formatPrice(sig.price)}</div>
|
||||||
<div style={styles.signalTime}>
|
<div className="text-[11px] text-text-dim">
|
||||||
{new Date(sig.created_at).toLocaleTimeString()}
|
{new Date(sig.created_at).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,32 +120,31 @@ export default function SignalPanel({ symbol }: Props) {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
trades.length === 0 ? (
|
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) => (
|
trades.map((trade) => (
|
||||||
<div key={trade.id} style={styles.tradeRow}>
|
<div key={trade.id} className="border-b border-border-muted px-3 py-2">
|
||||||
<div style={styles.tradeHeader}>
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ ...styles.tradeDirection, color: trade.direction === 'LONG' ? '#3fb950' : '#f85149' }}>
|
<span className={`text-xs font-bold ${trade.direction === 'LONG' ? 'text-green' : 'text-red'}`}>
|
||||||
{trade.direction === 'LONG' ? '▲' : '▼'} {trade.direction}
|
{trade.direction === 'LONG' ? '▲' : '▼'} {trade.direction}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.tradeSymbol}>{trade.symbol}</span>
|
<span className="text-xs text-text-primary">{trade.symbol}</span>
|
||||||
<span style={{
|
<span className={`ml-auto text-[11px] font-semibold ${
|
||||||
...styles.tradeStatus,
|
trade.status === 'OPEN' ? 'text-accent' : (trade.pnl && trade.pnl > 0 ? 'text-green' : 'text-red')
|
||||||
color: trade.status === 'OPEN' ? '#58a6ff' : (trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149'),
|
}`}>
|
||||||
}}>
|
|
||||||
{trade.status === 'OPEN' ? '🟢 OPEN' : `🔒 ${formatPnL(trade.pnl)}`}
|
{trade.status === 'OPEN' ? '🟢 OPEN' : `🔒 ${formatPnL(trade.pnl)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<span>Entry: ${formatPrice(trade.entry_price)}</span>
|
||||||
{trade.exit_price && <span> → Exit: ${formatPrice(trade.exit_price)}</span>}
|
{trade.exit_price && <span> → Exit: ${formatPrice(trade.exit_price)}</span>}
|
||||||
{trade.pnl_percent !== null && (
|
{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)}
|
{formatPnLPercent(trade.pnl_percent)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.tradeReason}>
|
<div className="mt-0.5 text-[10px] text-text-dim">
|
||||||
{trade.entry_reason && <span>📥 {trade.entry_reason}</span>}
|
{trade.entry_reason && <span>📥 {trade.entry_reason}</span>}
|
||||||
{trade.exit_reason && <span> 📤 {trade.exit_reason}</span>}
|
{trade.exit_reason && <span> 📤 {trade.exit_reason}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,167 +156,3 @@ export default function SignalPanel({ symbol }: Props) {
|
|||||||
</div>
|
</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 [loading, setLoading] = useState(true);
|
||||||
const [signals, setSignals] = useState<Signal[]>([]);
|
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
|
// Add symbol state
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -111,25 +103,24 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
|||||||
const availableSymbols = symbols.filter((s) => !watchlistSymbolIds.has(s.id));
|
const availableSymbols = symbols.filter((s) => !watchlistSymbolIds.has(s.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.panel}>
|
<div className="flex flex-col border-b border-border-muted">
|
||||||
<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>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={styles.header}>
|
<div className="flex items-center justify-between px-3.5 pb-1.5 pt-2.5">
|
||||||
<h3 style={styles.title}>👁️ Watchlist</h3>
|
<h3 className="m-0 text-[13px] font-semibold text-text-heading">👁️ Watchlist</h3>
|
||||||
<button style={styles.addBtn} onClick={() => setShowAdd(!showAdd)}>
|
<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 ? '✕' : '+'}
|
{showAdd ? '✕' : '+'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Symbol Panel */}
|
{/* Add Symbol Panel */}
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div style={styles.addPanel}>
|
<div className="border-b border-border-muted px-3.5 pb-2.5 pt-1.5">
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
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..."
|
placeholder="Search symbols..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -137,21 +128,21 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
|||||||
searchSymbols(e.target.value);
|
searchSymbols(e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{error && <div style={styles.error}>{error}</div>}
|
{error && <div className="mb-1 text-[11px] text-red">{error}</div>}
|
||||||
<div style={styles.symbolList}>
|
<div className="max-h-[180px] overflow-y-auto">
|
||||||
{searching ? (
|
{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 ? (
|
) : 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) => (
|
availableSymbols.slice(0, 30).map((sym) => (
|
||||||
<button
|
<button
|
||||||
key={sym.id}
|
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)}
|
onClick={() => handleAdd(sym)}
|
||||||
>
|
>
|
||||||
<span style={styles.symName}>{sym.symbol}</span>
|
<span className="font-medium">{sym.symbol}</span>
|
||||||
<span style={styles.symExchange}>{exchange}</span>
|
<span className="text-[10px] text-text-dim">{exchange}</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -160,11 +151,11 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Watchlist Items */}
|
{/* Watchlist Items */}
|
||||||
<div style={styles.list}>
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{loading ? (
|
{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 ? (
|
) : 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.
|
No symbols watched yet. Click + to add.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -175,30 +166,26 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="wl-item"
|
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] ${
|
||||||
style={{
|
isActive ? 'border-l-accent bg-accent/[0.08]' : 'border-l-transparent'
|
||||||
...styles.item,
|
}`}
|
||||||
...(isActive ? styles.itemActive : {}),
|
|
||||||
padding: isMobile ? '5px 10px' : '7px 14px',
|
|
||||||
}}
|
|
||||||
onClick={() => onSymbolChange(item.symbol)}
|
onClick={() => onSymbolChange(item.symbol)}
|
||||||
>
|
>
|
||||||
{/* Symbol info */}
|
{/* Symbol info */}
|
||||||
<div style={styles.itemLeft}>
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
<div style={{ ...styles.itemSymbol, fontSize: isMobile ? 12 : 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{item.symbol}</div>
|
<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 style={styles.itemLabel}>{item.label}</div>}
|
{item.label && <div className="text-[10px] text-text-muted">{item.label}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signal indicator */}
|
{/* Signal indicator */}
|
||||||
<div style={styles.itemRight}>
|
<div className="flex items-center gap-1">
|
||||||
{cfg && (
|
{cfg && (
|
||||||
<span style={{ fontSize: 14, marginRight: 4 }} title={cfg.label}>
|
<span className="mr-1 text-sm" title={cfg.label}>
|
||||||
{cfg.icon}
|
{cfg.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="wl-remove-btn"
|
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"
|
||||||
style={styles.removeBtn}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleRemove(item.id);
|
handleRemove(item.id);
|
||||||
@@ -216,148 +203,3 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
|||||||
</div>
|
</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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { apiFetch } from '../api/apiService';
|
||||||
import { useT } from '../../translations';
|
import { useT } from '../../translations';
|
||||||
|
|
||||||
@@ -11,10 +13,10 @@ function authFetch<T = any>(url: string, options: RequestInit = {}): Promise<T>
|
|||||||
return apiFetch<T>(apiUrl, options);
|
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 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 inputStyle: React.CSSProperties = { padding: '6px 10px', borderRadius: 6, border: '1px solid #30363d', background: '#0d1117', color: '#c9d1d9', fontSize: 13 };
|
const inputClass = 'rounded-md border border-border-default bg-bg-primary px-2.5 py-1.5 text-[13px] text-text-primary';
|
||||||
const labelStyle: React.CSSProperties = { fontSize: 11, color: '#8b949e', marginBottom: 4 };
|
const labelClass = 'mb-1 text-[11px] text-text-secondary';
|
||||||
const cardStyle: React.CSSProperties = { background: '#161b22', borderRadius: 8, padding: 14, border: '1px solid #21262d' };
|
const cardClass = 'rounded-lg border border-border-muted bg-bg-surface p-3.5';
|
||||||
|
|
||||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||||
const TFS_BT = ['15m', '30m', '1h', '4h'];
|
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 }) {
|
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||||
return (
|
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
|
||||||
<div style={{ width: 18, height: 18, borderRadius: '50%', background: '#fff', position: 'absolute', top: 2, left: value ? 20 : 2, transition: 'all 0.2s' }} />
|
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>
|
</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); }
|
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 (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||||
if (!profile) return <div style={{ color: '#ef4444' }}>❌ Không tải được profile</div>;
|
if (!profile) return <div className="text-red-alt">❌ Không tải được profile</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('✅') ? '#22c55e' : '#ef4444' }}>{msg}</div>}
|
{msg && <div className={`mb-2.5 ${msg.startsWith('✅') ? 'text-green-alt' : 'text-red-alt'}`}>{msg}</div>}
|
||||||
<div style={{ display: 'grid', gap: 10, maxWidth: 400 }}>
|
<div className="grid max-w-[400px] gap-2.5">
|
||||||
<div><div style={labelStyle}>Username</div><input disabled value={profile.username} style={{ ...inputStyle, width: '100%', opacity: 0.6 }} /></div>
|
<div><div className={labelClass}>Username</div><input disabled value={profile.username} className={`${inputClass} w-full opacity-60`} /></div>
|
||||||
{editing ? (<>
|
{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 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 style={labelStyle}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></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 style={{ display: 'flex', gap: 8 }}><button onClick={save} style={btnStyle}>💾 Save</button><button onClick={() => setEditing(false)} style={btnStyle}>Cancel</button></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 className={labelClass}>Display Name</div><div className="text-text-primary">{profile.display_name || '-'}</div></div>
|
||||||
<div><div style={labelStyle}>Email</div><div style={{ color: '#c9d1d9' }}>{profile.email}</div></div>
|
<div><div className={labelClass}>Email</div><div className="text-text-primary">{profile.email}</div></div>
|
||||||
<div><div style={labelStyle}>Role</div><div style={{ color: '#c9d1d9' }}>{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
|
<div><div className={labelClass}>Role</div><div className="text-text-primary">{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><div className={labelClass}>Member since</div><div className="text-text-primary">{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 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>
|
</div>
|
||||||
|
|
||||||
{pwModal && (
|
{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 onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60">
|
||||||
<div style={{ background: '#161b22', borderRadius: 12, padding: 24, border: '1px solid #30363d', maxWidth: 360, width: '90%' }}>
|
<div className="w-[90%] max-w-[360px] rounded-xl border border-border-default bg-bg-surface p-6">
|
||||||
<h3 style={{ color: '#f0f6fc', margin: '0 0 16px' }}>🔑 Change Password</h3>
|
<h3 className="mb-4 text-text-heading">🔑 Change Password</h3>
|
||||||
{pwMsg && <div style={{ marginBottom: 10, color: pwMsg.startsWith('✅') ? '#22c55e' : '#ef4444', fontSize: 12 }}>{pwMsg}</div>}
|
{pwMsg && <div className={`mb-2.5 text-xs ${pwMsg.startsWith('✅') ? 'text-green-alt' : 'text-red-alt'}`}>{pwMsg}</div>}
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div className="grid gap-2.5">
|
||||||
<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 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 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 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 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><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 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 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>
|
</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);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('❌') ? '#ef4444' : '#22c55e', fontSize: 12 }}>{msg}</div>}
|
{msg && <div className={`mb-2.5 text-xs ${msg.startsWith('❌') ? 'text-red-alt' : 'text-green-alt'}`}>{msg}</div>}
|
||||||
<div style={{ marginBottom: 12, color: activeExchanges.length > 0 ? '#3fb950' : '#8b949e', fontWeight: 600 }}>
|
<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'}
|
{activeExchanges.length > 0 ? `🔗 Connected to ${activeExchanges.map(e => e?.toUpperCase()).join(', ')}` : '🔌 No exchanges connected'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div className="mb-3">
|
||||||
<button onClick={() => setShowAdd(!showAdd)} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
|
<button onClick={() => setShowAdd(!showAdd)} className={`${btnClass} border-accent-blue bg-accent-blue text-white`}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
|
||||||
</div>
|
</div>
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div style={{ ...cardStyle, marginBottom: 16, maxWidth: 400 }}>
|
<div className={`${cardClass} mb-4 max-w-[400px]`}>
|
||||||
<div style={{ display: 'grid', gap: 8 }}>
|
<div className="grid gap-2">
|
||||||
<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 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 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 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 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 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 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>
|
<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} style={{ ...btnStyle, background: '#22c55e', color: '#000', borderColor: '#22c55e' }}>💾 Save Key</button>
|
<button onClick={addKey} className={`${btnClass} border-green bg-green text-black`}>💾 Save Key</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{keys.length === 0 ? (
|
{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) => (
|
{keys.map((k: any) => (
|
||||||
<div key={k.id} style={{ ...cardStyle }}>
|
<div key={k.id} className={cardClass}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="flex items-center justify-between">
|
||||||
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleExpand(k.id)}>
|
<div className="flex-1 cursor-pointer" onClick={() => toggleExpand(k.id)}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div className="flex items-center gap-1.5">
|
||||||
<span style={{ color: '#8b949e', fontSize: 10, transition: 'transform 0.2s', transform: expandedId === k.id ? 'rotate(90deg)' : 'rotate(0deg)' }}>▶</span>
|
<span className={`text-[10px] text-text-secondary transition-transform ${expandedId === k.id ? 'rotate-90' : 'rotate-0'}`}>▶</span>
|
||||||
<span style={{ color: '#f0f6fc', fontWeight: 600 }}>{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
|
<span className="font-semibold text-text-heading">{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#8b949e', fontSize: 12, marginLeft: 16 }}>Key: {k.api_key}</div>
|
<div className="ml-4 text-xs text-text-secondary">Key: {k.api_key}</div>
|
||||||
<div style={{ fontSize: 11, color: k.is_active ? '#3fb950' : '#484f58', marginTop: 2, marginLeft: 16 }}>
|
<div className={`ml-4 mt-0.5 text-[11px] ${k.is_active ? 'text-green' : 'text-text-dim'}`}>
|
||||||
{k.is_active ? '● Connected' : '● Disabled'}
|
{k.is_active ? '● Connected' : '● Disabled'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div className="flex gap-1">
|
||||||
<button onClick={() => testKey(k.id)} style={{ ...btnStyle, padding: '4px 10px', fontSize: 11 }}>🔍 Test</button>
|
<button onClick={() => testKey(k.id)} className={`${btnClass} px-2.5 py-1 text-[11px]`}>🔍 Test</button>
|
||||||
<button onClick={() => deleteKey(k.id)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', padding: '4px 10px', fontSize: 11 }}>🗑</button>
|
<button onClick={() => deleteKey(k.id)} className={`${btnClass} border-red-alt px-2.5 py-1 text-[11px] text-red-alt`}>🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{testResults[k.id] && (
|
{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}
|
{testResults[k.id].success ? '✅' : '❌'} {testResults[k.id].message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expandable details: token balances + open orders */}
|
{/* Expandable details: token balances + open orders */}
|
||||||
{expandedId === k.id && (
|
{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 ? (
|
{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 ? (
|
) : 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 */}
|
{/* Balances */}
|
||||||
<div>
|
<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
|
💰 Balances — ≈ ${keyDetails[k.id]?.usdt_balance?.toFixed(2) || '0.00'} USDT
|
||||||
</div>
|
</div>
|
||||||
{keyDetails[k.id]?.balances?.length > 0 ? (
|
{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) => (
|
{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 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 style={{ color: '#58a6ff' }}>{b.asset}</span>{' '}
|
<span className="text-accent">{b.asset}</span>{' '}
|
||||||
<span style={{ color: '#8b949e' }}>{Number(b.total).toFixed(4)}</span>
|
<span className="text-text-secondary">{Number(b.total).toFixed(4)}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).length === 0 && (
|
{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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Open Orders */}
|
{/* Open Orders */}
|
||||||
<div>
|
<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}
|
📋 Open Orders — {keyDetails[k.id]?.orders?.length || 0}
|
||||||
</div>
|
</div>
|
||||||
{keyDetails[k.id]?.orders?.length > 0 ? (
|
{keyDetails[k.id]?.orders?.length > 0 ? (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
<table className="w-full border-collapse text-[11px]">
|
||||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}>
|
<thead><tr className="border-b border-border-default text-text-secondary">
|
||||||
<th style={{ textAlign: 'left', padding: 2 }}>Symbol</th>
|
<th className="p-0.5 text-left">Symbol</th>
|
||||||
<th style={{ textAlign: 'left', padding: 2 }}>Side</th>
|
<th className="p-0.5 text-left">Side</th>
|
||||||
<th style={{ textAlign: 'left', padding: 2 }}>Type</th>
|
<th className="p-0.5 text-left">Type</th>
|
||||||
<th style={{ textAlign: 'right', padding: 2 }}>Amount</th>
|
<th className="p-0.5 text-right">Amount</th>
|
||||||
<th style={{ textAlign: 'right', padding: 2 }}>Price</th>
|
<th className="p-0.5 text-right">Price</th>
|
||||||
<th style={{ textAlign: 'left', padding: 2 }}>Status</th>
|
<th className="p-0.5 text-left">Status</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{keyDetails[k.id].orders.map((o: any, idx: number) => (
|
{keyDetails[k.id].orders.map((o: any, idx: number) => (
|
||||||
<tr key={o.order_id || idx} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={o.order_id || idx} className="border-b border-border-muted">
|
||||||
<td style={{ padding: 2, color: '#c9d1d9' }}>{o.symbol}</td>
|
<td className="p-0.5 text-text-primary">{o.symbol}</td>
|
||||||
<td style={{ padding: 2, color: o.side === 'buy' ? '#3fb950' : '#f85149' }}>{o.side}</td>
|
<td className={`p-0.5 ${o.side === 'buy' ? 'text-green' : 'text-red'}`}>{o.side}</td>
|
||||||
<td style={{ padding: 2, color: '#8b949e' }}>{o.order_type}</td>
|
<td className="p-0.5 text-text-secondary">{o.order_type}</td>
|
||||||
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.amount}</td>
|
<td className="p-0.5 text-right text-text-primary">{o.amount}</td>
|
||||||
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
|
<td className="p-0.5 text-right text-text-primary">{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
|
||||||
<td style={{ padding: 2, color: o.status === 'open' ? '#d29922' : '#8b949e' }}>{o.status}</td>
|
<td className={`p-0.5 ${o.status === 'open' ? 'text-yellow' : 'text-text-secondary'}`}>{o.status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#484f58', fontSize: 11 }}>No open orders</span>
|
<span className="text-[11px] text-text-dim">No open orders</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,27 +322,30 @@ function SessionsTab() {
|
|||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
const revoke = async (hash: string) => { try { await authFetch(`/api/v1/auth/sessions/${hash}`, { method: 'DELETE' }); load(); } catch {} };
|
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 (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||||
if (sessions.length === 0) return <div style={{ color: '#8b949e', padding: 40, textAlign: 'center' }}>🔓 Không có session nào.</div>;
|
if (sessions.length === 0) return <div className="p-10 text-center text-text-secondary">🔓 Không có session nào.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: '#8b949e', fontSize: 12, marginBottom: 12 }}>🛡️ Active Sessions — {sessions.length}</div>
|
<div className="mb-3 text-xs text-text-secondary">🛡️ Active Sessions — {sessions.length}</div>
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div className="grid gap-2.5">
|
||||||
{sessions.map((s: any) => {
|
{sessions.map((s: any) => {
|
||||||
const ua = parseUA(s.user_agent);
|
const ua = parseUA(s.user_agent);
|
||||||
const icon = deviceIcon[ua.device];
|
const icon = deviceIcon[ua.device];
|
||||||
const isCurrent = s.is_current;
|
const isCurrent = s.is_current;
|
||||||
return (
|
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' }}>
|
<div
|
||||||
{isCurrent && <span style={{ position: 'absolute', top: 8, right: 10, fontSize: 10, fontWeight: 700, color: '#1f6feb', background: '#1f6feb15', padding: '2px 8px', borderRadius: 4 }}>CURRENT</span>}
|
key={s.token_hash}
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
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'}`}
|
||||||
<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 }}>
|
{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 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 className="flex items-center gap-3">
|
||||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -398,57 +409,79 @@ function SettingsTab() {
|
|||||||
} catch (e: any) { setMsg('❌ ' + e.message); }
|
} 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()));
|
const filteredSymbols = allSymbols.filter(s => !tokenSearch || s.base.toLowerCase().includes(tokenSearch.toLowerCase()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>}
|
{msg && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
<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 ${
|
||||||
<button onClick={save} style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', fontWeight: 600 }}>💾 Save Settings</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Row 1: Exchange + Timeframe */}
|
{/* Row 1: Exchange + Timeframe */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
|
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<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 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 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={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>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Theme + Language */}
|
{/* Row 2: Theme + Language */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
|
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎨 Theme</div><div style={{ display: 'flex', gap: 8 }}>
|
<div className={cardClass}>
|
||||||
<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>
|
<div className="mb-1.5 text-[11px] text-text-secondary">🎨 Theme</div>
|
||||||
<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 className="flex gap-2">
|
||||||
</div></div>
|
<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>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Chart Colors */}
|
{/* Chart Colors */}
|
||||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
<div className={`${cardClass} mb-3`}>
|
||||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>📊 Chart Colors</div>
|
<div className="mb-1.5 text-[11px] text-text-secondary">📊 Chart Colors</div>
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
|
<div className="flex items-center gap-6">
|
||||||
<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 className="flex items-center gap-1.5">
|
||||||
<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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
<div className={`${cardClass} mb-3`}>
|
||||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🔔 Notifications</div>
|
<div className="mb-1.5 text-[11px] text-text-secondary">🔔 Notifications</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div className="flex flex-col gap-2.5">
|
||||||
<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 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 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Strategy Toggles */}
|
{/* Strategy Toggles */}
|
||||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
<div className={`${cardClass} mb-3`}>
|
||||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8 }}>📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
|
<div className="mb-2 text-[11px] text-text-secondary">📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
{strategyList.map(s => (
|
{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' }}>
|
<label
|
||||||
<span style={{ color: enabledStrategies.includes(s.name) ? '#c9d1d9' : '#484f58', fontSize: 12 }}>{s.display_name}</span>
|
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
|
<ToggleSwitch
|
||||||
value={enabledStrategies.includes(s.name)}
|
value={enabledStrategies.includes(s.name)}
|
||||||
onChange={(on) => setEnabledStrategies(prev => on ? [...prev, s.name] : prev.filter(n => n !== s.name))}
|
onChange={(on) => setEnabledStrategies(prev => on ? [...prev, s.name] : prev.filter(n => n !== s.name))}
|
||||||
@@ -459,35 +492,35 @@ function SettingsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trade Size */}
|
{/* Trade Size */}
|
||||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
<div className={`${cardClass} mb-3`}>
|
||||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>💰 Trade Size (USDT)</div>
|
<div className="mb-1 text-[13px] font-semibold text-text-heading">💰 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 className="mb-2 text-[11px] italic text-text-secondary">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="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>
|
</div>
|
||||||
|
|
||||||
{/* Auto Trade */}
|
{/* Auto Trade */}
|
||||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
<div className={`${cardClass} mb-3`}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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 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 style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>Bot tự động trade tín hiệu cho các token đã chọn</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>
|
</div>
|
||||||
<ToggleSwitch value={prefAutoTrade} onChange={setPrefAutoTrade} />
|
<ToggleSwitch value={prefAutoTrade} onChange={setPrefAutoTrade} />
|
||||||
</div>
|
</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 */}
|
{/* Token Summary */}
|
||||||
{prefAutoTradeTokens.length > 0 && (
|
{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 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 style={{ color: prefAutoTrade ? '#3fb950' : '#8b949e', fontSize: 12, fontWeight: 600 }}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
|
<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 style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>
|
<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`}
|
{prefAutoTrade ? `${prefAutoTradeTokens.length} cặp — click ✕ để bỏ theo dõi` : `Auto trade đang tắt — ${prefAutoTradeTokens.length} cặp đã lưu`}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{prefAutoTradeTokens.map(token => (
|
{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}
|
{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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -495,13 +528,18 @@ function SettingsTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Token Selector */}
|
{/* Token Selector */}
|
||||||
<div style={{ marginTop: 12 }}>
|
<div className="mt-3">
|
||||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎯 Chọn token cho Auto Trade</div>
|
<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)} style={{ ...inputStyle, width: '100%', marginBottom: 8 }} />
|
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} className={`${inputClass} mb-2 w-full`} />
|
||||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
<div className="flex max-h-[200px] flex-wrap gap-1 overflow-y-auto">
|
||||||
{filteredSymbols.slice(0, 100).map(s => (
|
{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])}
|
<button
|
||||||
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' }}>
|
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) && '✓'}
|
{s.base} {prefAutoTradeTokens.includes(s.symbol) && '✓'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -545,46 +583,56 @@ function HistoryTab() {
|
|||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const renderWinCard = (label: string, data: any) => (
|
const renderWinCard = (label: string, data: any) => (
|
||||||
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
|
<div className="flex-1 rounded-lg border border-border-default bg-bg-surface px-4 py-3 text-center">
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>{label}</div>
|
<div className="mb-1 text-[11px] text-text-secondary">{label}</div>
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: (data?.win_rate ?? 0) >= 50 ? '#3fb950' : '#f85149' }}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
|
<div className={`text-2xl font-bold ${(data?.win_rate ?? 0) >= 50 ? 'text-green' : 'text-red'}`}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
|
||||||
<div style={{ fontSize: 11, color: '#484f58' }}>{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
|
<div className="text-[11px] text-text-dim">{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Sub-tab bar */}
|
{/* Sub-tab bar */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
<div className="mb-4 flex gap-1">
|
||||||
<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
|
||||||
<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>
|
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>
|
</div>
|
||||||
|
|
||||||
{subTab === 'signals' && (<>
|
{subTab === 'signals' && (<>
|
||||||
{signalWinRate !== null && (
|
{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 })}
|
{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 className="flex-1 rounded-lg border border-border-default bg-bg-surface px-4 py-3 text-center">
|
||||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>TOTAL P&L</div>
|
<div className="mb-1 text-[11px] text-text-secondary">TOTAL P&L</div>
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: (signalTotalPnl ?? 0) >= 0 ? '#3fb950' : '#f85149' }}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
|
<div className={`text-2xl font-bold ${(signalTotalPnl ?? 0) >= 0 ? 'text-green' : 'text-red'}`}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{signalTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No signal trades yet.</div> : (
|
{signalTrades.length === 0 ? <div className="text-text-secondary">No signal trades yet.</div> : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
<table className="w-full border-collapse text-xs">
|
||||||
<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>
|
<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>
|
<tbody>
|
||||||
{signalTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
{signalTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={i} className="border-b border-border-muted">
|
||||||
<td style={{ padding: 4 }}>{t.symbol}</td>
|
<td className="p-1">{t.symbol}</td>
|
||||||
<td style={{ padding: 4, color: t.direction === 'LONG' ? '#3fb950' : '#f85149' }}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
|
<td className={`p-1 ${t.direction === 'LONG' ? 'text-green' : 'text-red'}`}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
|
||||||
<td style={{ padding: 4 }}>${t.entry_price?.toFixed(2) || '-'}</td>
|
<td className="p-1">${t.entry_price?.toFixed(2) || '-'}</td>
|
||||||
<td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td>
|
<td className="p-1">${t.exit_price?.toFixed(2) || '-'}</td>
|
||||||
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
|
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>${t.pnl?.toFixed(2)}</td>
|
||||||
<td style={{ padding: 4, color: t.status === 'OPEN' ? '#d29922' : '#8b949e' }}>{t.status}</td>
|
<td className={`p-1 ${t.status === 'OPEN' ? 'text-yellow' : 'text-text-secondary'}`}>{t.status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -595,25 +643,25 @@ function HistoryTab() {
|
|||||||
|
|
||||||
{subTab === 'real' && (<>
|
{subTab === 'real' && (<>
|
||||||
{winRate && (
|
{winRate && (
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
<div className="mb-4 flex gap-3">
|
||||||
{renderWinCard('Daily', winRate.daily)}
|
{renderWinCard('Daily', winRate.daily)}
|
||||||
{renderWinCard('Weekly', winRate.weekly)}
|
{renderWinCard('Weekly', winRate.weekly)}
|
||||||
{renderWinCard('Monthly', winRate.monthly)}
|
{renderWinCard('Monthly', winRate.monthly)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{realTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No real trades yet. Place an order from the dashboard.</div> : (
|
{realTrades.length === 0 ? <div className="text-text-secondary">No real trades yet. Place an order from the dashboard.</div> : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="overflow-x-auto">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
<table className="w-full border-collapse text-xs">
|
||||||
<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>
|
<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>
|
<tbody>
|
||||||
{realTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
{realTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={i} className="border-b border-border-muted">
|
||||||
<td style={{ padding: 4 }}>{t.symbol}</td>
|
<td className="p-1">{t.symbol}</td>
|
||||||
<td style={{ padding: 4 }}><span style={{ color: t.side === 'buy' ? '#3fb950' : '#f85149', fontWeight: 600 }}>{t.side?.toUpperCase()}</span></td>
|
<td className="p-1"><span className={`font-semibold ${t.side === 'buy' ? 'text-green' : 'text-red'}`}>{t.side?.toUpperCase()}</span></td>
|
||||||
<td style={{ padding: 4 }}>{t.amount}</td>
|
<td className="p-1">{t.amount}</td>
|
||||||
<td style={{ padding: 4 }}>${t.price?.toFixed(2) || '-'}</td>
|
<td className="p-1">${t.price?.toFixed(2) || '-'}</td>
|
||||||
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
|
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>${t.pnl?.toFixed(2)}</td>
|
||||||
<td style={{ padding: 4, color: t.status === 'open' ? '#d29922' : '#8b949e' }}>{t.status}</td>
|
<td className={`p-1 ${t.status === 'open' ? 'text-yellow' : 'text-text-secondary'}`}>{t.status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -651,37 +699,51 @@ function BacktestTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16, alignItems: 'end' }}>
|
<div className="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<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 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 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 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 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 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 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 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 style={labelStyle}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} style={{ ...inputStyle, width: 70 }} min={1} /></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} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb', padding: '6px 20px', fontWeight: 600 }}>{btLoading ? '⏳...' : '▶ Run'}</button>
|
<button onClick={runBacktest} disabled={btLoading} className={`${btnClass} border-accent-blue bg-accent-blue px-5 font-semibold text-white`}>{btLoading ? '⏳...' : '▶ Run'}</button>
|
||||||
</div>
|
</div>
|
||||||
{btError && <div style={{ color: '#ef4444', marginBottom: 12 }}>❌ {btError}</div>}
|
{btError && <div className="mb-3 text-red">❌ {btError}</div>}
|
||||||
{btResult && (<>
|
{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 className="mb-4 flex items-center gap-2">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10, marginBottom: 16 }}>
|
<span className={`text-xs ${btSaved ? 'text-green-alt' : 'text-text-secondary'}`}>{btSaved ? '✅ Saved to history' : '💾'}</span>
|
||||||
<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>
|
{!btSaved && <button onClick={saveBacktest} className={`${btnClass} px-2.5 py-1 text-[11px]`}>Save to History</button>}
|
||||||
<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>
|
{btSaveError && <span className="text-[11px] text-red">❌ {btSaveError}</span>}
|
||||||
<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>
|
||||||
<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 className="mb-4 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2.5">
|
||||||
<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 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 style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Losses</div><div style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{btResult.trades.losses}</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 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 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 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 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 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={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>
|
</div>
|
||||||
{btResult?.trades?.recent?.length > 0 && (
|
{btResult?.trades?.recent?.length > 0 && (
|
||||||
<div style={cardStyle}><h4 style={{ margin: '0 0 10px', color: '#f0f6fc', fontSize: 14 }}>📋 Recent Trades</h4>
|
<div className={cardClass}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
<h4 className="mb-2.5 text-sm text-text-heading">📋 Recent Trades</h4>
|
||||||
<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="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) => (
|
<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>
|
))}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -691,16 +753,44 @@ function BacktestTab() {
|
|||||||
// ═══════════════ MAIN ═══════════════
|
// ═══════════════ MAIN ═══════════════
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const user = useAppSelector(s => s.auth.user);
|
const user = useAppSelector(s => s.auth.user);
|
||||||
const tabs = ['info', 'keys', 'sessions', 'settings', 'history', 'backtest'];
|
const tabs = ['info', 'keys', 'sessions', 'settings', 'history', 'backtest'];
|
||||||
const [tab, setTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'info');
|
const [tab, setTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'info');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20, maxWidth: 1100, margin: '0 auto' }}>
|
<div className="min-h-screen bg-bg-primary">
|
||||||
<h2 style={{ color: '#f0f6fc', marginBottom: 16 }}>👤 {user?.username || 'Profile'}</h2>
|
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap' }}>
|
<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 => (
|
{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'}
|
{tb === 'info' ? 'ℹ️ Info' : tb === 'keys' ? '🔑 API Keys' : tb === 'sessions' ? '🔐 Sessions' : tb === 'settings' ? '⚙️ Settings' : tb === 'history' ? '📈 History' : '📊 Backtest'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -712,5 +802,6 @@ export default function ProfilePage() {
|
|||||||
{tab === 'history' && <HistoryTab />}
|
{tab === 'history' && <HistoryTab />}
|
||||||
{tab === 'backtest' && <BacktestTab />}
|
{tab === 'backtest' && <BacktestTab />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
/* ===== Trading Portal - Global Styles ===== */
|
/* ===== 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) --- */
|
/* --- CSS Variables (Dark Theme) --- */
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0d1117;
|
--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 loading for placeholders */
|
||||||
.pulse {
|
.pulse {
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const ACTION_OPTIONS = [
|
|||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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() {
|
export default function AuditLogPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -132,17 +136,17 @@ export default function AuditLogPage() {
|
|||||||
permission_change: '#ff7b72',
|
permission_change: '#ff7b72',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionBadge = (action: string) => {
|
const getActionBadge = (action: string): React.CSSProperties => ({
|
||||||
const color = actionColors[action] || '#8b949e';
|
color: actionColors[action] || '#8b949e',
|
||||||
return { color, fontWeight: 600 };
|
fontWeight: 600,
|
||||||
};
|
});
|
||||||
|
|
||||||
const formatDetails = (details: Record<string, unknown> | null) => {
|
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);
|
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 (
|
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(', ')}
|
{entries.map(([k, v]) => `${k}: ${String(v ?? '')}`).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -152,50 +156,40 @@ export default function AuditLogPage() {
|
|||||||
if (!user?.is_admin) return null;
|
if (!user?.is_admin) return null;
|
||||||
if (initializing || isLoading) {
|
if (initializing || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="flex min-h-screen items-center justify-center bg-bg-primary">
|
||||||
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading audit logs...</div>
|
<div className="text-sm text-text-secondary">Loading audit logs...</div>
|
||||||
</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 (
|
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 ═══ */}
|
{/* ═══ Navbar ═══ */}
|
||||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
|
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div className="flex items-center gap-3">
|
||||||
<span style={{ fontSize: 18 }}>📋</span>
|
<span className="text-lg">📋</span>
|
||||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Audit Log</span>
|
<span className="text-base font-bold text-text-heading">Audit Log</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="flex items-center gap-2">
|
||||||
<button style={btnStyle} onClick={() => navigate('/admin')}>← Admin Panel</button>
|
<button className={btnClass} onClick={() => navigate('/admin')}>← Admin Panel</button>
|
||||||
<button style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }} onClick={async () => {
|
<button
|
||||||
|
className={`${btnClass} border-red/20 text-red`}
|
||||||
|
onClick={async () => {
|
||||||
await dispatch(logoutThunk());
|
await dispatch(logoutThunk());
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
🚪 Logout
|
🚪 Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ═══ Controls ═══ */}
|
{/* ═══ 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
|
<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}
|
value={actionFilter}
|
||||||
onChange={(e) => handleFilterChange(e.target.value)}
|
onChange={(e) => handleFilterChange(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -204,92 +198,98 @@ export default function AuditLogPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<span style={{ fontSize: 12, color: '#484f58' }}>
|
<span className="text-xs text-text-dim">
|
||||||
{total > 0 ? `${total} entries` : ''}
|
{total > 0 ? `${total} entries` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ Table ═══ */}
|
{/* ═══ Table ═══ */}
|
||||||
<div style={{ padding: 16, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
<div className="scrollable-x overflow-x-auto p-4">
|
||||||
{loading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading audit logs...</p>}
|
{loading && <p className="text-[13px] text-text-dim">Loading audit logs...</p>}
|
||||||
{error && <p style={{ color: '#f85149', fontSize: 13 }}>{error}</p>}
|
{error && <p className="text-[13px] text-red">{error}</p>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
{logs.length === 0 ? (
|
{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.
|
No audit log entries found.
|
||||||
</div>
|
</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>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
<tr className="border-b border-border-default">
|
||||||
<th style={thStyle}>Time</th>
|
<th className={thClass}>Time</th>
|
||||||
<th style={thStyle}>User</th>
|
<th className={thClass}>User</th>
|
||||||
<th style={thStyle}>Action</th>
|
<th className={thClass}>Action</th>
|
||||||
<th style={thStyle}>Resource</th>
|
<th className={thClass}>Resource</th>
|
||||||
<th style={thStyle}>Details</th>
|
<th className={thClass}>Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{logs.map((entry) => (
|
{logs.map((entry) => (
|
||||||
<tr key={entry.id} style={{ borderBottom: '1px solid #21262d' }}>
|
<tr key={entry.id} className="border-b border-border-muted">
|
||||||
<td style={{ ...tdStyle, color: '#484f58', whiteSpace: 'nowrap' }}>
|
<td className={`${tdClass} whitespace-nowrap text-text-dim`}>
|
||||||
{formatTime(entry.created_at)}
|
{formatTime(entry.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
{entry.user_id ? (
|
{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)}...
|
{entry.user_id.substring(0, 8)}...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#484f58' }}>System</span>
|
<span className="text-text-dim">System</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
<span style={getActionBadge(entry.action)}>{entry.action}</span>
|
<span style={getActionBadge(entry.action)}>{entry.action}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...tdStyle, color: '#f0f6fc', fontFamily: 'monospace', fontSize: 11 }}>
|
<td className={`${tdClass} font-mono text-[11px] text-text-heading`}>
|
||||||
{entry.resource}
|
{entry.resource}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td className={tdClass}>
|
||||||
{formatDetails(entry.details)}
|
{formatDetails(entry.details)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══ Pagination ═══ */}
|
{/* ═══ Pagination ═══ */}
|
||||||
{totalPages > 1 && (
|
{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
|
<button
|
||||||
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
|
className={btnClass}
|
||||||
|
style={{ opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
onClick={() => goToPage(1)}
|
onClick={() => goToPage(1)}
|
||||||
>
|
>
|
||||||
««
|
««
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
|
className={btnClass}
|
||||||
|
style={{ opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
>
|
>
|
||||||
«
|
«
|
||||||
</button>
|
</button>
|
||||||
<span style={{ fontSize: 12, color: '#8b949e', padding: '0 8px' }}>
|
<span className="px-2 text-xs text-text-secondary">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
className={btnClass}
|
||||||
|
style={{ opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
>
|
>
|
||||||
»
|
»
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
className={btnClass}
|
||||||
|
style={{ opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
onClick={() => goToPage(totalPages)}
|
onClick={() => goToPage(totalPages)}
|
||||||
>
|
>
|
||||||
@@ -303,11 +303,3 @@ export default function AuditLogPage() {
|
|||||||
</div>
|
</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',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.svg', 'icons.svg'],
|
includeAssets: ['favicon.svg', 'icons.svg'],
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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."
|
||||||
@@ -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) |
|
||||||
Reference in New Issue
Block a user