662 lines
21 KiB
Python
662 lines
21 KiB
Python
import httpx
|
|
"""
|
|
Trading Bot Web Dashboard
|
|
Real-time tracking of trades, swaps, and performance
|
|
"""
|
|
|
|
from fastapi import FastAPI, WebSocket
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Dict, List
|
|
import os
|
|
|
|
app = FastAPI(title="Trading Bot Dashboard")
|
|
|
|
# Logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Shared state (will be updated by main_ml.py)
|
|
# RESET: Start with CLEAN state (no old test data)
|
|
trading_state = {
|
|
'current_trades': {}, # {pair: {qty, price, entry_time, ...}} - EMPTY
|
|
'completed_trades': [], # History of closed trades - EMPTY
|
|
'swaps': [], # Swap history - EMPTY
|
|
'balance': {'USDT': 0.0},
|
|
'daily_pnl': 0.0,
|
|
'total_pnl': 0.0,
|
|
'trades_today': 0,
|
|
'wins_today': 0,
|
|
'losses_today': 0,
|
|
'portfolio_value_usd': 0.0,
|
|
'portfolio_value_chf': 0.0,
|
|
'last_update': datetime.now().isoformat()
|
|
}
|
|
|
|
# WebSocket connections for live updates
|
|
active_connections: List[WebSocket] = []
|
|
|
|
async def broadcast_update():
|
|
"""Broadcast state update to all connected WebSocket clients"""
|
|
for connection in active_connections:
|
|
try:
|
|
await connection.send_json(trading_state)
|
|
except:
|
|
pass
|
|
|
|
@app.websocket("/ws")
|
|
async def websocket_endpoint(websocket: WebSocket):
|
|
"""WebSocket endpoint for live updates"""
|
|
await websocket.accept()
|
|
active_connections.append(websocket)
|
|
|
|
try:
|
|
# Send initial state
|
|
await websocket.send_json(trading_state)
|
|
|
|
# Keep connection alive
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
await websocket.send_json(trading_state)
|
|
except:
|
|
pass
|
|
finally:
|
|
active_connections.remove(websocket)
|
|
|
|
# @app.get("/api/state")
|
|
@app.get("/api/state")
|
|
async def get_state():
|
|
"""Proxy to State Manager"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
|
resp = await client.get("http://localhost:7001/api/bot-state")
|
|
return resp.json()
|
|
except:
|
|
return trading_state
|
|
async def get_state():
|
|
"""Get current trading state"""
|
|
return trading_state
|
|
|
|
|
|
@app.post("/api/clear")
|
|
async def clear_state():
|
|
"""RESET: Clear all historical data, start fresh"""
|
|
global trading_state
|
|
logger.info('🗑️ Dashboard state cleared')
|
|
trading_state = {
|
|
'current_trades': {},
|
|
'completed_trades': [],
|
|
'swaps': [],
|
|
'balance': {'USDT': 0.0},
|
|
'daily_pnl': 0.0,
|
|
'total_pnl': 0.0,
|
|
'trades_today': 0,
|
|
'wins_today': 0,
|
|
'losses_today': 0,
|
|
'portfolio_value_usd': 0.0,
|
|
'portfolio_value_chf': 0.0,
|
|
'last_update': datetime.now().isoformat()
|
|
}
|
|
await broadcast_update()
|
|
return {"status": "cleared"}
|
|
|
|
@app.post("/api/update")
|
|
async def update_state(data: dict):
|
|
"""Update trading state (called by main_ml.py)"""
|
|
global trading_state
|
|
|
|
# Explicitly set current_trades if provided (don't merge!)
|
|
if 'current_trades' in data:
|
|
trading_state['current_trades'] = data['current_trades']
|
|
data.pop('current_trades') # Remove so update() doesn't override
|
|
|
|
# Update rest of state
|
|
trading_state.update(data)
|
|
trading_state['last_update'] = datetime.now().isoformat()
|
|
|
|
logger.info(f'📊 Dashboard updated: USDT={trading_state["balance"].get("USDT", 0):.2f}, trades={len(trading_state.get("current_trades", {}))}')
|
|
|
|
# Broadcast to WebSocket clients
|
|
await broadcast_update()
|
|
return {"status": "updated"}
|
|
|
|
@app.post("/api/trade/buy")
|
|
async def record_buy(pair: str, qty: float, price: float, entry_time: str = None):
|
|
"""Record a buy trade"""
|
|
if entry_time is None:
|
|
entry_time = datetime.now().isoformat()
|
|
trading_state['current_trades'][pair] = {
|
|
'qty': qty,
|
|
'price': price,
|
|
'entry_time': entry_time,
|
|
'type': 'BUY'
|
|
}
|
|
trading_state['trades_today'] += 1
|
|
await broadcast_update()
|
|
return {"status": "recorded"}
|
|
|
|
@app.post("/api/trade/sell")
|
|
async def record_sell(pair: str, qty: float, price: float, profit_usd: float, profit_pct: float, hold_time_min: float):
|
|
"""Record a sell trade"""
|
|
entry = trading_state['current_trades'].pop(pair, {})
|
|
|
|
completed = {
|
|
'pair': pair,
|
|
'qty': qty,
|
|
'entry_price': entry.get('entry_price', 0),
|
|
'exit_price': price,
|
|
'profit_usd': profit_usd,
|
|
'profit_pct': profit_pct,
|
|
'hold_time_min': hold_time_min,
|
|
'entry_time': entry.get('entry_time', ''),
|
|
'exit_time': datetime.now().isoformat()
|
|
}
|
|
|
|
trading_state['completed_trades'].append(completed)
|
|
trading_state['daily_pnl'] += profit_usd
|
|
trading_state['total_pnl'] += profit_usd
|
|
|
|
if profit_pct >= 0:
|
|
trading_state['wins_today'] += 1
|
|
else:
|
|
trading_state['losses_today'] += 1
|
|
|
|
# Keep last 100 trades in history
|
|
if len(trading_state['completed_trades']) > 100:
|
|
trading_state['completed_trades'] = trading_state['completed_trades'][-100:]
|
|
|
|
await broadcast_update()
|
|
return {"status": "recorded"}
|
|
|
|
@app.post("/api/swap")
|
|
async def record_swap(from_asset: str, to_asset: str, qty: float, rate: float):
|
|
"""Record a swap transaction"""
|
|
swap_entry = {
|
|
'from': from_asset,
|
|
'to': to_asset,
|
|
'qty': qty,
|
|
'rate': rate,
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
|
|
trading_state['swaps'].append(swap_entry)
|
|
|
|
# Keep last 50 swaps in history
|
|
if len(trading_state['swaps']) > 50:
|
|
trading_state['swaps'] = trading_state['swaps'][-50:]
|
|
|
|
await broadcast_update()
|
|
return {"status": "recorded"}
|
|
|
|
@app.post("/api/liquidate")
|
|
async def trigger_liquidation():
|
|
"""FORCE LIQUIDATE: Marc calls this to sell ALL holdings immediately"""
|
|
if bot_instance is None:
|
|
return {"status": "error", "message": "Bot not running"}
|
|
|
|
logger.warning("🔥 MANUAL LIQUIDATION TRIGGERED")
|
|
result = await bot_instance.force_liquidate_all()
|
|
|
|
# Update dashboard with result
|
|
await broadcast_update()
|
|
|
|
return result
|
|
|
|
|
|
@app.get("/")
|
|
async def get_dashboard():
|
|
"""Serve dashboard HTML"""
|
|
return HTMLResponse(html_content)
|
|
|
|
@app.get("/api/state")
|
|
async def get_state():
|
|
"""Get current trading state"""
|
|
return trading_state
|
|
async def get_state():
|
|
"""Proxy to State Manager"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
|
resp = await client.get("http://localhost:7001/api/bot-state")
|
|
return resp.json()
|
|
except:
|
|
return trading_state
|
|
async def get_dashboard():
|
|
"""Serve web dashboard HTML"""
|
|
return HTMLResponse(html_content)
|
|
|
|
# HTML Dashboard
|
|
html_content = """
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Trading Bot Dashboard</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #0f0c29 0%, #302b63 100%);
|
|
color: #e0e0e0;
|
|
padding: 20px;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
border-bottom: 2px solid #6c63ff;
|
|
padding-bottom: 20px;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 2.5em;
|
|
color: #6c63ff;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.status {
|
|
display: inline-block;
|
|
padding: 8px 16px;
|
|
background: #00c853;
|
|
color: white;
|
|
border-radius: 20px;
|
|
font-weight: bold;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.card {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: 1px solid #6c63ff;
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
backdrop-filter: blur(10px);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.card:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border-color: #00c853;
|
|
transform: translateY(-5px);
|
|
}
|
|
|
|
.card-title {
|
|
color: #6c63ff;
|
|
font-size: 0.9em;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.card-value {
|
|
font-size: 2em;
|
|
font-weight: bold;
|
|
color: #e0e0e0;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.card-sub {
|
|
color: #a0a0a0;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.positive {
|
|
color: #00c853;
|
|
}
|
|
|
|
.negative {
|
|
color: #ff3d00;
|
|
}
|
|
|
|
.section {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.section-title {
|
|
color: #6c63ff;
|
|
font-size: 1.5em;
|
|
margin-bottom: 15px;
|
|
border-bottom: 1px solid #6c63ff;
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.trades-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
|
|
.trade-card {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-left: 4px solid #6c63ff;
|
|
border-radius: 5px;
|
|
padding: 15px;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.trade-card.open {
|
|
border-left-color: #6c63ff;
|
|
}
|
|
|
|
.trade-card.closed {
|
|
border-left-color: #00c853;
|
|
}
|
|
|
|
.trade-pair {
|
|
font-weight: bold;
|
|
color: #6c63ff;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.trade-info {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
font-size: 0.85em;
|
|
color: #a0a0a0;
|
|
}
|
|
|
|
.trade-info strong {
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.swap-item {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
margin-bottom: 8px;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.performance-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: rgba(108, 99, 255, 0.2);
|
|
border: 1px solid #6c63ff;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-label {
|
|
color: #a0a0a0;
|
|
font-size: 0.8em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.8em;
|
|
font-weight: bold;
|
|
color: #00c853;
|
|
}
|
|
|
|
.stat-value.loss {
|
|
color: #ff3d00;
|
|
}
|
|
|
|
.update-time {
|
|
text-align: right;
|
|
color: #666;
|
|
font-size: 0.8em;
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #333;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
.fade-in {
|
|
animation: fadeIn 0.3s ease-in;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🤖 Trading Bot Dashboard</h1>
|
|
<span class="status">● LIVE</span>
|
|
</div>
|
|
|
|
<!-- Key Metrics -->
|
|
<div class="grid">
|
|
<div class="card">
|
|
<div class="card-title">Liquid USDT</div>
|
|
<div class="card-value" id="usdt">0.00</div>
|
|
<div class="card-sub">Available Balance</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">Portfolio Value</div>
|
|
<div class="card-value" id="portfolio">$0.00</div>
|
|
<div class="card-sub">All Assets USD</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">Daily P&L</div>
|
|
<div class="card-value" id="daily-pnl">$0.00</div>
|
|
<div class="card-sub">Today's Profit/Loss</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">Total P&L</div>
|
|
<div class="card-value" id="total-pnl">$0.00</div>
|
|
<div class="card-sub">Lifetime Profit/Loss</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Performance Stats -->
|
|
<div class="section">
|
|
<div class="section-title">📊 Performance</div>
|
|
<div class="performance-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Trades Today</div>
|
|
<div class="stat-value" id="trades-count">0</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-label">Win Rate</div>
|
|
<div class="stat-value" id="win-rate">0%</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-label">Wins</div>
|
|
<div class="stat-value" id="wins">0</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-label">Losses</div>
|
|
<div class="stat-value loss" id="losses">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Open Trades -->
|
|
<div class="section">
|
|
<div class="section-title">📈 Open Trades</div>
|
|
<div class="trades-list" id="open-trades">
|
|
<div class="trade-card open">
|
|
<div class="trade-pair">No open trades</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Closed Trades -->
|
|
<div class="section">
|
|
<div class="section-title">✅ Recent Closed Trades</div>
|
|
<div class="trades-list" id="closed-trades">
|
|
<div class="trade-card closed">
|
|
<div class="trade-pair">No closed trades yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Swaps -->
|
|
<div class="section">
|
|
<div class="section-title">🔄 Recent Swaps</div>
|
|
<div id="swaps-list">
|
|
<div class="swap-item">No swaps yet</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="update-time">
|
|
Last update: <span id="update-time">--:--:--</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function formatCurrency(value) {
|
|
return new Intl.NumberFormat('de-CH', {
|
|
style: 'currency',
|
|
currency: 'USD'
|
|
}).format(value);
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
return value.toFixed(1) + '%';
|
|
}
|
|
|
|
function updateDashboard(data) {
|
|
// Update key metrics - CHF PRIMARY, USD secondary
|
|
const usdt = data.balance.USDT || 0;
|
|
const usdt_chf = usdt * 0.84;
|
|
document.getElementById('usdt').textContent = `$${usdt.toFixed(2)}`;
|
|
|
|
const portfolio_usd = data.portfolio_value_usd || 0;
|
|
const portfolio_chf = data.portfolio_value_chf || (portfolio_usd * 0.84);
|
|
document.getElementById('portfolio').textContent = `$${portfolio_usd.toFixed(2)}`;
|
|
|
|
// Update P&L with color - CHF primary
|
|
const dailyPnl = data.daily_pnl || 0;
|
|
const dailyPnl_chf = dailyPnl * 0.84;
|
|
const dailyPnlEl = document.getElementById('daily-pnl');
|
|
dailyPnlEl.textContent = `$${dailyPnl >= 0 ? '+' : ''}${dailyPnl.toFixed(2)}`;
|
|
dailyPnlEl.className = 'card-value ' + (dailyPnl >= 0 ? 'positive' : 'negative');
|
|
|
|
const totalPnl = data.total_pnl || 0;
|
|
const totalPnl_chf = totalPnl * 0.84;
|
|
const totalPnlEl = document.getElementById('total-pnl');
|
|
totalPnlEl.textContent = `$${totalPnl >= 0 ? '+' : ''}${totalPnl.toFixed(2)}`;
|
|
totalPnlEl.className = 'card-value ' + (totalPnl >= 0 ? 'positive' : 'negative');
|
|
|
|
// Update performance
|
|
document.getElementById('trades-count').textContent = data.trades_today || 0;
|
|
document.getElementById('wins').textContent = data.wins_today || 0;
|
|
document.getElementById('losses').textContent = data.losses_today || 0;
|
|
|
|
const total_trades = (data.wins_today || 0) + (data.losses_today || 0);
|
|
const win_rate = total_trades > 0 ? ((data.wins_today || 0) / total_trades * 100) : 0;
|
|
document.getElementById('win-rate').textContent = formatPercent(win_rate);
|
|
|
|
// Update open trades
|
|
const openTradesHtml = Object.entries(data.current_trades || {})
|
|
.map(([pair, trade]) => `
|
|
<div class="trade-card open fade-in">
|
|
<div class="trade-pair">${pair}</div>
|
|
<div class="trade-info">
|
|
<div><strong>Qty:</strong> ${trade.qty?.toFixed(8)}</div>
|
|
<div><strong>Price:</strong> ${formatCurrency(trade.entry_price)}</div>
|
|
<div><strong>Entry:</strong> ${new Date(trade.entry_time).toLocaleTimeString('de-CH')}</div>
|
|
</div>
|
|
</div>
|
|
`)
|
|
.join('');
|
|
|
|
const openTradesEl = document.getElementById('open-trades');
|
|
openTradesEl.innerHTML = openTradesHtml || '<div class="trade-card open"><div class="trade-pair">No open trades</div></div>';
|
|
|
|
// Update closed trades (last 10)
|
|
const closedTradesHtml = (data.completed_trades || []).slice(-10).reverse()
|
|
.map(trade => `
|
|
<div class="trade-card closed fade-in">
|
|
<div class="trade-pair">${trade.pair}</div>
|
|
<div class="trade-info">
|
|
<div><strong>Entry:</strong> ${formatCurrency(trade.entry_price)}</div>
|
|
<div><strong>Exit:</strong> ${formatCurrency(trade.exit_price)}</div>
|
|
<div><strong>Profit:</strong> <span class="${trade.profit_usd >= 0 ? 'positive' : 'negative'}">${formatCurrency(trade.profit_usd)} (${trade.profit_pct >= 0 ? '+' : ''}${trade.profit_pct.toFixed(2)}%)</span></div>
|
|
<div><strong>Hold:</strong> ${trade.hold_time_min?.toFixed(0)} min</div>
|
|
</div>
|
|
</div>
|
|
`)
|
|
.join('');
|
|
|
|
const closedTradesEl = document.getElementById('closed-trades');
|
|
closedTradesEl.innerHTML = closedTradesHtml || '<div class="trade-card closed"><div class="trade-pair">No closed trades yet</div></div>';
|
|
|
|
// Update swaps (last 10)
|
|
const swapsHtml = (data.swaps || []).slice(-10).reverse()
|
|
.map(swap => `
|
|
<div class="swap-item">
|
|
<strong>${swap.from} → ${swap.to}:</strong> ${swap.qty?.toFixed(8)} @ ${swap.rate?.toFixed(8)}
|
|
<br><span style="color: #666;">${new Date(swap.timestamp).toLocaleTimeString('de-CH')}</span>
|
|
</div>
|
|
`)
|
|
.join('');
|
|
|
|
const swapsEl = document.getElementById('swaps-list');
|
|
swapsEl.innerHTML = swapsHtml || '<div class="swap-item">No swaps yet</div>';
|
|
|
|
// Update time
|
|
document.getElementById('update-time').textContent = new Date().toLocaleTimeString('de-CH');
|
|
}
|
|
|
|
// WebSocket connection
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
|
|
|
|
ws.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateDashboard(data);
|
|
};
|
|
|
|
ws.onerror = function(error) {
|
|
console.error('WebSocket error:', error);
|
|
// Fallback to polling
|
|
setInterval(async () => {
|
|
const response = await fetch('/api/state');
|
|
const data = await response.json();
|
|
updateDashboard(data);
|
|
}, 1000);
|
|
};
|
|
|
|
// Initial load
|
|
fetch('/api/state')
|
|
.then(r => r.json())
|
|
.then(data => updateDashboard(data));
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=7000)
|