Initial commit: Trading Portal - FastAPI + React + PostgreSQL

This commit is contained in:
2026-07-03 13:08:09 +00:00
commit 34a1e91541
198 changed files with 35110 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Quick: just update is_trading flag from pre-computed top 100 base symbols."""
import os
import asyncio
import asyncpg
DB_URL = os.environ.get("DATABASE_URL", "postgresql://trading:***@db:5432/trading_portal")
# Convert asyncpg URL to postgresql
if "+asyncpg" in DB_URL:
DB_URL = DB_URL.replace("+asyncpg", "")
TOP100_BASES = [
"BTC","ETH","SOL","XRP","USD1","DOGE","XLM","BNB","NEAR","ENA",
"WLD","ADA","SUI","RE","AAVE","TRX","TAO","LTC","XPL","LINK",
"AVAX","PYTH","BCH","DYDX","ORDI","UNI","HBAR","JTO","ONDO","DOT",
"TIA","TRUMP","INJ","APT","BIO","FIL","ARB","MANTA","MEGA","ACT",
"ZBT","ESP","ICP","ASTER","CHZ","OP","ID","ZRO","RENDER","HEI",
"VIRTUAL","OPG","ATOM","PENDLE","SEI","STRK","FF","ALLO","ALGO","LDO",
"EIGEN","CRV","BICO","APE","ETHFI","POL","KITE","ZAMA","ETC","AXS",
"STG","S","RESOLV","SAHARA","SKY","MORPHO","CHIP","IOTA","LRC","SAND",
"EGLD","BEL","GRT","CAKE","AR","PORTAL","TNSR","IO","ME","KAITO",
"HOME","SPK","MITO","AVNT","MMT","OPN","CFG","GENIUS","NEO","ONT",
]
async def main():
conn = await asyncpg.connect(DB_URL)
try:
async with conn.transaction():
await conn.execute("UPDATE symbols SET is_trading = false")
result = await conn.execute(
"""
UPDATE symbols s
SET is_trading = true
FROM exchanges e
WHERE s.exchange_id = e.id
AND s.base = ANY($1::text[])
AND s.quote = 'USDT'
AND s.symbol NOT LIKE '%:%' -- exclude futures/options (USDT:USDT-...)
AND s.is_active = true
AND e.is_active = true
""",
TOP100_BASES,
)
print(f"UPDATE result: {result}")
verify = await conn.fetch("""
SELECT e.name as exchange, COUNT(*) as cnt
FROM symbols s
JOIN exchanges e ON e.id = s.exchange_id
WHERE s.is_trading = true
GROUP BY e.name
ORDER BY e.name
""")
total = 0
print("=== SYMBOLS PER EXCHANGE (is_trading=true) ===")
for row in verify:
print(f" {row['exchange']}: {row['cnt']}")
total += row['cnt']
print(f" TOTAL: {total}")
# Check missing bases
db_bases = await conn.fetch("SELECT DISTINCT base FROM symbols WHERE is_trading = true")
db_set = {r["base"] for r in db_bases}
missing = [b for b in TOP100_BASES if b not in db_set]
if missing:
print(f"WARNING: bases NOT in DB: {missing}")
finally:
await conn.close()
asyncio.run(main())
+458
View File
@@ -0,0 +1,458 @@
"""Backtest script: replay historical candles with PnL + Win Rate simulation.
Usage:
docker exec trading-backend python3 /opt/backtest.py --symbol BTC/USDT --timeframe 30m --days 30 --trade-size 10
Or from host:
python3 /opt/data/trading-portal/backend/scripts/backtest.py --symbol BTC/USDT --timeframe 30m --days 30
"""
import asyncio
import argparse
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from collections import defaultdict
import sys
sys.path.insert(0, "/app")
from app.database import async_session_factory
from app.models.candle import Candle
from app.models.symbol import Symbol
from app.models.exchange import Exchange
from sqlalchemy import select, and_
from app.services.indicator_service import (
bollinger_bands, rsi, sma, macd, supertrend,
volume_breakout, ichimoku, detect_divergence, market_structure,
)
from app.services.signal_service import (
_classify_signal_combined, _classify_signal_bb, _detect_squeeze,
SQUEEZE_ALERT, CAUTION_LONG, CAUTION_SHORT,
STRONG_BUY, BUY, STRONG_SELL, SELL,
)
TRADE_SIZE = Decimal("10") # USDT per trade
MAX_HOLD_CANDLES = 48 # Max candles before auto-close (48 * 30m = 24h for 30m)
# P1-24: Realistic trading costs for backtest
SPREAD_BPS = 5 # 0.05% spread (half-spread per side)
FEE_BPS = 10 # 0.10% fee (maker 0.05% + taker 0.05%)
SLIPPAGE_BPS = 5 # 0.05% slippage
TOTAL_COST_PCT = (SPREAD_BPS + FEE_BPS + SLIPPAGE_BPS) / 10000.0
def _net_pnl(entry: float, exit: float, qty: float, direction: str) -> tuple[float, float]:
"""Compute PnL with spread, fee, and slippage costs deducted."""
notional = entry * qty
cost = notional * TOTAL_COST_PCT # entry cost (spread+fee+slippage)
# Also pay cost on exit for realistic round-trip
exit_notional = exit * qty
exit_cost = exit_notional * TOTAL_COST_PCT
if direction == "LONG":
gross = (exit - entry) * qty
else:
gross = (entry - exit) * qty
net = gross - cost - exit_cost
net_pct = (net / notional) * 100 if notional > 0 else 0.0
return net, net_pct
async def get_mtf_indicators(
prices: list[float],
candle_dicts: list[dict],
tf_multiplier: int,
) -> dict:
"""Resample candles to a different timeframe and compute indicators.
tf_multiplier: how many main-TF candles per MTF candle (e.g. 2 for 1h from 30m).
"""
if len(candle_dicts) < tf_multiplier * 3:
return {}
# Aggregate candles into MTF candles
mtf_candles: list[dict] = []
for i in range(0, len(candle_dicts) - tf_multiplier + 1, tf_multiplier):
chunk = candle_dicts[i:i + tf_multiplier]
mtf_open = chunk[0]["open"]
mtf_high = max(c["high"] for c in chunk)
mtf_low = min(c["low"] for c in chunk)
mtf_close = chunk[-1]["close"]
mtf_volume = sum(c["volume"] for c in chunk)
mtf_candles.append({
"open": mtf_open, "high": mtf_high, "low": mtf_low,
"close": mtf_close, "volume": mtf_volume,
})
if len(mtf_candles) < 60:
return {}
mtf_prices = [c["close"] for c in mtf_candles]
return {
"bb": bollinger_bands(mtf_prices),
"rsi": rsi(mtf_prices),
"sma": sma(mtf_prices, 20),
"macd": macd(mtf_prices),
"st": supertrend(mtf_candles),
"vb": volume_breakout(mtf_candles),
"ichi": ichimoku(mtf_candles),
"smc": market_structure(mtf_candles),
}
def classify_mtf(mtf_inds: dict, latest_close: float) -> tuple:
"""Classify a single MTF timeframe and return a vote."""
if not mtf_inds or not mtf_inds.get("bb"):
return None, None
rsi_vals = mtf_inds.get("rsi", [])
mtf_rsi_div = detect_divergence(
[c["close"] for c in mtf_inds.get("_candles", [])],
rsi_vals,
)
macd_data = mtf_inds.get("macd", {})
macd_hist = macd_data.get("histogram", [None] * (rsi_vals and len(rsi_vals) or 0))
mtf_macd_div = detect_divergence(
[c["close"] for c in mtf_inds.get("_candles", [])],
macd_hist,
)
mtf_sig, mtf_str, _ = _classify_signal_combined(
latest_close,
mtf_inds["bb"], mtf_inds.get("rsi"), mtf_inds.get("sma"),
mtf_inds.get("macd"), mtf_inds.get("st"), mtf_inds.get("vb"),
mtf_inds.get("ichi"), mtf_rsi_div, mtf_macd_div, mtf_inds.get("smc"),
)
return mtf_sig, mtf_str
async def run_backtest(symbol: str, exchange: str, timeframe: str, days: int):
print(f"\n{'='*65}")
print(f"📊 BACKTEST: {symbol} @ {exchange} [{timeframe}] — {days} ngày")
print(f"{'='*65}\n")
async with async_session_factory() as db:
# Get symbol
result = await db.execute(
select(Symbol)
.join(Exchange, Exchange.id == Symbol.exchange_id)
.where(and_(Exchange.name == exchange, Symbol.symbol == symbol))
)
db_symbol = result.scalar_one_or_none()
if not db_symbol:
print(f"❌ Symbol {symbol} not found on {exchange}")
return
# Fetch candles
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(Candle)
.where(and_(
Candle.symbol_id == db_symbol.id,
Candle.timeframe == timeframe,
Candle.timestamp >= cutoff,
))
.order_by(Candle.timestamp.asc())
)
candles = result.scalars().all()
print(f"📈 {len(candles)} candles loaded")
if len(candles) < 60:
print(f"❌ Cần ít nhất 60 nến để backtest (chỉ có {len(candles)})")
return
# Determine MTF multipliers (relative to main TF)
tf_minutes = {"15m": 15, "30m": 30, "1h": 60, "4h": 240}
main_minutes = tf_minutes.get(timeframe, 30)
mtf_config = []
for mtf_tf, mtf_minutes in [("15m", 15), ("1h", 60), ("4h", 240)]:
if mtf_tf == timeframe:
continue
mult = mtf_minutes // main_minutes
if mult >= 1 and len(candles) >= mult * 60:
mtf_config.append((mtf_tf, mult, {"15m": 0.5, "1h": 1.5, "4h": 2.0}[mtf_tf]))
else:
print(f" ℹ️ Bỏ qua {mtf_tf} (cần {mult * 60} candles, chỉ có {len(candles)})")
if mtf_config:
print(f" 🔄 Multi-TF: {', '.join(t for t, _, _ in mtf_config)}")
else:
print(f" 🔄 Multi-TF: không đủ dữ liệu")
# ── Simulation ──
all_signals: list[tuple[str, str, str, float]] = [] # (timestamp, sig, strength, price)
trades: list[dict] = [] # simulated trades
current_position: dict | None = None
PnL_signals: list[dict] = [] # signals used for PnL simulation
for i in range(60, len(candles)):
candle = candles[i]
window = candles[:i + 1]
candle_dicts = [
{"high": float(c.high), "low": float(c.low),
"close": float(c.close), "volume": float(c.volume),
"open": float(c.open)}
for c in window
]
close_prices = [float(c.close) for c in window]
latest_close = float(candle.close)
timestamp = candle.timestamp.strftime("%m-%d %H:%M")
# Compute indicators
bb_data = bollinger_bands(close_prices)
rsi_data = rsi(close_prices)
sma_data = sma(close_prices, 20)
macd_data = macd(close_prices)
st_data = supertrend(candle_dicts)
vb_data = volume_breakout(candle_dicts)
ichi_data = ichimoku(candle_dicts)
smc_data = market_structure(candle_dicts)
# Divergence
rsi_vals = rsi_data if rsi_data else [None] * len(close_prices)
rsi_div = detect_divergence(close_prices, rsi_vals)
macd_hist = macd_data.get("histogram", [None] * len(close_prices)) if macd_data else [None] * len(close_prices)
macd_div = detect_divergence(close_prices, macd_hist)
# ── Multi-Timeframe votes ──
mtf_votes: list[tuple] = []
for mtf_name, mtf_mult, mtf_w in mtf_config:
mtf_inds = await get_mtf_indicators(close_prices, candle_dicts, mtf_mult)
mtf_sig, mtf_str = classify_mtf(mtf_inds, latest_close)
if mtf_sig and mtf_sig not in (CAUTION_LONG, CAUTION_SHORT, SQUEEZE_ALERT):
mtf_votes.append((mtf_sig, mtf_str, mtf_w))
# Classify
signal_type, strength, _ = _classify_signal_combined(
latest_close, bb_data, rsi_data if rsi_data else None,
sma_data if sma_data else None,
macd_data, st_data, vb_data, ichi_data,
rsi_div, macd_div, smc_data, mtf_votes if mtf_votes else None,
)
if signal_type:
all_signals.append((timestamp, signal_type, strength or "", latest_close))
# ── PnL Simulation (only STRONG can reverse) ──
if signal_type in (STRONG_BUY, BUY):
# BUY signal
if current_position and current_position["direction"] == "SHORT":
if signal_type == STRONG_BUY:
# Only STRONG_BUY can close SHORT
entry = current_position["entry_price"]
qty = current_position["quantity"]
pnl, pnl_pct = _net_pnl(entry, latest_close, float(qty), "SHORT")
current_position["exit_price"] = latest_close
current_position["exit_time"] = timestamp
current_position["pnl"] = pnl
current_position["pnl_pct"] = pnl_pct
current_position["status"] = "CLOSED"
current_position["exit_reason"] = "REVERSAL"
trades.append(current_position)
current_position = None
else:
# Regular BUY can't reverse SHORT
continue
if not current_position:
qty = float(TRADE_SIZE) / latest_close
current_position = {
"direction": "LONG",
"entry_price": latest_close,
"entry_time": timestamp,
"quantity": qty,
"entry_signal": signal_type,
"entry_index": i,
"status": "OPEN",
}
elif signal_type in (STRONG_SELL, SELL):
# SELL signal
if current_position and current_position["direction"] == "LONG":
if signal_type == STRONG_SELL:
# Only STRONG_SELL can close LONG
entry = current_position["entry_price"]
qty = current_position["quantity"]
pnl, pnl_pct = _net_pnl(entry, latest_close, float(qty), "LONG")
current_position["exit_price"] = latest_close
current_position["exit_time"] = timestamp
current_position["pnl"] = pnl
current_position["pnl_pct"] = pnl_pct
current_position["status"] = "CLOSED"
current_position["exit_reason"] = "REVERSAL"
trades.append(current_position)
current_position = None
else:
# Regular SELL can't reverse LONG
continue
if not current_position:
qty = float(TRADE_SIZE) / latest_close
current_position = {
"direction": "SHORT",
"entry_price": latest_close,
"entry_time": timestamp,
"quantity": qty,
"entry_signal": signal_type,
"entry_index": i,
"status": "OPEN",
}
# ── Time limit check for open position ──
if current_position and current_position["status"] == "OPEN":
hold_candles = i - current_position["entry_index"]
if hold_candles >= MAX_HOLD_CANDLES:
entry = current_position["entry_price"]
qty = current_position["quantity"]
pnl, pnl_pct = _net_pnl(entry, latest_close, float(qty), current_position["direction"])
current_position["exit_price"] = latest_close
current_position["exit_time"] = timestamp
current_position["pnl"] = pnl
current_position["pnl_pct"] = pnl_pct
current_position["status"] = "CLOSED"
current_position["exit_reason"] = "TIME_LIMIT"
trades.append(current_position)
current_position = None
# Close any remaining open position at the last candle
if current_position and current_position["status"] == "OPEN":
last_close = float(candles[-1].close)
entry = current_position["entry_price"]
qty = current_position["quantity"]
pnl, pnl_pct = _net_pnl(entry, last_close, float(qty), current_position["direction"])
current_position["exit_price"] = last_close
current_position["exit_time"] = candles[-1].timestamp.strftime("%m-%d %H:%M")
current_position["pnl"] = pnl
current_position["pnl_pct"] = pnl_pct
current_position["status"] = "CLOSED"
current_position["exit_reason"] = "END_OF_DATA"
trades.append(current_position)
# ── Print Results ──
print(f"\n{'─'*65}")
print(f"📊 KẾT QUẢ PHÂN TÍCH & BACKTEST")
print(f"{'─'*65}")
# Signal counts
counts = defaultdict(int)
for _, sig, _, _ in all_signals:
counts[sig] += 1
if not all_signals:
print("❌ Không có tín hiệu nào được tạo ra")
return
print(f"\n📋 Tổng tín hiệu: {len(all_signals)}")
print(f" 🚀 STRONG_BUY: {counts.get('STRONG_BUY', 0)}")
print(f" 📈 BUY: {counts.get('BUY', 0)}")
print(f" 📉 SELL: {counts.get('SELL', 0)}")
print(f" 🔻 STRONG_SELL: {counts.get('STRONG_SELL', 0)}")
warnings = counts.get(CAUTION_LONG, 0) + counts.get(CAUTION_SHORT, 0)
squeezes = counts.get(SQUEEZE_ALERT, 0)
if warnings:
print(f" ⚠️ CAUTION: {warnings}")
if squeezes:
print(f" ⚡ SQUEEZE: {squeezes}")
# ── PnL Summary ──
closed_trades = [t for t in trades if t.get("status") == "CLOSED"]
winning_trades = [t for t in closed_trades if t.get("pnl", 0) > 0]
losing_trades = [t for t in closed_trades if t.get("pnl", 0) <= 0]
total_pnl = sum(t.get("pnl", 0) for t in closed_trades)
total_pnl_pct = sum(t.get("pnl_pct", 0) for t in closed_trades)
gross_profit = sum(t.get("pnl", 0) for t in winning_trades)
gross_loss = sum(t.get("pnl", 0) for t in losing_trades)
win_rate = (len(winning_trades) / len(closed_trades) * 100) if closed_trades else 0
profit_factor = abs(gross_profit / gross_loss) if gross_loss != 0 else float("inf")
avg_win = gross_profit / len(winning_trades) if winning_trades else 0
avg_loss = gross_loss / len(losing_trades) if losing_trades else 0
print(f"\n💰 KẾT QUẢ GIAO DỊCH (Trade Size: {TRADE_SIZE} USDT)")
print(f"{'─'*40}")
print(f" 📊 Tổng giao dịch: {len(closed_trades)}")
print(f" ✅ Thắng: {len(winning_trades)} ({win_rate:.1f}%)")
print(f" ❌ Thua: {len(losing_trades)} ({100 - win_rate:.1f}%)")
print(f" 💵 Tổng PnL: ${total_pnl:+.2f}")
print(f" 📈 Tổng PnL %: {total_pnl_pct:+.2f}%")
print(f" 🏆 Lợi nhuận gộp: ${gross_profit:.2f}")
print(f" 💸 Thua lỗ gộp: ${gross_loss:.2f}")
print(f" ⚡ Profit Factor: {profit_factor:.2f}x")
if avg_win and avg_loss:
print(f" 📈 Trung bình lãi: ${avg_win:.2f}")
print(f" 📉 Trung bình lỗ: ${avg_loss:.2f}")
if avg_loss != 0:
print(f" 🎯 Lãi/Lỗ ratio: {abs(avg_win / avg_loss):.2f}x")
# Best / worst trades
if closed_trades:
best = max(closed_trades, key=lambda t: t.get("pnl", 0))
worst = min(closed_trades, key=lambda t: t.get("pnl", 0))
print(f"\n 🥇 Giao dịch tốt nhất:")
print(f" {best.get('direction','')} {symbol} | Entry: ${best.get('entry_price',0):.4f} | "
f"Exit: ${best.get('exit_price',0):.4f} | PnL: ${best.get('pnl',0):+.2f}")
print(f" 🥉 Giao dịch tệ nhất:")
print(f" {worst.get('direction','')} {symbol} | Entry: ${worst.get('entry_price',0):.4f} | "
f"Exit: ${worst.get('exit_price',0):.4f} | PnL: ${worst.get('pnl',0):+.2f}")
# Per-signal breakdown
print(f"\n📊 CHI TIẾT THEO TÍN HIỆU")
print(f"{'─'*50}")
for sig_name in [STRONG_BUY, BUY, SELL, STRONG_SELL]:
sig_count = counts.get(sig_name, 0)
if sig_count == 0:
continue
sig_trades = [t for t in closed_trades if t.get("entry_signal") == sig_name]
sig_wins = sum(1 for t in sig_trades if t.get("pnl", 0) > 0)
sig_pnl = sum(t.get("pnl", 0) for t in sig_trades)
sig_wr = (sig_wins / len(sig_trades) * 100) if sig_trades else 0
icon = {"STRONG_BUY": "🚀", "BUY": "📈", "SELL": "📉", "STRONG_SELL": "🔻"}.get(sig_name, "❓")
print(f" {icon} {sig_name:14s}: {sig_count:3d} signals → {len(sig_trades):2d} trades, "
f"{sig_wins:2d}W/{len(sig_trades)-sig_wins}L, WR={sig_wr:.0f}%, PnL=${sig_pnl:+.2f}")
# Print last 15 signals
print(f"\n📋 {min(15, len(all_signals))} tín hiệu gần nhất:")
print(f" {'Thời gian':<14} {'Signal':<16} {'Giá':<12}")
print(f" {'─'*42}")
for ts, sig, st, price in all_signals[-15:]:
icon = {"STRONG_BUY": "🚀", "BUY": "📈", "STRONG_SELL": "🔻", "SELL": "📉",
CAUTION_LONG: "⚠️", CAUTION_SHORT: "⚠️", SQUEEZE_ALERT: "⚡"}.get(sig, "❓")
print(f" {ts:<14} {icon} {sig:<13} {price:<10.4f}")
# Print last 10 trades
if closed_trades:
print(f"\n📋 {min(10, len(closed_trades))} giao dịch gần nhất:")
print(f" {'Thời gian':<14} {'Loại':<6} {'Entry':<10} {'Exit':<10} {'PnL':<10}")
print(f" {'─'*50}")
for t in closed_trades[-10:]:
pnl_str = f"${t.get('pnl',0):+.2f}"
print(f" {t.get('entry_time',''):<14} {t.get('direction',''):<6} "
f"{t.get('entry_price',0):<10.4f} {t.get('exit_price',0):<10.4f} "
f"{pnl_str:<10}")
def main():
parser = argparse.ArgumentParser(description="Backtest signal algorithms with PnL + Win Rate")
parser.add_argument("--symbol", default="BTC/USDT", help="Symbol to test")
parser.add_argument("--exchange", default="mexc", help="Exchange")
parser.add_argument("--timeframe", default="30m", help="Timeframe")
parser.add_argument("--days", type=int, default=7, help="Days of history")
parser.add_argument("--trade-size", type=float, default=10, help="Trade size in USDT")
args = parser.parse_args()
global TRADE_SIZE
TRADE_SIZE = Decimal(str(args.trade_size))
asyncio.run(run_backtest(
symbol=args.symbol,
exchange=args.exchange,
timeframe=args.timeframe,
days=args.days,
))
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
"""P2-9: Strategy backup utility.
Exports all enabled strategies to a JSON file for disaster recovery.
Run manually or via cron.
Usage:
python3 /app/scripts/backup_strategies.py
python3 /app/scripts/backup_strategies.py --restore backup.json
"""
import argparse
import json
import sys
from datetime import datetime, timezone
sys.path.insert(0, "/app")
from app.database import async_session_factory
from app.models import User
from sqlalchemy import select
BACKUP_DIR = "/app/backups"
async def backup_strategies(output_path: str | None = None):
"""Export all user strategy preferences."""
if output_path is None:
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_path = f"{BACKUP_DIR}/strategies_{ts}.json"
async with async_session_factory() as db:
result = await db.execute(select(User).where(User.is_active == True))
users = result.scalars().all()
backup = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"version": "2.0",
"users": [],
}
for u in users:
prefs = u.preferences or {}
backup["users"].append({
"username": u.username,
"email": u.email,
"enabled_strategies": prefs.get("enabled_strategies", []),
"auto_trade": prefs.get("auto_trade", False),
"auto_trade_tokens": prefs.get("auto_trade_tokens", []),
"trade_size": prefs.get("trade_size", 10),
"timeframe": prefs.get("timeframe", "30m"),
})
# Ensure backup dir exists
import os
os.makedirs(BACKUP_DIR, exist_ok=True)
with open(output_path, "w") as f:
json.dump(backup, f, indent=2)
print(f"✅ Strategy backup saved to {output_path}")
print(f" {len(backup['users'])} user(s) exported")
async def restore_strategies(input_path: str, dry_run: bool = False):
"""Restore strategy preferences from a backup file."""
with open(input_path) as f:
backup = json.load(f)
print(f"📥 Restoring from {input_path}")
print(f" Exported at: {backup.get('exported_at', 'unknown')}")
print(f" Users: {len(backup['users'])}")
if dry_run:
print(" 🔍 DRY RUN — no changes made")
print()
async with async_session_factory() as db:
for bu in backup["users"]:
result = await db.execute(select(User).where(User.username == bu["username"]))
user = result.scalar_one_or_none()
if not user:
print(f" ⚠️ User '{bu['username']}' not found — skipping")
continue
prefs = user.preferences or {}
prefs["enabled_strategies"] = bu["enabled_strategies"]
prefs["auto_trade"] = bu["auto_trade"]
prefs["auto_trade_tokens"] = bu["auto_trade_tokens"]
prefs["trade_size"] = bu["trade_size"]
prefs["timeframe"] = bu["timeframe"]
user.preferences = prefs
db.add(user)
print(f" ✅ {bu['username']}: {len(bu['enabled_strategies'])} strategies, "
f"auto_trade={'ON' if bu['auto_trade'] else 'OFF'}")
if not dry_run:
await db.commit()
print("\n✅ Restore complete")
async def main():
parser = argparse.ArgumentParser(description="P2-9: Strategy backup/restore")
parser.add_argument("--restore", type=str, help="Restore from backup file")
parser.add_argument("--dry-run", action="store_true", help="Preview restore without changes")
parser.add_argument("--output", type=str, help="Custom output path for backup")
args = parser.parse_args()
if args.restore:
await restore_strategies(args.restore, dry_run=args.dry_run)
else:
await backup_strategies(args.output)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Export all trading symbols (is_trading=true) to Excel."""
import os
import asyncio
import asyncpg
import pandas as pd
from datetime import datetime
DB_URL = os.environ.get("DATABASE_URL", "postgresql://trading:***@db:5432/trading_portal")
if "+asyncpg" in DB_URL:
DB_URL = DB_URL.replace("+asyncpg", "")
OUTPUT_DIR = "/opt/data"
OUTPUT_FILE = f"{OUTPUT_DIR}/trading_symbols_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
async def main():
conn = await asyncpg.connect(DB_URL)
try:
rows = await conn.fetch("""
SELECT e.name as exchange, s.symbol, s.base, s.quote, s.id
FROM symbols s
JOIN exchanges e ON e.id = s.exchange_id
WHERE s.is_trading = true
ORDER BY e.name, s.symbol
""")
data = [dict(r) for r in rows]
print(f"Got {len(data)} rows")
df = pd.DataFrame(data)
# Summary per exchange
summary = df.groupby("exchange").agg(
symbol_count=("symbol", "count"),
examples=("symbol", lambda x: ", ".join(x.head(5)))
).reset_index()
summary.columns = ["Sàn", "Số lượng", "Ví dụ"]
# Full list
full = df[["exchange", "symbol", "base", "quote"]]
full.columns = ["Sàn", "Symbol", "Base", "Quote"]
# Write to Excel with multiple sheets
with pd.ExcelWriter(OUTPUT_FILE, engine="openpyxl") as writer:
summary.to_excel(writer, sheet_name="Tổng quan", index=False)
full.to_excel(writer, sheet_name="Chi tiết", index=False)
# Per exchange sheets
for ex in sorted(df["exchange"].unique()):
ex_df = df[df["exchange"] == ex][["symbol", "base", "quote"]]
ex_df.columns = ["Symbol", "Base", "Quote"]
sheet_name = ex.capitalize()[:31]
ex_df.to_excel(writer, sheet_name=sheet_name, index=False)
print(f"Exported to {OUTPUT_FILE}")
finally:
await conn.close()
asyncio.run(main())
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Cron job: fetch candles for watched symbols x ALL timeframes (except 1m, 5m).
Gets watched symbols from API, adds default BTC/USDT, then fetches.
Timeframes: 15m, 30m, 1h, 4h, 1d, 1w, 1M
"""
import urllib.request, urllib.error, json, sys
BASE = "http://localhost:8001"
TIMEFRAMES = ["15m", "30m", "1h", "4h", "1d", "1w", "1M"]
LIMIT = 200
TIMEOUT_PER = 25
MAX_CALLS = 30 # max API calls total (3 symbols x 7 TFs = 21 + buffer)
# --- 1. Get token ---
# Use env vars if available, fallback to file-based credentials
import os
DEMO_USER = os.environ.get("DEMO_USER", "")
DEMO_PASS = os.environ.get("DEMO_PASS", "")
if not DEMO_USER or not DEMO_PASS:
# Fallback: read from .env file in project root
env_file = os.path.join(os.path.dirname(__file__), "..", ".env")
if os.path.exists(env_file):
with open(env_file) as f:
for line in f:
line = line.strip()
if line.startswith("DEMO_USER="):
DEMO_USER = line.split("=", 1)[1]
elif line.startswith("DEMO_PASS="):
DEMO_PASS = line.split("=", 1)[1]
if not DEMO_USER or not DEMO_PASS:
print("FAIL: No credentials configured (set DEMO_USER/DEMO_PASS env vars or .env file)")
sys.exit(1)
try:
body = json.dumps({"username": DEMO_USER, "password": DEMO_PASS}).encode()
req = urllib.request.Request(BASE + "/api/v1/auth/login",
data=body, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=10) as r:
token = json.loads(r.read())["access_token"]
except Exception as e:
print("FAIL: get token -", str(e))
sys.exit(1)
auth = "Bearer " + token
hdr = {"Content-Type": "application/json", "Authorization": auth}
# --- 2. Get watched symbols ---
watched = set()
# Demo user watchlist
try:
req = urllib.request.Request(BASE + "/api/v1/watchlist", headers=hdr)
with urllib.request.urlopen(req, timeout=10) as r:
items = json.loads(r.read())
for i in items if isinstance(items, list) else []:
if isinstance(i, dict) and i.get("symbol"):
watched.add(i["symbol"])
except Exception:
pass
# Also try admin endpoint for all users
try:
req = urllib.request.Request(BASE + "/api/v1/watchlist", headers=hdr)
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read())
for i in data.get("items", []) if isinstance(data, dict) else data if isinstance(data, list) else []:
if isinstance(i, dict) and i.get("symbol"):
watched.add(i["symbol"])
except Exception:
pass
# Add defaults
for d in ["BTC/USDT", "ETH/USDT"]:
watched.add(d)
watched_list = sorted(watched)
print("Symbols (" + str(len(watched_list)) + "): " + ", ".join(watched_list))
print("Timeframes: " + ", ".join(TIMEFRAMES))
print("Total calls: " + str(len(watched_list) * len(TIMEFRAMES)))
print()
# --- 3. Fetch candles (max MAX_CALLS total) ---
ok = fail = 0
errs = []
calls = 0
for sym in watched_list:
for tf in TIMEFRAMES:
if calls >= MAX_CALLS:
break
calls += 1
try:
payload = json.dumps({"symbol": sym, "timeframe": tf, "limit": LIMIT}).encode()
req3 = urllib.request.Request(BASE + "/api/v1/exchanges/1/fetch_candles",
data=payload, headers=hdr, method="POST")
with urllib.request.urlopen(req3, timeout=TIMEOUT_PER) as r:
data = json.loads(r.read())
cnt = len(data.get("candles", [])) if isinstance(data, dict) else (len(data) if isinstance(data, list) else 0)
if cnt > 0 or "message" in data:
ok += 1
else:
fail += 1
errs.append(sym + "/" + tf + " (0 candles)")
except Exception as e:
fail += 1
errs.append(sym + "/" + tf + " (" + str(e)[:60] + ")")
print("OK=" + str(ok) + " FAIL=" + str(fail))
if errs:
for e in errs[:8]:
print(" " + e)
if len(errs) > 8:
print(" ... +" + str(len(errs) - 8) + " more")
+232
View File
@@ -0,0 +1,232 @@
"""
Scan CCXT 5 exchanges for all USDT pairs, filter by quality criteria,
score by volume (60%) + exchange coverage (40%), and select top 100.
Updates symbols.is_trading = true for the top 100 bases across ALL exchanges.
"""
import asyncio
import logging
from collections import defaultdict
import ccxt.async_support as ccxt_async
import asyncpg
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger(__name__)
import os
DB_URL = os.environ.get("DATABASE_URL", "postgresql://trading:trading_secret@db:5432/trading_portal").replace(
"asyncpg", "postgresql"
).replace("+asyncpg", "")
MAX_SYMBOLS = 100
MIN_PRICE = 0.01
VOLUME_WEIGHT = 0.6
EXCHANGE_COVERAGE_WEIGHT = 0.4
EXCHANGES = ["binance", "bybit", "mexc", "gate", "bingx"]
STABLECOINS = {
"USDC", "BUSD", "DAI", "TUSD", "USDP", "USDD", "FDUSD", "USDE",
"USTC", "USDJ", "EURC", "USDS", "PYUSD", "CRVUSD", "GHO", "USR",
}
LEVERAGED_SUFFIXES = ("UP", "DOWN", "BULL", "BEAR")
ALLOWED_W = {"WBTC", "WETH", "WBNB", "WAVAX", "WSOL", "WMATIC", "WTRX", "WLD"}
def is_stablecoin(base: str) -> bool:
return base.upper() in STABLECOINS
def is_leveraged(base: str) -> bool:
upper = base.upper()
for sfx in LEVERAGED_SUFFIXES:
if upper.endswith(f"{sfx}") or f"{sfx}USDT" in upper:
return True
return False
def is_valid_symbol(base: str, quote: str, price: float) -> bool:
if quote.upper() != "USDT":
return False
if price is not None and price < MIN_PRICE:
return False
if is_stablecoin(base):
return False
if is_leveraged(base):
return False
if base.upper().startswith("W") and base.upper() not in ALLOWED_W:
return False
return True
def safe_ticker_field(ticker: dict, field: str, default=None):
"""Get a field from ticker, handling None values."""
val = ticker.get(field, default)
return val if val is not None else default
async def fetch_exchange_tickers(exchange_id: str) -> list[dict]:
exchange_class = getattr(ccxt_async, exchange_id, None)
if exchange_class is None:
logger.warning("CCXT class not found for %s", exchange_id)
return []
exchange = exchange_class({"enableRateLimit": True, "timeout": 30000})
try:
logger.info("Fetching tickers from %s...", exchange_id)
tickers = await exchange.fetch_tickers()
logger.info(" %s: got %d tickers total", exchange_id, len(tickers))
results = []
for symbol_str, ticker in tickers.items():
# Parse symbol parts
if "/" in symbol_str:
parts = symbol_str.split("/")
base_raw = parts[0]
quote_raw = parts[1].split(":")[0] # handle "USDT:USDC" etc
else:
base_raw = ticker.get("base", "")
quote_raw = ticker.get("quote", "")
# Safely extract numeric fields
last = safe_ticker_field(ticker, "last", 1.0)
if last is None:
last = 1.0
if not is_valid_symbol(base_raw, quote_raw, last):
continue
# Volume: prefer quoteVolume, fallback to baseVolume * last
quote_vol = safe_ticker_field(ticker, "quoteVolume", 0)
if quote_vol and quote_vol > 0:
volume = quote_vol
else:
base_vol = safe_ticker_field(ticker, "baseVolume", 0) or 0
volume = float(base_vol) * float(last) if base_vol else 0
results.append({
"base": base_raw.upper(),
"quote": "USDT",
"exchange": exchange_id,
"last_price": last,
"volume_24h": volume,
"symbol_str": symbol_str,
})
logger.info(" %s: %d valid USDT trading pairs", exchange_id, len(results))
return results
except Exception as e:
logger.error("Error fetching %s: %s", exchange_id, str(e))
return []
finally:
await exchange.close()
async def main():
# 1. Fetch all tickers
all_tickers = []
for ex in EXCHANGES:
tickers = await fetch_exchange_tickers(ex)
all_tickers.extend(tickers)
logger.info("Total valid USDT pairs: %d", len(all_tickers))
# 2. Group by base, compute exchange coverage + total volume
by_base: dict[str, dict] = defaultdict(lambda: {
"exchanges": set(),
"total_volume": 0.0,
"prices": [],
})
for t in all_tickers:
base = t["base"]
by_base[base]["exchanges"].add(t["exchange"])
by_base[base]["total_volume"] += t["volume_24h"]
by_base[base]["prices"].append(t["last_price"])
# 3. Score
max_volume = max((v["total_volume"] for v in by_base.values()), default=1.0)
max_exchanges = max((len(v["exchanges"]) for v in by_base.values()), default=1)
scored = []
for base, data in by_base.items():
volume_score = data["total_volume"] / max_volume if max_volume > 0 else 0
exchange_score = len(data["exchanges"]) / max_exchanges if max_exchanges > 0 else 0
final_score = volume_score * VOLUME_WEIGHT + exchange_score * EXCHANGE_COVERAGE_WEIGHT
avg_price = sum(data["prices"]) / len(data["prices"]) if data["prices"] else 0
scored.append({
"base": base,
"exchanges": sorted(data["exchanges"]),
"exchange_count": len(data["exchanges"]),
"volume_24h": data["total_volume"],
"avg_price": avg_price,
"score": round(final_score, 4),
})
scored.sort(key=lambda x: x["score"], reverse=True)
top100 = scored[:MAX_SYMBOLS]
logger.info("=== TOP %d TRADING SYMBOLS ===", MAX_SYMBOLS)
for i, s in enumerate(top100, 1):
vol_str = f"${s['volume_24h']/1e6:.1f}M" if s['volume_24h'] < 1e9 else f"${s['volume_24h']/1e9:.2f}B"
logger.info(
"%3d. %-10s | %d sàn (%s) | %s | score=%.4f | $%.4f",
i, s["base"], s["exchange_count"],
",".join(s["exchanges"]),
vol_str, s["score"], s["avg_price"],
)
# 4. Update DB
bases = [s["base"] for s in top100]
logger.info("Updating is_trading flag for %d bases...", len(bases))
conn = await asyncpg.connect(DB_URL)
try:
async with conn.transaction():
await conn.execute("UPDATE symbols SET is_trading = false")
result = await conn.execute(
"""
UPDATE symbols s
SET is_trading = true
FROM exchanges e
WHERE s.exchange_id = e.id
AND s.base = ANY($1::text[])
AND s.is_active = true
AND e.is_active = true
""",
bases,
)
# result is like "UPDATE N"
logger.info("DB UPDATE result: %s", result)
# Verify
verify = await conn.fetch("""
SELECT e.name as exchange, COUNT(*) as cnt
FROM symbols s
JOIN exchanges e ON e.id = s.exchange_id
WHERE s.is_trading = true
GROUP BY e.name
ORDER BY e.name
""")
logger.info("=== SYMBOLS PER EXCHANGE (is_trading=true) ===")
total = 0
for row in verify:
logger.info(" %s: %d", row["exchange"], row["cnt"])
total += row["cnt"]
logger.info(" TOTAL: %d symbols across all exchanges", total)
# Show non-matching bases
verify_bases = await conn.fetch("""
SELECT DISTINCT base FROM symbols WHERE is_trading = true ORDER BY base
""")
db_bases = {r["base"] for r in verify_bases}
missing = [b for b in bases if b.upper() not in db_bases]
if missing:
logger.warning("Bases NOT found in DB: %s", missing[:20])
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Fetch candles for tracked symbols/timeframes and cleanup old 1m data.
Runs via cron every 30 minutes.
Timeframes retained:
1M, 1W, 1D, 4H, 1H, 30m -> unlimited (forever)
1m (minute) -> max 7 days
"""
import subprocess, json, sys
from datetime import datetime, timezone, timedelta
# --- Config ---
SYMBOLS = ["BTC/USDT", "ETH/USDT", "SOL/USDT"]
TIMEFRAMES = [
# (tf, fetch_limit) limit=0 means fetch all available
("1M", 200),
("1w", 600),
("1d", 2000),
("4h", 2000),
("1h", 2000),
("30m", 2000),
("1m", 10000), # ~7 days
]
API_BASE = "http://localhost:8001/api/v1"
EXCHANGE_ID = 1
MINUTE_RETENTION_DAYS = 7
def login():
r = subprocess.run([
"curl", "-s", "-X", "POST",
f"{API_BASE}/auth/login",
"-H", "Content-Type: application/json",
"-d", '{"username":"demo","password":"demo1234"}'
], capture_output=True, text=True, timeout=10)
try:
data = json.loads(r.stdout)
return data["access_token"]
except (json.JSONDecodeError, KeyError):
print("FAIL: Cannot login", r.stdout[:100])
sys.exit(1)
def fetch_candles(token, symbol, tf, limit):
"""Fetch candles for one symbol/timeframe."""
auth_h = "Authorization: Bearer " + token
payload = json.dumps({"symbol": symbol, "timeframe": tf, "limit": limit})
cmd = [
"curl", "-s", "-X", "POST",
f"{API_BASE}/exchanges/{EXCHANGE_ID}/fetch_candles",
"-H", auth_h,
"-H", "Content-Type: application/json",
"-d", payload
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return r.stdout.strip()
def cleanup_old_1m(token):
"""Delete 1m candles older than MINUTE_RETENTION_DAYS."""
cutoff = (datetime.now(timezone.utc) - timedelta(days=MINUTE_RETENTION_DAYS)).isoformat()
# Delete via direct DB
cmd = [
"docker", "exec", "trading-db", "psql", "-U", "trading", "trading_portal",
"-c",
f"DELETE FROM candles WHERE timeframe='1m' AND timestamp < '{cutoff}'::timestamptz;"
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
out = r.stdout.strip()
# Extract count from "DELETE N"
if out.startswith("DELETE"):
count = out.split()[1] if len(out.split()) > 1 else "?"
if count != "0":
print(f"CLEANUP: Deleted {count} old 1m candles (>{MINUTE_RETENTION_DAYS} days)")
return out
def main():
token = login()
total = 0
errors = 0
# 1. Fetch candles for all symbols x timeframes
for sym in SYMBOLS:
for tf, limit in TIMEFRAMES:
try:
result = fetch_candles(token, sym, tf, limit)
if '"message"' in result:
total += 1
else:
errors += 1
print(f"ERR {sym:>9} {tf:>4}: {result[:80]}")
except Exception as e:
errors += 1
print(f"EXC {sym:>9} {tf:>4}: {e}")
# 2. Cleanup old 1m data
cleanup_out = cleanup_old_1m(token)
print(f"DONE: {total}/{total+errors} fetches OK, {errors} errors")
if errors == 0 and total > 0:
print("All timeframes synced successfully!")
if __name__ == "__main__":
main()
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Train XGBoost model from collected algo_scores in signals.indicators_snapshot.
Run by cron job on Wednesday to update the signal fusion model.
Saves model to /opt/data/trading-portal/models/xgb_model.json
"""
import json
import sys
import os
from decimal import Decimal
from datetime import datetime, timezone
# Add project to path
sys.path.insert(0, '/opt/data/trading-portal/backend')
try:
import xgboost as xgb
import numpy as np
from sqlalchemy import select, text
from app.database import async_session_factory
from app.models.signal import Signal
except ImportError:
print("XGBoost not installed. Run: pip install xgboost")
sys.exit(1)
STRATEGY_ORDER = [
"double_bb_rsi", "macd_crossover", "supertrend",
"volume_breakout", "ichimoku", "divergence",
"smc", "mtf", "obv", "stoch_rsi",
"mfi", "fvg", "candlestick",
]
async def collect_training_data():
"""Fetch all signals with algo_scores + corresponding trade PnL."""
async with async_session_factory() as db:
result = await db.execute(
select(Signal)
.where(Signal.indicators_snapshot.isnot(None))
.order_by(Signal.created_at.desc())
)
signals = result.scalars().all()
features = []
labels = []
symbol_list = []
timestamps = []
for sig in signals:
try:
snap = json.loads(sig.indicators_snapshot)
except (json.JSONDecodeError, TypeError):
continue
scores = snap.get("algo_scores")
if not scores or not isinstance(scores, dict):
continue
# Build feature vector in STRATEGY_ORDER
feature_row = [scores.get(s, 0.0) for s in STRATEGY_ORDER]
# Add confidence
feature_row.append(snap.get("confidence", 0.5))
# P2-11: Enhanced feature engineering
# RSI value (momentum)
rsi_val = snap.get("rsi")
feature_row.append(float(rsi_val) / 100.0 if rsi_val is not None else 0.5)
# BB width (volatility indicator)
bb_upper = snap.get("bb_upper_2")
bb_lower = snap.get("bb_lower_2")
close_val = snap.get("close")
if bb_upper is not None and bb_lower is not None and close_val is not None and close_val > 0:
feature_row.append((float(bb_upper) - float(bb_lower)) / float(close_val))
else:
feature_row.append(0.0)
# Signal strength (numeric encoding)
strength_map = {"STRONG": 1.0, "MODERATE": 0.5, "WEAK": 0.2}
feature_row.append(strength_map.get(sig.strength, 0.5) if sig.strength else 0.5)
# One-hot encode market_regime
regime = snap.get("market_regime", "neutral")
for r in ["trending", "sideways", "volatile", "breakout", "squeeze", "neutral"]:
feature_row.append(1.0 if regime == r else 0.0)
# Label: use actual PnL from hypothetical_trades (joined by signal_id)
# Default to signal_type as fallback if no trade data exists
label = None
try:
from app.models.hypothetical_trade import HypotheticalTrade
trade_result = await db.execute(
select(HypotheticalTrade.pnl_percent)
.where(HypotheticalTrade.signal_id == sig.id)
.order_by(HypotheticalTrade.closed_at.desc().nulls_last())
.limit(1)
)
pnl_pct = trade_result.scalar()
if pnl_pct is not None:
label = 1.0 if float(pnl_pct) > 0 else 0.0
except Exception:
pass
if label is None:
# Fallback: signal_type as weak proxy
if sig.signal_type in ("STRONG_BUY", "BUY"):
label = 1.0
elif sig.signal_type in ("STRONG_SELL", "SELL"):
label = 0.0
else:
continue
features.append(feature_row)
labels.append(label)
symbol_list.append(sig.symbol)
timestamps.append(sig.created_at.isoformat() if sig.created_at else "")
return np.array(features), np.array(labels), symbol_list, timestamps
FEATURE_NAMES = STRATEGY_ORDER + ["confidence", "rsi", "bb_width", "signal_strength"] + [
f"regime_{r}" for r in ["trending", "sideways", "volatile", "breakout", "squeeze", "neutral"]
]
def main():
import asyncio
print("=" * 60)
print("📊 XGBoost Signal Fusion — Training Pipeline")
print(f" Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print("=" * 60)
# Collect data
X, y, symbols, timestamps = asyncio.run(collect_training_data())
if len(X) < 100:
print(f"\n❌ Not enough data: only {len(X)} samples (need ≥ 100)")
print(" Let the system run longer to accumulate features.")
sys.exit(1)
print(f"\n📈 Training data: {len(X)} samples")
print(f" Features: {len(FEATURE_NAMES)} ({', '.join(FEATURE_NAMES[:5])}...)")
print(f" Class balance: {y.mean():.1%} positive / {1-y.mean():.1%} negative")
# Split: 80% train, 20% test
split = int(len(X) * 0.8)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
# Train XGBoost
model = xgb.XGBClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
random_state=42,
eval_metric="logloss",
use_label_encoder=False,
)
model.fit(
X_train, y_train,
eval_set=[(X_test, y_test)],
verbose=False,
)
# Evaluate
train_acc = model.score(X_train, y_train)
test_acc = model.score(X_test, y_test)
from sklearn.metrics import classification_report
y_pred = model.predict(X_test)
print(f"\n🎯 Model Performance:")
print(f" Train accuracy: {train_acc:.1%}")
print(f" Test accuracy: {test_acc:.1%}")
print(f"\n Classification Report (test set):")
print(classification_report(y_test, y_pred, target_names=["SELL/WAIT", "BUY"], zero_division=0))
# Feature importance
importance = model.feature_importances_
top_idx = np.argsort(importance)[-5:][::-1]
print(f"\n🔑 Top 5 Features by Importance:")
for idx in top_idx:
print(f" {FEATURE_NAMES[idx]}: {importance[idx]:.3f}")
# Save model
os.makedirs("/opt/data/trading-portal/models", exist_ok=True)
model_path = "/opt/data/trading-portal/models/xgb_model.json"
model.save_model(model_path)
print(f"\n💾 Model saved to: {model_path}")
# Summary
print(f"\n{'='*60}")
print(f"✅ Training complete! Model ready for inference.")
print(f" Next step: Deploy model and replace linear voting in signal_service.py")
print(f"{'='*60}")
if __name__ == "__main__":
main()
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Weekly trading report — queries the last 7 days of signals and trades,
formats a summary, and prints it to stdout for Telegram delivery.
Usage:
python scripts/weekly_report.py
Requires:
psycopg2-binary
Environment variables (or .env):
TRADING_DB_HOST, TRADING_DB_PORT, TRADING_DB_NAME,
TRADING_DB_USER, TRADING_DB_PASSWORD
Or relies on psycopg2 defaults / pgpass.
Output: Markdown-formatted report printed to stdout.
"""
from __future__ import annotations
import os
import sys
from collections import Counter
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
try:
import psycopg2
except ImportError:
print("❌ psycopg2 not installed. Run: pip install psycopg2-binary", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DB_CONFIG = {
"host": os.environ.get("TRADING_DB_HOST", "trading-db"),
"port": int(os.environ.get("TRADING_DB_PORT", "5432")),
"dbname": os.environ.get("TRADING_DB_NAME", "trading_portal"),
"user": os.environ.get("TRADING_DB_USER", "trading"),
"password": os.environ.get("TRADING_DB_PASSWORD", "trading_secret"),
}
# Fallback: also try standard trading-portal .env variable names
if not os.environ.get("TRADING_DB_HOST"):
DB_CONFIG["host"] = os.environ.get("DB_HOST", "trading-db")
DB_CONFIG["port"] = int(os.environ.get("DB_PORT", "5432"))
DB_CONFIG["dbname"] = os.environ.get("DB_NAME", "trading_portal")
DB_CONFIG["user"] = os.environ.get("DB_USER", "trading")
DB_CONFIG["password"] = os.environ.get("DB_PASSWORD", "trading_secret")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_connection():
"""Create and return a database connection."""
try:
conn = psycopg2.connect(**DB_CONFIG)
conn.autocommit = True
return conn
except psycopg2.OperationalError as e:
print(f"❌ Database connection failed: {e}", file=sys.stderr)
sys.exit(1)
def query(conn, sql: str, params: tuple = ()) -> list[tuple]:
"""Execute a query and return all rows."""
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
def format_pnl(value: Decimal | None) -> str:
"""Format PnL with sign and $."""
if value is None:
return "$0.00"
v = float(value)
prefix = "+" if v >= 0 else ""
return f"{prefix}${v:.2f}"
def format_pct(value: Decimal | None) -> str:
"""Format percentage."""
if value is None:
return "0.0%"
return f"{float(value):.1f}%"
# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------
def generate_report() -> str:
"""Query the database and build the weekly report string.
Returns
-------
str
Markdown-formatted report for Telegram.
"""
conn = get_connection()
now = datetime.now(timezone.utc)
week_ago = now - timedelta(days=7)
start_str = week_ago.strftime("%d %b %H:%M UTC")
end_str = now.strftime("%d %b %H:%M UTC")
lines: list[str] = []
lines.append("📊 *Weekly Trading Report*")
lines.append(f"Period: {start_str} → {end_str}")
lines.append("")
# ------------------------------------------------------------------
# 1. Signal summary
# ------------------------------------------------------------------
sig_rows = query(
conn,
"""
SELECT signal_type, COUNT(*)
FROM signals
WHERE created_at >= %s
GROUP BY signal_type
ORDER BY signal_type
""",
(week_ago,),
)
total_signals = 0
signal_counts: dict[str, int] = {}
for sig_type, cnt in sig_rows:
signal_counts[sig_type] = cnt
total_signals += cnt
# Count each type
strong_buy = signal_counts.get("STRONG_BUY", 0)
buy = signal_counts.get("BUY", 0)
strong_sell = signal_counts.get("STRONG_SELL", 0)
sell = signal_counts.get("SELL", 0)
lines.append(
f"📈 *Signals:* {total_signals} "
f"({strong_buy} SB / {buy} B / {sell} S / {strong_sell} SS)"
)
# ------------------------------------------------------------------
# 2. Top 5 symbols
# ------------------------------------------------------------------
top5_rows = query(
conn,
"""
SELECT symbol, COUNT(*) as cnt
FROM signals
WHERE created_at >= %s
GROUP BY symbol
ORDER BY cnt DESC
LIMIT 5
""",
(week_ago,),
)
if top5_rows:
top5_str = ", ".join(f"{sym}({cnt})" for sym, cnt in top5_rows)
lines.append(f"🔥 *Hot symbols:* {top5_str}")
else:
lines.append("🔥 *Hot symbols:* (none)")
lines.append("")
# ------------------------------------------------------------------
# 3. Hypothetical trades summary
# ------------------------------------------------------------------
trade_rows = query(
conn,
"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) as losses,
SUM(pnl) as total_pnl
FROM hypothetical_trades
WHERE created_at >= %s
AND status = 'CLOSED'
""",
(week_ago,),
)
if trade_rows and trade_rows[0][0] and trade_rows[0][0] > 0:
total_trades = int(trade_rows[0][0])
wins = int(trade_rows[0][1] or 0)
losses = int(trade_rows[0][2] or 0)
total_pnl = trade_rows[0][3] or Decimal("0.00")
win_rate = (wins / total_trades * 100) if total_trades > 0 else 0.0
lines.append(
f"💰 *Trades:* {total_trades} ({wins}W/{losses}L) — WR: {win_rate:.1f}%"
)
lines.append(f"PnL: {format_pnl(total_pnl)}")
else:
lines.append("💰 *Trades:* 0 (0W/0L) — WR: 0.0%")
lines.append("PnL: $0.00")
# ------------------------------------------------------------------
# 4. Best / Worst symbol by PnL
# ------------------------------------------------------------------
best_worst_rows = query(
conn,
"""
SELECT symbol, SUM(pnl) as total_pnl
FROM hypothetical_trades
WHERE created_at >= %s
AND status = 'CLOSED'
GROUP BY symbol
ORDER BY total_pnl DESC
""",
(week_ago,),
)
if best_worst_rows:
best_symbol, best_pnl = best_worst_rows[0]
worst_symbol, worst_pnl = best_worst_rows[-1]
lines.append(f"Best: {best_symbol} ({format_pnl(best_pnl)})")
lines.append(f"Worst: {worst_symbol} ({format_pnl(worst_pnl)})")
else:
lines.append("Best: — ($0.00)")
lines.append("Worst: — ($0.00)")
# ------------------------------------------------------------------
# 5. Footer
# ------------------------------------------------------------------
lines.append("")
lines.append("━━━━━━━━━━━━━━━")
lines.append(f"_Generated {now.strftime('%Y-%m-%d %H:%M UTC')}_")
conn.close()
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
report = generate_report()
print(report)