"""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("""
""", 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('
', 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"""
""", 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"""
Balance
${current_balance:,.2f}
Start: ${starting_balance:,.2f}
""", unsafe_allow_html=True)
with c2:
st.markdown(f"""
Total PnL
{pnl_sign}${current_pnl:,.2f}
{pnl_sign}{pnl_pct:.2f}% return
""", unsafe_allow_html=True)
with c3:
wr_color = "green" if win_rate >= 50 else "amber" if win_rate >= 40 else "red"
st.markdown(f"""
Win Rate
{win_rate:.1f}%
{wins}W / {losses}L ({resolved_count} resolved)
""", unsafe_allow_html=True)
with c4:
avg_bet = f"${total_bet/total_trades:,.0f}" if total_trades > 0 else "$0"
st.markdown(f"""
Total Bets
${total_bet:,.0f}
{total_trades} trades • avg {avg_bet}/trade
""", unsafe_allow_html=True)
with c5:
st.markdown(f"""
Active Exposure
${active_exposure:,.0f}
{len(open_df)} open positions
""", 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"""
PnL / Trade
{'+' if pnl_per_trade >= 0 else ''}${pnl_per_trade:.2f}
Avg edge: {avg_edge:.1f}%
""", unsafe_allow_html=True)
else:
st.markdown("""
📡
Awaiting Balance Data
Bot is initializing. Balance snapshots will appear once trading begins.
""", unsafe_allow_html=True)
# ==================================================================
# Section 2: Live Prices + Connections
# ==================================================================
st.markdown("""
""", 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"""
Binance WebSocket
{'Live' if binance_ok else 'Reconnecting'}
Polymarket CLOB
{'Active' if poly_ok else 'Scanning'}
Chainlink Oracle
{'Polling' if not oracle_df.empty else 'Waiting'}
SQLite Database
{len(windows_df)} snapshots
Last update: {last_update}
""", 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"""
{asset_icons[asset]} {asset}/USDT
{fmt_price(price)}
{arrow} {change:+.4f}%
""", unsafe_allow_html=True)
# ==================================================================
# Section 2b: Chainlink Oracle vs Binance
# ==================================================================
if not oracle_df.empty:
st.markdown("""
""", 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"""
{asset} Oracle Gap
{gap_label}
{dev_sign}{dev:.4f}%
CHAINLINK
{fmt_price(o_price)}
BINANCE
{fmt_price(c_price)}
Oracle lag: {lag:.0f}s
""", 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("""
""", 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"""
Open: {fmt_price(start_p)}
Now: {fmt_price(end_p)}
{change:+.4f}%
{remaining:.0f}s remaining
""", 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("""
""", 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("""
""", 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("""
""", 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("""
⚡
Scanning for Opportunities
The bot is monitoring CEX prices and scanning Polymarket markets.
Trades will appear here once arbitrage opportunities are detected and executed.
""", unsafe_allow_html=True)
# ==================================================================
# Section 6: Window History
# ==================================================================
if not windows_df.empty:
st.markdown("""
""", 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("""
""", unsafe_allow_html=True)
st.dataframe(daily_df, use_container_width=True, hide_index=True)
# ==================================================================
# Sidebar stats
# ==================================================================
st.sidebar.markdown("---")
st.sidebar.markdown('', 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'', 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('', 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()