936 lines
34 KiB
Python
936 lines
34 KiB
Python
"""Streamlit real-time trading dashboard.
|
|
|
|
Launch with: streamlit run dashboard/app.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import streamlit as st
|
|
|
|
# ------------------------------------------------------------------
|
|
# Config
|
|
# ------------------------------------------------------------------
|
|
|
|
DB_PATH = Path(__file__).parent.parent / "trades.db"
|
|
PAPER_DB_PATH = Path(__file__).parent.parent / "paper_trades.db"
|
|
|
|
st.set_page_config(
|
|
page_title="Polymarket Arb Bot",
|
|
page_icon="⚡",
|
|
layout="wide",
|
|
initial_sidebar_state="collapsed",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Premium CSS
|
|
# ------------------------------------------------------------------
|
|
|
|
st.markdown("""
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
|
|
|
/* Global */
|
|
.stApp { background: #0a0e17; color: #e2e8f0; }
|
|
[data-testid="stHeader"] { background: transparent; }
|
|
[data-testid="stSidebar"] { background: #0f1420; border-right: 1px solid #1e293b; }
|
|
.block-container { padding-top: 1rem; max-width: 1400px; }
|
|
|
|
/* Hide default streamlit elements */
|
|
#MainMenu { visibility: hidden; }
|
|
footer { visibility: hidden; }
|
|
.stDeployButton { display: none; }
|
|
|
|
/* Typography */
|
|
h1, h2, h3, h4 { font-family: 'Inter', sans-serif !important; color: #f1f5f9 !important; }
|
|
|
|
/* Header bar */
|
|
.header-bar {
|
|
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
|
|
border: 1px solid #312e81;
|
|
border-radius: 16px;
|
|
padding: 1.5rem 2rem;
|
|
margin-bottom: 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.header-title {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 1.8rem;
|
|
font-weight: 800;
|
|
background: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.header-subtitle {
|
|
font-family: 'Inter', sans-serif;
|
|
color: #94a3b8;
|
|
font-size: 0.85rem;
|
|
margin-top: 4px;
|
|
}
|
|
.header-badge {
|
|
background: rgba(34, 197, 94, 0.15);
|
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
|
color: #4ade80;
|
|
padding: 6px 14px;
|
|
border-radius: 20px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
.header-badge-warn {
|
|
background: rgba(251, 191, 36, 0.15);
|
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
|
color: #fbbf24;
|
|
}
|
|
|
|
/* Metric Cards */
|
|
.metric-card {
|
|
background: linear-gradient(145deg, #111827, #1e293b);
|
|
border: 1px solid #1e293b;
|
|
border-radius: 14px;
|
|
padding: 1.2rem 1.4rem;
|
|
position: relative;
|
|
overflow: hidden;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.metric-card:hover { border-color: #334155; }
|
|
.metric-card::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0;
|
|
height: 3px;
|
|
border-radius: 14px 14px 0 0;
|
|
}
|
|
.metric-card.accent-blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
|
.metric-card.accent-green::before { background: linear-gradient(90deg, #22c55e, #4ade80); }
|
|
.metric-card.accent-red::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
|
.metric-card.accent-purple::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
|
.metric-card.accent-amber::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
|
.metric-card.accent-cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
|
|
|
.metric-label {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: #64748b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 8px;
|
|
}
|
|
.metric-value {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 1.7rem;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
}
|
|
.metric-sub {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 0.78rem;
|
|
color: #64748b;
|
|
margin-top: 6px;
|
|
}
|
|
.green { color: #4ade80; }
|
|
.red { color: #f87171; }
|
|
.blue { color: #60a5fa; }
|
|
.purple { color: #a78bfa; }
|
|
.amber { color: #fbbf24; }
|
|
.cyan { color: #22d3ee; }
|
|
.white { color: #f1f5f9; }
|
|
|
|
/* Section headers */
|
|
.section-header {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
color: #e2e8f0;
|
|
margin: 1.8rem 0 1rem 0;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid #1e293b;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.section-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.9rem;
|
|
}
|
|
.section-icon.blue { background: rgba(59,130,246,0.15); }
|
|
.section-icon.green { background: rgba(34,197,94,0.15); }
|
|
.section-icon.purple { background: rgba(139,92,246,0.15); }
|
|
.section-icon.amber { background: rgba(245,158,11,0.15); }
|
|
|
|
/* Price cards */
|
|
.price-card {
|
|
background: linear-gradient(145deg, #111827, #1e293b);
|
|
border: 1px solid #1e293b;
|
|
border-radius: 14px;
|
|
padding: 1.2rem;
|
|
text-align: center;
|
|
}
|
|
.price-card:hover { border-color: #334155; }
|
|
.price-asset {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: #94a3b8;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.price-value {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: #f1f5f9;
|
|
margin: 6px 0;
|
|
}
|
|
.price-change {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Window tracker */
|
|
.window-card {
|
|
background: #111827;
|
|
border: 1px solid #1e293b;
|
|
border-radius: 12px;
|
|
padding: 1rem 1.2rem;
|
|
margin-bottom: 0.6rem;
|
|
}
|
|
.window-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
.window-asset-badge {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
padding: 3px 10px;
|
|
border-radius: 6px;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.badge-5m { background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
|
|
.badge-15m { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); }
|
|
.window-prices {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.82rem;
|
|
color: #94a3b8;
|
|
}
|
|
.window-bar-bg {
|
|
background: #1e293b;
|
|
border-radius: 4px;
|
|
height: 5px;
|
|
margin-top: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.window-bar-fill {
|
|
height: 5px;
|
|
border-radius: 4px;
|
|
transition: width 0.3s;
|
|
}
|
|
|
|
/* Connection dots */
|
|
.conn-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid rgba(30,41,59,0.5);
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 0.85rem;
|
|
}
|
|
.conn-item:last-child { border-bottom: none; }
|
|
.conn-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.conn-dot.on { background: #4ade80; box-shadow: 0 0 8px rgba(74,222,128,0.4); }
|
|
.conn-dot.off { background: #f87171; box-shadow: 0 0 8px rgba(248,113,113,0.4); }
|
|
.conn-dot.warn { background: #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,0.4); }
|
|
.conn-name { color: #e2e8f0; font-weight: 500; }
|
|
.conn-status { color: #64748b; font-size: 0.78rem; margin-left: auto; }
|
|
|
|
/* Table styling */
|
|
[data-testid="stDataFrame"] {
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
[data-testid="stDataFrame"] table {
|
|
font-family: 'JetBrains Mono', monospace !important;
|
|
font-size: 0.82rem !important;
|
|
}
|
|
|
|
/* Chart styling */
|
|
[data-testid="stVegaLiteChart"] {
|
|
background: #111827;
|
|
border: 1px solid #1e293b;
|
|
border-radius: 12px;
|
|
padding: 0.8rem;
|
|
}
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
background: linear-gradient(145deg, #111827, #1e293b);
|
|
border: 1px dashed #334155;
|
|
border-radius: 14px;
|
|
padding: 3rem 2rem;
|
|
text-align: center;
|
|
}
|
|
.empty-icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
.empty-title {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: #e2e8f0;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.empty-desc {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 0.85rem;
|
|
color: #64748b;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar-section {
|
|
background: #111827;
|
|
border: 1px solid #1e293b;
|
|
border-radius: 12px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.sidebar-label {
|
|
font-family: 'Inter', sans-serif;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
color: #475569;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 8px;
|
|
}
|
|
.sidebar-value {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.9rem;
|
|
color: #e2e8f0;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Hide streamlit metric delta arrows */
|
|
[data-testid="stMetricDelta"] { display: none; }
|
|
</style>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_db_path() -> Path:
|
|
mode = st.sidebar.radio("Mode", ["Paper", "Live"], index=0, horizontal=True)
|
|
return PAPER_DB_PATH if mode == "Paper" else DB_PATH
|
|
|
|
|
|
def query_db(db_path: Path, sql: str) -> pd.DataFrame:
|
|
if not db_path.exists():
|
|
return pd.DataFrame()
|
|
try:
|
|
conn = sqlite3.connect(str(db_path))
|
|
df = pd.read_sql_query(sql, conn)
|
|
conn.close()
|
|
return df
|
|
except Exception:
|
|
return pd.DataFrame()
|
|
|
|
|
|
def fmt_price(price: float, asset: str = "") -> str:
|
|
if price >= 1000:
|
|
return f"${price:,.2f}"
|
|
return f"${price:.2f}"
|
|
|
|
|
|
def time_ago(ts: float) -> str:
|
|
diff = time.time() - ts
|
|
if diff < 60:
|
|
return f"{diff:.0f}s ago"
|
|
elif diff < 3600:
|
|
return f"{diff/60:.0f}m ago"
|
|
else:
|
|
return f"{diff/3600:.1f}h ago"
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Layout
|
|
# ------------------------------------------------------------------
|
|
|
|
db_path = get_db_path()
|
|
|
|
# Sidebar settings
|
|
st.sidebar.markdown("---")
|
|
st.sidebar.markdown('<div class="sidebar-label">REFRESH</div>', unsafe_allow_html=True)
|
|
refresh_rate = st.sidebar.selectbox("Interval (sec)", [5, 10, 30], index=1, label_visibility="collapsed")
|
|
auto_refresh = st.sidebar.checkbox("Auto-refresh", value=True)
|
|
|
|
# Load data
|
|
trades_df = query_db(db_path, "SELECT * FROM trades ORDER BY created_at DESC")
|
|
windows_df = query_db(db_path, "SELECT * FROM window_snapshots ORDER BY created_at DESC")
|
|
daily_df = query_db(db_path, "SELECT * FROM daily_summary ORDER BY date DESC")
|
|
balance_df = query_db(db_path, "SELECT * FROM balance_history ORDER BY timestamp ASC")
|
|
oracle_df = query_db(db_path, "SELECT * FROM oracle_snapshots ORDER BY timestamp DESC")
|
|
|
|
|
|
# ==================================================================
|
|
# Header
|
|
# ==================================================================
|
|
|
|
# Use balance_history (every 30s) for liveness check instead of window_snapshots (every 5min)
|
|
_latest_ts = balance_df.iloc[-1]["timestamp"] if not balance_df.empty else 0
|
|
has_recent = (time.time() - _latest_ts) < 120
|
|
mode_label = "PAPER" if "paper" in str(db_path) else "LIVE"
|
|
status_class = "header-badge" if has_recent else "header-badge header-badge-warn"
|
|
status_text = "RUNNING" if has_recent else "OFFLINE"
|
|
|
|
st.markdown(f"""
|
|
<div class="header-bar">
|
|
<div>
|
|
<div class="header-title">Polymarket Temporal Arb</div>
|
|
<div class="header-subtitle">Real-time arbitrage monitoring • {mode_label} MODE</div>
|
|
</div>
|
|
<div style="display:flex; gap:10px; align-items:center">
|
|
<span class="{status_class}">{status_text}</span>
|
|
<span style="color:#64748b; font-size:0.78rem; font-family:'Inter',sans-serif">
|
|
{datetime.now(timezone.utc).strftime('%H:%M:%S UTC')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ==================================================================
|
|
# Section 1: Key Metrics
|
|
# ==================================================================
|
|
|
|
if not balance_df.empty:
|
|
latest_bal = balance_df.iloc[-1]
|
|
current_balance = latest_bal["balance"]
|
|
current_pnl = latest_bal["pnl"]
|
|
starting_balance = balance_df.iloc[0]["balance"]
|
|
pnl_pct = (current_pnl / starting_balance * 100) if starting_balance > 0 else 0
|
|
|
|
total_trades = len(trades_df) if not trades_df.empty else 0
|
|
# Only count resolved trades for win/loss (exclude pnl=0 which are still open)
|
|
resolved_df = trades_df[trades_df["pnl"] != 0] if not trades_df.empty and "pnl" in trades_df.columns else pd.DataFrame()
|
|
open_df = trades_df[trades_df["pnl"] == 0] if not trades_df.empty and "pnl" in trades_df.columns else pd.DataFrame()
|
|
wins = len(resolved_df[resolved_df["pnl"] > 0]) if not resolved_df.empty else 0
|
|
losses = len(resolved_df[resolved_df["pnl"] < 0]) if not resolved_df.empty else 0
|
|
resolved_count = wins + losses
|
|
win_rate = wins / resolved_count * 100 if resolved_count > 0 else 0
|
|
total_fees = trades_df["fee"].sum() if not trades_df.empty and "fee" in trades_df.columns else 0
|
|
avg_edge = trades_df["signal_edge"].mean() * 100 if not trades_df.empty and "signal_edge" in trades_df.columns else 0
|
|
# Total bet volume (cost basis = fill_price * size)
|
|
total_bet = (trades_df["fill_price"] * trades_df["size"]).sum() if not trades_df.empty and "fill_price" in trades_df.columns else 0
|
|
# Active position exposure
|
|
active_exposure = (open_df["fill_price"] * open_df["size"]).sum() if not open_df.empty else 0
|
|
|
|
pnl_color = "green" if current_pnl >= 0 else "red"
|
|
pnl_sign = "+" if current_pnl >= 0 else ""
|
|
|
|
c1, c2, c3, c4, c5, c6 = st.columns(6)
|
|
|
|
with c1:
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-blue">
|
|
<div class="metric-label">Balance</div>
|
|
<div class="metric-value white">${current_balance:,.2f}</div>
|
|
<div class="metric-sub">Start: ${starting_balance:,.2f}</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
with c2:
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-{'green' if current_pnl >= 0 else 'red'}">
|
|
<div class="metric-label">Total PnL</div>
|
|
<div class="metric-value {pnl_color}">{pnl_sign}${current_pnl:,.2f}</div>
|
|
<div class="metric-sub">{pnl_sign}{pnl_pct:.2f}% return</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
with c3:
|
|
wr_color = "green" if win_rate >= 50 else "amber" if win_rate >= 40 else "red"
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-purple">
|
|
<div class="metric-label">Win Rate</div>
|
|
<div class="metric-value {wr_color}">{win_rate:.1f}%</div>
|
|
<div class="metric-sub">{wins}W / {losses}L ({resolved_count} resolved)</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
with c4:
|
|
avg_bet = f"${total_bet/total_trades:,.0f}" if total_trades > 0 else "$0"
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-cyan">
|
|
<div class="metric-label">Total Bets</div>
|
|
<div class="metric-value white">${total_bet:,.0f}</div>
|
|
<div class="metric-sub">{total_trades} trades • avg {avg_bet}/trade</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
with c5:
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-amber">
|
|
<div class="metric-label">Active Exposure</div>
|
|
<div class="metric-value amber">${active_exposure:,.0f}</div>
|
|
<div class="metric-sub">{len(open_df)} open positions</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
with c6:
|
|
# PnL per resolved trade
|
|
pnl_per_trade = current_pnl / resolved_count if resolved_count > 0 else 0
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-blue">
|
|
<div class="metric-label">PnL / Trade</div>
|
|
<div class="metric-value {'green' if pnl_per_trade >= 0 else 'red'}">{'+' if pnl_per_trade >= 0 else ''}${pnl_per_trade:.2f}</div>
|
|
<div class="metric-sub">Avg edge: {avg_edge:.1f}%</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
else:
|
|
st.markdown("""
|
|
<div class="empty-state">
|
|
<div class="empty-icon">📡</div>
|
|
<div class="empty-title">Awaiting Balance Data</div>
|
|
<div class="empty-desc">Bot is initializing. Balance snapshots will appear once trading begins.</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ==================================================================
|
|
# Section 2: Live Prices + Connections
|
|
# ==================================================================
|
|
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon blue">📡</div>
|
|
Market Data & Connections
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
latest_prices = {}
|
|
if not windows_df.empty:
|
|
for asset in ["BTC", "ETH", "SOL"]:
|
|
asset_rows = windows_df[windows_df["asset"] == asset]
|
|
if not asset_rows.empty:
|
|
row = asset_rows.iloc[0]
|
|
latest_prices[asset] = {
|
|
"price": row.get("end_price", row.get("start_price", 0)),
|
|
"start": row.get("start_price", 0),
|
|
"change_pct": row.get("price_change_pct", 0),
|
|
"updated": row.get("created_at", 0),
|
|
}
|
|
|
|
col_conn, col_btc, col_eth, col_sol = st.columns([1.3, 1, 1, 1])
|
|
|
|
with col_conn:
|
|
binance_ok = has_recent # based on balance_history (30s interval)
|
|
poly_ok = not windows_df.empty and windows_df.iloc[0].get("market_condition_id") is not None
|
|
last_update = time_ago(windows_df.iloc[0]["created_at"]) if not windows_df.empty else "N/A"
|
|
|
|
st.markdown(f"""
|
|
<div class="price-card" style="text-align:left; padding:1rem 1.2rem">
|
|
<div class="conn-item">
|
|
<div class="conn-dot {'on' if binance_ok else 'warn'}"></div>
|
|
<span class="conn-name">Binance WebSocket</span>
|
|
<span class="conn-status">{'Live' if binance_ok else 'Reconnecting'}</span>
|
|
</div>
|
|
<div class="conn-item">
|
|
<div class="conn-dot {'on' if poly_ok else 'warn'}"></div>
|
|
<span class="conn-name">Polymarket CLOB</span>
|
|
<span class="conn-status">{'Active' if poly_ok else 'Scanning'}</span>
|
|
</div>
|
|
<div class="conn-item">
|
|
<div class="conn-dot {'on' if not oracle_df.empty else 'warn'}"></div>
|
|
<span class="conn-name">Chainlink Oracle</span>
|
|
<span class="conn-status">{'Polling' if not oracle_df.empty else 'Waiting'}</span>
|
|
</div>
|
|
<div class="conn-item">
|
|
<div class="conn-dot on"></div>
|
|
<span class="conn-name">SQLite Database</span>
|
|
<span class="conn-status">{len(windows_df)} snapshots</span>
|
|
</div>
|
|
<div style="margin-top:8px; font-size:0.75rem; color:#475569; font-family:'Inter',sans-serif">
|
|
Last update: {last_update}
|
|
</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
asset_icons = {"BTC": "₿", "ETH": "Ξ", "SOL": "◎"}
|
|
asset_colors = {"BTC": "#f7931a", "ETH": "#627eea", "SOL": "#9945ff"}
|
|
|
|
for col, asset in [(col_btc, "BTC"), (col_eth, "ETH"), (col_sol, "SOL")]:
|
|
with col:
|
|
info = latest_prices.get(asset, {})
|
|
price = info.get("price", 0)
|
|
change = info.get("change_pct", 0)
|
|
color = "green" if change >= 0 else "red"
|
|
arrow = "▲" if change > 0 else "▼" if change < 0 else "—"
|
|
st.markdown(f"""
|
|
<div class="price-card">
|
|
<div class="price-asset">{asset_icons[asset]} {asset}/USDT</div>
|
|
<div class="price-value">{fmt_price(price)}</div>
|
|
<div class="price-change {color}">{arrow} {change:+.4f}%</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ==================================================================
|
|
# Section 2b: Chainlink Oracle vs Binance
|
|
# ==================================================================
|
|
|
|
if not oracle_df.empty:
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon purple">🔗</div>
|
|
Chainlink Oracle vs Binance (Arb Gap)
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
# Latest oracle data per asset
|
|
o_cols = st.columns(3)
|
|
for i, asset in enumerate(["BTC", "ETH", "SOL"]):
|
|
asset_oracle = oracle_df[oracle_df["asset"] == asset]
|
|
if asset_oracle.empty:
|
|
continue
|
|
latest = asset_oracle.iloc[0]
|
|
o_price = latest["oracle_price"]
|
|
c_price = latest["cex_price"]
|
|
dev = latest["deviation_pct"]
|
|
lag = latest["oracle_lag_sec"]
|
|
|
|
dev_color = "amber" if abs(dev) > 0.1 else "green" if abs(dev) < 0.05 else "cyan"
|
|
dev_sign = "+" if dev >= 0 else ""
|
|
gap_label = "GAP!" if abs(dev) > 0.1 else "OK"
|
|
gap_color = "#fbbf24" if abs(dev) > 0.1 else "#4ade80"
|
|
|
|
with o_cols[i]:
|
|
st.markdown(f"""
|
|
<div class="metric-card accent-purple">
|
|
<div style="display:flex; justify-content:space-between; align-items:center">
|
|
<div class="metric-label">{asset} Oracle Gap</div>
|
|
<span style="color:{gap_color}; font-size:0.7rem; font-weight:700; font-family:'Inter',sans-serif">{gap_label}</span>
|
|
</div>
|
|
<div class="metric-value {dev_color}">{dev_sign}{dev:.4f}%</div>
|
|
<div style="display:flex; justify-content:space-between; margin-top:8px">
|
|
<div>
|
|
<div style="font-size:0.65rem; color:#475569; font-family:'Inter',sans-serif">CHAINLINK</div>
|
|
<div style="font-family:'JetBrains Mono',monospace; font-size:0.85rem; color:#a78bfa">{fmt_price(o_price)}</div>
|
|
</div>
|
|
<div style="text-align:right">
|
|
<div style="font-size:0.65rem; color:#475569; font-family:'Inter',sans-serif">BINANCE</div>
|
|
<div style="font-family:'JetBrains Mono',monospace; font-size:0.85rem; color:#60a5fa">{fmt_price(c_price)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="metric-sub">Oracle lag: {lag:.0f}s</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
# Deviation over time chart
|
|
if len(oracle_df) > 3:
|
|
st.markdown("**Oracle Deviation Over Time (%)**")
|
|
chart_oracle = oracle_df.copy()
|
|
chart_oracle["time"] = pd.to_datetime(chart_oracle["timestamp"], unit="s")
|
|
# Pivot by asset
|
|
pivot = chart_oracle.pivot_table(index="time", columns="asset", values="deviation_pct", aggfunc="last")
|
|
pivot = pivot.sort_index()
|
|
if not pivot.empty:
|
|
st.line_chart(pivot, color=["#f7931a", "#627eea", "#9945ff"])
|
|
|
|
|
|
# ==================================================================
|
|
# Section 3: Window Tracker
|
|
# ==================================================================
|
|
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon green">⏱</div>
|
|
Active Windows
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
if not windows_df.empty:
|
|
seen = set()
|
|
window_rows = []
|
|
for _, row in windows_df.iterrows():
|
|
key = f"{row['asset']}_{row['timeframe']}"
|
|
if key not in seen:
|
|
seen.add(key)
|
|
window_rows.append(row)
|
|
if len(seen) >= 6:
|
|
break
|
|
|
|
w_cols = st.columns(3)
|
|
|
|
for i, row in enumerate(window_rows):
|
|
asset = row["asset"]
|
|
tf = row["timeframe"]
|
|
start_p = row.get("start_price", 0)
|
|
end_p = row.get("end_price", start_p)
|
|
change = row.get("price_change_pct", 0)
|
|
w_start = row.get("window_start", 0)
|
|
w_end = row.get("window_end", 0)
|
|
remaining = max(0, w_end - time.time())
|
|
|
|
color = "green" if change >= 0 else "red"
|
|
direction = "▲ UP" if change > 0 else "▼ DOWN" if change < 0 else "— FLAT"
|
|
badge_class = "badge-5m" if tf == "5M" else "badge-15m"
|
|
|
|
total_window = w_end - w_start
|
|
elapsed_frac = max(0, min(1, 1 - (remaining / total_window))) if total_window > 0 else 0
|
|
bar_width = int(elapsed_frac * 100)
|
|
bar_color = "#4ade80" if change >= 0 else "#f87171"
|
|
|
|
with w_cols[i % 3]:
|
|
st.markdown(f"""
|
|
<div class="window-card">
|
|
<div class="window-header">
|
|
<span style="font-family:'JetBrains Mono',monospace; font-weight:700; color:#e2e8f0; font-size:0.95rem">{asset}</span>
|
|
<div style="display:flex; gap:8px; align-items:center">
|
|
<span class="window-asset-badge {badge_class}">{tf}</span>
|
|
<span class="{color}" style="font-family:'JetBrains Mono',monospace; font-weight:600; font-size:0.85rem">{direction}</span>
|
|
</div>
|
|
</div>
|
|
<div class="window-prices">
|
|
<span>Open: {fmt_price(start_p)}</span>
|
|
<span>Now: {fmt_price(end_p)}</span>
|
|
<span class="{color}" style="font-weight:600">{change:+.4f}%</span>
|
|
</div>
|
|
<div class="window-bar-bg">
|
|
<div class="window-bar-fill" style="width:{bar_width}%; background:{bar_color}"></div>
|
|
</div>
|
|
<div style="text-align:right; margin-top:4px; font-family:'JetBrains Mono',monospace; font-size:0.72rem; color:#64748b">{remaining:.0f}s remaining</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
else:
|
|
st.info("Waiting for first window data...")
|
|
|
|
|
|
# ==================================================================
|
|
# Section 4: Charts
|
|
# ==================================================================
|
|
|
|
if not balance_df.empty and len(balance_df) > 1:
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon purple">📈</div>
|
|
Performance Charts
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
chart_col1, chart_col2 = st.columns(2)
|
|
|
|
with chart_col1:
|
|
st.markdown("**Balance Over Time**")
|
|
chart_bal = balance_df.copy()
|
|
chart_bal["time"] = pd.to_datetime(chart_bal["timestamp"], unit="s")
|
|
chart_bal = chart_bal.set_index("time")[["balance"]]
|
|
st.area_chart(chart_bal, color="#818cf8")
|
|
|
|
with chart_col2:
|
|
st.markdown("**PnL Curve**")
|
|
chart_pnl = balance_df.copy()
|
|
chart_pnl["time"] = pd.to_datetime(chart_pnl["timestamp"], unit="s")
|
|
chart_pnl = chart_pnl.set_index("time")[["pnl"]]
|
|
st.area_chart(chart_pnl, color="#4ade80")
|
|
|
|
|
|
# ==================================================================
|
|
# Section 5: Trades
|
|
# ==================================================================
|
|
|
|
# ------------------------------------------------------------------
|
|
# Active Positions (open trades with pnl=0)
|
|
# ------------------------------------------------------------------
|
|
|
|
if not trades_df.empty and "pnl" in trades_df.columns:
|
|
open_trades = trades_df[trades_df["pnl"] == 0].copy()
|
|
if not open_trades.empty:
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon green">🎯</div>
|
|
Active Positions
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
open_trades["bet_amount"] = (open_trades["fill_price"] * open_trades["size"]).round(2)
|
|
open_trades["max_payout"] = open_trades["size"].apply(lambda s: round(s * 1.0, 2))
|
|
open_trades["potential_profit"] = (open_trades["max_payout"] - open_trades["bet_amount"]).round(2)
|
|
open_trades["edge_pct"] = (open_trades["signal_edge"] * 100).round(1)
|
|
|
|
pos_cols = [c for c in ["asset", "direction", "timeframe", "fill_price",
|
|
"size", "bet_amount", "potential_profit", "edge_pct"]
|
|
if c in open_trades.columns]
|
|
st.dataframe(
|
|
open_trades[pos_cols].head(20).rename(columns={
|
|
"fill_price": "Entry Price",
|
|
"size": "Shares",
|
|
"bet_amount": "Bet ($)",
|
|
"potential_profit": "Max Profit ($)",
|
|
"edge_pct": "Edge %",
|
|
"asset": "Asset",
|
|
"direction": "Dir",
|
|
"timeframe": "TF",
|
|
}),
|
|
use_container_width=True,
|
|
hide_index=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Trading Activity (resolved trades)
|
|
# ------------------------------------------------------------------
|
|
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon amber">⚡</div>
|
|
Trading Activity
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
if not trades_df.empty:
|
|
# Cumulative PnL chart (only resolved trades)
|
|
if "pnl" in trades_df.columns and "created_at" in trades_df.columns:
|
|
resolved_chart = trades_df[trades_df["pnl"] != 0].sort_values("created_at").copy()
|
|
if not resolved_chart.empty:
|
|
st.markdown("**Cumulative PnL (Resolved Trades)**")
|
|
resolved_chart["cumulative_pnl"] = resolved_chart["pnl"].cumsum()
|
|
resolved_chart["trade_num"] = range(1, len(resolved_chart) + 1)
|
|
st.line_chart(resolved_chart.set_index("trade_num")[["cumulative_pnl"]], color="#22d3ee")
|
|
|
|
# Add bet amount column for display
|
|
display_df = trades_df.copy()
|
|
display_df["bet_amount"] = (display_df["fill_price"] * display_df["size"]).round(2)
|
|
|
|
st.markdown("**Recent Trades**")
|
|
display_cols = [c for c in ["asset", "direction", "timeframe", "fill_price",
|
|
"size", "bet_amount", "pnl", "fee", "signal_edge", "status"]
|
|
if c in display_df.columns]
|
|
st.dataframe(
|
|
display_df[display_cols].head(50).rename(columns={
|
|
"fill_price": "Price",
|
|
"bet_amount": "Bet ($)",
|
|
"signal_edge": "Edge",
|
|
}),
|
|
use_container_width=True,
|
|
hide_index=True,
|
|
)
|
|
else:
|
|
st.markdown("""
|
|
<div class="empty-state">
|
|
<div class="empty-icon">⚡</div>
|
|
<div class="empty-title">Scanning for Opportunities</div>
|
|
<div class="empty-desc">
|
|
The bot is monitoring CEX prices and scanning Polymarket markets.<br>
|
|
Trades will appear here once arbitrage opportunities are detected and executed.
|
|
</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ==================================================================
|
|
# Section 6: Window History
|
|
# ==================================================================
|
|
|
|
if not windows_df.empty:
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon blue">🕐</div>
|
|
Window History
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
col_wh1, col_wh2 = st.columns([2, 1])
|
|
|
|
with col_wh1:
|
|
hist_df = windows_df.copy()
|
|
hist_df["time"] = pd.to_datetime(hist_df["created_at"], unit="s").dt.strftime("%H:%M:%S")
|
|
hist_df["change"] = hist_df["price_change_pct"].apply(lambda x: f"{x:+.4f}%")
|
|
hist_df["start"] = hist_df["start_price"].apply(lambda x: f"${x:,.2f}")
|
|
hist_df["end"] = hist_df["end_price"].apply(lambda x: f"${x:,.2f}")
|
|
hist_df["market"] = hist_df["market_condition_id"].apply(lambda x: x[:12] + "..." if x else "—")
|
|
|
|
display = hist_df[["time", "asset", "timeframe", "start", "end", "change", "market"]].head(30)
|
|
st.dataframe(display, use_container_width=True, hide_index=True)
|
|
|
|
with col_wh2:
|
|
st.markdown("**Avg Price Change by Asset**")
|
|
chart_data = windows_df[["asset", "price_change_pct"]].copy()
|
|
chart_data["abs_change"] = chart_data["price_change_pct"].abs()
|
|
pivot = chart_data.groupby("asset")["abs_change"].mean()
|
|
st.bar_chart(pivot, color="#a78bfa")
|
|
|
|
st.markdown("**Windows by Asset**")
|
|
counts = windows_df["asset"].value_counts()
|
|
st.bar_chart(counts, color="#60a5fa")
|
|
|
|
|
|
# ==================================================================
|
|
# Section 7: Daily Summary
|
|
# ==================================================================
|
|
|
|
if not daily_df.empty:
|
|
st.markdown("""
|
|
<div class="section-header">
|
|
<div class="section-icon green">📅</div>
|
|
Daily Summary
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
st.dataframe(daily_df, use_container_width=True, hide_index=True)
|
|
|
|
|
|
# ==================================================================
|
|
# Sidebar stats
|
|
# ==================================================================
|
|
|
|
st.sidebar.markdown("---")
|
|
st.sidebar.markdown('<div class="sidebar-label">ACCOUNT</div>', unsafe_allow_html=True)
|
|
if not balance_df.empty:
|
|
sb_bal = balance_df.iloc[-1]["balance"]
|
|
sb_pnl = balance_df.iloc[-1]["pnl"]
|
|
sb_icon = "🟢" if sb_pnl >= 0 else "🔴"
|
|
st.sidebar.markdown(f'<div class="sidebar-value">${sb_bal:,.2f}</div>', unsafe_allow_html=True)
|
|
st.sidebar.markdown(f"{sb_icon} PnL: **${sb_pnl:+,.2f}**")
|
|
else:
|
|
st.sidebar.markdown("Balance: **—**")
|
|
|
|
st.sidebar.markdown("---")
|
|
st.sidebar.markdown('<div class="sidebar-label">DATABASE</div>', unsafe_allow_html=True)
|
|
st.sidebar.code(str(db_path.name), language=None)
|
|
st.sidebar.markdown(f"Windows: **{len(windows_df)}**")
|
|
st.sidebar.markdown(f"Trades: **{len(trades_df)}**")
|
|
st.sidebar.markdown(f"Snapshots: **{len(balance_df)}**")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Auto-refresh
|
|
# ------------------------------------------------------------------
|
|
|
|
if auto_refresh:
|
|
time.sleep(refresh_rate)
|
|
st.rerun()
|