update 03-22 09:28

This commit is contained in:
2026-03-22 09:28:14 +09:00
commit 7f45211276
43 changed files with 9373 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Polymarket
POLYMARKET_PRIVATE_KEY=0x...
POLYMARKET_PROXY_WALLET=0x...
POLYMARKET_API_KEY=
POLYMARKET_API_SECRET=
POLYMARKET_API_PASSPHRASE=
# Telegram
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Optional: Coinbase
COINBASE_WS_URL=wss://ws-feed.exchange.coinbase.com

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env
.env.local
*.pyc
__pycache__/
*.db
*.sqlite
.venv/
venv/
dist/
*.egg-info/
.streamlit/

554
backtest.py Normal file
View File

@@ -0,0 +1,554 @@
"""Historical backtester for the temporal arbitrage strategy.
Supports two modes:
1. Synthetic: Random walk price simulation for quick parameter testing.
2. Historical: Real Binance kline data for realistic backtesting.
Usage:
python backtest.py --mode synthetic --windows 2000
python backtest.py --mode historical --asset BTC --days 7
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
from src.config import load_config
from src.data.models import Asset, Direction, Signal, Timeframe
from src.risk.fee_calculator import FeeCalculator
from src.strategy.temporal_arb import TemporalArbStrategy
import structlog
log = structlog.get_logger()
@dataclass
class BacktestTrade:
"""Single backtest trade result."""
window_idx: int
asset: str
timeframe: str
direction: str
entry_price: float
size: int
edge: float
estimated_prob: float
won: bool
pnl: float
fee: float
timestamp: float = 0.0
@dataclass
class BacktestResult:
"""Aggregated backtest results."""
mode: str = "synthetic"
asset: str = ""
timeframe: str = ""
total_windows: int = 0
total_trades: int = 0
wins: int = 0
losses: int = 0
total_pnl: float = 0.0
total_fees: float = 0.0
total_volume: float = 0.0
max_drawdown: float = 0.0
best_trade: float = 0.0
worst_trade: float = 0.0
peak_balance: float = 0.0
trades: list[BacktestTrade] = field(default_factory=list)
@property
def win_rate(self) -> float:
return self.wins / self.total_trades * 100 if self.total_trades > 0 else 0
@property
def avg_pnl(self) -> float:
return self.total_pnl / self.total_trades if self.total_trades > 0 else 0
@property
def profit_factor(self) -> float:
gross_wins = sum(t.pnl for t in self.trades if t.pnl > 0)
gross_losses = abs(sum(t.pnl for t in self.trades if t.pnl < 0))
return gross_wins / gross_losses if gross_losses > 0 else float("inf")
@property
def sharpe_ratio(self) -> float:
"""Approximate Sharpe ratio from trade PnLs."""
if len(self.trades) < 2:
return 0.0
pnls = [t.pnl for t in self.trades]
avg = sum(pnls) / len(pnls)
variance = sum((p - avg) ** 2 for p in pnls) / len(pnls)
std = math.sqrt(variance) if variance > 0 else 1e-9
return avg / std * math.sqrt(len(pnls)) # Annualized approximation
@property
def max_consecutive_losses(self) -> int:
max_streak = 0
current = 0
for t in self.trades:
if not t.won:
current += 1
max_streak = max(max_streak, current)
else:
current = 0
return max_streak
def to_dict(self) -> dict:
return {
"mode": self.mode,
"asset": self.asset,
"timeframe": self.timeframe,
"total_windows": self.total_windows,
"total_trades": self.total_trades,
"wins": self.wins,
"losses": self.losses,
"win_rate": round(self.win_rate, 2),
"total_pnl": round(self.total_pnl, 2),
"avg_pnl": round(self.avg_pnl, 2),
"total_fees": round(self.total_fees, 2),
"total_volume": round(self.total_volume, 2),
"profit_factor": round(self.profit_factor, 2),
"sharpe_ratio": round(self.sharpe_ratio, 2),
"max_drawdown": round(self.max_drawdown, 2),
"best_trade": round(self.best_trade, 2),
"worst_trade": round(self.worst_trade, 2),
"max_consecutive_losses": self.max_consecutive_losses,
}
class Backtester:
"""Replay historical or synthetic data through the temporal arb strategy."""
def __init__(self, config_path: str = "config.toml", balance: float = 10000.0) -> None:
self.config = load_config(config_path)
self.initial_balance = balance
self.fee_calc = FeeCalculator(self.config.fees)
def _make_strategy(self, balance: float) -> TemporalArbStrategy:
return TemporalArbStrategy(
arb_config=self.config.temporal_arb,
risk_config=self.config.risk,
fees_config=self.config.fees,
balance=balance,
)
# ------------------------------------------------------------------
# Synthetic backtest
# ------------------------------------------------------------------
async def run_synthetic(
self,
asset: str = "BTC",
timeframe: str = "15M",
num_windows: int = 1000,
avg_volatility_pct: float = 0.3,
) -> BacktestResult:
"""Run a synthetic backtest using simulated price movements."""
import numpy as np
strategy = self._make_strategy(self.initial_balance)
result = BacktestResult(
mode="synthetic",
asset=asset,
timeframe=timeframe,
total_windows=num_windows,
)
balance = self.initial_balance
peak_balance = balance
window_sec = 300 if timeframe == "5M" else 900
rng = np.random.default_rng(42)
base_prices = {"BTC": 84000, "ETH": 2300, "SOL": 135}
base_price = base_prices.get(asset, 50000)
for i in range(num_windows):
start_price = base_price * (1 + rng.normal(0, 0.02))
num_ticks = 100
returns = rng.normal(0, avg_volatility_pct / 100 / math.sqrt(num_ticks), num_ticks)
prices = [start_price]
for r in returns:
prices.append(prices[-1] * (1 + r))
end_price = prices[-1]
actual_direction = "UP" if end_price > start_price else "DOWN"
# Evaluate at multiple points in the window
for eval_frac in [0.3, 0.5, 0.7]:
eval_idx = int(num_ticks * eval_frac)
eval_price = prices[eval_idx]
time_remaining = window_sec * (1 - eval_frac)
change_pct = (eval_price - start_price) / start_price * 100
# Simulate Polymarket price (lagging behind reality)
if abs(change_pct) > 0.05:
lag_factor = 0.3 # Polymarket adjusts at 30% of actual
if change_pct > 0:
sim_poly_up = 0.50 + abs(change_pct) * lag_factor * 10
sim_poly_up = min(sim_poly_up, 0.75)
sim_poly_down = max(0.25, 1.0 - sim_poly_up - rng.uniform(0, 0.04))
else:
sim_poly_down = 0.50 + abs(change_pct) * lag_factor * 10
sim_poly_down = min(sim_poly_down, 0.75)
sim_poly_up = max(0.25, 1.0 - sim_poly_down - rng.uniform(0, 0.04))
else:
sim_poly_up = 0.50 + rng.uniform(-0.02, 0.02)
sim_poly_down = 0.50 + rng.uniform(-0.02, 0.02)
strategy.update_balance(balance)
signal = await strategy.evaluate(
symbol=asset,
cex_price=eval_price,
window_start_price=start_price,
window_end_time=time.time() + time_remaining,
poly_up_ask=sim_poly_up,
poly_down_ask=sim_poly_down,
up_token_id=f"up_{i}",
down_token_id=f"down_{i}",
timeframe=timeframe,
)
if signal is None:
continue
# Simulate outcome
won = (signal.direction == Direction.UP and actual_direction == "UP") or \
(signal.direction == Direction.DOWN and actual_direction == "DOWN")
pnl = self.fee_calc.net_payout(
timeframe=timeframe,
entry_price=signal.price,
size=signal.size,
won=won,
)
fee = self.fee_calc.taker_fee(timeframe, signal.price, signal.size) if won else 0
balance += pnl
peak_balance = max(peak_balance, balance)
drawdown = peak_balance - balance
trade = BacktestTrade(
window_idx=i,
asset=asset,
timeframe=timeframe,
direction=signal.direction.value,
entry_price=signal.price,
size=signal.size,
edge=signal.edge,
estimated_prob=signal.estimated_prob,
won=won,
pnl=pnl,
fee=fee,
)
result.trades.append(trade)
result.total_trades += 1
result.total_pnl += pnl
result.total_fees += fee
result.total_volume += signal.price * signal.size
if won:
result.wins += 1
else:
result.losses += 1
result.best_trade = max(result.best_trade, pnl)
result.worst_trade = min(result.worst_trade, pnl)
result.max_drawdown = max(result.max_drawdown, drawdown)
result.peak_balance = peak_balance
break # Only take one trade per window
return result
# ------------------------------------------------------------------
# Historical backtest (Binance klines)
# ------------------------------------------------------------------
async def fetch_binance_klines(
self,
symbol: str,
interval: str = "1m",
days: int = 7,
) -> list[dict]:
"""Fetch historical kline data from Binance REST API."""
import aiohttp
pair = f"{symbol}USDT"
url = "https://api.binance.com/api/v3/klines"
end_time = int(time.time() * 1000)
start_time = end_time - (days * 24 * 60 * 60 * 1000)
all_klines = []
current_start = start_time
async with aiohttp.ClientSession() as session:
while current_start < end_time:
params = {
"symbol": pair,
"interval": interval,
"startTime": current_start,
"endTime": end_time,
"limit": 1000,
}
async with session.get(url, params=params) as resp:
if resp.status != 200:
log.error("binance_klines_error", status=resp.status)
break
data = await resp.json()
if not data:
break
for k in data:
all_klines.append({
"open_time": k[0],
"open": float(k[1]),
"high": float(k[2]),
"low": float(k[3]),
"close": float(k[4]),
"volume": float(k[5]),
"close_time": k[6],
})
current_start = data[-1][6] + 1 # Next ms after last close
await asyncio.sleep(0.1) # Rate limiting
log.info("klines_fetched", symbol=symbol, count=len(all_klines), days=days)
return all_klines
async def run_historical(
self,
asset: str = "BTC",
timeframe: str = "15M",
days: int = 7,
) -> BacktestResult:
"""Run backtest using real Binance historical data."""
strategy = self._make_strategy(self.initial_balance)
result = BacktestResult(
mode="historical",
asset=asset,
timeframe=timeframe,
)
balance = self.initial_balance
peak_balance = balance
# Fetch 1-minute klines
klines = await self.fetch_binance_klines(asset, interval="1m", days=days)
if not klines:
log.error("no_klines_data", asset=asset)
return result
window_minutes = 5 if timeframe == "5M" else 15
window_sec = window_minutes * 60
# Group klines into windows
window_idx = 0
i = 0
while i + window_minutes <= len(klines):
window_klines = klines[i:i + window_minutes]
start_price = window_klines[0]["open"]
end_price = window_klines[-1]["close"]
actual_direction = "UP" if end_price > start_price else "DOWN"
result.total_windows += 1
# Simulate evaluation at mid-point
mid_idx = window_minutes // 2
mid_price = window_klines[mid_idx]["close"]
time_remaining = window_sec * 0.5
change_pct = (mid_price - start_price) / start_price * 100
# Simulate Polymarket prices based on actual market behavior
# More conservative lag simulation for historical
if abs(change_pct) > 0.05:
lag = 0.25 # Market adjusts at ~25% speed
if change_pct > 0:
poly_up = 0.50 + abs(change_pct) * lag * 8
poly_up = min(poly_up, 0.72)
poly_down = max(0.28, 1.0 - poly_up - 0.02)
else:
poly_down = 0.50 + abs(change_pct) * lag * 8
poly_down = min(poly_down, 0.72)
poly_up = max(0.28, 1.0 - poly_down - 0.02)
else:
poly_up = 0.50
poly_down = 0.50
strategy.update_balance(balance)
signal = await strategy.evaluate(
symbol=asset,
cex_price=mid_price,
window_start_price=start_price,
window_end_time=time.time() + time_remaining,
poly_up_ask=poly_up,
poly_down_ask=poly_down,
up_token_id=f"hist_up_{window_idx}",
down_token_id=f"hist_down_{window_idx}",
timeframe=timeframe,
)
if signal:
won = (signal.direction == Direction.UP and actual_direction == "UP") or \
(signal.direction == Direction.DOWN and actual_direction == "DOWN")
pnl = self.fee_calc.net_payout(
timeframe=timeframe,
entry_price=signal.price,
size=signal.size,
won=won,
)
fee = self.fee_calc.taker_fee(timeframe, signal.price, signal.size) if won else 0
balance += pnl
peak_balance = max(peak_balance, balance)
drawdown = peak_balance - balance
trade = BacktestTrade(
window_idx=window_idx,
asset=asset,
timeframe=timeframe,
direction=signal.direction.value,
entry_price=signal.price,
size=signal.size,
edge=signal.edge,
estimated_prob=signal.estimated_prob,
won=won,
pnl=pnl,
fee=fee,
timestamp=window_klines[0]["open_time"] / 1000,
)
result.trades.append(trade)
result.total_trades += 1
result.total_pnl += pnl
result.total_fees += fee
result.total_volume += signal.price * signal.size
if won:
result.wins += 1
else:
result.losses += 1
result.best_trade = max(result.best_trade, pnl)
result.worst_trade = min(result.worst_trade, pnl)
result.max_drawdown = max(result.max_drawdown, drawdown)
result.peak_balance = peak_balance
i += window_minutes
window_idx += 1
return result
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def print_results(result: BacktestResult) -> None:
"""Pretty-print backtest results."""
print("\n" + "=" * 65)
print(f" BACKTEST RESULTS — {result.asset} {result.timeframe} ({result.mode})")
print("=" * 65)
print(f" Windows Tested: {result.total_windows}")
print(f" Total Trades: {result.total_trades}")
print(f" Wins / Losses: {result.wins} / {result.losses}")
print(f" Win Rate: {result.win_rate:.1f}%")
print(f" Total PnL: ${result.total_pnl:+,.2f}")
print(f" Average PnL: ${result.avg_pnl:+,.2f}")
print(f" Total Fees: ${result.total_fees:,.2f}")
print(f" Total Volume: ${result.total_volume:,.0f}")
print(f" Profit Factor: {result.profit_factor:.2f}")
print(f" Sharpe Ratio: {result.sharpe_ratio:.2f}")
print(f" Max Drawdown: ${result.max_drawdown:,.2f}")
print(f" Best Trade: ${result.best_trade:+,.2f}")
print(f" Worst Trade: ${result.worst_trade:+,.2f}")
print(f" Max Consec. Losses: {result.max_consecutive_losses}")
print("=" * 65)
def save_results(results: list[BacktestResult], output_path: str = "backtest_results.json") -> None:
"""Save all backtest results to JSON."""
data = [r.to_dict() for r in results]
Path(output_path).write_text(json.dumps(data, indent=2))
print(f"\nResults saved to {output_path}")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
async def main() -> None:
parser = argparse.ArgumentParser(description="Polymarket Arb Bot Backtester")
parser.add_argument("--mode", choices=["synthetic", "historical", "both"],
default="synthetic", help="Backtest mode")
parser.add_argument("--asset", nargs="+", default=["BTC", "ETH", "SOL"],
help="Assets to backtest")
parser.add_argument("--timeframe", nargs="+", default=["5M", "15M"],
help="Timeframes")
parser.add_argument("--windows", type=int, default=1000,
help="Number of windows for synthetic mode")
parser.add_argument("--days", type=int, default=7,
help="Days of history for historical mode")
parser.add_argument("--balance", type=float, default=10000.0,
help="Starting balance")
parser.add_argument("--output", default="backtest_results.json",
help="Output JSON file")
args = parser.parse_args()
bt = Backtester(balance=args.balance)
all_results = []
if args.mode in ("synthetic", "both"):
print("\n>>> SYNTHETIC BACKTEST <<<\n")
vol_map = {"BTC": 0.3, "ETH": 0.4, "SOL": 0.6}
for asset in args.asset:
for tf in args.timeframe:
result = await bt.run_synthetic(
asset=asset,
timeframe=tf,
num_windows=args.windows,
avg_volatility_pct=vol_map.get(asset, 0.3),
)
print_results(result)
all_results.append(result)
if args.mode in ("historical", "both"):
print("\n>>> HISTORICAL BACKTEST <<<\n")
for asset in args.asset:
for tf in args.timeframe:
print(f"Fetching {args.days} days of {asset} data...")
result = await bt.run_historical(
asset=asset,
timeframe=tf,
days=args.days,
)
print_results(result)
all_results.append(result)
if all_results:
save_results(all_results, args.output)
# Summary
print("\n" + "=" * 65)
print(" COMBINED SUMMARY")
print("=" * 65)
total_pnl = sum(r.total_pnl for r in all_results)
total_trades = sum(r.total_trades for r in all_results)
total_wins = sum(r.wins for r in all_results)
print(f" Total Trades: {total_trades}")
print(f" Overall PnL: ${total_pnl:+,.2f}")
print(f" Overall WR: {total_wins / total_trades * 100:.1f}%" if total_trades > 0 else " N/A")
print("=" * 65)
if __name__ == "__main__":
asyncio.run(main())

52
config.toml Normal file
View File

@@ -0,0 +1,52 @@
[general]
mode = "paper"
assets = ["BTC", "ETH", "SOL"]
timeframes = ["5M", "15M"]
log_level = "INFO"
starting_balance = 500.0
[strategy.temporal_arb]
enabled = true
min_price_move_pct = 0.03 # 낮춤: 0.03%만 움직여도 평가 (원래 0.15%)
max_poly_entry_price = 0.65
min_edge = 0.05 # 낮춤: 5% 엣지에서 진입 (원래 20%)
exit_before_resolution_sec = 5
[strategy.sum_to_one]
enabled = true
min_spread_after_fee = 0.02
[strategy.spread_capture]
enabled = false
spread_target = 0.04
[risk]
max_position_per_market_usd = 5000
max_total_exposure_usd = 20000
max_daily_loss_usd = 2000
kelly_fraction_cap = 0.25
max_concurrent_positions = 6
[fees]
taker_fee_5m = 0.0156
taker_fee_15m = 0.03
[exchange.binance]
ws_url = "wss://stream.binance.com:9443/stream"
symbols = ["btcusdt", "ethusdt", "solusdt"]
[exchange.polymarket]
clob_url = "https://clob.polymarket.com"
gamma_url = "https://gamma-api.polymarket.com"
data_url = "https://data-api.polymarket.com"
ws_url = "wss://ws-subscriptions-clob.polymarket.com/ws/"
chain_id = 137
signature_type = 2
[notifications]
telegram_enabled = true
telegram_token = ""
telegram_chat_id = ""
notify_on_trade = true
notify_on_daily_summary = true
notify_on_error = true

935
dashboard/app.py Normal file
View File

@@ -0,0 +1,935 @@
"""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 &bull; {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 &bull; 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()

770
deploy.sh Normal file
View File

@@ -0,0 +1,770 @@
#!/bin/bash
#====================================================================
# 솔메카 Smart Deploy v4.0
# - 서버 자동 스캔 (CPU/RAM/포트/기존 배포)
# - 최적 서버 자동 선택
# - SSH + rsync + systemd 직접 배포 (Docker 없음)
# - 배포 레지스트리 관리 (충돌 회피)
#
# 사용법:
# deploy.sh . 현재 프로젝트 배포
# deploy.sh status 전체 상태 확인
# deploy.sh list 배포 목록
# deploy.sh stop <이름> 서비스 중지
# deploy.sh rm <이름> 서비스 삭제
# deploy.sh logs <이름> 로그 보기
#====================================================================
set -eo pipefail
# ── 설정 ──────────────────────────────────────────────────────────
SSH_KEY="$HOME/.ssh/id_ed25519"
REGISTRY_FILE="C:/Users/User/.solmeca-deploy.json"
PORT_RANGE_START=9100
PORT_RANGE_END=9300
# 서버 목록: name|ip|ssh_user|sudo_pass|cpus|has_gpu|tags
SERVERS=(
"kakao-main|100.125.85.86|kakao|2828fire!!|8|yes|web,bot,dashboard"
"worker-ai|100.118.136.45|server|9220fire!!|20|yes|ai,ml,bot,dashboard,heavy"
"worker-downsys|100.121.159.128|downsys|2828fire!!|8|no|web,bot,dashboard,light"
"worker-kakao2|100.70.23.63|kakao|2828fire!!|12|yes|ai,gpu,bot,dashboard,automation"
)
# ── 색상 ──────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
log() { echo -e "${BLUE}$1${NC}"; }
ok() { echo -e " ${GREEN}$1${NC}"; }
warn() { echo -e " ${YELLOW}! $1${NC}"; }
err() { echo -e " ${RED}$1${NC}"; }
info() { echo -e " ${CYAN}$1${NC}"; }
# ── SSH 헬퍼 ──────────────────────────────────────────────────────
run_ssh() {
local user=$1 ip=$2; shift 2
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o LogLevel=ERROR \
-i "$SSH_KEY" "${user}@${ip}" "$@" 2>/dev/null
}
# ── 레지스트리 관리 ───────────────────────────────────────────────
init_registry() {
[ -f "$REGISTRY_FILE" ] || echo '{"deployments":{}}' > "$REGISTRY_FILE"
}
get_registry() {
cat "$REGISTRY_FILE"
}
save_deployment() {
local name=$1 server=$2 ip=$3 port=$4 type=$5 path=$6 user=$7
local now
now=$(date -Iseconds)
python3 -c "
import json,sys
reg = json.load(open('$REGISTRY_FILE'))
reg['deployments']['$name'] = {
'server': '$server', 'ip': '$ip', 'port': $port, 'type': '$type',
'remote_path': '$path', 'ssh_user': '$user',
'deployed_at': '$now', 'status': 'running'
}
json.dump(reg, open('$REGISTRY_FILE','w'), indent=2, ensure_ascii=False)
print('saved')
" 2>/dev/null
}
remove_deployment() {
local name=$1
python3 -c "
import json
reg = json.load(open('$REGISTRY_FILE'))
if '$name' in reg['deployments']:
del reg['deployments']['$name']
json.dump(reg, open('$REGISTRY_FILE','w'), indent=2, ensure_ascii=False)
print('removed')
else: print('not_found')
" 2>/dev/null
}
update_md() {
local md_file="C:/Users/User/Desktop/SERVER_ 운영/서버_배포_현황.md"
python3 -c "
import json
from datetime import datetime
reg = json.load(open('$REGISTRY_FILE'))
deps = reg.get('deployments', {})
servers = {}
for name, d in sorted(deps.items()):
srv = d.get('server', '?')
if srv not in servers:
servers[srv] = []
port = d.get('port', 0)
url = f\"http://{d['ip']}:{port}\" if port else '-'
servers[srv].append({
'name': name, 'type': d.get('type','?'),
'port': port, 'url': url,
'path': d.get('remote_path','?'),
'date': d.get('deployed_at','?')[:10]
})
lines = ['# 서버 배포 현황', f'', f'> Updated: {datetime.now().strftime(\"%Y-%m-%d %H:%M\")}', f'> Total: {len(deps)} services', '']
for srv in ['worker-ai','worker-downsys','worker-kakao2','kakao-main']:
if srv not in servers:
continue
lines.append(f'## {srv}')
lines.append('')
lines.append('| Name | Type | Port | URL | Path | Date |')
lines.append('|------|------|------|-----|------|------|')
for s in servers[srv]:
lines.append(f\"| {s['name']} | {s['type']} | {s['port'] or '-'} | {s['url']} | {s['path']} | {s['date']} |\")
lines.append('')
with open('$md_file', 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
" 2>/dev/null
}
get_deployment_info() {
local name=$1
python3 -c "
import json
reg = json.load(open('$REGISTRY_FILE'))
d = reg['deployments'].get('$name')
if d: print(f\"{d['server']}|{d['ip']}|{d['port']}|{d['ssh_user']}|{d['remote_path']}\")
" 2>/dev/null
}
get_server_deployments() {
local ip=$1
python3 -c "
import json
reg = json.load(open('$REGISTRY_FILE'))
ports = []
for name, d in reg['deployments'].items():
if d['ip'] == '$ip':
ports.append(str(d['port']))
print(' '.join(ports) if ports else '')
" 2>/dev/null
}
# ── 프로젝트 타입 감지 ───────────────────────────────────────────
detect_project() {
local dir=$1
local type="unknown" cmd="" install=""
if [ -f "$dir/requirements.txt" ]; then
type="python"
install="pip install -r requirements.txt"
# main 파일 찾기
if [ -f "$dir/main.py" ]; then
if grep -q "uvicorn\|fastapi\|flask" "$dir/main.py" 2>/dev/null; then
cmd="python3 -m uvicorn main:app --host 0.0.0.0 --port __PORT__"
else
cmd="python3 main.py"
fi
elif [ -f "$dir/app.py" ]; then
cmd="python3 app.py"
elif [ -f "$dir/bot.py" ]; then
cmd="python3 bot.py"
elif [ -f "$dir/dashboard.py" ]; then
cmd="python3 dashboard.py --port __PORT__"
else
# 아무 .py 찾기
local pyfile
pyfile=$(ls "$dir"/*.py 2>/dev/null | head -1)
[ -n "$pyfile" ] && cmd="python3 $(basename "$pyfile")"
fi
elif [ -f "$dir/pyproject.toml" ]; then
type="python"
install="pip install ."
cmd="python3 -m uvicorn main:app --host 0.0.0.0 --port __PORT__"
elif [ -f "$dir/package.json" ]; then
type="node"
install="npm install"
if grep -q '"start"' "$dir/package.json" 2>/dev/null; then
cmd="npm start"
elif [ -f "$dir/server.js" ]; then
cmd="node server.js"
elif [ -f "$dir/index.js" ]; then
cmd="node index.js"
elif [ -f "$dir/bot.js" ]; then
cmd="node bot.js"
else
cmd="npm start"
fi
elif [ -f "$dir/go.mod" ]; then
type="go"
install="go build -o server ."
cmd="./server"
elif [ -f "$dir/Cargo.toml" ]; then
type="rust"
install="cargo build --release"
cmd="./target/release/$(basename "$dir")"
elif [ -f "$dir/index.html" ]; then
type="static"
install=""
cmd="python3 -m http.server __PORT__"
fi
echo "${type}|${cmd}|${install}"
}
# ── 사용 가능한 포트 찾기 ─────────────────────────────────────────
find_available_port() {
local ssh_user=$1 ip=$2
# 서버의 사용 중 포트
local used_ports
used_ports=$(run_ssh "$ssh_user" "$ip" "ss -tlnp | awk 'NR>1{print \$4}' | grep -oP ':\K[0-9]+' | sort -n | uniq" 2>/dev/null)
# 레지스트리에 기록된 포트
local reg_ports
reg_ports=$(get_server_deployments "$ip")
local all_used="${used_ports} ${reg_ports}"
for port in $(seq $PORT_RANGE_START $PORT_RANGE_END); do
if ! echo "$all_used" | grep -qw "$port"; then
echo "$port"
return 0
fi
done
echo "0"
return 1
}
# ── 서버 리소스 조회 (병렬) ──────────────────────────────────────
declare -A SERVER_LOADS=()
fetch_all_server_loads() {
local tmpdir
tmpdir=$(mktemp -d)
for server_info in "${SERVERS[@]}"; do
IFS='|' read -r name ip ssh_user _ _ _ <<< "$server_info"
(
result=$(run_ssh "$ssh_user" "$ip" '
read -r _ u1 n1 s1 i1 _ < /proc/stat; sleep 1; read -r _ u2 n2 s2 i2 _ < /proc/stat
total=$(( (u2+n2+s2+i2) - (u1+n1+s1+i1) )); idle=$(( i2 - i1 ))
[ "$total" -gt 0 ] && cpu_free=$(( idle * 100 / total )) || cpu_free=50
read -r mem_total mem_avail <<< $(awk "/MemTotal/{t=int(\$2/1024)} /MemAvailable/{a=int(\$2/1024)} END{print t,a}" /proc/meminfo)
containers=$(docker ps -q 2>/dev/null | wc -l)
procs=$(ps aux --no-heading 2>/dev/null | wc -l)
disk_pct=$(df / | awk "NR==2{gsub(/%/,\"\",\$5); print \$5}")
echo "$cpu_free $mem_total $mem_avail $containers $procs $disk_pct"
' 2>/dev/null || echo "50 0 0 0 0 0")
echo "$result" > "${tmpdir}/${name}"
) &
done
wait
for server_info in "${SERVERS[@]}"; do
IFS='|' read -r name _ _ _ _ _ <<< "$server_info"
[ -f "${tmpdir}/${name}" ] && SERVER_LOADS["$name"]=$(cat "${tmpdir}/${name}") || SERVER_LOADS["$name"]="50 0 0 0 0 0"
done
rm -rf "$tmpdir"
}
# ── 최적 서버 선택 ───────────────────────────────────────────────
select_best_server() {
local app_type=$1
local best_server="" best_score=-999
fetch_all_server_loads
for server_info in "${SERVERS[@]}"; do
IFS='|' read -r name ip ssh_user sudo_pass cpus has_gpu tags <<< "$server_info"
local cpu_free mem_total mem_avail containers procs disk_pct
read -r cpu_free mem_total mem_avail containers procs disk_pct <<< "${SERVER_LOADS[$name]:-50 0 0 0 0 0}"
local cpu_score=$((cpu_free))
local mem_score=50
[ "${mem_total:-0}" -gt 0 ] && mem_score=$((mem_avail * 100 / mem_total))
local disk_score=0
[ "${disk_pct:-0}" -gt 90 ] && disk_score=-50
[ "${disk_pct:-0}" -lt 50 ] && disk_score=10
# 배포 수 패널티 (이 서버에 이미 배포된 수)
local deploy_count
deploy_count=$(python3 -c "
import json
reg=json.load(open('$REGISTRY_FILE'))
print(sum(1 for d in reg['deployments'].values() if d['ip']=='$ip'))
" 2>/dev/null || echo 0)
local deploy_penalty=$((deploy_count * 8))
# GPU 필요 시
local gpu_score=0
if [[ "$app_type" =~ ai|ml|gpu|cuda ]]; then
[ "$has_gpu" = "no" ] && gpu_score=-200 || gpu_score=40
fi
local score=$((cpu_score + mem_score + disk_score - deploy_penalty + gpu_score))
local gpu_label="--"
[ "$has_gpu" = "yes" ] && gpu_label="GPU"
printf " %-16s CPU=%3d%% RAM=%5dMB 배포=%d ${YELLOW}점수=%d${NC}\n" \
"$name" "$cpu_free" "$mem_avail" "$deploy_count" "$score" >&2
[ "$score" -gt "$best_score" ] && best_score=$score && best_server="$server_info"
done
echo "$best_server"
}
# ══════════════════════════════════════════════════════════════════
# 메인 배포
# ══════════════════════════════════════════════════════════════════
deploy() {
local project_dir
if [ "${1:-.}" = "." ]; then
project_dir=$(pwd)
else
cd "$1" 2>/dev/null || { err "폴더 없음: $1"; return 1; }
project_dir=$(pwd)
fi
local project_name
project_name=$(basename "$project_dir")
echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}${CYAN} Deploy: ${project_name}${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
init_registry
# ── [1] 프로젝트 감지 ──
log "[1/6] 프로젝트 분석"
local detect_result
detect_result=$(detect_project "$project_dir")
IFS='|' read -r proj_type proj_cmd proj_install <<< "$detect_result"
if [ "$proj_type" = "unknown" ] || [ -z "$proj_cmd" ]; then
err "프로젝트 타입을 감지할 수 없습니다"
info "main.py, bot.py, package.json, index.html 등이 필요합니다"
return 1
fi
ok "타입: ${proj_type}"
ok "실행: ${proj_cmd}"
# ── [2] 기존 배포 확인 ──
log "[2/6] 기존 배포 확인"
local existing
existing=$(get_deployment_info "$project_name")
local srv_name srv_ip srv_user srv_pass srv_cpus srv_gpu srv_tags
local port remote_path
if [ -n "$existing" ]; then
IFS='|' read -r old_server old_ip old_port old_user old_path <<< "$existing"
warn "기존 배포 발견: ${old_server} 포트 ${old_port} → 업데이트합니다"
# 기존 서버 정보 찾기
for s in "${SERVERS[@]}"; do
IFS='|' read -r sn si su sp sc sg st <<< "$s"
if [ "$si" = "$old_ip" ]; then
srv_name=$sn; srv_ip=$si; srv_user=$su; srv_pass=$sp; srv_cpus=$sc; srv_gpu=$sg; srv_tags=$st
break
fi
done
port=$old_port
remote_path=$old_path
else
# ── [3] 서버 선택 ──
log "[3/6] 서버 선택"
info "서버 스캔 중..."
local selected
selected=$(select_best_server "$proj_type")
IFS='|' read -r srv_name srv_ip srv_user srv_pass srv_cpus srv_gpu srv_tags <<< "$selected"
echo -e " ${GREEN}${BOLD}>>> ${srv_name} (${srv_ip})${NC}"
# 포트 할당
port=$(find_available_port "$srv_user" "$srv_ip")
if [ "$port" = "0" ]; then
err "사용 가능한 포트가 없습니다"
return 1
fi
ok "포트: ${port}"
remote_path="/opt/solmeca/${project_name}"
fi
# 실행 명령어에 포트 적용
proj_cmd="${proj_cmd//__PORT__/$port}"
# 환경변수로 포트 전달 (PORT 환경변수)
local env_port="PORT=${port}"
# ── [4] 코드 전송 ──
log "[4/6] 코드 전송"
# 원격 디렉토리 생성
run_ssh "$srv_user" "$srv_ip" "mkdir -p '${remote_path}' 2>/dev/null || (echo '${srv_pass}' | sudo -S mkdir -p '${remote_path}' && echo '${srv_pass}' | sudo -S chown \$(whoami) '${remote_path}') 2>/dev/null"
# tar로 묶어서 전송 (.git, node_modules 등 제외)
tar czf /tmp/_solmeca_deploy.tar.gz \
--exclude='.git' --exclude='node_modules' --exclude='__pycache__' \
--exclude='.venv' --exclude='venv' --exclude='.env' \
--exclude='target' --exclude='dist' --exclude='build' \
-C "${project_dir}" . 2>/dev/null
scp -o StrictHostKeyChecking=no -o LogLevel=ERROR -i "$SSH_KEY" \
/tmp/_solmeca_deploy.tar.gz "${srv_user}@${srv_ip}:/tmp/_solmeca_deploy.tar.gz" 2>/dev/null
run_ssh "$srv_user" "$srv_ip" "cd '${remote_path}' && tar xzf /tmp/_solmeca_deploy.tar.gz && rm -f /tmp/_solmeca_deploy.tar.gz"
rm -f /tmp/_solmeca_deploy.tar.gz
ok "코드 전송 완료"
# ── [5] 설치 + systemd 서비스 생성 ──
log "[5/6] 설치 & 서비스 등록"
# 서비스 이름
local service_name="solmeca-${project_name}"
# ExecStart 명령어 결정
local exec_cmd="$proj_cmd"
if [ "$proj_type" = "python" ]; then
exec_cmd=$(echo "$proj_cmd" | sed "s|python3|${remote_path}/venv/bin/python3|")
fi
# 로컬에서 서비스 파일 생성
cat > /tmp/_solmeca_service.tmp << EOF
[Unit]
Description=Solmeca - ${project_name}
After=network.target
[Service]
Type=simple
WorkingDirectory=${remote_path}
Environment=${env_port}
ExecStart=${exec_cmd}
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${service_name}
User=${srv_user}
[Install]
WantedBy=multi-user.target
EOF
# 서비스 파일 전송
scp -o StrictHostKeyChecking=no -o LogLevel=ERROR -i "$SSH_KEY" \
/tmp/_solmeca_service.tmp "${srv_user}@${srv_ip}:/tmp/_solmeca_service.tmp" 2>/dev/null
rm -f /tmp/_solmeca_service.tmp
# 원격: 의존성 설치 + 서비스 등록 + 시작
run_ssh "$srv_user" "$srv_ip" "
cd '${remote_path}'
if [ '${proj_type}' = 'python' ]; then
python3 -m venv venv 2>/dev/null || true
. venv/bin/activate 2>/dev/null || true
pip install -q -r requirements.txt 2>/dev/null || pip install -q . 2>/dev/null || true
elif [ '${proj_type}' = 'node' ]; then
npm install --omit=dev 2>/dev/null || true
elif [ '${proj_type}' = 'go' ]; then
go build -o server . 2>/dev/null || true
fi
echo '${srv_pass}' | sudo -S cp /tmp/_solmeca_service.tmp /etc/systemd/system/${service_name}.service 2>/dev/null
rm -f /tmp/_solmeca_service.tmp
echo '${srv_pass}' | sudo -S systemctl daemon-reload 2>/dev/null
echo '${srv_pass}' | sudo -S systemctl enable ${service_name} 2>/dev/null
echo '${srv_pass}' | sudo -S systemctl restart ${service_name} 2>/dev/null
"
# 서비스 상태 확인
sleep 2
local svc_status
svc_status=$(run_ssh "$srv_user" "$srv_ip" "systemctl is-active ${service_name}" 2>/dev/null || echo "failed")
if [ "$svc_status" = "active" ]; then
ok "서비스 실행 중: ${service_name}"
else
warn "서비스 상태: ${svc_status}"
info "로그 확인: deploy.sh logs ${project_name}"
fi
# ── [6] 레지스트리 기록 ──
log "[6/6] 배포 기록"
save_deployment "$project_name" "$srv_name" "$srv_ip" "$port" "$proj_type" "$remote_path" "$srv_user"
update_md
ok "기록 완료"
# 배포 후 실시간 정보 수집
sleep 1
local proc_info
proc_info=$(run_ssh "$srv_user" "$srv_ip" "
PID=\$(systemctl show solmeca-${project_name} --property=MainPID --value 2>/dev/null)
if [ -n \"\$PID\" ] && [ \"\$PID\" != '0' ]; then
UPTIME=\$(ps -o etime= -p \$PID 2>/dev/null | tr -d ' ')
MEM=\$(ps -o rss= -p \$PID 2>/dev/null | tr -d ' ')
CPU=\$(ps -o %cpu= -p \$PID 2>/dev/null | tr -d ' ')
echo \"\$PID|\${UPTIME:-0}|\${MEM:-0}|\${CPU:-0}\"
else
echo '0|0|0|0'
fi
" 2>/dev/null || echo "0|0|0|0")
local p_pid p_uptime p_mem p_cpu
IFS='|' read -r p_pid p_uptime p_mem p_cpu <<< "$proc_info"
local mem_mb=0
[ "${p_mem:-0}" -gt 0 ] 2>/dev/null && mem_mb=$((p_mem / 1024))
echo -e "\n${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
if [ "$svc_status" = "active" ]; then
echo -e "${GREEN}${BOLD} Deploy OK${NC}"
else
echo -e "${YELLOW}${BOLD} Deployed (starting...)${NC}"
fi
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
printf " %-12s %s\n" "App" "${project_name}"
printf " %-12s %s\n" "Type" "${proj_type}"
printf " %-12s %s (%s)\n" "Server" "${srv_name}" "${srv_ip}"
printf " %-12s %s\n" "Port" "${port}"
printf " %-12s %s\n" "URL" "http://${srv_ip}:${port}"
printf " %-12s %s\n" "Status" "${svc_status}"
printf " %-12s %s\n" "PID" "${p_pid}"
printf " %-12s %s\n" "Uptime" "${p_uptime}"
printf " %-12s %s%%\n" "CPU" "${p_cpu}"
printf " %-12s %sMB\n" "Memory" "${mem_mb}"
printf " %-12s %s\n" "Path" "${remote_path}"
echo ""
echo -e " ${DIM}deploy.sh logs ${project_name} -- view logs${NC}"
echo -e " ${DIM}deploy.sh stop ${project_name} -- stop service${NC}"
echo -e " ${DIM}deploy.sh rm ${project_name} -- remove service${NC}"
echo ""
}
# ── status 명령 ───────────────────────────────────────────────────
status_cmd() {
init_registry
echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}${CYAN} 서버 & 배포 상태${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
log "서버 상태"
fetch_all_server_loads
printf " ${BOLD}%-16s %6s %10s %6s %4s${NC}\n" "서버" "CPU" "RAM여유" "디스크" "GPU"
echo " ─────────────────────────────────────────────"
for server_info in "${SERVERS[@]}"; do
IFS='|' read -r name ip _ cpus has_gpu _ <<< "$server_info"
local cpu_free mem_total mem_avail _ _ disk_pct
read -r cpu_free mem_total mem_avail _ _ disk_pct <<< "${SERVER_LOADS[$name]:-0 0 0 0 0 0}"
local gpu_label=" "
[ "$has_gpu" = "yes" ] && gpu_label="GPU"
local cpu_color=$GREEN
[ "${cpu_free:-0}" -lt 30 ] && cpu_color=$YELLOW
[ "${cpu_free:-0}" -lt 10 ] && cpu_color=$RED
printf " %-16s ${cpu_color}%5d%%${NC} %5d/%4dMB %5d%% %4s\n" \
"$name" "$cpu_free" "$mem_avail" "$mem_total" "$disk_pct" "$gpu_label"
done
echo ""
log "배포 목록"
local deployments
deployments=$(python3 -c "
import json
reg=json.load(open('$REGISTRY_FILE'))
if not reg['deployments']:
print(' (없음)')
else:
print(f' {\"Name\":<20s} {\"Server\":<16s} {\"Port\":<6s} {\"Type\":<8s} {\"URL\"}')
print(' ' + '-' * 70)
for name, d in sorted(reg['deployments'].items()):
print(f' {name:<20s} {d[\"server\"]:<16s} {d[\"port\"]:<6d} {d[\"type\"]:<8s} http://{d[\"ip\"]}:{d[\"port\"]}')
" 2>/dev/null)
echo "$deployments"
# 실시간 서비스 상세 정보
echo ""
log "Service Details"
local has_services=false
for server_info in "${SERVERS[@]}"; do
IFS='|' read -r sname sip suser _ _ _ _ <<< "$server_info"
local services
services=$(run_ssh "$suser" "$sip" "systemctl list-units 'solmeca-*' --no-pager --plain 2>/dev/null | grep solmeca | awk '{print \$1, \$3}'" 2>/dev/null)
if [ -n "$services" ]; then
has_services=true
while IFS= read -r line; do
local svc_name svc_status
svc_name=$(echo "$line" | awk '{print $1}' | sed 's/solmeca-//;s/.service//')
svc_status=$(echo "$line" | awk '{print $2}')
# 프로세스 상세 정보 조회
local proc_detail
proc_detail=$(run_ssh "$suser" "$sip" "
PID=\$(systemctl show solmeca-${svc_name} --property=MainPID --value 2>/dev/null)
if [ -n \"\$PID\" ] && [ \"\$PID\" != '0' ]; then
UPTIME=\$(ps -o etime= -p \$PID 2>/dev/null | tr -d ' ')
MEM=\$(ps -o rss= -p \$PID 2>/dev/null | tr -d ' ')
CPU=\$(ps -o %cpu= -p \$PID 2>/dev/null | tr -d ' ')
echo \"\$PID|\${UPTIME:-?}|\${MEM:-0}|\${CPU:-0}\"
else
echo '0|?|0|0'
fi
" 2>/dev/null || echo "0|?|0|0")
local p_pid p_up p_mem p_cpu
IFS='|' read -r p_pid p_up p_mem p_cpu <<< "$proc_detail"
local mem_mb=0
[ "${p_mem:-0}" -gt 0 ] 2>/dev/null && mem_mb=$((p_mem / 1024))
# 레지스트리에서 포트 조회
local reg_port
reg_port=$(python3 -c "
import json
reg=json.load(open('$REGISTRY_FILE'))
d=reg['deployments'].get('$svc_name',{})
print(d.get('port','?'))
" 2>/dev/null || echo "?")
if [ "$svc_status" = "active" ]; then
echo -e " ${GREEN}[ON]${NC} ${BOLD}${svc_name}${NC}"
else
echo -e " ${RED}[--]${NC} ${BOLD}${svc_name}${NC}"
fi
echo -e " Server: ${sname} (${sip})"
echo -e " URL: ${CYAN}http://${sip}:${reg_port}${NC}"
echo -e " PID: ${p_pid} Uptime: ${p_up} CPU: ${p_cpu}% RAM: ${mem_mb}MB"
echo ""
done <<< "$services"
fi
done
if ! $has_services; then
echo " (no services deployed)"
fi
echo ""
}
# ── list 명령 ─────────────────────────────────────────────────────
list_cmd() {
init_registry
python3 -c "
import json
reg=json.load(open('$REGISTRY_FILE'))
if not reg['deployments']:
print('배포된 프로젝트가 없습니다.')
else:
for name, d in sorted(reg['deployments'].items()):
print(f\"{name:20s} {d['server']:16s} 포트:{d['port']:<5d} {d['type']:8s} http://{d['ip']}:{d['port']}\")
" 2>/dev/null
}
# ── stop 명령 ─────────────────────────────────────────────────────
stop_cmd() {
local name=$1
init_registry
local info
info=$(get_deployment_info "$name")
if [ -z "$info" ]; then
err "배포를 찾을 수 없습니다: $name"
return 1
fi
IFS='|' read -r _ ip _ user _ <<< "$info"
# 서버 비밀번호 찾기
local pass=""
for s in "${SERVERS[@]}"; do IFS='|' read -r _ si _ sp _ _ _ <<< "$s"; [ "$si" = "$ip" ] && pass=$sp && break; done
run_ssh "$user" "$ip" "echo '${pass}' | sudo -S systemctl stop solmeca-${name}" 2>/dev/null
ok "중지됨: ${name}"
}
# ── rm 명령 ───────────────────────────────────────────────────────
rm_cmd() {
local name=$1
init_registry
local info
info=$(get_deployment_info "$name")
if [ -z "$info" ]; then
err "배포를 찾을 수 없습니다: $name"
return 1
fi
IFS='|' read -r _ ip _ user path <<< "$info"
local pass=""
for s in "${SERVERS[@]}"; do IFS='|' read -r _ si _ sp _ _ _ <<< "$s"; [ "$si" = "$ip" ] && pass=$sp && break; done
run_ssh "$user" "$ip" "
echo '${pass}' | sudo -S systemctl stop solmeca-${name} 2>/dev/null
echo '${pass}' | sudo -S systemctl disable solmeca-${name} 2>/dev/null
echo '${pass}' | sudo -S rm -f /etc/systemd/system/solmeca-${name}.service
echo '${pass}' | sudo -S systemctl daemon-reload
echo '${pass}' | sudo -S rm -rf '${path}'
" 2>/dev/null
remove_deployment "$name"
update_md
ok "삭제됨: ${name}"
}
# ── logs 명령 ─────────────────────────────────────────────────────
logs_cmd() {
local name=$1
init_registry
local info
info=$(get_deployment_info "$name")
if [ -z "$info" ]; then
err "배포를 찾을 수 없습니다: $name"
return 1
fi
IFS='|' read -r _ ip _ user _ <<< "$info"
local pass=""
for s in "${SERVERS[@]}"; do IFS='|' read -r _ si _ sp _ _ _ <<< "$s"; [ "$si" = "$ip" ] && pass=$sp && break; done
run_ssh "$user" "$ip" "echo '${pass}' | sudo -S journalctl -u solmeca-${name} -n 50 --no-pager"
}
# ── 사용법 ────────────────────────────────────────────────────────
usage() {
echo -e "${BOLD}${CYAN}솔메카 Deploy v4.0${NC}\n"
echo "사용법:"
echo " deploy.sh . 현재 프로젝트 배포"
echo " deploy.sh status 서버/배포 상태"
echo " deploy.sh list 배포 목록"
echo " deploy.sh logs <이름> 로그 보기"
echo " deploy.sh stop <이름> 서비스 중지"
echo " deploy.sh rm <이름> 서비스 삭제"
echo ""
echo "지원 프로젝트:"
echo " Python (main.py, bot.py, app.py + requirements.txt)"
echo " Node.js (server.js, bot.js + package.json)"
echo " Go (main.go + go.mod)"
echo " Rust (src/main.rs + Cargo.toml)"
echo " Static (index.html)"
}
# ── 실행 ──────────────────────────────────────────────────────────
case "${1:---help}" in
.) deploy "$(pwd)" ;;
status|st) status_cmd ;;
list|ls) list_cmd ;;
stop) stop_cmd "${2:?이름을 입력하세요}" ;;
rm|remove) rm_cmd "${2:?이름을 입력하세요}" ;;
logs|log) logs_cmd "${2:?이름을 입력하세요}" ;;
-h|--help|help) usage ;;
*) deploy "$1" ;;
esac

463
docs/gap-analysis.md Normal file
View File

@@ -0,0 +1,463 @@
# Design-Implementation Gap Analysis Report
> **Summary**: Comprehensive comparison of polymarket-arb-bot-prompt.md specification against actual implementation
>
> **Author**: gap-detector
> **Created**: 2026-03-18
> **Last Modified**: 2026-03-18
> **Status**: Review
---
## Analysis Overview
- **Analysis Target**: Polymarket Temporal Arbitrage Bot
- **Design Document**: `D:\PRJ\poly_company\dtr2_poly\polymarket-arb-bot-prompt.md`
- **Implementation Path**: `D:\PRJ\poly_company\dtr2_poly\polymarket-arb-bot\`
- **Analysis Date**: 2026-03-18
---
## Overall Scores
| Category | Score | Status |
|----------|:-----:|:------:|
| Directory Structure | 90% | PARTIAL |
| Dependencies | 100% | IMPLEMENTED |
| API Integrations | 88% | PARTIAL |
| Core Strategy Logic | 95% | IMPLEMENTED |
| Risk Management | 100% | IMPLEMENTED |
| Implementation Phases | 82% | PARTIAL |
| Execution Commands | 67% | PARTIAL |
| Speed Requirements | 70% | PARTIAL |
| Asset-Specific Handling | 100% | IMPLEMENTED |
| **Overall** | **87%** | PARTIAL |
---
## 1. Directory Structure (Spec lines 75-123)
### Status: PARTIAL (90%)
| Specified File/Directory | Status | Notes |
|--------------------------|:------:|-------|
| `.env` | IMPLEMENTED | `.env.example` exists (actual `.env` gitignored, correct) |
| `config.toml` | IMPLEMENTED | All sections present, values match spec |
| `requirements.txt` | IMPLEMENTED | All 14 dependencies present |
| `src/__init__.py` | IMPLEMENTED | Exists (empty) |
| `src/main.py` | IMPLEMENTED | Full ArbBot class with event loop |
| `src/config.py` | IMPLEMENTED | Nested dataclasses, TOML loader, env overlay |
| `src/feeds/__init__.py` | IMPLEMENTED | Exports BinanceFeed, PolymarketFeed |
| `src/feeds/binance_ws.py` | IMPLEMENTED | Full async WebSocket with reconnect |
| `src/feeds/coinbase_ws.py` | MISSING | Coinbase cross-validation feed not implemented |
| `src/feeds/polymarket_ws.py` | IMPLEMENTED | Full orderbook WebSocket with auto-reconnect |
| `src/strategy/__init__.py` | IMPLEMENTED | Exports all strategy classes |
| `src/strategy/temporal_arb.py` | IMPLEMENTED | Full strategy with probability model |
| `src/strategy/sum_to_one.py` | IMPLEMENTED | Full sum-to-one with signal generation |
| `src/strategy/spread_capture.py` | IMPLEMENTED | Full spread capture (disabled by default) |
| `src/strategy/signal.py` | IMPLEMENTED | SignalAggregator coordinating all strategies |
| `src/execution/__init__.py` | IMPLEMENTED | Exists |
| `src/execution/clob_client.py` | IMPLEMENTED | EIP-712 auth, order ops, async wrapper |
| `src/execution/order_manager.py` | IMPLEMENTED | Full lifecycle, expiry cancellation |
| `src/execution/position_tracker.py` | IMPLEMENTED | Mark-to-market, PnL tracking |
| `src/market/__init__.py` | IMPLEMENTED | Exports MarketDiscovery, WindowTracker |
| `src/market/discovery.py` | IMPLEMENTED | Gamma API with filtering |
| `src/market/window_tracker.py` | IMPLEMENTED | Clock-aligned windows for 3 assets x 2 TFs |
| `src/market/oracle.py` | IMPLEMENTED | Chainlink ABI, latency monitoring |
| `src/risk/__init__.py` | IMPLEMENTED | Exists |
| `src/risk/position_sizer.py` | IMPLEMENTED | Kelly Criterion with 4 safety caps |
| `src/risk/risk_manager.py` | IMPLEMENTED | Daily loss, exposure, position count checks |
| `src/risk/fee_calculator.py` | IMPLEMENTED | 5M vs 15M fees, breakeven calc, EV calc |
| `src/data/__init__.py` | IMPLEMENTED | Exports TradeDB |
| `src/data/models.py` | IMPLEMENTED | All models: Market, Position, Trade, etc. |
| `src/data/db.py` | IMPLEMENTED | SQLite with trades, windows, daily_summary, balance |
| `src/utils/__init__.py` | IMPLEMENTED | Exists |
| `src/utils/logger.py` | IMPLEMENTED | structlog with console/JSON renderers |
| `src/utils/telegram.py` | IMPLEMENTED | Trade, fill, daily, error, halt notifications |
| `src/utils/metrics.py` | IMPLEMENTED | Rolling windows, asset breakdown, hourly stats |
| `paper_trade.py` | IMPLEMENTED | Full paper bot with virtual execution engine |
| `backtest.py` | IMPLEMENTED | Synthetic + historical modes (not in spec but bonus) |
| `dashboard/app.py` | MISSING | Streamlit dashboard not implemented |
---
## 2. Dependencies (Spec lines 126-143)
### Status: IMPLEMENTED (100%)
| Dependency | Spec Version | Impl Version | Status |
|------------|:------------:|:------------:|:------:|
| py-clob-client | >=0.18.0 | >=0.18.0 | IMPLEMENTED |
| web3 | >=6.0 | >=6.0 | IMPLEMENTED |
| websockets | >=12.0 | >=12.0 | IMPLEMENTED |
| aiohttp | >=3.9 | >=3.9 | IMPLEMENTED |
| python-dotenv | >=1.0 | >=1.0 | IMPLEMENTED |
| toml | >=0.10 | >=0.10 | IMPLEMENTED |
| eth-account | >=0.11 | >=0.11 | IMPLEMENTED |
| structlog | >=24.0 | >=24.0 | IMPLEMENTED |
| sqlite-utils | >=3.36 | >=3.36 | IMPLEMENTED |
| ccxt | >=4.2 | >=4.2 | IMPLEMENTED |
| numpy | >=1.26 | >=1.26 | IMPLEMENTED |
| pandas | >=2.2 | >=2.2 | IMPLEMENTED |
| streamlit | >=1.32 | >=1.32 | IMPLEMENTED |
| python-telegram-bot | >=21.0 | >=21.0 | IMPLEMENTED |
Note: `streamlit` is listed in requirements.txt but `dashboard/app.py` is not implemented. `python-telegram-bot` is in requirements.txt but the actual Telegram implementation uses `aiohttp` directly instead.
---
## 3. API Integrations (Spec lines 147-240)
### Status: PARTIAL (88%)
#### 3.1 Polymarket Gamma API Market Discovery
| Requirement | Status | Location |
|-------------|:------:|----------|
| GET /events endpoint | IMPLEMENTED | `src/market/discovery.py:16` |
| Query params: tag=crypto, active=true, closed=false, limit=100 | IMPLEMENTED | `src/market/discovery.py:17-22` |
| Filter: "Up or Down" in title | IMPLEMENTED | `src/market/discovery.py:179` (regex) |
| Filter: "5 Min" or "15 Min" | IMPLEMENTED | `src/market/discovery.py:30-37` |
| Filter: BTC / ETH / SOL | IMPLEMENTED | `src/market/discovery.py:24-28` |
| Filter: enableOrderBook == true | IMPLEMENTED | `src/market/discovery.py:201` |
| Filter: active == true | IMPLEMENTED | `src/market/discovery.py:199` |
| Continuous discovery loop | IMPLEMENTED | `src/market/discovery.py:235` |
| Rate limit handling (429) | IMPLEMENTED | `src/market/discovery.py:143-147` |
#### 3.2 CLOB API Authentication (EIP-712)
| Requirement | Status | Location |
|-------------|:------:|----------|
| py-clob-client ClobClient usage | IMPLEMENTED | `src/execution/clob_client.py:53-61` |
| host, key, chain_id, signature_type, funder params | IMPLEMENTED | `src/execution/clob_client.py:56-61` |
| API key creation (create_api_key) | IMPLEMENTED | `src/execution/clob_client.py:68` |
| Order placement (OrderArgs, GTC) | IMPLEMENTED | `src/execution/clob_client.py:122-132` |
| Market order (FOK) | IMPLEMENTED | `src/execution/clob_client.py:155-167` |
| Async wrapper (thread executor) | IMPLEMENTED | `src/execution/clob_client.py:43-46` |
#### 3.3 Binance WebSocket
| Requirement | Status | Location |
|-------------|:------:|----------|
| Combined stream URL format | IMPLEMENTED | `src/feeds/binance_ws.py:31-34` |
| btcusdt@trade, ethusdt@trade, solusdt@trade | IMPLEMENTED | `src/feeds/binance_ws.py:21-25` |
| Parse stream name -> symbol mapping | IMPLEMENTED | `src/feeds/binance_ws.py:206-209` |
| Extract price (data['p']), volume (data['q']) | IMPLEMENTED | `src/feeds/binance_ws.py:212-213` |
| Auto-reconnect with exponential backoff | IMPLEMENTED | `src/feeds/binance_ws.py:96-131` |
#### 3.4 Polymarket WebSocket (Orderbook)
| Requirement | Status | Location |
|-------------|:------:|----------|
| Subscribe message format | IMPLEMENTED | `src/feeds/polymarket_ws.py:211-217` |
| Channel: "book" (spec says "market") | CHANGED | Spec: `"channel": "market"`, Impl: `"channel": "book"` |
| assets_ids field (spec: assets_id singular) | CHANGED | Spec: `"assets_id"`, Impl: `"assets_ids"` (array) |
| Parse bid/ask updates | IMPLEMENTED | `src/feeds/polymarket_ws.py:250-269` |
| Maintain OrderBookSnapshot | IMPLEMENTED | `src/feeds/polymarket_ws.py:271-278` |
| Auto-reconnect | IMPLEMENTED | `src/feeds/polymarket_ws.py:108-143` |
#### 3.5 Coinbase WebSocket (Cross-validation)
| Requirement | Status | Notes |
|-------------|:------:|-------|
| coinbase_ws.py module | MISSING | File does not exist |
| Cross-validation feed | MISSING | No Coinbase integration at all |
**Note**: The spec marks this as "auxiliary feed for cross-validation". The `.env.example` includes `COINBASE_WS_URL` but no implementation exists.
---
## 4. Core Strategy Logic (Spec lines 244-333)
### Status: IMPLEMENTED (95%)
#### 4.1 TemporalArbStrategy Class
| Spec Requirement | Status | Implementation Detail |
|-----------------|:------:|----------------------|
| `__init__` with config params | IMPLEMENTED | Takes `TemporalArbConfig`, `RiskConfig`, `FeesConfig` |
| `min_price_move_pct = 0.15` | CHANGED | Config default 0.15, but `config.toml` has 0.03 |
| `max_poly_price = 0.65` | IMPLEMENTED | `max_poly_entry_price = 0.65` |
| `min_edge = 0.20` | CHANGED | Config default 0.20, but `config.toml` has 0.05 |
| `exit_before_resolution_sec = 5` | IMPLEMENTED | Matches spec |
| `max_position_per_market = 5000` | IMPLEMENTED | In RiskConfig |
| `taker_fee_rate = 0.0156` | IMPLEMENTED | FeesConfig with timeframe-aware fee |
#### 4.2 Evaluate Function - 10 Steps
| Step | Spec Description | Status | Notes |
|:----:|-----------------|:------:|-------|
| 1 | Price direction calculation | IMPLEMENTED | `(cex_price - window_start_price) / window_start_price * 100` |
| 2 | Direction magnitude check (`abs < min_price_move_pct`) | IMPLEMENTED | `temporal_arb.py:85-86` |
| 3 | Direction determination (UP/DOWN) | IMPLEMENTED | `temporal_arb.py:89` |
| 4 | Probability estimation | IMPLEMENTED | Enhanced: multi-factor model (base + time decay + volatility) |
| 5 | Polymarket price selection | IMPLEMENTED | `temporal_arb.py:98-99` |
| 6 | Edge calculation (prob - price - fee) | IMPLEMENTED | `temporal_arb.py:112-113` |
| 7 | Edge threshold check | IMPLEMENTED | `temporal_arb.py:115-116` |
| 8 | Entry price ceiling check | IMPLEMENTED | `temporal_arb.py:102-103` |
| 9 | Time-to-resolution check | IMPLEMENTED | `temporal_arb.py:93-95` |
| 10 | Position sizing | IMPLEMENTED | `temporal_arb.py:120-125` |
#### 4.3 Kelly Criterion Position Sizing
| Requirement | Status | Notes |
|-------------|:------:|-------|
| Formula: f* = (bp - q) / b | IMPLEMENTED | `temporal_arb.py:233` |
| b = (1/price - 1) | IMPLEMENTED | `temporal_arb.py:226` |
| p = estimated_prob | IMPLEMENTED | `temporal_arb.py:227` |
| Max 25% Kelly cap | IMPLEMENTED | `temporal_arb.py:234` via `kelly_fraction_cap` |
| Dollar size = balance * kelly_fraction | IMPLEMENTED | `temporal_arb.py:236` |
| max_position_per_market cap | IMPLEMENTED | `temporal_arb.py:236` |
| Return shares = dollar_size / price | IMPLEMENTED | `temporal_arb.py:237` |
#### 4.4 Signal Generation
| Requirement | Status | Notes |
|-------------|:------:|-------|
| Signal dataclass with all fields | IMPLEMENTED | `models.py:72-81` |
| Signal aggregation across strategies | IMPLEMENTED | `signal.py` SignalAggregator |
| Signal deduplication / cooldown | IMPLEMENTED | 30-second cooldown per direction/asset/timeframe |
---
## 5. Risk Management (Spec lines 337-394)
### Status: IMPLEMENTED (100%)
#### 5.1 Config Parameters (config.toml)
| Parameter | Spec Value | Impl Value | Status |
|-----------|:----------:|:----------:|:------:|
| `mode` | "paper" | "paper" | IMPLEMENTED |
| `assets` | ["BTC","ETH","SOL"] | ["BTC","ETH","SOL"] | IMPLEMENTED |
| `timeframes` | ["5M","15M"] | ["5M","15M"] | IMPLEMENTED |
| `log_level` | "INFO" | "INFO" | IMPLEMENTED |
| `temporal_arb.enabled` | true | true | IMPLEMENTED |
| `temporal_arb.min_price_move_pct` | 0.15 | 0.03 | CHANGED |
| `temporal_arb.max_poly_entry_price` | 0.65 | 0.65 | IMPLEMENTED |
| `temporal_arb.min_edge` | 0.20 | 0.05 | CHANGED |
| `temporal_arb.exit_before_resolution_sec` | 5 | 5 | IMPLEMENTED |
| `sum_to_one.enabled` | true | true | IMPLEMENTED |
| `sum_to_one.min_spread_after_fee` | 0.02 | 0.02 | IMPLEMENTED |
| `spread_capture.enabled` | false | false | IMPLEMENTED |
| `spread_capture.spread_target` | 0.04 | 0.04 | IMPLEMENTED |
| `risk.max_position_per_market_usd` | 5000 | 5000 | IMPLEMENTED |
| `risk.max_total_exposure_usd` | 20000 | 20000 | IMPLEMENTED |
| `risk.max_daily_loss_usd` | 2000 | 2000 | IMPLEMENTED |
| `risk.kelly_fraction_cap` | 0.25 | 0.25 | IMPLEMENTED |
| `risk.max_concurrent_positions` | 6 | 6 | IMPLEMENTED |
| `fees.taker_fee_5m` | 0.0156 | 0.0156 | IMPLEMENTED |
| `fees.taker_fee_15m` | 0.03 | 0.03 | IMPLEMENTED |
| `exchange.binance.ws_url` | spec URL | matches | IMPLEMENTED |
| `exchange.binance.symbols` | 3 pairs | matches | IMPLEMENTED |
| `exchange.polymarket.*` | all URLs | matches | IMPLEMENTED |
| `notifications.*` | all fields | matches | IMPLEMENTED |
**Note on CHANGED values**: `min_price_move_pct` (0.15->0.03) and `min_edge` (0.20->0.05) were intentionally lowered in config.toml (comments explain: "lower for evaluation"). The default dataclass values still match the spec. This appears to be parameter tuning, not a design deviation.
#### 5.2 Risk Manager Features
| Feature | Status | Location |
|---------|:------:|----------|
| Maximum exposure check | IMPLEMENTED | `risk_manager.py:110-117` |
| Daily loss limit with auto-halt | IMPLEMENTED | `risk_manager.py:93-108` |
| Position count limit | IMPLEMENTED | `risk_manager.py:119-125` |
| Trading halt/resume | IMPLEMENTED | `risk_manager.py:131-141` |
| Risk summary for dashboard | IMPLEMENTED | `risk_manager.py:147-161` |
#### 5.3 Fee Calculator
| Feature | Status | Notes |
|---------|:------:|-------|
| 5M taker fee (1.56%) | IMPLEMENTED | `fee_calculator.py:39` |
| 15M taker fee (3%) | IMPLEMENTED | `fee_calculator.py:39` |
| Fee applied to profit, not cost | IMPLEMENTED | `fee_calculator.py:40-43` |
| Breakeven probability calc | IMPLEMENTED | `fee_calculator.py:45-57` |
| Expected value calculation | IMPLEMENTED | `fee_calculator.py:80-90` |
---
## 6. Implementation Phases (Spec lines 398-430)
### Status: PARTIAL (82%)
#### Phase 1: Infrastructure & Data Pipeline
| Item | Status | Notes |
|------|:------:|-------|
| Project scaffolding | IMPLEMENTED | All directories, deps, config |
| Binance WebSocket BTC/ETH/SOL | IMPLEMENTED | Full async with reconnect |
| Polymarket Gamma API discovery | IMPLEMENTED | With filtering and continuous loop |
| Window tracker (start price / end time) | IMPLEMENTED | Clock-aligned for 3x2 windows |
| Logging & SQLite trade log | IMPLEMENTED | structlog + SQLite with 4 tables |
#### Phase 2: Strategy Engine
| Item | Status | Notes |
|------|:------:|-------|
| Temporal arb signal generator | IMPLEMENTED | Enhanced probability model |
| Sum-to-one arb monitor | IMPLEMENTED | Full with signal generation |
| Probability estimation model | IMPLEMENTED | 3-factor model (base + time + volatility) |
| Kelly Criterion sizing | IMPLEMENTED | With 4 safety caps in PositionSizer |
#### Phase 3: Execution Engine
| Item | Status | Notes |
|------|:------:|-------|
| CLOB auth (EIP-712 + API Key) | IMPLEMENTED | py-clob-client wrapper |
| Order manager (create/modify/cancel) | IMPLEMENTED | Full lifecycle management |
| Position tracker (real-time PnL) | IMPLEMENTED | Mark-to-market updates |
| Fee calculator (5M vs 15M) | IMPLEMENTED | With breakeven and EV calcs |
#### Phase 4: Risk & Monitoring
| Item | Status | Notes |
|------|:------:|-------|
| Risk manager (daily loss, max exposure) | IMPLEMENTED | Auto-halt on breach |
| Telegram notification bot | IMPLEMENTED | Trade, fill, daily, error, halt alerts |
| Streamlit real-time dashboard | MISSING | `dashboard/app.py` does not exist |
| Paper trading mode | IMPLEMENTED | Full virtual execution engine |
#### Phase 5: Optimization & Live
| Item | Status | Notes |
|------|:------:|-------|
| Paper trading validation | IMPLEMENTED | `paper_trade.py` with simulated orderbooks |
| Small-amount live trading | IMPLEMENTED | `src/main.py` with live mode support |
| Parameter tuning | PARTIAL | Config supports it, no auto-tuning |
| Performance analysis | IMPLEMENTED | `backtest.py` (bonus: not in original spec) |
---
## 7. Execution Commands (Spec lines 482-496)
### Status: PARTIAL (67%)
| Command | Status | Notes |
|---------|:------:|-------|
| `python paper_trade.py` | IMPLEMENTED | Full paper trading bot at project root |
| `python src/main.py` | IMPLEMENTED | Full live/paper ArbBot with all components |
| `streamlit run dashboard/app.py` | MISSING | Dashboard directory does not exist |
---
## 8. Speed Requirements (Spec lines 463-467)
### Status: PARTIAL (70%)
| Requirement | Status | Implementation |
|-------------|:------:|----------------|
| Opportunity window: 2-5 seconds | IMPLEMENTED | Strategy evaluates on every tick |
| Order detect -> fill < 500ms | PARTIAL | Async architecture supports it; actual latency depends on network |
| Binance tick -> signal: < 50ms | PARTIAL | Callback-based, should be <50ms; no explicit measurement |
| Signal -> CLOB order: < 200ms | PARTIAL | Uses thread executor for sync SDK; no latency metrics |
| Server near Polygon RPC | N/A | Deployment concern, not code |
**Notes**:
- `src/utils/logger.py` provides `log_timing` context manager for measurement, but it is not used in the hot path (strategy eval -> order submission).
- No explicit latency benchmarking or monitoring exists in the strategy pipeline.
- The `signal.py` aggregator has rate limiting at 0.5s intervals per market, which could theoretically delay signals.
---
## 9. Asset-Specific Handling (BTC, ETH, SOL Simultaneous)
### Status: IMPLEMENTED (100%)
| Requirement | Status | Notes |
|-------------|:------:|-------|
| BTC support | IMPLEMENTED | Asset.BTC enum, Binance stream, Gamma filter |
| ETH support | IMPLEMENTED | Asset.ETH enum, Binance stream, Gamma filter |
| SOL support | IMPLEMENTED | Asset.SOL enum, Binance stream, Gamma filter |
| Simultaneous operation | IMPLEMENTED | WindowTracker manages 6 windows (3 assets x 2 TFs) |
| Per-asset price tracking | IMPLEMENTED | BinanceFeed._latest_prices keyed by symbol |
| Oracle monitoring per asset | IMPLEMENTED | OracleMonitor.FEEDS has all 3 Chainlink addresses |
---
## Differences Found
### MISSING Features (Design O, Implementation X)
| Item | Spec Location | Description | Impact |
|------|---------------|-------------|--------|
| `src/feeds/coinbase_ws.py` | Spec line 87 | Coinbase WebSocket for cross-validation | Low |
| `dashboard/app.py` | Spec line 122 | Streamlit real-time dashboard | Medium |
| Explicit latency measurement | Spec lines 463-467 | No tick-to-order latency tracking in hot path | Medium |
### ADDED Features (Design X, Implementation O)
| Item | Implementation Location | Description |
|------|------------------------|-------------|
| `backtest.py` | Project root | Synthetic + historical backtester (not in spec) |
| Enhanced probability model | `temporal_arb.py:169-205` | Multi-factor model (base + time decay + volatility boost) vs spec's simple linear model |
| `PositionSizer` class | `src/risk/position_sizer.py` | Standalone Kelly sizer with 4 caps (half-Kelly, exposure cap) beyond spec |
| `OracleMonitor` class | `src/market/oracle.py` | Chainlink oracle latency tracking (spec mentions oracle but no dedicated monitor) |
| Signal deduplication | `signal.py:78-84` | 30-second cooldown per market direction |
| Simulated orderbooks in paper mode | `paper_trade.py:461-529` | Generates fake Polymarket prices for paper trading when no real market exists |
| Balance history table | `src/data/db.py:102-113` | Tracks balance over time (not in spec) |
| Early exit logic | `temporal_arb.py:245-284` | `should_exit_early()` method for reversal/take-profit detection |
### CHANGED Features (Design != Implementation)
| Item | Spec | Implementation | Impact |
|------|------|----------------|--------|
| WebSocket subscribe channel | `"channel": "market"` | `"channel": "book"` | Low (likely API correction) |
| WebSocket subscribe field | `"assets_id": TOKEN_ID` | `"assets_ids": [token_id]` (array) | Low (likely API correction) |
| `config.toml` min_price_move_pct | 0.15 | 0.03 | Low (tuning, default still 0.15) |
| `config.toml` min_edge | 0.20 | 0.05 | Low (tuning, default still 0.20) |
| Probability estimation | `min(0.95, 0.55 + abs(pct) * 100)` | Multi-factor: base + time_decay + vol_boost | Low (improvement) |
| Telegram implementation | `python-telegram-bot` SDK | Direct `aiohttp` HTTP calls | Low (simpler, fewer deps) |
| Fee calculation scope | `taker_fee_rate = 0.0156` (fixed) | Timeframe-aware via `FeesConfig.fee_for_timeframe()` | Low (improvement) |
---
## Summary by Component
| Component | Files | Completeness | Quality |
|-----------|:-----:|:------------:|:-------:|
| Config & Setup | 4 | 100% | High - frozen dataclasses, env overlay |
| Data Feeds | 2/3 | 67% | High - auto-reconnect, heartbeat |
| Market Discovery | 3 | 100% | High - rate limiting, continuous loop |
| Strategy Engine | 4 | 100% | High - 3 strategies, aggregator, dedup |
| Execution Engine | 3 | 100% | High - async SDK wrapper, lifecycle |
| Risk Management | 3 | 100% | High - auto-halt, multi-check |
| Data Layer | 2 | 100% | High - 4 tables, aggregation queries |
| Utilities | 3 | 100% | High - structured logging, metrics |
| Entry Points | 2/3 | 67% | High - paper + live, missing dashboard |
| Bonus | 1 | N/A | backtest.py not in spec |
---
## Recommended Actions
### Immediate Actions (Priority: High)
1. **Implement `dashboard/app.py`** -- The Streamlit dashboard is specified in the design and `streamlit` is already in `requirements.txt`. This provides visibility into bot performance and is explicitly called out as an execution command (`streamlit run dashboard/app.py`). Should display: live positions, PnL chart, trade log, risk status, asset breakdown using data from `MetricsCollector` and `TradeDB`.
### Medium Priority
2. **Add latency instrumentation** -- The `log_timing` utility exists in `src/utils/logger.py` but is never used in the critical path. Wrap the strategy evaluation and order submission flows to verify the spec's <50ms and <200ms targets.
3. **Implement `src/feeds/coinbase_ws.py`** -- Coinbase cross-validation was specified as auxiliary. Consider adding as a secondary price source to detect exchange-specific anomalies or confirm Binance prices.
### Low Priority / Documentation
4. **Document config.toml parameter changes** -- The deployed `config.toml` has `min_price_move_pct=0.03` and `min_edge=0.05` vs spec defaults of `0.15` and `0.20`. These are clearly intentional tuning choices (inline comments explain), but should be documented in a tuning log.
5. **WebSocket field differences** -- The Polymarket WebSocket uses `"channel": "book"` and `"assets_ids"` (array) instead of spec's `"channel": "market"` and `"assets_id"` (singular). These appear to be corrections reflecting the actual API behavior. Update the spec to match.
---
## Match Rate: 87%
The implementation covers the vast majority of the specification with high code quality. The two primary gaps (Streamlit dashboard and Coinbase feed) are well-contained and do not affect core trading functionality. Several additions (backtester, oracle monitor, enhanced probability model, early exit logic) represent improvements beyond the original spec.
### Verdict
Match Rate >= 70% && < 90%: **There are some differences. Document update and focused implementation recommended.**
Specific focus areas:
- Build the Streamlit dashboard to complete Phase 4
- Add latency metrics to validate speed requirements
- Update spec to reflect API corrections and intentional improvements

901
paper_trade.py Normal file
View File

@@ -0,0 +1,901 @@
"""Paper trading wrapper for the Polymarket Temporal Arbitrage Bot.
Runs the same event loop as ``src.main`` but forces paper-trading mode,
adds a simulated order execution layer that tracks virtual positions
and PnL, and prints periodic performance summaries.
Usage::
python paper_trade.py
"""
from __future__ import annotations
import asyncio
import signal
import sys
import time
import uuid
from dataclasses import dataclass, field
from typing import Optional
import structlog
from src.config import (
BinanceConfig,
Config,
FeesConfig,
GeneralConfig,
NotificationConfig,
PolymarketConfig,
RiskConfig,
SpreadCaptureConfig,
SumToOneConfig,
TemporalArbConfig,
load_config,
)
from src.data import TradeDB
from src.data.models import (
ActiveMarket,
Asset,
Direction,
OrderBookSnapshot,
Signal,
Timeframe,
Trade,
TradeStatus,
WindowState,
)
from src.feeds import BinanceFeed, PolymarketFeed
from src.market import MarketDiscovery, WindowTracker
from src.market.oracle import OracleMonitor
from src.strategy import SignalAggregator
from src.utils import get_logger, setup_logging
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_STATUS_INTERVAL_S: float = 30.0
_SUMMARY_INTERVAL_S: float = 300.0 # 5 minutes
_DISCOVERY_INTERVAL_S: float = 30.0
_BANNER = r"""
____ _____ _
| _ \ __ _ _ __ ___ _ __ |_ _| __ __ _ __| | ___
| |_) / _` | '_ \ / _ \ '__| | || '__/ _` |/ _` |/ _ \
| __/ (_| | |_) | __/ | | || | | (_| | (_| | __/
|_| \__,_| .__/ \___|_| |_||_| \__,_|\__,_|\___|
|_|
"""
# ---------------------------------------------------------------------------
# Virtual position tracking
# ---------------------------------------------------------------------------
@dataclass
class VirtualPosition:
"""A simulated open position."""
position_id: str
asset: Asset
timeframe: Timeframe
direction: Direction
token_id: str
entry_price: float
size: int
opened_at: float = field(default_factory=time.time)
@dataclass
class VirtualTradeRecord:
"""A completed (closed) virtual trade."""
trade_id: str
asset: Asset
timeframe: Timeframe
direction: Direction
token_id: str
entry_price: float
exit_price: float
size: int
fee: float
pnl: float
opened_at: float
closed_at: float = field(default_factory=time.time)
class PaperExecutionEngine:
"""Simulated order execution that tracks virtual positions and PnL.
Accepts trade signals, creates virtual positions, and resolves them
when the window expires. All trades are logged to SQLite via
:class:`TradeDB`.
"""
def __init__(self, db: TradeDB, fees: FeesConfig) -> None:
self._db = db
self._fees = fees
self._log = get_logger("paper_engine")
self._positions: dict[str, VirtualPosition] = {}
self._closed_trades: list[VirtualTradeRecord] = []
self._total_pnl: float = 0.0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def active_positions(self) -> list[VirtualPosition]:
return list(self._positions.values())
@property
def total_pnl(self) -> float:
return self._total_pnl
@property
def trade_count(self) -> int:
return len(self._closed_trades)
@property
def win_rate(self) -> float:
if not self._closed_trades:
return 0.0
wins = sum(1 for t in self._closed_trades if t.pnl > 0)
return wins / len(self._closed_trades)
def open_position(
self,
asset: Asset,
timeframe: Timeframe,
direction: Direction,
token_id: str,
entry_price: float,
size: int,
edge: float,
estimated_prob: float,
) -> str:
"""Simulate opening a position. Returns the position ID."""
position_id = uuid.uuid4().hex[:12]
pos = VirtualPosition(
position_id=position_id,
asset=asset,
timeframe=timeframe,
direction=direction,
token_id=token_id,
entry_price=entry_price,
size=size,
)
self._positions[position_id] = pos
# Log the pending trade to DB
sig = Signal(
direction=direction,
asset=asset,
timeframe=timeframe,
token_id=token_id,
price=entry_price,
size=size,
edge=edge,
estimated_prob=estimated_prob,
)
trade = Trade(
id=f"paper-{position_id}",
signal=sig,
status=TradeStatus.FILLED,
fill_price=entry_price,
fill_size=size,
fee=0.0,
pnl=0.0,
)
self._db.log_trade(trade)
self._log.info(
"virtual_position_opened",
position_id=position_id,
asset=asset.value,
timeframe=timeframe.value,
direction=direction.value,
entry_price=entry_price,
size=size,
)
return position_id
def close_position(self, position_id: str, exit_price: float) -> Optional[VirtualTradeRecord]:
"""Simulate closing a position at *exit_price*. Returns the trade record."""
pos = self._positions.pop(position_id, None)
if pos is None:
self._log.warning("close_unknown_position", position_id=position_id)
return None
# Calculate fee based on timeframe
fee_rate = self._fees.fee_for_timeframe(pos.timeframe.value)
notional = pos.size * pos.entry_price
fee = notional * fee_rate
# PnL: if direction is UP and price went up, the binary resolves to 1
# Simplified: pnl = size * (exit_price - entry_price) - fee
pnl = pos.size * (exit_price - pos.entry_price) - fee
record = VirtualTradeRecord(
trade_id=f"paper-{pos.position_id}",
asset=pos.asset,
timeframe=pos.timeframe,
direction=pos.direction,
token_id=pos.token_id,
entry_price=pos.entry_price,
exit_price=exit_price,
size=pos.size,
fee=fee,
pnl=pnl,
opened_at=pos.opened_at,
)
self._closed_trades.append(record)
self._total_pnl += pnl
# Update the trade record in DB
self._db.update_trade(
record.trade_id,
fill_price=exit_price,
fee=fee,
pnl=pnl,
status=TradeStatus.FILLED.value,
)
self._log.info(
"virtual_position_closed",
position_id=pos.position_id,
asset=pos.asset.value,
direction=pos.direction.value,
entry=pos.entry_price,
exit=exit_price,
pnl=round(pnl, 4),
fee=round(fee, 4),
total_pnl=round(self._total_pnl, 4),
)
return record
def print_summary(self) -> None:
"""Print a performance summary to the log."""
self._log.info(
"paper_trade_summary",
total_pnl=round(self._total_pnl, 4),
trade_count=self.trade_count,
win_rate=round(self.win_rate * 100, 1),
active_positions=len(self._positions),
positions=[
{
"id": p.position_id,
"asset": p.asset.value,
"dir": p.direction.value,
"entry": p.entry_price,
"size": p.size,
}
for p in self._positions.values()
],
)
# ---------------------------------------------------------------------------
# Paper trading bot
# ---------------------------------------------------------------------------
class PaperTradingBot:
"""Orchestrates Phase 1 components with a paper execution layer."""
def __init__(self, config: Config) -> None:
self._cfg = config
self._log = get_logger("paper_bot")
self._starting_balance = config.general.starting_balance
self._balance = config.general.starting_balance
# Components
self._db: Optional[TradeDB] = None
self._engine: Optional[PaperExecutionEngine] = None
self._discovery: Optional[MarketDiscovery] = None
self._tracker: Optional[WindowTracker] = None
self._binance_feed: Optional[BinanceFeed] = None
self._poly_feed: Optional[PolymarketFeed] = None
self._signal_agg: Optional[SignalAggregator] = None
self._oracle: Optional[OracleMonitor] = None
# Discovered markets cache
self._active_markets: list[ActiveMarket] = []
# Live orderbook state (token_id → snapshot)
self._orderbooks: dict[str, OrderBookSnapshot] = {}
# Pending strategy evaluations (initialized in start())
self._eval_queue: Optional[asyncio.Queue] = None
# Shutdown coordination
self._shutdown_event = asyncio.Event()
# ------------------------------------------------------------------
# Callbacks
# ------------------------------------------------------------------
def _on_binance_price(
self, symbol: str, price: float, timestamp: float, volume: float
) -> None:
"""Process each Binance trade tick — also triggers strategy evaluation."""
self._log.debug(
"binance_price",
symbol=symbol,
price=price,
volume=volume,
)
if self._tracker is not None:
self._tracker.update_price(symbol, price, timestamp)
# Trigger strategy evaluation for each timeframe
if self._signal_agg is None or self._tracker is None:
return
for tf_str in self._cfg.general.timeframes:
window = self._tracker.get_window(symbol, tf_str)
if window is None or window.start_price is None:
continue
# If no real Polymarket orderbooks, simulate them every tick
orderbooks = self._orderbooks
is_simulated = window.market is not None and window.market.condition_id.startswith("sim_")
if window.market is None or is_simulated:
orderbooks = self._simulate_orderbooks(symbol, price, window)
# Now window.market is guaranteed to be set
if window.market is not None and self._eval_queue is not None:
try:
self._eval_queue.put_nowait((symbol, price, window, orderbooks))
except (asyncio.QueueFull, Exception):
pass
def _on_orderbook_update(
self, token_id: str, snapshot: OrderBookSnapshot
) -> None:
"""Process each Polymarket orderbook update — store for strategy use."""
self._orderbooks[token_id] = snapshot
self._log.debug(
"poly_orderbook",
token_id=token_id[:12],
best_bid=snapshot.best_bid,
best_ask=snapshot.best_ask,
)
def _on_window_change(self, window: WindowState) -> None:
"""Fired when a price window transitions.
*window* is the COMPLETED window (with final current_price and
price_change_pct). Resolve open positions and snapshot to DB.
"""
self._log.info(
"window_completed",
asset=window.asset.value,
timeframe=window.timeframe.value,
start_price=window.start_price,
end_price=window.current_price,
change_pct=window.price_change_pct,
window_start=window.window_start_time,
window_end=window.window_end_time,
)
# Resolve positions from the completed window
self._on_window_change_resolve(window)
# Snapshot completed window to DB
if self._db is not None and window.start_price is not None:
try:
self._db.log_window(window)
except Exception:
self._log.exception("window_snapshot_failed")
def _on_signal(self, signal: Signal) -> None:
"""Handle a trading signal from the strategy engine — open a virtual position."""
if self._engine is None:
return
# Check balance
cost = signal.price * signal.size
if cost > self._balance * 0.5: # Don't risk more than 50% on one trade
self._log.warning("signal_rejected_balance", cost=round(cost, 2), balance=round(self._balance, 2))
return
position_id = self._engine.open_position(
asset=signal.asset,
timeframe=signal.timeframe,
direction=signal.direction,
token_id=signal.token_id,
entry_price=signal.price,
size=signal.size,
edge=signal.edge,
estimated_prob=signal.estimated_prob,
)
self._log.info(
"paper_trade_opened",
position_id=position_id,
asset=signal.asset.value,
direction=signal.direction.value,
price=signal.price,
size=signal.size,
edge=round(signal.edge, 4),
)
def _on_window_change_resolve(self, window: WindowState) -> None:
"""When a window changes, resolve any open positions from the PREVIOUS window.
If the price went UP and we held UP, we win ($1 payout).
If the price went DOWN and we held DOWN, we win ($1 payout).
Otherwise, we lose ($0 payout).
"""
if self._engine is None:
return
# Check if any active positions match this asset+timeframe
for pos in list(self._engine.active_positions):
if pos.asset == window.asset and pos.timeframe == window.timeframe:
# Determine outcome from the COMPLETED window's price change
if window.price_change_pct is not None:
actual_up = window.price_change_pct > 0
position_won = (
(pos.direction == Direction.UP and actual_up) or
(pos.direction == Direction.DOWN and not actual_up)
)
exit_price = 1.0 if position_won else 0.0
else:
# No price data — treat as loss
exit_price = 0.0
record = self._engine.close_position(pos.position_id, exit_price)
if record:
# Update balance
self._balance = self._starting_balance + self._engine.total_pnl
self._log.info(
"paper_trade_resolved",
asset=pos.asset.value,
direction=pos.direction.value,
pnl=round(record.pnl, 4),
balance=round(self._balance, 2),
won=record.pnl > 0,
)
def _simulate_orderbooks(
self, symbol: str, cex_price: float, window: WindowState
) -> dict[str, OrderBookSnapshot]:
"""Generate simulated Polymarket orderbooks when no real market exists.
Simulates the oracle-lag effect: Polymarket odds lag behind the CEX
price. A bigger price move = higher true prob, but the simulated
Polymarket price adjusts more slowly.
"""
import random
from src.data.models import OrderBookLevel
if window.start_price is None or window.start_price <= 0:
return {}
change_pct = (cex_price - window.start_price) / window.start_price * 100
# Simulate Polymarket's lagging price
# Real prob might be 85%, but Polymarket shows ~55-60% (the arb opportunity)
lag_factor = 0.35 # Polymarket adjusts at ~35% of true movement speed
noise = random.uniform(-0.02, 0.02)
if abs(change_pct) < 0.05:
# Flat — both sides near 50%
up_ask = 0.50 + noise
down_ask = 0.50 - noise
else:
# Up direction confirmed on CEX
# True prob: ~70-90%, but Polymarket shows ~52-65%
poly_adjustment = abs(change_pct) * lag_factor
if change_pct > 0:
up_ask = min(0.65, 0.50 + poly_adjustment + noise)
down_ask = max(0.35, 1.0 - up_ask - 0.02)
else:
down_ask = min(0.65, 0.50 + poly_adjustment + noise)
up_ask = max(0.35, 1.0 - down_ask - 0.02)
up_ask = round(max(0.01, min(0.99, up_ask)), 2)
down_ask = round(max(0.01, min(0.99, down_ask)), 2)
# Create synthetic token IDs
up_token = f"sim_up_{symbol}_{window.timeframe.value}"
down_token = f"sim_down_{symbol}_{window.timeframe.value}"
# Ensure window has a simulated market for signal generation
if window.market is None:
sim_market = ActiveMarket(
condition_id=f"sim_{symbol}_{window.timeframe.value}",
up_token_id=up_token,
down_token_id=down_token,
asset=window.asset,
timeframe=window.timeframe,
end_date="",
question=f"Simulated {symbol} {window.timeframe.value}",
)
window.market = sim_market
return {
up_token: OrderBookSnapshot(
token_id=up_token,
asks=[OrderBookLevel(price=up_ask, size=10000)],
bids=[OrderBookLevel(price=up_ask - 0.02, size=10000)],
),
down_token: OrderBookSnapshot(
token_id=down_token,
asks=[OrderBookLevel(price=down_ask, size=10000)],
bids=[OrderBookLevel(price=down_ask - 0.02, size=10000)],
),
}
def _on_new_markets(self, markets: list[ActiveMarket]) -> None:
"""Callback when MarketDiscovery finds new markets."""
self._active_markets.extend(markets)
if self._tracker is not None:
for mkt in markets:
self._tracker.link_market(
asset=mkt.asset.value,
timeframe=mkt.timeframe.value,
market=mkt,
)
if self._poly_feed is not None:
for mkt in markets:
self._poly_feed.subscribe_market(mkt.up_token_id)
self._poly_feed.subscribe_market(mkt.down_token_id)
# ------------------------------------------------------------------
# Status and summary loops
# ------------------------------------------------------------------
async def _strategy_eval_loop(self) -> None:
"""Consume price ticks from the queue and run strategy evaluations."""
eval_count = 0
while not self._shutdown_event.is_set():
try:
symbol, price, window, orderbooks = await asyncio.wait_for(
self._eval_queue.get(), timeout=1.0
)
except asyncio.TimeoutError:
continue
except Exception:
continue
eval_count += 1
if eval_count % 100 == 1:
self._log.info(
"strategy_eval_tick",
eval_count=eval_count,
symbol=symbol,
price=price,
change_pct=round(window.price_change_pct or 0, 4),
has_market=window.market is not None,
orderbook_tokens=list(orderbooks.keys())[:2],
)
if self._signal_agg is not None:
try:
await self._signal_agg.on_price_tick(
symbol=symbol,
cex_price=price,
window=window,
orderbooks=orderbooks,
)
except Exception:
self._log.exception("strategy_eval_error")
async def _status_loop(self) -> None:
"""Log a status summary every ``_STATUS_INTERVAL_S`` seconds."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=_STATUS_INTERVAL_S,
)
break
except asyncio.TimeoutError:
pass
windows = (
self._tracker.get_all_active_windows()
if self._tracker
else []
)
latest_prices: dict[str, Optional[float]] = {}
if self._binance_feed is not None:
for sym in self._cfg.general.assets:
latest_prices[sym] = self._binance_feed.get_latest_price(sym)
# Update balance from PnL
pnl = self._engine.total_pnl if self._engine else 0.0
self._balance = self._starting_balance + pnl
# Record balance snapshot to DB
if self._db is not None:
self._db.log_balance(self._balance, pnl, event="periodic")
self._log.info(
"status",
mode="paper",
balance=round(self._balance, 2),
pnl=round(pnl, 2),
binance_connected=(
self._binance_feed.is_connected
if self._binance_feed
else False
),
polymarket_connected=(
self._poly_feed.is_connected
if self._poly_feed
else False
),
active_windows=len(windows),
active_markets=len(self._active_markets),
latest_prices=latest_prices,
)
async def _oracle_snapshot_loop(self) -> None:
"""Log oracle vs CEX deviation to DB every 30 seconds."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(), timeout=30.0,
)
break
except asyncio.TimeoutError:
pass
if self._oracle is None or self._binance_feed is None or self._db is None:
continue
for asset in self._cfg.general.assets:
cex_price = self._binance_feed.get_latest_price(asset)
oracle_price = self._oracle._last_oracle_prices.get(asset)
if cex_price is None or oracle_price is None:
continue
deviation = self._oracle.get_oracle_vs_cex_deviation(asset, cex_price)
lag = self._oracle.get_estimated_lag(asset)
round_id = self._oracle._last_oracle_round_ids.get(asset, 0)
if deviation is not None and lag is not None:
self._db.log_oracle(
asset=asset,
oracle_price=oracle_price,
cex_price=cex_price,
deviation_pct=deviation,
oracle_lag_sec=lag,
oracle_round_id=round_id,
)
self._log.info(
"oracle_snapshot",
stats=self._oracle.get_stats(),
)
async def _summary_loop(self) -> None:
"""Print paper trading summary every ``_SUMMARY_INTERVAL_S`` seconds."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=_SUMMARY_INTERVAL_S,
)
break
except asyncio.TimeoutError:
pass
if self._engine is not None:
self._engine.print_summary()
if self._db is not None:
self._log.info(
"db_summary",
today_trades=self._db.get_today_trade_count(),
today_pnl=round(self._db.get_today_pnl(), 4),
total_pnl=round(self._db.get_total_pnl(), 4),
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
"""Initialise all components, then run feeds concurrently."""
self._log.info("paper_bot_initialising")
# 0. Initialize eval queue (must be in async context)
self._eval_queue = asyncio.Queue(maxsize=1000)
# 1. Database (paper-specific path)
self._db = TradeDB(db_path="paper_trades.db")
# 2. Paper execution engine
self._engine = PaperExecutionEngine(
db=self._db, fees=self._cfg.fees,
)
# Record starting balance
self._db.log_balance(self._balance, 0.0, event="start")
self._log.info("starting_balance", balance=self._balance)
# 2b. Signal aggregator (strategy engine)
self._signal_agg = SignalAggregator(
config=self._cfg, balance=self._balance,
)
self._signal_agg.on_signal(self._on_signal)
self._log.info("strategy_engine_ready",
temporal_arb=self._cfg.temporal_arb.enabled,
sum_to_one=self._cfg.sum_to_one.enabled)
# 3. Market discovery
self._discovery = MarketDiscovery(
on_new_markets=self._on_new_markets,
)
self._log.info("running_initial_discovery")
initial_markets = await self._discovery.discover()
self._active_markets = initial_markets
self._log.info(
"initial_discovery_complete",
count=len(initial_markets),
)
# 4. Window tracker
assets = [Asset(a) for a in self._cfg.general.assets]
timeframes = [Timeframe(t) for t in self._cfg.general.timeframes]
self._tracker = WindowTracker(assets=assets, timeframes=timeframes)
self._tracker.on_window_change(self._on_window_change)
# Link initial markets to windows
for mkt in initial_markets:
self._tracker.link_market(
asset=mkt.asset.value,
timeframe=mkt.timeframe.value,
market=mkt,
)
# 4b. Chainlink oracle monitor
self._oracle = OracleMonitor()
oracle_ok = await self._oracle.initialize()
self._log.info("oracle_init", success=oracle_ok)
# 5. Binance feed
ws_url = self._cfg.binance.ws_url
streams = "/".join(
f"{s}@trade" for s in self._cfg.binance.symbols
)
full_url = f"{ws_url}?streams={streams}"
self._binance_feed = BinanceFeed(url=full_url)
self._binance_feed.subscribe(self._on_binance_price)
# 6. Polymarket feed
self._poly_feed = PolymarketFeed(url=self._cfg.polymarket.ws_url)
self._poly_feed.on_orderbook_update(self._on_orderbook_update)
for mkt in initial_markets:
self._poly_feed.subscribe_market(mkt.up_token_id)
self._poly_feed.subscribe_market(mkt.down_token_id)
self._log.info("paper_bot_starting_feeds")
# 7. Run all tasks concurrently
tasks = [
self._binance_feed.start(),
self._poly_feed.start(),
self._discovery.discover_loop(interval_sec=_DISCOVERY_INTERVAL_S),
self._strategy_eval_loop(),
self._status_loop(),
self._summary_loop(),
]
if self._oracle and self._oracle._initialized:
tasks.append(self._oracle.poll_loop(interval=5.0))
tasks.append(self._oracle_snapshot_loop())
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
self._log.info("gather_cancelled")
async def shutdown(self) -> None:
"""Gracefully stop all components."""
self._log.info("paper_bot_shutting_down")
self._shutdown_event.set()
if self._binance_feed is not None:
await self._binance_feed.stop()
if self._poly_feed is not None:
await self._poly_feed.stop()
# Print final summary
if self._engine is not None:
self._engine.print_summary()
self._log.info("paper_bot_stopped")
# ---------------------------------------------------------------------------
# Config override helper
# ---------------------------------------------------------------------------
def _force_paper_config(base: Config) -> Config:
"""Return a new Config with ``general.mode`` forced to ``'paper'``."""
return Config(
general=GeneralConfig(
mode="paper",
assets=base.general.assets,
timeframes=base.general.timeframes,
log_level=base.general.log_level,
starting_balance=base.general.starting_balance,
),
temporal_arb=base.temporal_arb,
sum_to_one=base.sum_to_one,
spread_capture=base.spread_capture,
risk=base.risk,
fees=base.fees,
binance=base.binance,
polymarket=base.polymarket,
notifications=base.notifications,
)
# ---------------------------------------------------------------------------
# Signal handling and entry point
# ---------------------------------------------------------------------------
def _install_signal_handlers(
loop: asyncio.AbstractEventLoop, bot: PaperTradingBot
) -> None:
"""Register SIGINT / SIGTERM handlers that trigger graceful shutdown."""
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(
sig,
lambda: asyncio.ensure_future(bot.shutdown()),
)
except NotImplementedError:
pass
async def async_main() -> None:
"""Async entry point for the paper trading wrapper."""
base_config = load_config()
config = _force_paper_config(base_config)
setup_logging(config.general.log_level)
print(_BANNER)
print(f" Mode: {config.general.mode} (forced)")
print(f" Assets: {', '.join(config.general.assets)}")
print(f" Timeframes: {', '.join(config.general.timeframes)}")
print(f" Log level: {config.general.log_level}")
print(f" Balance: ${config.general.starting_balance:,.2f}")
print(f" DB: paper_trades.db")
print()
bot = PaperTradingBot(config)
loop = asyncio.get_running_loop()
_install_signal_handlers(loop, bot)
try:
await bot.start()
except KeyboardInterrupt:
pass
finally:
await bot.shutdown()
def main() -> None:
"""Synchronous entry point for ``python paper_trade.py``."""
try:
asyncio.run(async_main())
except KeyboardInterrupt:
print("\nPaper trading shutdown complete.")
sys.exit(0)
if __name__ == "__main__":
main()

39
push.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# 솔메카 Git Push - 신규/기존 프로젝트 동일하게 사용
# 사용법: 프로젝트 폴더에서 push.sh
GITEA_HOST="100.125.85.86"
GITEA_PORT="3000"
GITEA_USER="downsys@naver.com"
GITEA_PASS='@A9220fire!!'
GITEA_ORG="choijaewook"
SSH_KEY="$HOME/.ssh/id_ed25519"
COOLIFY_USER="kakao"
NAME=$(basename "$(pwd)")
REMOTE="http://${GITEA_HOST}:${GITEA_PORT}/${GITEA_ORG}/${NAME}.git"
echo -e "\033[0;36m[push] ${NAME}\033[0m"
# Git 초기화
[ ! -d ".git" ] && git init -b main > /dev/null 2>&1 && echo " git init"
# Gitea 레포 없으면 생성
ssh -o ConnectTimeout=5 -o LogLevel=ERROR -i "$SSH_KEY" "${COOLIFY_USER}@${GITEA_HOST}" \
"curl -sf -u '${GITEA_USER}:${GITEA_PASS}' 'http://localhost:${GITEA_PORT}/api/v1/repos/${GITEA_ORG}/${NAME}'" > /dev/null 2>&1 \
|| ssh -o ConnectTimeout=5 -o LogLevel=ERROR -i "$SSH_KEY" "${COOLIFY_USER}@${GITEA_HOST}" \
"curl -sf -X POST -u '${GITEA_USER}:${GITEA_PASS}' -H 'Content-Type: application/json' \
-d '{\"name\": \"${NAME}\", \"auto_init\": false}' \
'http://localhost:${GITEA_PORT}/api/v1/user/repos'" > /dev/null 2>&1 \
&& echo " 레포 생성: ${NAME}"
# Remote 설정
git remote get-url origin > /dev/null 2>&1 || git remote add origin "$REMOTE"
# 커밋 + 푸시
git add -A
git commit -m "${1:-update $(date '+%m-%d %H:%M')}" > /dev/null 2>&1 || true
git pull --rebase origin main > /dev/null 2>&1 || true
git push -u origin main > /dev/null 2>&1 || git push -u origin main --force > /dev/null 2>&1
echo -e "\033[0;32m 완료: http://${GITEA_HOST}:${GITEA_PORT}/${GITEA_ORG}/${NAME}\033[0m"

14
requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
py-clob-client>=0.18.0
web3>=6.0
websockets>=12.0
aiohttp>=3.9
python-dotenv>=1.0
toml>=0.10
eth-account>=0.11
structlog>=24.0
sqlite-utils>=3.36
ccxt>=4.2
numpy>=1.26
pandas>=2.2
streamlit>=1.32
python-telegram-bot>=21.0

0
src/__init__.py Normal file
View File

262
src/config.py Normal file
View File

@@ -0,0 +1,262 @@
"""Configuration loader for the Polymarket Temporal Arbitrage Bot.
Loads environment variables from .env and typed settings from config.toml,
exposing them through nested dataclasses with validated, typed access.
"""
from __future__ import annotations
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import structlog
import toml
from dotenv import load_dotenv
# ---------------------------------------------------------------------------
# Nested config dataclasses
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class GeneralConfig:
mode: str = "paper"
assets: list[str] = field(default_factory=lambda: ["BTC", "ETH", "SOL"])
timeframes: list[str] = field(default_factory=lambda: ["5M", "15M"])
log_level: str = "INFO"
starting_balance: float = 500.0
@dataclass(frozen=True)
class TemporalArbConfig:
enabled: bool = True
min_price_move_pct: float = 0.15
max_poly_entry_price: float = 0.65
min_edge: float = 0.20
exit_before_resolution_sec: int = 5
@dataclass(frozen=True)
class SumToOneConfig:
enabled: bool = True
min_spread_after_fee: float = 0.02
@dataclass(frozen=True)
class SpreadCaptureConfig:
enabled: bool = False
spread_target: float = 0.04
@dataclass(frozen=True)
class RiskConfig:
max_position_per_market_usd: float = 5000.0
max_total_exposure_usd: float = 20000.0
max_daily_loss_usd: float = 2000.0
kelly_fraction_cap: float = 0.25
max_concurrent_positions: int = 6
@dataclass(frozen=True)
class FeesConfig:
taker_fee_5m: float = 0.0156
taker_fee_15m: float = 0.03
def fee_for_timeframe(self, timeframe: str) -> float:
"""Return the taker fee for a given timeframe string (e.g. '5M')."""
mapping = {
"5M": self.taker_fee_5m,
"15M": self.taker_fee_15m,
}
if timeframe not in mapping:
raise ValueError(f"Unknown timeframe '{timeframe}'; expected one of {list(mapping)}")
return mapping[timeframe]
@dataclass(frozen=True)
class BinanceConfig:
ws_url: str = "wss://stream.binance.com:9443/stream"
symbols: list[str] = field(default_factory=lambda: ["btcusdt", "ethusdt", "solusdt"])
@dataclass(frozen=True)
class PolymarketConfig:
clob_url: str = "https://clob.polymarket.com"
gamma_url: str = "https://gamma-api.polymarket.com"
data_url: str = "https://data-api.polymarket.com"
ws_url: str = "wss://ws-subscriptions-clob.polymarket.com/ws/"
chain_id: int = 137
signature_type: int = 2
# Credentials sourced from environment variables
private_key: str = ""
proxy_wallet: str = ""
api_key: str = ""
api_secret: str = ""
api_passphrase: str = ""
@dataclass(frozen=True)
class NotificationConfig:
telegram_enabled: bool = True
telegram_token: str = ""
telegram_chat_id: str = ""
notify_on_trade: bool = True
notify_on_daily_summary: bool = True
notify_on_error: bool = True
# ---------------------------------------------------------------------------
# Top-level Config
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Config:
general: GeneralConfig = field(default_factory=GeneralConfig)
temporal_arb: TemporalArbConfig = field(default_factory=TemporalArbConfig)
sum_to_one: SumToOneConfig = field(default_factory=SumToOneConfig)
spread_capture: SpreadCaptureConfig = field(default_factory=SpreadCaptureConfig)
risk: RiskConfig = field(default_factory=RiskConfig)
fees: FeesConfig = field(default_factory=FeesConfig)
binance: BinanceConfig = field(default_factory=BinanceConfig)
polymarket: PolymarketConfig = field(default_factory=PolymarketConfig)
notifications: NotificationConfig = field(default_factory=NotificationConfig)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_dataclass(cls: type, raw: dict[str, Any]) -> Any:
"""Instantiate a frozen dataclass, silently ignoring unknown keys."""
valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
filtered = {k: v for k, v in raw.items() if k in valid_fields}
return cls(**filtered)
def _setup_logging(level_name: str) -> None:
"""Configure structlog with human-readable console output."""
level = getattr(logging, level_name.upper(), logging.INFO)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(level),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
# Also align stdlib logging so third-party libraries respect the level
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=level,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def load_config(config_path: str = "config.toml") -> Config:
"""Load configuration from *config_path* and environment variables.
1. Reads ``.env`` (if present) into the process environment.
2. Parses ``config.toml`` for all trading parameters.
3. Overlays sensitive credentials from environment variables.
4. Configures structured logging via *structlog*.
Returns a fully-populated, immutable :class:`Config` instance.
"""
# --- .env ---------------------------------------------------------------
env_path = Path(config_path).parent / ".env"
load_dotenv(dotenv_path=env_path if env_path.exists() else None)
# --- config.toml --------------------------------------------------------
config_file = Path(config_path)
if not config_file.exists():
raise FileNotFoundError(f"Config file not found: {config_file.resolve()}")
raw: dict[str, Any] = toml.load(config_file)
# --- Build nested configs -----------------------------------------------
general = _build_dataclass(GeneralConfig, raw.get("general", {}))
strategy = raw.get("strategy", {})
temporal_arb = _build_dataclass(TemporalArbConfig, strategy.get("temporal_arb", {}))
sum_to_one = _build_dataclass(SumToOneConfig, strategy.get("sum_to_one", {}))
spread_capture = _build_dataclass(SpreadCaptureConfig, strategy.get("spread_capture", {}))
risk = _build_dataclass(RiskConfig, raw.get("risk", {}))
fees = _build_dataclass(FeesConfig, raw.get("fees", {}))
exchanges = raw.get("exchange", {})
binance = _build_dataclass(BinanceConfig, exchanges.get("binance", {}))
# Polymarket: merge file config with env-var credentials
poly_raw = dict(exchanges.get("polymarket", {}))
poly_raw.update({
"private_key": os.getenv("POLYMARKET_PRIVATE_KEY", ""),
"proxy_wallet": os.getenv("POLYMARKET_PROXY_WALLET", ""),
"api_key": os.getenv("POLYMARKET_API_KEY", ""),
"api_secret": os.getenv("POLYMARKET_API_SECRET", ""),
"api_passphrase": os.getenv("POLYMARKET_API_PASSPHRASE", ""),
})
polymarket = _build_dataclass(PolymarketConfig, poly_raw)
# Notifications: merge file config with env-var tokens
notif_raw = dict(raw.get("notifications", {}))
notif_raw.setdefault("telegram_token", os.getenv("TELEGRAM_BOT_TOKEN", ""))
notif_raw.setdefault("telegram_chat_id", os.getenv("TELEGRAM_CHAT_ID", ""))
# Env vars override empty strings from the config file
if not notif_raw.get("telegram_token"):
notif_raw["telegram_token"] = os.getenv("TELEGRAM_BOT_TOKEN", "")
if not notif_raw.get("telegram_chat_id"):
notif_raw["telegram_chat_id"] = os.getenv("TELEGRAM_CHAT_ID", "")
notifications = _build_dataclass(NotificationConfig, notif_raw)
# --- Assemble top-level config ------------------------------------------
config = Config(
general=general,
temporal_arb=temporal_arb,
sum_to_one=sum_to_one,
spread_capture=spread_capture,
risk=risk,
fees=fees,
binance=binance,
polymarket=polymarket,
notifications=notifications,
)
# --- Logging ------------------------------------------------------------
_setup_logging(config.general.log_level)
log = structlog.get_logger()
log.info(
"config_loaded",
mode=config.general.mode,
assets=config.general.assets,
timeframes=config.general.timeframes,
strategies_enabled=[
name
for name, enabled in [
("temporal_arb", config.temporal_arb.enabled),
("sum_to_one", config.sum_to_one.enabled),
("spread_capture", config.spread_capture.enabled),
]
if enabled
],
)
return config

5
src/data/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Data layer for the Polymarket Arbitrage Bot."""
from .db import TradeDB
__all__ = ["TradeDB"]

323
src/data/db.py Normal file
View File

@@ -0,0 +1,323 @@
"""SQLite trade-log database backed by sqlite-utils.
Provides persistent storage for trades, window snapshots, and daily
performance summaries with lightweight query helpers.
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from typing import Any, Optional
import structlog
from sqlite_utils import Database
from src.data.models import Trade, WindowState
logger = structlog.get_logger(__name__)
class TradeDB:
"""Thin wrapper around a SQLite database for trade logging and analytics.
Parameters
----------
db_path:
Path to the SQLite database file. Use ``":memory:"`` for tests.
"""
def __init__(self, db_path: str = "trades.db") -> None:
self._db_path = db_path
self._db = Database(db_path)
self._log = logger.bind(component="TradeDB", db=db_path)
self._ensure_tables()
self._log.info("database_ready")
# ------------------------------------------------------------------
# Schema
# ------------------------------------------------------------------
def _ensure_tables(self) -> None:
"""Create tables if they do not already exist."""
if "trades" not in self._db.table_names():
self._db["trades"].create(
{
"id": str,
"asset": str,
"timeframe": str,
"direction": str,
"token_id": str,
"entry_price": float,
"fill_price": float,
"size": int,
"fee": float,
"pnl": float,
"status": str,
"signal_edge": float,
"signal_prob": float,
"created_at": float,
"updated_at": float,
},
pk="id",
)
self._log.debug("table_created", table="trades")
if "window_snapshots" not in self._db.table_names():
self._db["window_snapshots"].create(
{
"id": int,
"asset": str,
"timeframe": str,
"start_price": float,
"end_price": float,
"price_change_pct": float,
"window_start": float,
"window_end": float,
"market_condition_id": str,
"created_at": float,
},
pk="id",
)
self._log.debug("table_created", table="window_snapshots")
if "daily_summary" not in self._db.table_names():
self._db["daily_summary"].create(
{
"date": str,
"total_trades": int,
"wins": int,
"losses": int,
"total_pnl": float,
"total_fees": float,
"total_volume": float,
"best_trade_pnl": float,
"worst_trade_pnl": float,
},
pk="date",
)
self._log.debug("table_created", table="daily_summary")
if "balance_history" not in self._db.table_names():
self._db["balance_history"].create(
{
"id": int,
"timestamp": float,
"balance": float,
"pnl": float,
"event": str,
},
pk="id",
)
self._log.debug("table_created", table="balance_history")
if "oracle_snapshots" not in self._db.table_names():
self._db["oracle_snapshots"].create(
{
"id": int,
"timestamp": float,
"asset": str,
"oracle_price": float,
"cex_price": float,
"deviation_pct": float,
"oracle_lag_sec": float,
"oracle_round_id": str,
},
pk="id",
)
self._log.debug("table_created", table="oracle_snapshots")
# ------------------------------------------------------------------
# Trade CRUD
# ------------------------------------------------------------------
def log_trade(self, trade: Trade) -> None:
"""Insert a new trade record derived from a :class:`Trade` dataclass."""
row = {
"id": trade.id,
"asset": trade.signal.asset.value,
"timeframe": trade.signal.timeframe.value,
"direction": trade.signal.direction.value,
"token_id": trade.signal.token_id,
"entry_price": trade.signal.price,
"fill_price": trade.fill_price,
"size": trade.signal.size,
"fee": trade.fee,
"pnl": trade.pnl,
"status": trade.status.value,
"signal_edge": trade.signal.edge,
"signal_prob": trade.signal.estimated_prob,
"created_at": trade.created_at,
"updated_at": trade.updated_at,
}
self._db["trades"].insert(row)
self._log.info("trade_logged", trade_id=trade.id, asset=row["asset"])
def update_trade(self, trade_id: str, **fields: Any) -> None:
"""Update arbitrary fields on an existing trade row."""
fields["updated_at"] = time.time()
self._db["trades"].update(trade_id, fields)
self._log.info("trade_updated", trade_id=trade_id, fields=list(fields.keys()))
def get_trades(
self,
start_time: Optional[float] = None,
end_time: Optional[float] = None,
asset: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Return trades matching optional time-range and asset filters."""
clauses: list[str] = []
params: list[Any] = []
if start_time is not None:
clauses.append("created_at >= ?")
params.append(start_time)
if end_time is not None:
clauses.append("created_at <= ?")
params.append(end_time)
if asset is not None:
clauses.append("asset = ?")
params.append(asset)
where = " AND ".join(clauses) if clauses else "1=1"
sql = f"SELECT * FROM trades WHERE {where} ORDER BY created_at DESC"
return list(self._db.execute(sql, params).fetchall())
# ------------------------------------------------------------------
# Window snapshots
# ------------------------------------------------------------------
def log_window(self, window: WindowState) -> None:
"""Snapshot a completed window into the database."""
row = {
"asset": window.asset.value,
"timeframe": window.timeframe.value,
"start_price": window.start_price,
"end_price": window.current_price,
"price_change_pct": window.price_change_pct,
"window_start": window.window_start_time,
"window_end": window.window_end_time,
"market_condition_id": (
window.market.condition_id if window.market else None
),
"created_at": time.time(),
}
self._db["window_snapshots"].insert(row)
self._log.info(
"window_logged",
asset=row["asset"],
timeframe=row["timeframe"],
change_pct=row["price_change_pct"],
)
# ------------------------------------------------------------------
# Daily summary helpers
# ------------------------------------------------------------------
def get_daily_summary(self, date: str) -> dict[str, Any]:
"""Compute (but do not store) daily stats for *date* (``YYYY-MM-DD``)."""
sql = """
SELECT
COUNT(*) AS total_trades,
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN pnl <= 0 THEN 1 ELSE 0 END) AS losses,
COALESCE(SUM(pnl), 0.0) AS total_pnl,
COALESCE(SUM(fee), 0.0) AS total_fees,
COALESCE(SUM(size * fill_price), 0.0) AS total_volume,
COALESCE(MAX(pnl), 0.0) AS best_trade_pnl,
COALESCE(MIN(pnl), 0.0) AS worst_trade_pnl
FROM trades
WHERE date(created_at, 'unixepoch') = ?
"""
row = self._db.execute(sql, [date]).fetchone()
return {
"date": date,
"total_trades": row[0],
"wins": row[1],
"losses": row[2],
"total_pnl": row[3],
"total_fees": row[4],
"total_volume": row[5],
"best_trade_pnl": row[6],
"worst_trade_pnl": row[7],
}
def update_daily_summary(self, date: str) -> None:
"""Recalculate and upsert the daily summary row for *date*."""
summary = self.get_daily_summary(date)
self._db["daily_summary"].upsert(summary, pk="date")
self._log.info("daily_summary_updated", date=date, pnl=summary["total_pnl"])
# ------------------------------------------------------------------
# Quick-access aggregates
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Balance history
# ------------------------------------------------------------------
def log_balance(self, balance: float, pnl: float, event: str = "update") -> None:
"""Record a balance snapshot."""
self._db["balance_history"].insert({
"timestamp": time.time(),
"balance": balance,
"pnl": pnl,
"event": event,
})
def log_oracle(
self,
asset: str,
oracle_price: float,
cex_price: float,
deviation_pct: float,
oracle_lag_sec: float,
oracle_round_id: int = 0,
) -> None:
"""Record an oracle price snapshot."""
self._db["oracle_snapshots"].insert({
"timestamp": time.time(),
"asset": asset,
"oracle_price": oracle_price,
"cex_price": cex_price,
"deviation_pct": round(deviation_pct, 4),
"oracle_lag_sec": round(oracle_lag_sec, 1),
"oracle_round_id": str(oracle_round_id),
})
def get_latest_balance(self) -> Optional[float]:
"""Return the most recent balance, or None."""
row = self._db.execute(
"SELECT balance FROM balance_history ORDER BY timestamp DESC LIMIT 1"
).fetchone()
return float(row[0]) if row else None
# ------------------------------------------------------------------
# Quick-access aggregates
# ------------------------------------------------------------------
def get_total_pnl(self) -> float:
"""Return the all-time cumulative PnL."""
row = self._db.execute("SELECT COALESCE(SUM(pnl), 0.0) FROM trades").fetchone()
return float(row[0])
def get_today_pnl(self) -> float:
"""Return today's cumulative PnL (UTC)."""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
row = self._db.execute(
"SELECT COALESCE(SUM(pnl), 0.0) FROM trades "
"WHERE date(created_at, 'unixepoch') = ?",
[today],
).fetchone()
return float(row[0])
def get_today_trade_count(self) -> int:
"""Return the number of trades placed today (UTC)."""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
row = self._db.execute(
"SELECT COUNT(*) FROM trades "
"WHERE date(created_at, 'unixepoch') = ?",
[today],
).fetchone()
return int(row[0])

136
src/data/models.py Normal file
View File

@@ -0,0 +1,136 @@
"""Core data models used throughout the Polymarket Arbitrage Bot."""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import time
class Asset(str, Enum):
BTC = "BTC"
ETH = "ETH"
SOL = "SOL"
class Timeframe(str, Enum):
FIVE_MIN = "5M"
FIFTEEN_MIN = "15M"
class Direction(str, Enum):
UP = "UP"
DOWN = "DOWN"
class OrderSide(str, Enum):
BUY = "BUY"
SELL = "SELL"
class TradeStatus(str, Enum):
PENDING = "PENDING"
FILLED = "FILLED"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
CANCELLED = "CANCELLED"
FAILED = "FAILED"
@dataclass
class ActiveMarket:
condition_id: str
up_token_id: str
down_token_id: str
asset: Asset
timeframe: Timeframe
end_date: str # ISO format resolution time
question: str
description: str = ""
@dataclass
class WindowState:
asset: Asset
timeframe: Timeframe
window_start_time: float # Unix timestamp
window_end_time: float # Unix timestamp
start_price: Optional[float] = None
current_price: Optional[float] = None
market: Optional[ActiveMarket] = None
@property
def time_remaining(self) -> float:
return max(0, self.window_end_time - time.time())
@property
def price_change_pct(self) -> Optional[float]:
if self.start_price and self.current_price and self.start_price > 0:
return (self.current_price - self.start_price) / self.start_price * 100
return None
@dataclass
class Signal:
direction: Direction
asset: Asset
timeframe: Timeframe
token_id: str
price: float # Polymarket entry price
size: int # Number of shares
edge: float # Estimated edge after fees
estimated_prob: float # Estimated true probability
timestamp: float = field(default_factory=time.time)
@dataclass
class Trade:
id: str
signal: Signal
order_id: str = ""
status: TradeStatus = TradeStatus.PENDING
fill_price: float = 0.0
fill_size: int = 0
fee: float = 0.0
pnl: float = 0.0
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
@dataclass
class Position:
market_id: str
asset: Asset
timeframe: Timeframe
direction: Direction
token_id: str
size: int
avg_price: float
current_value: float = 0.0
unrealized_pnl: float = 0.0
@dataclass
class OrderBookLevel:
price: float
size: float
@dataclass
class OrderBookSnapshot:
token_id: str
bids: list[OrderBookLevel] = field(default_factory=list)
asks: list[OrderBookLevel] = field(default_factory=list)
timestamp: float = field(default_factory=time.time)
@property
def best_bid(self) -> Optional[float]:
return self.bids[0].price if self.bids else None
@property
def best_ask(self) -> Optional[float]:
return self.asks[0].price if self.asks else None
@property
def spread(self) -> Optional[float]:
if self.best_bid is not None and self.best_ask is not None:
return self.best_ask - self.best_bid
return None

View File

@@ -0,0 +1,7 @@
"""Execution engine modules."""
from src.execution.clob_client import ClobClientWrapper
from src.execution.order_manager import OrderManager
from src.execution.position_tracker import PositionTracker
__all__ = ["ClobClientWrapper", "OrderManager", "PositionTracker"]

View File

@@ -0,0 +1,235 @@
"""Polymarket CLOB API wrapper with EIP-712 authentication.
Handles order creation, cancellation, and position queries via
the py-clob-client SDK.
"""
from __future__ import annotations
import asyncio
from typing import Any, Optional
import structlog
from src.config import PolymarketConfig
log = structlog.get_logger()
class ClobClientWrapper:
"""Async-friendly wrapper around py-clob-client.
The underlying SDK is synchronous, so heavy calls are delegated
to a thread-pool executor to avoid blocking the event loop.
"""
def __init__(self, config: PolymarketConfig) -> None:
self.config = config
self._client: Any = None
self._initialized = False
async def initialize(self) -> None:
"""Initialize the CLOB client with credentials.
Must be called before any trading operations.
"""
if self._initialized:
return
if not self.config.private_key:
log.warning("clob_client_no_key", msg="No private key — running in read-only mode")
return
loop = asyncio.get_event_loop()
try:
self._client = await loop.run_in_executor(None, self._create_client)
self._initialized = True
log.info("clob_client_initialized")
except Exception:
log.exception("clob_client_init_failed")
def _create_client(self) -> Any:
"""Create the synchronous CLOB client (runs in executor)."""
from py_clob_client.client import ClobClient
client = ClobClient(
host=self.config.clob_url,
key=self.config.private_key,
chain_id=self.config.chain_id,
signature_type=self.config.signature_type,
funder=self.config.proxy_wallet if self.config.proxy_wallet else None,
)
# Set API credentials if available
if self.config.api_key:
client.set_api_creds(client.create_or_derive_api_creds())
else:
# Generate new API key
api_creds = client.create_api_key()
client.set_api_creds(api_creds)
log.info("clob_api_key_created")
return client
@property
def is_ready(self) -> bool:
return self._initialized and self._client is not None
# ------------------------------------------------------------------
# Order operations
# ------------------------------------------------------------------
async def place_limit_order(
self,
token_id: str,
price: float,
size: int,
side: str = "BUY",
) -> Optional[dict]:
"""Place a limit order on the CLOB.
Args:
token_id: The CLOB token ID (Up or Down outcome).
price: Limit price (0.01 to 0.99).
size: Number of shares.
side: "BUY" or "SELL".
Returns:
Order response dict or None on failure.
"""
if not self.is_ready:
log.error("clob_not_ready", msg="Client not initialized")
return None
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None, self._place_order_sync, token_id, price, size, side
)
log.info(
"order_placed",
token_id=token_id[:16],
price=price,
size=size,
side=side,
order_id=result.get("orderID", ""),
)
return result
except Exception:
log.exception("order_place_failed", token_id=token_id[:16], price=price, size=size)
return None
def _place_order_sync(self, token_id: str, price: float, size: int, side: str) -> dict:
from py_clob_client.clob_types import OrderArgs, OrderType
order_args = OrderArgs(
token_id=token_id,
price=price,
size=size,
side=side,
)
signed_order = self._client.create_order(order_args)
return self._client.post_order(signed_order, orderType=OrderType.GTC)
async def place_market_order(
self,
token_id: str,
size: int,
side: str = "BUY",
) -> Optional[dict]:
"""Place a market order (FOK at worst available price)."""
if not self.is_ready:
return None
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None, self._place_market_order_sync, token_id, size, side
)
log.info("market_order_placed", token_id=token_id[:16], size=size, side=side)
return result
except Exception:
log.exception("market_order_failed")
return None
def _place_market_order_sync(self, token_id: str, size: int, side: str) -> dict:
from py_clob_client.clob_types import OrderArgs, OrderType
# Use price 0.99 for BUY (worst case) or 0.01 for SELL
price = 0.99 if side == "BUY" else 0.01
order_args = OrderArgs(
token_id=token_id,
price=price,
size=size,
side=side,
)
signed_order = self._client.create_order(order_args)
return self._client.post_order(signed_order, orderType=OrderType.FOK)
async def cancel_order(self, order_id: str) -> bool:
"""Cancel a specific order by ID."""
if not self.is_ready:
return False
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, self._client.cancel, order_id)
log.info("order_cancelled", order_id=order_id)
return True
except Exception:
log.exception("order_cancel_failed", order_id=order_id)
return False
async def cancel_all_orders(self) -> bool:
"""Cancel all open orders."""
if not self.is_ready:
return False
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, self._client.cancel_all)
log.info("all_orders_cancelled")
return True
except Exception:
log.exception("cancel_all_failed")
return False
# ------------------------------------------------------------------
# Query operations
# ------------------------------------------------------------------
async def get_order(self, order_id: str) -> Optional[dict]:
"""Fetch order status by ID."""
if not self.is_ready:
return None
loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor(None, self._client.get_order, order_id)
except Exception:
log.exception("get_order_failed", order_id=order_id)
return None
async def get_open_orders(self) -> list[dict]:
"""Fetch all open orders."""
if not self.is_ready:
return []
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(None, self._client.get_orders)
return result if isinstance(result, list) else []
except Exception:
log.exception("get_open_orders_failed")
return []
async def get_orderbook(self, token_id: str) -> Optional[dict]:
"""Fetch current orderbook for a token."""
loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor(
None, self._client.get_order_book, token_id
)
except Exception:
log.exception("get_orderbook_failed", token_id=token_id[:16])
return None

View File

@@ -0,0 +1,243 @@
"""Order Manager — handles order lifecycle from signal to fill.
Manages order creation, tracking, modification, and cancellation
with position limit enforcement.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from typing import Callable, Optional
import structlog
from src.config import Config
from src.data.models import Direction, Signal, Trade, TradeStatus
from src.execution.clob_client import ClobClientWrapper
log = structlog.get_logger()
class OrderManager:
"""Manages the full lifecycle of orders from signal to fill.
Enforces position limits, tracks pending/active orders,
and handles cancellation near resolution time.
"""
def __init__(self, config: Config, clob_client: ClobClientWrapper) -> None:
self.config = config
self.clob = clob_client
self._pending_orders: dict[str, Trade] = {}
self._active_trades: dict[str, Trade] = {}
self._on_fill_callbacks: list[Callable[[Trade], None]] = []
self._running = False
def on_fill(self, callback: Callable[[Trade], None]) -> None:
"""Register callback for when an order is filled."""
self._on_fill_callbacks.append(callback)
# ------------------------------------------------------------------
# Order submission
# ------------------------------------------------------------------
async def submit_signal(self, signal: Signal) -> Optional[Trade]:
"""Submit a signal for execution.
Validates against risk limits, creates the order, and tracks it.
Returns a Trade object if the order was submitted.
"""
# Check concurrent position limit
active_count = len(self._active_trades) + len(self._pending_orders)
if active_count >= self.config.risk.max_concurrent_positions:
log.warning(
"position_limit_reached",
active=active_count,
limit=self.config.risk.max_concurrent_positions,
)
return None
# Check per-market exposure
market_exposure = self._get_market_exposure(signal.token_id)
new_exposure = signal.price * signal.size
if market_exposure + new_exposure > self.config.risk.max_position_per_market_usd:
log.warning(
"market_exposure_limit",
token_id=signal.token_id[:16],
current=round(market_exposure, 2),
new=round(new_exposure, 2),
)
return None
# Create trade record
trade = Trade(
id=str(uuid.uuid4())[:8],
signal=signal,
status=TradeStatus.PENDING,
)
# Submit order to CLOB
result = await self.clob.place_limit_order(
token_id=signal.token_id,
price=signal.price,
size=signal.size,
side="BUY",
)
if result is None:
trade.status = TradeStatus.FAILED
log.error("order_submission_failed", trade_id=trade.id)
return trade
trade.order_id = result.get("orderID", "")
self._pending_orders[trade.id] = trade
log.info(
"order_submitted",
trade_id=trade.id,
order_id=trade.order_id,
asset=signal.asset.value,
direction=signal.direction.value,
price=signal.price,
size=signal.size,
)
return trade
# ------------------------------------------------------------------
# Order monitoring
# ------------------------------------------------------------------
async def check_order_status(self, trade: Trade) -> Trade:
"""Check the status of a pending order and update trade accordingly."""
if not trade.order_id:
return trade
order_info = await self.clob.get_order(trade.order_id)
if order_info is None:
return trade
status = order_info.get("status", "").upper()
if status in ("MATCHED", "FILLED"):
trade.status = TradeStatus.FILLED
trade.fill_price = float(order_info.get("price", trade.signal.price))
trade.fill_size = int(order_info.get("size_matched", trade.signal.size))
trade.fee = self._calculate_fee(trade)
trade.updated_at = time.time()
# Move from pending to active
self._pending_orders.pop(trade.id, None)
self._active_trades[trade.id] = trade
for cb in self._on_fill_callbacks:
try:
cb(trade)
except Exception:
log.exception("fill_callback_error")
log.info(
"order_filled",
trade_id=trade.id,
fill_price=trade.fill_price,
fill_size=trade.fill_size,
fee=round(trade.fee, 4),
)
elif status == "CANCELLED":
trade.status = TradeStatus.CANCELLED
trade.updated_at = time.time()
self._pending_orders.pop(trade.id, None)
return trade
async def monitor_loop(self, interval: float = 2.0) -> None:
"""Continuously monitor pending orders for fills."""
self._running = True
while self._running:
for trade_id in list(self._pending_orders.keys()):
trade = self._pending_orders.get(trade_id)
if trade:
await self.check_order_status(trade)
await asyncio.sleep(interval)
def stop(self) -> None:
self._running = False
# ------------------------------------------------------------------
# Cancellation
# ------------------------------------------------------------------
async def cancel_pending(self, trade_id: str) -> bool:
"""Cancel a specific pending order."""
trade = self._pending_orders.get(trade_id)
if not trade or not trade.order_id:
return False
success = await self.clob.cancel_order(trade.order_id)
if success:
trade.status = TradeStatus.CANCELLED
trade.updated_at = time.time()
self._pending_orders.pop(trade_id, None)
return success
async def cancel_all_pending(self) -> int:
"""Cancel all pending orders. Returns count of cancelled orders."""
cancelled = 0
for trade_id in list(self._pending_orders.keys()):
if await self.cancel_pending(trade_id):
cancelled += 1
return cancelled
async def cancel_expiring_orders(self, seconds_before_resolution: float = 5.0) -> int:
"""Cancel orders that are too close to market resolution."""
cancelled = 0
now = time.time()
for trade_id, trade in list(self._pending_orders.items()):
# Check if the signal's window is about to resolve
# (Signal doesn't store window_end_time, but we can infer from context)
if trade.created_at + 300 - now < seconds_before_resolution: # Rough heuristic
if await self.cancel_pending(trade_id):
cancelled += 1
log.info("expiring_order_cancelled", trade_id=trade_id)
return cancelled
# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------
def get_pending_trades(self) -> list[Trade]:
return list(self._pending_orders.values())
def get_active_trades(self) -> list[Trade]:
return list(self._active_trades.values())
def get_all_trades(self) -> list[Trade]:
return self.get_pending_trades() + self.get_active_trades()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_market_exposure(self, token_id: str) -> float:
"""Calculate current dollar exposure for a given token."""
exposure = 0.0
for trade in list(self._pending_orders.values()) + list(self._active_trades.values()):
if trade.signal.token_id == token_id:
exposure += trade.signal.price * trade.signal.size
return exposure
def _calculate_fee(self, trade: Trade) -> float:
"""Calculate the taker fee for a filled trade."""
tf = trade.signal.timeframe.value
fee_rate = self.config.fees.fee_for_timeframe(tf)
# Fee is on the potential payout (winning amount)
payout = trade.fill_size * 1.0 # $1 per share if winning
cost = trade.fill_price * trade.fill_size
profit = payout - cost
return max(0, profit * fee_rate)

View File

@@ -0,0 +1,211 @@
"""Position Tracker — real-time position & PnL tracking.
Monitors active positions, calculates unrealized PnL based on
current Polymarket prices, and enforces exposure limits.
"""
from __future__ import annotations
import time
from typing import Optional
import structlog
from src.config import Config
from src.data.models import (
Asset,
Direction,
OrderBookSnapshot,
Position,
Timeframe,
Trade,
TradeStatus,
)
log = structlog.get_logger()
class PositionTracker:
"""Tracks all open positions and computes real-time PnL.
Positions are created from filled trades and closed when
the underlying market resolves or the position is sold.
"""
def __init__(self, config: Config) -> None:
self.config = config
self._positions: dict[str, Position] = {} # keyed by token_id
self._realized_pnl: float = 0.0
self._total_fees: float = 0.0
self._trade_count: int = 0
self._win_count: int = 0
# ------------------------------------------------------------------
# Position management
# ------------------------------------------------------------------
def open_position(self, trade: Trade) -> Position:
"""Open or add to a position from a filled trade."""
token_id = trade.signal.token_id
signal = trade.signal
if token_id in self._positions:
# Add to existing position
pos = self._positions[token_id]
total_cost = pos.avg_price * pos.size + trade.fill_price * trade.fill_size
new_size = pos.size + trade.fill_size
pos.avg_price = total_cost / new_size if new_size > 0 else 0
pos.size = new_size
else:
pos = Position(
market_id=token_id,
asset=signal.asset,
timeframe=signal.timeframe,
direction=signal.direction,
token_id=token_id,
size=trade.fill_size,
avg_price=trade.fill_price,
)
self._positions[token_id] = pos
self._trade_count += 1
self._total_fees += trade.fee
log.info(
"position_opened",
token_id=token_id[:16],
asset=signal.asset.value,
direction=signal.direction.value,
size=pos.size,
avg_price=round(pos.avg_price, 4),
)
return pos
def close_position(self, token_id: str, resolution_price: float) -> Optional[float]:
"""Close a position at resolution.
Args:
token_id: The token ID of the position.
resolution_price: 1.0 if the outcome won, 0.0 if lost.
Returns:
Realized PnL for this position, or None if no position.
"""
pos = self._positions.pop(token_id, None)
if pos is None:
return None
payout = resolution_price * pos.size
cost = pos.avg_price * pos.size
pnl = payout - cost
self._realized_pnl += pnl
if pnl > 0:
self._win_count += 1
log.info(
"position_closed",
token_id=token_id[:16],
asset=pos.asset.value,
direction=pos.direction.value,
size=pos.size,
avg_price=round(pos.avg_price, 4),
payout=round(payout, 2),
pnl=round(pnl, 2),
result="WIN" if pnl > 0 else "LOSS",
)
return pnl
# ------------------------------------------------------------------
# Mark-to-market
# ------------------------------------------------------------------
def update_mark(self, token_id: str, current_price: float) -> None:
"""Update mark-to-market for a position."""
pos = self._positions.get(token_id)
if pos is None:
return
pos.current_value = current_price * pos.size
pos.unrealized_pnl = pos.current_value - (pos.avg_price * pos.size)
def update_from_orderbooks(self, orderbooks: dict[str, OrderBookSnapshot]) -> None:
"""Update all positions from latest orderbook data."""
for token_id, pos in self._positions.items():
book = orderbooks.get(token_id)
if book and book.best_bid is not None:
self.update_mark(token_id, book.best_bid)
# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------
def get_position(self, token_id: str) -> Optional[Position]:
return self._positions.get(token_id)
def get_all_positions(self) -> list[Position]:
return list(self._positions.values())
def get_positions_by_asset(self, asset: str) -> list[Position]:
a = Asset(asset)
return [p for p in self._positions.values() if p.asset == a]
@property
def total_unrealized_pnl(self) -> float:
return sum(p.unrealized_pnl for p in self._positions.values())
@property
def total_realized_pnl(self) -> float:
return self._realized_pnl
@property
def total_pnl(self) -> float:
return self._realized_pnl + self.total_unrealized_pnl
@property
def total_fees(self) -> float:
return self._total_fees
@property
def total_exposure(self) -> float:
return sum(p.avg_price * p.size for p in self._positions.values())
@property
def position_count(self) -> int:
return len(self._positions)
@property
def trade_count(self) -> int:
return self._trade_count
@property
def win_rate(self) -> float:
closed = self._trade_count - len(self._positions)
return self._win_count / closed if closed > 0 else 0.0
# ------------------------------------------------------------------
# Risk checks
# ------------------------------------------------------------------
def is_exposure_ok(self, additional_usd: float = 0) -> bool:
"""Check if total exposure is within limits."""
return (self.total_exposure + additional_usd) <= self.config.risk.max_total_exposure_usd
def is_daily_loss_ok(self, daily_pnl: float) -> bool:
"""Check if daily loss is within limits."""
return daily_pnl > -self.config.risk.max_daily_loss_usd
def get_summary(self) -> dict:
"""Return a summary dict for logging/display."""
return {
"positions": self.position_count,
"total_exposure": round(self.total_exposure, 2),
"unrealized_pnl": round(self.total_unrealized_pnl, 2),
"realized_pnl": round(self.total_realized_pnl, 2),
"total_pnl": round(self.total_pnl, 2),
"total_fees": round(self.total_fees, 2),
"trades": self.trade_count,
"win_rate": round(self.win_rate * 100, 1),
}

6
src/feeds/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Price feed integrations for the Polymarket Arbitrage Bot."""
from .binance_ws import BinanceFeed, PriceCallback
from .polymarket_ws import PolymarketFeed
__all__ = ["BinanceFeed", "PolymarketFeed", "PriceCallback"]

250
src/feeds/binance_ws.py Normal file
View File

@@ -0,0 +1,250 @@
"""Binance WebSocket price feed for real-time trade data.
Connects to Binance combined streams for BTC, ETH, and SOL trade data,
providing low-latency price updates via an async callback pattern.
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import Callable, Optional
import structlog
import websockets
from websockets.exceptions import ConnectionClosed, InvalidStatusCode
logger = structlog.get_logger(__name__)
# Stream symbol mapping: Binance stream name -> canonical symbol
_STREAM_TO_SYMBOL: dict[str, str] = {
"btcusdt@trade": "BTC",
"ethusdt@trade": "ETH",
"solusdt@trade": "SOL",
}
_SYMBOL_TO_STREAM: dict[str, str] = {v: k for k, v in _STREAM_TO_SYMBOL.items()}
PriceCallback = Callable[[str, float, float, float], None]
_DEFAULT_URL = (
"wss://stream.binance.com:9443/stream"
"?streams=btcusdt@trade/ethusdt@trade/solusdt@trade"
)
_PING_INTERVAL_S = 30
_RECONNECT_BASE_S = 1.0
_RECONNECT_MAX_S = 30.0
class BinanceFeed:
"""Async Binance WebSocket price feed with auto-reconnect.
Usage::
feed = BinanceFeed()
feed.subscribe(my_callback)
await feed.start() # runs until stop() is called
"""
def __init__(self, url: str = _DEFAULT_URL) -> None:
self._url = url
self._callbacks: list[PriceCallback] = []
self._latest_prices: dict[str, float] = {}
self._last_recv_ts: dict[str, float] = {}
self._connected = False
self._ws: Optional[websockets.WebSocketClientProtocol] = None
self._stop_event: asyncio.Event = asyncio.Event()
self._tasks: list[asyncio.Task[None]] = []
self._reconnect_delay = _RECONNECT_BASE_S
self._log = logger.bind(component="BinanceFeed")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
"""Return ``True`` when the WebSocket connection is open."""
return self._connected
def subscribe(self, callback: PriceCallback) -> None:
"""Register a price-update callback.
The callback signature is::
callback(symbol: str, price: float, timestamp: float, volume: float)
"""
if callback not in self._callbacks:
self._callbacks.append(callback)
self._log.info("callback_subscribed", total=len(self._callbacks))
def get_latest_price(self, symbol: str) -> Optional[float]:
"""Return the most recent trade price for *symbol*, or ``None``."""
return self._latest_prices.get(symbol.upper())
async def start(self) -> None:
"""Open the WebSocket and begin consuming messages.
Automatically reconnects on disconnection with exponential backoff.
Blocks until :meth:`stop` is called.
"""
self._stop_event.clear()
self._log.info("feed_starting", url=self._url)
while not self._stop_event.is_set():
try:
await self._connect_and_listen()
except (ConnectionClosed, ConnectionError, InvalidStatusCode, OSError) as exc:
self._set_disconnected()
self._log.warning(
"connection_lost",
error=str(exc),
reconnect_in=self._reconnect_delay,
)
except asyncio.CancelledError:
self._log.info("feed_cancelled")
break
except Exception:
self._set_disconnected()
self._log.exception(
"unexpected_error",
reconnect_in=self._reconnect_delay,
)
if self._stop_event.is_set():
break
# Wait with exponential backoff, but allow early exit via stop().
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=self._reconnect_delay,
)
break # stop_event was set during the wait
except asyncio.TimeoutError:
pass
self._reconnect_delay = min(
self._reconnect_delay * 2, _RECONNECT_MAX_S
)
await self._cleanup()
self._log.info("feed_stopped")
async def stop(self) -> None:
"""Signal the feed to shut down gracefully."""
self._log.info("feed_stop_requested")
self._stop_event.set()
for task in self._tasks:
task.cancel()
if self._ws is not None:
await self._ws.close()
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
async def _connect_and_listen(self) -> None:
"""Establish a WebSocket connection and consume messages."""
async with websockets.connect(
self._url,
ping_interval=None, # we manage our own heartbeat
close_timeout=5,
) as ws:
self._ws = ws
self._connected = True
self._reconnect_delay = _RECONNECT_BASE_S
self._log.info("connected")
ping_task = asyncio.create_task(self._heartbeat(ws))
self._tasks.append(ping_task)
try:
async for raw_msg in ws:
if self._stop_event.is_set():
break
self._handle_message(raw_msg)
finally:
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
self._tasks = [t for t in self._tasks if t is not ping_task]
async def _heartbeat(self, ws: websockets.WebSocketClientProtocol) -> None:
"""Send a ping frame every ``_PING_INTERVAL_S`` seconds."""
try:
while True:
await asyncio.sleep(_PING_INTERVAL_S)
pong = await ws.ping()
await asyncio.wait_for(pong, timeout=10)
self._log.debug("heartbeat_ok")
except asyncio.CancelledError:
pass
except Exception as exc:
self._log.warning("heartbeat_failed", error=str(exc))
def _handle_message(self, raw: str | bytes) -> None:
"""Parse a combined-stream message and dispatch callbacks."""
try:
msg = json.loads(raw)
except (json.JSONDecodeError, TypeError) as exc:
self._log.warning("json_parse_error", error=str(exc), raw=raw[:200])
return
stream: str | None = msg.get("stream")
data: dict | None = msg.get("data")
if stream is None or data is None:
self._log.debug("ignored_message", keys=list(msg.keys()))
return
symbol = _STREAM_TO_SYMBOL.get(stream)
if symbol is None:
self._log.debug("unknown_stream", stream=stream)
return
try:
price = float(data["p"])
volume = float(data["q"])
# Binance trade timestamp is in milliseconds
timestamp = float(data["T"]) / 1000.0
except (KeyError, ValueError, TypeError) as exc:
self._log.warning(
"trade_parse_error",
stream=stream,
error=str(exc),
data=data,
)
return
self._latest_prices[symbol] = price
self._last_recv_ts[symbol] = time.time()
for cb in self._callbacks:
try:
cb(symbol, price, timestamp, volume)
except Exception:
self._log.exception("callback_error", symbol=symbol)
def _set_disconnected(self) -> None:
self._connected = False
self._ws = None
async def _cleanup(self) -> None:
"""Cancel lingering tasks and close the socket."""
for task in self._tasks:
task.cancel()
self._tasks.clear()
if self._ws is not None:
try:
await self._ws.close()
except Exception:
pass
self._set_disconnected()

303
src/feeds/polymarket_ws.py Normal file
View File

@@ -0,0 +1,303 @@
"""Polymarket CLOB WebSocket feed for real-time orderbook data.
Connects to the Polymarket WebSocket subscriptions endpoint, subscribes
to token-level orderbook channels, and maintains the latest
:class:`OrderBookSnapshot` per token with auto-reconnect.
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import Callable, Optional
import structlog
import websockets
from websockets.exceptions import ConnectionClosed, InvalidStatusCode
from src.data.models import OrderBookLevel, OrderBookSnapshot
logger = structlog.get_logger(__name__)
OrderBookCallback = Callable[[str, OrderBookSnapshot], None]
_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/"
_RECONNECT_BASE_S = 1.0
_RECONNECT_MAX_S = 60.0
_PING_INTERVAL_S = 30
class PolymarketFeed:
"""Async Polymarket CLOB WebSocket feed with auto-reconnect.
Usage::
feed = PolymarketFeed()
feed.on_orderbook_update(my_callback)
feed.subscribe_market("0xabc...")
await feed.start()
"""
def __init__(self, url: str = _WS_URL) -> None:
self._url = url
self._callbacks: list[OrderBookCallback] = []
self._subscriptions: set[str] = set()
self._orderbooks: dict[str, OrderBookSnapshot] = {}
self._connected = False
self._ws: Optional[websockets.WebSocketClientProtocol] = None
self._stop_event: asyncio.Event = asyncio.Event()
self._tasks: list[asyncio.Task[None]] = []
self._reconnect_delay = _RECONNECT_BASE_S
self._log = logger.bind(component="PolymarketFeed")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
"""Return ``True`` when the WebSocket connection is open."""
return self._connected
def subscribe_market(self, token_id: str) -> None:
"""Subscribe to orderbook updates for *token_id*.
If the WebSocket is already connected the subscription message is
sent immediately; otherwise it will be sent on the next connect.
"""
self._subscriptions.add(token_id)
self._log.info("market_subscribed", token_id=token_id)
if self._ws is not None and self._connected:
asyncio.ensure_future(self._send_subscribe(token_id))
def unsubscribe_market(self, token_id: str) -> None:
"""Unsubscribe from orderbook updates for *token_id*."""
self._subscriptions.discard(token_id)
self._orderbooks.pop(token_id, None)
self._log.info("market_unsubscribed", token_id=token_id)
if self._ws is not None and self._connected:
asyncio.ensure_future(self._send_unsubscribe(token_id))
def on_orderbook_update(self, callback: OrderBookCallback) -> None:
"""Register a callback invoked on every orderbook update.
Signature::
callback(token_id: str, snapshot: OrderBookSnapshot)
"""
if callback not in self._callbacks:
self._callbacks.append(callback)
self._log.info("callback_registered", total=len(self._callbacks))
def get_orderbook(self, token_id: str) -> Optional[OrderBookSnapshot]:
"""Return the latest cached orderbook for *token_id*, or ``None``."""
return self._orderbooks.get(token_id)
async def start(self) -> None:
"""Connect and begin receiving orderbook data.
Blocks until :meth:`stop` is called. Automatically reconnects
with exponential backoff on connection failures.
"""
self._stop_event.clear()
self._log.info("feed_starting", url=self._url)
while not self._stop_event.is_set():
try:
await self._connect_and_listen()
except (ConnectionClosed, ConnectionError, InvalidStatusCode, OSError) as exc:
self._set_disconnected()
self._log.warning(
"connection_lost",
error=str(exc),
reconnect_in=self._reconnect_delay,
)
except asyncio.CancelledError:
self._log.info("feed_cancelled")
break
except Exception:
self._set_disconnected()
self._log.exception(
"unexpected_error",
reconnect_in=self._reconnect_delay,
)
if self._stop_event.is_set():
break
# Exponential backoff, interruptible by stop().
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=self._reconnect_delay,
)
break
except asyncio.TimeoutError:
pass
self._reconnect_delay = min(
self._reconnect_delay * 2, _RECONNECT_MAX_S
)
await self._cleanup()
self._log.info("feed_stopped")
async def stop(self) -> None:
"""Signal the feed to shut down gracefully."""
self._log.info("feed_stop_requested")
self._stop_event.set()
for task in self._tasks:
task.cancel()
if self._ws is not None:
await self._ws.close()
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
async def _connect_and_listen(self) -> None:
"""Establish WebSocket and consume messages."""
async with websockets.connect(
self._url,
ping_interval=None,
close_timeout=5,
) as ws:
self._ws = ws
self._connected = True
self._reconnect_delay = _RECONNECT_BASE_S
self._log.info("connected")
# Re-subscribe to all tracked markets on (re)connect.
for token_id in self._subscriptions:
await self._send_subscribe(token_id)
ping_task = asyncio.create_task(self._heartbeat(ws))
self._tasks.append(ping_task)
try:
async for raw_msg in ws:
if self._stop_event.is_set():
break
self._handle_message(raw_msg)
finally:
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
self._tasks = [t for t in self._tasks if t is not ping_task]
async def _heartbeat(self, ws: websockets.WebSocketClientProtocol) -> None:
"""Send periodic pings to keep the connection alive."""
try:
while True:
await asyncio.sleep(_PING_INTERVAL_S)
pong = await ws.ping()
await asyncio.wait_for(pong, timeout=10)
self._log.debug("heartbeat_ok")
except asyncio.CancelledError:
pass
except Exception as exc:
self._log.warning("heartbeat_failed", error=str(exc))
async def _send_subscribe(self, token_id: str) -> None:
"""Send a subscribe message for a token's orderbook channel."""
if self._ws is None:
return
msg = {
"type": "subscribe",
"channel": "book",
"assets_ids": [token_id],
}
await self._ws.send(json.dumps(msg))
self._log.debug("subscribe_sent", token_id=token_id)
async def _send_unsubscribe(self, token_id: str) -> None:
"""Send an unsubscribe message for a token's orderbook channel."""
if self._ws is None:
return
msg = {
"type": "unsubscribe",
"channel": "book",
"assets_ids": [token_id],
}
await self._ws.send(json.dumps(msg))
self._log.debug("unsubscribe_sent", token_id=token_id)
def _handle_message(self, raw: str | bytes) -> None:
"""Parse an incoming message and update the local orderbook cache."""
try:
msg = json.loads(raw)
except (json.JSONDecodeError, TypeError) as exc:
self._log.warning("json_parse_error", error=str(exc))
return
msg_type = msg.get("type") or msg.get("event_type")
# Handle book snapshot or delta messages
if msg_type in ("book", "book_snapshot", "book_delta"):
self._process_book_message(msg)
elif msg_type == "error":
self._log.error("server_error", message=msg.get("message"))
else:
self._log.debug("ignored_message", msg_type=msg_type)
def _process_book_message(self, msg: dict) -> None:
"""Extract bids/asks from a book message and update state."""
# Polymarket sends asset_id at the top level of book messages.
token_id: str | None = msg.get("asset_id") or msg.get("market")
if token_id is None:
self._log.debug("book_message_missing_token", keys=list(msg.keys()))
return
bids = [
OrderBookLevel(price=float(b["price"]), size=float(b["size"]))
for b in msg.get("bids", [])
]
asks = [
OrderBookLevel(price=float(a["price"]), size=float(a["size"]))
for a in msg.get("asks", [])
]
# Sort: bids descending, asks ascending
bids.sort(key=lambda lvl: lvl.price, reverse=True)
asks.sort(key=lambda lvl: lvl.price)
snapshot = OrderBookSnapshot(
token_id=token_id,
bids=bids,
asks=asks,
timestamp=time.time(),
)
self._orderbooks[token_id] = snapshot
# Dispatch to registered callbacks
for cb in self._callbacks:
try:
cb(token_id, snapshot)
except Exception:
self._log.exception("callback_error", token_id=token_id)
def _set_disconnected(self) -> None:
self._connected = False
self._ws = None
async def _cleanup(self) -> None:
"""Cancel lingering tasks and close the socket."""
for task in self._tasks:
task.cancel()
self._tasks.clear()
if self._ws is not None:
try:
await self._ws.close()
except Exception:
pass
self._set_disconnected()

762
src/main.py Normal file
View File

@@ -0,0 +1,762 @@
"""Main entrypoint for the Polymarket Temporal Arbitrage Bot.
Ties together all components: market discovery, window tracking, Binance and
Polymarket price feeds, strategy evaluation, order execution, position tracking,
risk management, and notifications in a single async event loop.
"""
from __future__ import annotations
import asyncio
import signal
import sys
import time
from datetime import datetime, timezone
from typing import Optional
import structlog
from src.config import Config, load_config
from src.data import TradeDB
from src.data.models import (
ActiveMarket,
Asset,
Direction,
OrderBookSnapshot,
Signal,
Timeframe,
Trade,
TradeStatus,
WindowState,
)
from src.execution.clob_client import ClobClientWrapper
from src.execution.order_manager import OrderManager
from src.execution.position_tracker import PositionTracker
from src.feeds import BinanceFeed, PolymarketFeed
from src.market import MarketDiscovery, WindowTracker
from src.risk.risk_manager import RiskManager
from src.strategy import SignalAggregator
from src.utils import get_logger, setup_logging
from src.utils.telegram import TelegramNotifier
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_STATUS_INTERVAL_S: float = 30.0
_DISCOVERY_INTERVAL_S: float = 30.0
_ORDER_MONITOR_INTERVAL_S: float = 2.0
_BALANCE_SNAPSHOT_INTERVAL_S: float = 60.0
_DAILY_SUMMARY_HOUR_UTC: int = 0 # midnight UTC
_BANNER = r"""
____ _ _ _ _ _
| _ \ ___ | |_ _ _ __ ___ __ _ _ __| | _______| |_ / \ _ __| |__
| |_) / _ \| | | | | '_ ` _ \ / _` | '__| |/ / _ \ __/ / _ \ | '__| '_ \
| __/ (_) | | |_| | | | | | | (_| | | | < __/ |_ / ___ \| | | |_) |
|_| \___/|_|\__, |_| |_| |_|\__,_|_| |_|\_\___|\__| /_/ \_\_| |_.__/
|___/
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _print_startup_banner(cfg: Config) -> None:
"""Print a startup banner summarising the active configuration."""
print(_BANNER)
strategies = []
if cfg.temporal_arb.enabled:
strategies.append("temporal_arb")
if cfg.sum_to_one.enabled:
strategies.append("sum_to_one")
if cfg.spread_capture.enabled:
strategies.append("spread_capture")
print(f" Mode: {cfg.general.mode}")
print(f" Assets: {', '.join(cfg.general.assets)}")
print(f" Timeframes: {', '.join(cfg.general.timeframes)}")
print(f" Strategies: {', '.join(strategies) or 'none'}")
print(f" Log level: {cfg.general.log_level}")
print(f" Binance WS: {cfg.binance.ws_url}")
print(f" Polymarket: {cfg.polymarket.ws_url}")
print()
def _link_markets_to_tracker(
markets: list[ActiveMarket],
tracker: WindowTracker,
log: structlog.stdlib.BoundLogger,
) -> None:
"""Link each discovered market to the corresponding window in the tracker."""
for mkt in markets:
tracker.link_market(
asset=mkt.asset.value,
timeframe=mkt.timeframe.value,
market=mkt,
)
log.info(
"market_linked_to_window",
asset=mkt.asset.value,
timeframe=mkt.timeframe.value,
condition_id=mkt.condition_id,
)
def _subscribe_market_tokens(
markets: list[ActiveMarket],
poly_feed: PolymarketFeed,
log: structlog.stdlib.BoundLogger,
) -> None:
"""Subscribe the Polymarket feed to token IDs from discovered markets."""
for mkt in markets:
poly_feed.subscribe_market(mkt.up_token_id)
poly_feed.subscribe_market(mkt.down_token_id)
log.debug(
"poly_tokens_subscribed",
condition_id=mkt.condition_id,
up_token=mkt.up_token_id[:12],
down_token=mkt.down_token_id[:12],
)
# ---------------------------------------------------------------------------
# Core application
# ---------------------------------------------------------------------------
class ArbBot:
"""Full-featured arbitrage bot with strategy execution and risk management."""
def __init__(self, config: Config) -> None:
self._cfg = config
self._log = get_logger("arb_bot")
# Components
self._db: Optional[TradeDB] = None
self._discovery: Optional[MarketDiscovery] = None
self._tracker: Optional[WindowTracker] = None
self._binance_feed: Optional[BinanceFeed] = None
self._poly_feed: Optional[PolymarketFeed] = None
self._clob: Optional[ClobClientWrapper] = None
self._order_manager: Optional[OrderManager] = None
self._position_tracker: Optional[PositionTracker] = None
self._risk_manager: Optional[RiskManager] = None
self._signal_aggregator: Optional[SignalAggregator] = None
self._telegram: Optional[TelegramNotifier] = None
# Discovered markets cache
self._active_markets: list[ActiveMarket] = []
# Orderbook state
self._orderbooks: dict[str, OrderBookSnapshot] = {}
# Balance tracking
self._balance: float = config.general.starting_balance
# Shutdown coordination
self._shutdown_event = asyncio.Event()
# Daily summary tracking
self._last_daily_summary_date: str = ""
# ------------------------------------------------------------------
# Callbacks
# ------------------------------------------------------------------
def _on_binance_price(
self, symbol: str, price: float, timestamp: float, volume: float
) -> None:
"""Callback invoked for each Binance trade tick."""
if self._tracker is not None:
self._tracker.update_price(symbol, price, timestamp)
# Strategy evaluation on each tick
if self._signal_aggregator and self._tracker:
window = self._tracker.get_window(symbol, "5M")
if window:
asyncio.get_event_loop().call_soon(
lambda s=symbol, p=price, w=window: asyncio.ensure_future(
self._evaluate_strategies(s, p, w)
)
)
# Also evaluate 15M windows
window_15m = self._tracker.get_window(symbol, "15M")
if window_15m:
asyncio.get_event_loop().call_soon(
lambda s=symbol, p=price, w=window_15m: asyncio.ensure_future(
self._evaluate_strategies(s, p, w)
)
)
async def _evaluate_strategies(
self, symbol: str, cex_price: float, window: WindowState
) -> None:
"""Evaluate strategies for a given symbol and window."""
if not self._signal_aggregator:
return
try:
await self._signal_aggregator.on_price_tick(
symbol=symbol,
cex_price=cex_price,
window=window,
orderbooks=self._orderbooks,
)
except Exception:
self._log.exception("strategy_eval_error", symbol=symbol)
def _on_orderbook_update(
self, token_id: str, snapshot: OrderBookSnapshot
) -> None:
"""Callback invoked for each Polymarket orderbook update."""
self._orderbooks[token_id] = snapshot
# Update position mark-to-market
if self._position_tracker:
self._position_tracker.update_from_orderbooks(self._orderbooks)
def _on_window_change(self, window: WindowState) -> None:
"""Callback fired when a price window transitions."""
self._log.info(
"window_changed",
asset=window.asset.value,
timeframe=window.timeframe.value,
start_price=window.start_price,
window_start=window.window_start_time,
window_end=window.window_end_time,
)
# Snapshot the previous window to DB
if self._db is not None and window.start_price is not None:
try:
self._db.log_window(window)
except Exception:
self._log.exception("window_snapshot_failed")
# Resolve positions for this window (window ended = resolution)
if self._position_tracker and window.current_price and window.start_price:
self._resolve_window_positions(window)
def _resolve_window_positions(self, window: WindowState) -> None:
"""Close positions whose window just resolved."""
if not window.market or not self._position_tracker:
return
actual_direction = (
Direction.UP
if window.current_price and window.start_price
and window.current_price > window.start_price
else Direction.DOWN
)
market = window.market
for token_id, is_up in [
(market.up_token_id, True),
(market.down_token_id, False),
]:
pos = self._position_tracker.get_position(token_id)
if pos is None:
continue
won = (is_up and actual_direction == Direction.UP) or (
not is_up and actual_direction == Direction.DOWN
)
resolution_price = 1.0 if won else 0.0
pnl = self._position_tracker.close_position(token_id, resolution_price)
if pnl is not None:
self._balance += pnl
if self._db:
self._db.update_trade(
pos.market_id,
pnl=pnl,
status=TradeStatus.FILLED.value,
)
self._db.log_balance(self._balance, self._position_tracker.total_pnl)
# Notify
if self._telegram:
result = "WIN" if pnl > 0 else "LOSS"
asyncio.ensure_future(
self._telegram.send(
f"{'' if pnl > 0 else ''} <b>Position Resolved</b>\n"
f"Asset: {pos.asset.value} | {pos.direction.value}\n"
f"Result: {result} | PnL: ${pnl:+.2f}\n"
f"Balance: ${self._balance:.2f}"
)
)
self._log.info(
"position_resolved",
asset=pos.asset.value,
direction=pos.direction.value,
won=won,
pnl=round(pnl, 2),
balance=round(self._balance, 2),
)
# Update strategy balance
if self._signal_aggregator:
self._signal_aggregator.update_balance(self._balance)
def _on_new_markets(self, markets: list[ActiveMarket]) -> None:
"""Callback invoked when MarketDiscovery finds new markets."""
self._active_markets.extend(markets)
if self._tracker is not None:
_link_markets_to_tracker(markets, self._tracker, self._log)
if self._poly_feed is not None:
_subscribe_market_tokens(markets, self._poly_feed, self._log)
def _on_signal(self, signal: Signal) -> None:
"""Handle a trading signal from the strategy aggregator."""
if not self._risk_manager or not self._order_manager:
return
# Risk check
additional_usd = signal.price * signal.size
if not self._risk_manager.can_open_position(additional_usd):
self._log.warning(
"signal_rejected_risk",
asset=signal.asset.value,
direction=signal.direction.value,
reason=self._risk_manager.halt_reason or "risk_limit",
)
if self._risk_manager.is_halted and self._telegram:
asyncio.ensure_future(
self._telegram.notify_halt(self._risk_manager.halt_reason)
)
return
# Submit order
asyncio.ensure_future(self._execute_signal(signal))
async def _execute_signal(self, signal: Signal) -> None:
"""Execute a signal through the order manager."""
if not self._order_manager or not self._position_tracker:
return
trade = await self._order_manager.submit_signal(signal)
if trade is None:
return
if trade.status == TradeStatus.FAILED:
self._log.error("trade_failed", asset=signal.asset.value)
return
# Log trade to DB
if self._db:
self._db.log_trade(trade)
# Notify via telegram
if self._telegram:
await self._telegram.notify_trade(
asset=signal.asset.value,
direction=signal.direction.value,
timeframe=signal.timeframe.value,
price=signal.price,
size=signal.size,
edge=signal.edge,
)
self._log.info(
"trade_submitted",
trade_id=trade.id,
asset=signal.asset.value,
direction=signal.direction.value,
price=signal.price,
size=signal.size,
edge=round(signal.edge, 4),
)
def _on_fill(self, trade: Trade) -> None:
"""Handle a filled order."""
if not self._position_tracker:
return
# Open position
self._position_tracker.open_position(trade)
# Update DB
if self._db:
self._db.update_trade(
trade.id,
fill_price=trade.fill_price,
fill_size=trade.fill_size,
fee=trade.fee,
status=TradeStatus.FILLED.value,
)
# Notify
if self._telegram:
asyncio.ensure_future(
self._telegram.notify_fill(
asset=trade.signal.asset.value,
direction=trade.signal.direction.value,
fill_price=trade.fill_price,
fill_size=trade.fill_size,
trade_id=trade.id,
)
)
# ------------------------------------------------------------------
# Background loops
# ------------------------------------------------------------------
async def _status_loop(self) -> None:
"""Log a status summary every ``_STATUS_INTERVAL_S`` seconds."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=_STATUS_INTERVAL_S,
)
break
except asyncio.TimeoutError:
pass
windows = (
self._tracker.get_all_active_windows()
if self._tracker
else []
)
latest_prices: dict[str, Optional[float]] = {}
if self._binance_feed is not None:
for sym in self._cfg.general.assets:
latest_prices[sym] = self._binance_feed.get_latest_price(sym)
position_summary = (
self._position_tracker.get_summary()
if self._position_tracker
else {}
)
risk_summary = (
self._risk_manager.get_risk_summary()
if self._risk_manager
else {}
)
strategy_stats = (
self._signal_aggregator.get_stats()
if self._signal_aggregator
else {}
)
self._log.info(
"status",
binance_connected=(
self._binance_feed.is_connected
if self._binance_feed
else False
),
polymarket_connected=(
self._poly_feed.is_connected
if self._poly_feed
else False
),
active_windows=len(windows),
active_markets=len(self._active_markets),
latest_prices=latest_prices,
balance=round(self._balance, 2),
positions=position_summary,
risk=risk_summary,
strategies=strategy_stats,
today_trades=(
self._db.get_today_trade_count() if self._db else 0
),
today_pnl=(
self._db.get_today_pnl() if self._db else 0.0
),
)
async def _balance_snapshot_loop(self) -> None:
"""Periodically snapshot balance to DB."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=_BALANCE_SNAPSHOT_INTERVAL_S,
)
break
except asyncio.TimeoutError:
pass
if self._db and self._position_tracker:
self._db.log_balance(
self._balance,
self._position_tracker.total_pnl,
)
async def _daily_summary_loop(self) -> None:
"""Send daily summary at midnight UTC."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=60.0,
)
break
except asyncio.TimeoutError:
pass
now_utc = datetime.now(timezone.utc)
today = now_utc.strftime("%Y-%m-%d")
if (
now_utc.hour == _DAILY_SUMMARY_HOUR_UTC
and today != self._last_daily_summary_date
):
self._last_daily_summary_date = today
# Summarize yesterday
yesterday = (
datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
)
yesterday_str = yesterday.strftime("%Y-%m-%d")
if self._db:
self._db.update_daily_summary(yesterday_str)
summary = self._db.get_daily_summary(yesterday_str)
if self._telegram and summary["total_trades"] > 0:
await self._telegram.notify_daily_summary(
date=yesterday_str,
total_trades=summary["total_trades"],
wins=summary["wins"],
losses=summary["losses"],
pnl=summary["total_pnl"],
fees=summary["total_fees"],
volume=summary["total_volume"],
)
async def _order_expiry_loop(self) -> None:
"""Cancel orders close to resolution."""
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=5.0,
)
break
except asyncio.TimeoutError:
pass
if self._order_manager:
try:
cancelled = await self._order_manager.cancel_expiring_orders(
seconds_before_resolution=self._cfg.temporal_arb.exit_before_resolution_sec,
)
if cancelled > 0:
self._log.info("expiring_orders_cancelled", count=cancelled)
except Exception:
self._log.exception("expiry_loop_error")
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
"""Initialise all components, then run feeds concurrently."""
self._log.info("bot_initialising")
# 1. Database
db_path = "trades.db"
if self._cfg.general.mode == "paper":
db_path = "paper_trades.db"
self._db = TradeDB(db_path=db_path)
# Record initial balance
self._db.log_balance(self._balance, 0.0, event="start")
# 2. Market discovery
self._discovery = MarketDiscovery(
on_new_markets=self._on_new_markets,
)
self._log.info("running_initial_discovery")
initial_markets = await self._discovery.discover()
self._active_markets = initial_markets
self._log.info(
"initial_discovery_complete",
count=len(initial_markets),
)
# 3. Window tracker
assets = [Asset(a) for a in self._cfg.general.assets]
timeframes = [Timeframe(t) for t in self._cfg.general.timeframes]
self._tracker = WindowTracker(assets=assets, timeframes=timeframes)
self._tracker.on_window_change(self._on_window_change)
# Link initial markets to windows
_link_markets_to_tracker(initial_markets, self._tracker, self._log)
# 4. CLOB client (for live mode)
self._clob = ClobClientWrapper(self._cfg.polymarket)
if self._cfg.general.mode == "live":
await self._clob.initialize()
self._log.info("clob_client_ready", is_ready=self._clob.is_ready)
# 5. Execution components
self._position_tracker = PositionTracker(self._cfg)
self._order_manager = OrderManager(self._cfg, self._clob)
self._order_manager.on_fill(self._on_fill)
self._risk_manager = RiskManager(
self._cfg.risk, self._position_tracker, self._db
)
# 6. Strategy aggregator
self._signal_aggregator = SignalAggregator(
self._cfg, balance=self._balance
)
self._signal_aggregator.on_signal(self._on_signal)
# 7. Telegram
self._telegram = TelegramNotifier(self._cfg.notifications)
await self._telegram.send(
f"🚀 <b>Polymarket Arb Bot Started</b>\n"
f"Mode: {self._cfg.general.mode}\n"
f"Balance: ${self._balance:.2f}\n"
f"Assets: {', '.join(self._cfg.general.assets)}"
)
# 8. Binance feed
ws_url = self._cfg.binance.ws_url
streams = "/".join(
f"{s}@trade" for s in self._cfg.binance.symbols
)
full_url = f"{ws_url}?streams={streams}"
self._binance_feed = BinanceFeed(url=full_url)
self._binance_feed.subscribe(self._on_binance_price)
# 9. Polymarket feed
self._poly_feed = PolymarketFeed(url=self._cfg.polymarket.ws_url)
self._poly_feed.on_orderbook_update(self._on_orderbook_update)
# Subscribe to discovered market tokens
_subscribe_market_tokens(
initial_markets, self._poly_feed, self._log
)
self._log.info("bot_starting_feeds")
# 10. Run all tasks concurrently
tasks = [
self._binance_feed.start(),
self._poly_feed.start(),
self._discovery.discover_loop(
interval_sec=_DISCOVERY_INTERVAL_S,
),
self._status_loop(),
self._balance_snapshot_loop(),
self._daily_summary_loop(),
self._order_expiry_loop(),
]
# Add order monitor for live mode
if self._cfg.general.mode == "live" and self._clob.is_ready:
tasks.append(
self._order_manager.monitor_loop(
interval=_ORDER_MONITOR_INTERVAL_S
)
)
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
self._log.info("gather_cancelled")
async def shutdown(self) -> None:
"""Gracefully stop all components."""
self._log.info("bot_shutting_down")
self._shutdown_event.set()
# Cancel all pending orders
if self._order_manager:
cancelled = await self._order_manager.cancel_all_pending()
self._log.info("shutdown_orders_cancelled", count=cancelled)
self._order_manager.stop()
if self._binance_feed is not None:
await self._binance_feed.stop()
if self._poly_feed is not None:
await self._poly_feed.stop()
# Final balance snapshot
if self._db and self._position_tracker:
self._db.log_balance(
self._balance,
self._position_tracker.total_pnl,
event="shutdown",
)
# Update daily summary
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
self._db.update_daily_summary(today)
# Notify shutdown
if self._telegram:
position_summary = (
self._position_tracker.get_summary()
if self._position_tracker
else {}
)
await self._telegram.send(
f"🛑 <b>Bot Stopped</b>\n"
f"Balance: ${self._balance:.2f}\n"
f"Total PnL: ${position_summary.get('total_pnl', 0):.2f}\n"
f"Trades: {position_summary.get('trades', 0)}"
)
await self._telegram.close()
self._log.info("bot_stopped")
# ---------------------------------------------------------------------------
# Signal handling and entry point
# ---------------------------------------------------------------------------
def _install_signal_handlers(
loop: asyncio.AbstractEventLoop, bot: ArbBot
) -> None:
"""Register SIGINT / SIGTERM handlers that trigger graceful shutdown."""
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(
sig,
lambda: asyncio.ensure_future(bot.shutdown()),
)
except NotImplementedError:
pass
async def async_main(config: Optional[Config] = None) -> None:
"""Async entry point — load config, build the bot, and run."""
if config is None:
config = load_config()
setup_logging(config.general.log_level)
_print_startup_banner(config)
bot = ArbBot(config)
loop = asyncio.get_running_loop()
_install_signal_handlers(loop, bot)
try:
await bot.start()
except KeyboardInterrupt:
pass
finally:
await bot.shutdown()
def main() -> None:
"""Synchronous entry point for ``python src/main.py``."""
try:
asyncio.run(async_main())
except KeyboardInterrupt:
print("\nShutdown complete.")
sys.exit(0)
if __name__ == "__main__":
main()

6
src/market/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Market discovery and window tracking."""
from src.market.discovery import MarketDiscovery
from src.market.window_tracker import WindowTracker
__all__ = ["MarketDiscovery", "WindowTracker"]

275
src/market/discovery.py Normal file
View File

@@ -0,0 +1,275 @@
"""Active 5min/15min Up/Down crypto market discovery via Gamma API."""
from __future__ import annotations
import asyncio
import re
from typing import Callable, Optional
import aiohttp
import structlog
from src.data.models import ActiveMarket, Asset, Timeframe
logger = structlog.get_logger(__name__)
GAMMA_API_URL = "https://gamma-api.polymarket.com/events"
GAMMA_QUERY_PARAMS = {
"tag": "crypto",
"active": "true",
"closed": "false",
"limit": "100",
}
SUPPORTED_ASSETS: dict[str, Asset] = {
"BTC": Asset.BTC,
"ETH": Asset.ETH,
"SOL": Asset.SOL,
}
TIMEFRAME_PATTERNS: dict[str, Timeframe] = {
"5 Min": Timeframe.FIVE_MIN,
"5 min": Timeframe.FIVE_MIN,
"5Min": Timeframe.FIVE_MIN,
"15 Min": Timeframe.FIFTEEN_MIN,
"15 min": Timeframe.FIFTEEN_MIN,
"15Min": Timeframe.FIFTEEN_MIN,
}
def _extract_asset(title: str) -> Optional[Asset]:
"""Extract the crypto asset from an event/market title."""
for keyword, asset in SUPPORTED_ASSETS.items():
if keyword in title:
return asset
return None
def _extract_timeframe(title: str) -> Optional[Timeframe]:
"""Extract the timeframe from an event/market title."""
for pattern, timeframe in TIMEFRAME_PATTERNS.items():
if pattern in title:
return timeframe
return None
def _extract_token_ids(market: dict) -> tuple[str, str] | None:
"""Extract (up_token_id, down_token_id) from a market dict.
The Gamma API returns token IDs as a JSON-encoded list or a
``clobTokenIds`` field. The first token is conventionally the
"Up" outcome, the second is "Down". We also inspect ``outcomes``
to verify ordering when available.
"""
tokens: list[str] = []
outcomes: list[str] = []
# Token IDs
raw_tokens = market.get("clobTokenIds")
if isinstance(raw_tokens, list):
tokens = [str(t) for t in raw_tokens]
elif isinstance(raw_tokens, str):
# Sometimes returned as JSON-encoded string "[\"0xabc\",\"0xdef\"]"
try:
import json
tokens = [str(t) for t in json.loads(raw_tokens)]
except (json.JSONDecodeError, TypeError):
return None
# Outcomes
raw_outcomes = market.get("outcomes")
if isinstance(raw_outcomes, list):
outcomes = [str(o).upper().strip() for o in raw_outcomes]
elif isinstance(raw_outcomes, str):
try:
import json
outcomes = [str(o).upper().strip() for o in json.loads(raw_outcomes)]
except (json.JSONDecodeError, TypeError):
pass
if len(tokens) < 2:
return None
# Determine which token is Up and which is Down
up_token: str = tokens[0]
down_token: str = tokens[1]
if len(outcomes) >= 2:
if outcomes[0] == "DOWN" and outcomes[1] == "UP":
up_token, down_token = tokens[1], tokens[0]
return up_token, down_token
class MarketDiscovery:
"""Discovers active 5-min and 15-min Up/Down crypto markets on Polymarket.
Uses the Gamma API to fetch events tagged as crypto, then filters for
short-duration binary Up/Down markets on BTC, ETH, or SOL.
"""
def __init__(
self,
session: Optional[aiohttp.ClientSession] = None,
on_new_markets: Optional[Callable[[list[ActiveMarket]], None]] = None,
) -> None:
self._external_session = session
self._session: Optional[aiohttp.ClientSession] = session
self._on_new_markets = on_new_markets
self._seen_condition_ids: set[str] = set()
self._log = logger.bind(component="MarketDiscovery")
async def _ensure_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def _close_session_if_owned(self) -> None:
"""Close the HTTP session only if we created it ourselves."""
if self._external_session is None and self._session is not None and not self._session.closed:
await self._session.close()
# ------------------------------------------------------------------
# Core discovery
# ------------------------------------------------------------------
async def discover(self) -> list[ActiveMarket]:
"""Fetch events from the Gamma API and return matching ActiveMarket instances."""
session = await self._ensure_session()
self._log.info("gamma_api_fetch_start")
try:
async with session.get(GAMMA_API_URL, params=GAMMA_QUERY_PARAMS, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 429:
retry_after = float(resp.headers.get("Retry-After", "5"))
self._log.warning("gamma_api_rate_limited", retry_after=retry_after)
await asyncio.sleep(retry_after)
return []
if resp.status != 200:
self._log.error("gamma_api_http_error", status=resp.status, reason=resp.reason)
return []
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError) as exc:
self._log.error("gamma_api_json_parse_error", error=str(exc))
return []
except asyncio.TimeoutError:
self._log.error("gamma_api_timeout")
return []
except aiohttp.ClientError as exc:
self._log.error("gamma_api_client_error", error=str(exc))
return []
events: list[dict] = data if isinstance(data, list) else []
markets = self._filter_and_parse(events)
self._log.info("gamma_api_fetch_done", total_events=len(events), matched_markets=len(markets))
return markets
def _filter_and_parse(self, events: list[dict]) -> list[ActiveMarket]:
"""Filter events and their sub-markets, returning ActiveMarket instances."""
results: list[ActiveMarket] = []
for event in events:
title: str = event.get("title", "")
# Must be an Up or Down style market
if not re.search(r"[Uu]p\s+or\s+[Dd]own", title):
continue
# Must match a supported timeframe
timeframe = _extract_timeframe(title)
if timeframe is None:
continue
# Must be for a supported asset
asset = _extract_asset(title)
if asset is None:
continue
# Process each sub-market within the event
sub_markets: list[dict] = event.get("markets", [])
if not sub_markets:
# Some API shapes embed market data at the event level
sub_markets = [event]
for mkt in sub_markets:
if not mkt.get("active", False):
continue
if not mkt.get("enableOrderBook", False):
continue
condition_id: str = mkt.get("conditionId", "") or mkt.get("condition_id", "")
if not condition_id:
continue
token_pair = _extract_token_ids(mkt)
if token_pair is None:
self._log.debug("skip_market_missing_tokens", condition_id=condition_id)
continue
up_token, down_token = token_pair
end_date: str = mkt.get("endDate", "") or mkt.get("end_date_iso", "") or ""
results.append(
ActiveMarket(
condition_id=condition_id,
up_token_id=up_token,
down_token_id=down_token,
asset=asset,
timeframe=timeframe,
end_date=end_date,
question=mkt.get("question", title),
description=mkt.get("description", ""),
)
)
return results
# ------------------------------------------------------------------
# Continuous discovery loop
# ------------------------------------------------------------------
async def discover_loop(self, interval_sec: float = 30) -> None:
"""Continuously discover markets and invoke the callback with new ones.
Runs indefinitely. Each iteration sleeps for *interval_sec* seconds
after completing a fetch cycle. Previously seen ``condition_id`` values
are cached so the callback only receives genuinely new markets.
"""
self._log.info("discover_loop_start", interval_sec=interval_sec)
try:
while True:
try:
all_markets = await self.discover()
new_markets = [
m for m in all_markets
if m.condition_id not in self._seen_condition_ids
]
for m in new_markets:
self._seen_condition_ids.add(m.condition_id)
if new_markets:
self._log.info("new_markets_found", count=len(new_markets))
if self._on_new_markets is not None:
self._on_new_markets(new_markets)
except Exception:
self._log.exception("discover_loop_iteration_error")
await asyncio.sleep(interval_sec)
finally:
await self._close_session_if_owned()
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def reset_cache(self) -> None:
"""Clear the set of previously seen condition IDs."""
self._seen_condition_ids.clear()
self._log.info("cache_cleared")

186
src/market/oracle.py Normal file
View File

@@ -0,0 +1,186 @@
"""Chainlink Oracle monitor — tracks oracle update latency via web3.
Monitors the delay between real CEX prices and Chainlink oracle updates
on Polygon, which is the core edge in temporal arbitrage.
"""
from __future__ import annotations
import asyncio
import time
from typing import Optional
import structlog
log = structlog.get_logger()
# Chainlink AggregatorV3Interface ABI (latestRoundData only)
AGGREGATOR_ABI = [
{
"inputs": [],
"name": "latestRoundData",
"outputs": [
{"name": "roundId", "type": "uint80"},
{"name": "answer", "type": "int256"},
{"name": "startedAt", "type": "uint256"},
{"name": "updatedAt", "type": "uint256"},
{"name": "answeredInRound", "type": "uint80"},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "decimals",
"outputs": [{"name": "", "type": "uint8"}],
"stateMutability": "view",
"type": "function",
},
]
class OracleMonitor:
"""Monitors Chainlink oracle update frequency and latency.
The oracle typically updates every ~10-30 seconds or on 0.5% deviation.
Tracking this helps calibrate the temporal arbitrage opportunity window.
"""
# Chainlink price feed addresses on Polygon
FEEDS = {
"BTC": "0xc907E116054Ad103354f2D350FD2514433D57F6f",
"ETH": "0xF9680D99D6C9589e2a93a78A04A279e509205945",
"SOL": "0x10C8264C0935b3B9870013e057f330Ff3e9C56dC",
}
def __init__(self, rpc_url: Optional[str] = None) -> None:
self._rpc_url = rpc_url or "https://polygon.drpc.org"
self._w3 = None
self._contracts: dict[str, object] = {}
self._decimals: dict[str, int] = {}
self._last_oracle_prices: dict[str, float] = {}
self._last_oracle_timestamps: dict[str, float] = {}
self._last_oracle_round_ids: dict[str, int] = {}
self._update_intervals: dict[str, list[float]] = {
"BTC": [], "ETH": [], "SOL": []
}
self._initialized = False
async def initialize(self) -> bool:
"""Initialize web3 connection and contract instances."""
try:
from web3 import Web3
self._w3 = Web3(Web3.HTTPProvider(self._rpc_url))
if not self._w3.is_connected():
log.error("oracle_web3_not_connected", rpc_url=self._rpc_url)
return False
for asset, address in self.FEEDS.items():
checksum = self._w3.to_checksum_address(address)
contract = self._w3.eth.contract(
address=checksum, abi=AGGREGATOR_ABI
)
self._contracts[asset] = contract
self._decimals[asset] = contract.functions.decimals().call()
self._initialized = True
log.info(
"oracle_initialized",
rpc_url=self._rpc_url,
assets=list(self._contracts.keys()),
)
return True
except ImportError:
log.warning("oracle_web3_not_installed", msg="pip install web3")
return False
except Exception:
log.exception("oracle_init_failed")
return False
async def get_oracle_price(self, asset: str) -> Optional[float]:
"""Fetch the latest oracle price for an asset from Chainlink."""
if not self._initialized or asset not in self._contracts:
return self._last_oracle_prices.get(asset)
try:
contract = self._contracts[asset]
result = contract.functions.latestRoundData().call()
round_id, answer, started_at, updated_at, answered_in_round = result
decimals = self._decimals.get(asset, 8)
price = answer / (10 ** decimals)
# Record the update
self.record_oracle_update(asset, price, updated_at)
self._last_oracle_round_ids[asset] = round_id
return price
except Exception:
log.exception("oracle_fetch_failed", asset=asset)
return self._last_oracle_prices.get(asset)
def record_oracle_update(self, asset: str, price: float, timestamp: float) -> None:
"""Record an observed oracle price update."""
prev_ts = self._last_oracle_timestamps.get(asset)
if prev_ts is not None and timestamp > prev_ts:
interval = timestamp - prev_ts
intervals = self._update_intervals[asset]
intervals.append(interval)
# Keep last 100 intervals
if len(intervals) > 100:
intervals.pop(0)
self._last_oracle_prices[asset] = price
self._last_oracle_timestamps[asset] = timestamp
def get_avg_update_interval(self, asset: str) -> Optional[float]:
"""Get average oracle update interval in seconds."""
intervals = self._update_intervals.get(asset, [])
if not intervals:
return None
return sum(intervals) / len(intervals)
def get_estimated_lag(self, asset: str) -> Optional[float]:
"""Estimate current oracle lag (time since last known update)."""
last_ts = self._last_oracle_timestamps.get(asset)
if last_ts is None:
return None
return time.time() - last_ts
def get_oracle_vs_cex_deviation(
self, asset: str, cex_price: float
) -> Optional[float]:
"""Calculate percentage deviation between oracle and CEX price."""
oracle_price = self._last_oracle_prices.get(asset)
if oracle_price is None or oracle_price <= 0:
return None
return (cex_price - oracle_price) / oracle_price * 100
async def poll_loop(self, interval: float = 5.0) -> None:
"""Continuously poll oracle prices."""
if not self._initialized:
log.warning("oracle_poll_not_initialized")
return
while True:
for asset in self.FEEDS:
try:
await self.get_oracle_price(asset)
except Exception:
log.exception("oracle_poll_error", asset=asset)
await asyncio.sleep(interval)
def get_stats(self) -> dict:
return {
asset: {
"last_price": self._last_oracle_prices.get(asset),
"last_round_id": self._last_oracle_round_ids.get(asset),
"avg_interval": round(avg, 2) if (avg := self.get_avg_update_interval(asset)) else None,
"estimated_lag": round(lag, 2) if (lag := self.get_estimated_lag(asset)) else None,
"initialized": self._initialized,
}
for asset in self.FEEDS
}

View File

@@ -0,0 +1,193 @@
"""Track 5-minute and 15-minute price windows for BTC, ETH, SOL.
Maintains six simultaneous windows (3 assets x 2 timeframes), capturing the
start price from the first CEX tick after each window opens and tracking the
current price throughout.
"""
from __future__ import annotations
import math
from typing import Callable, Optional
import structlog
from src.data.models import ActiveMarket, Asset, Timeframe, WindowState
log = structlog.get_logger(__name__)
# Timeframe durations in seconds.
_TIMEFRAME_SECONDS: dict[Timeframe, int] = {
Timeframe.FIVE_MIN: 5 * 60,
Timeframe.FIFTEEN_MIN: 15 * 60,
}
def _window_bounds(timestamp: float, timeframe: Timeframe) -> tuple[float, float]:
"""Return (start, end) UTC-aligned window boundaries for *timestamp*."""
interval = _TIMEFRAME_SECONDS[timeframe]
start = math.floor(timestamp / interval) * interval
return float(start), float(start + interval)
class WindowTracker:
"""Track clock-aligned price windows for multiple assets and timeframes.
Parameters
----------
assets:
Assets to track. Defaults to BTC, ETH, SOL.
timeframes:
Timeframes to track. Defaults to 5M and 15M.
"""
def __init__(
self,
assets: Optional[list[Asset]] = None,
timeframes: Optional[list[Timeframe]] = None,
) -> None:
self._assets: list[Asset] = assets or list(Asset)
self._timeframes: list[Timeframe] = timeframes or list(Timeframe)
# Keyed by (asset, timeframe).
self._windows: dict[tuple[Asset, Timeframe], WindowState] = {}
self._callbacks: list[Callable[[WindowState], None]] = []
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def update_price(self, asset: str, price: float, timestamp: float) -> None:
"""Process an incoming CEX price tick.
If *timestamp* falls within a new window that we haven't initialised
yet (or a different window from the one we're tracking), a fresh
``WindowState`` is created and any registered callbacks are fired.
The first tick inside a window sets ``start_price``. Every tick
updates ``current_price``.
"""
asset_enum = Asset(asset)
for tf in self._timeframes:
key = (asset_enum, tf)
win_start, win_end = _window_bounds(timestamp, tf)
existing = self._windows.get(key)
# Detect window transition (or first-ever window).
if existing is None or existing.window_start_time != win_start:
# Carry over any linked market from the previous window.
linked_market = existing.market if existing else None
new_window = WindowState(
asset=asset_enum,
timeframe=tf,
window_start_time=win_start,
window_end_time=win_end,
start_price=price,
current_price=price,
market=linked_market,
)
self._windows[key] = new_window
log.info(
"window_transition",
asset=asset_enum.value,
timeframe=tf.value,
window_start=win_start,
window_end=win_end,
start_price=price,
)
# Fire callbacks with the COMPLETED window so that
# subscribers can read its final price_change_pct.
# On the very first window there is no completed window
# to report, so fire with the new one instead.
completed = existing if existing is not None else new_window
self._fire_callbacks(completed)
else:
# Same window — update current price. If start_price was
# somehow not captured (shouldn't happen with this logic,
# but defensive), fill it with the first available price.
if existing.start_price is None:
existing.start_price = price
log.info(
"late_start_price",
asset=asset_enum.value,
timeframe=tf.value,
price=price,
)
existing.current_price = price
def get_window(self, asset: str, timeframe: str) -> Optional[WindowState]:
"""Return the current window state for an asset/timeframe pair."""
key = (Asset(asset), Timeframe(timeframe))
return self._windows.get(key)
def get_all_active_windows(self) -> list[WindowState]:
"""Return every currently tracked window."""
return list(self._windows.values())
def is_window_expired(self, asset: str, timeframe: str) -> bool:
"""Check whether the tracked window has already ended.
Returns ``True`` when the window end time is in the past **or**
when no window has been initialised for the given pair.
"""
import time
key = (Asset(asset), Timeframe(timeframe))
window = self._windows.get(key)
if window is None:
return True
return time.time() >= window.window_end_time
def link_market(
self, asset: str, timeframe: str, market: ActiveMarket
) -> None:
"""Associate a Polymarket market with the current window.
If no window exists yet for the pair, one is **not** created — the
market will be linked once the first price tick arrives and triggers
a window transition.
"""
key = (Asset(asset), Timeframe(timeframe))
window = self._windows.get(key)
if window is not None:
window.market = market
log.info(
"market_linked",
asset=asset,
timeframe=timeframe,
condition_id=market.condition_id,
)
else:
log.warning(
"link_market_no_window",
asset=asset,
timeframe=timeframe,
condition_id=market.condition_id,
)
def on_window_change(self, callback: Callable[[WindowState], None]) -> None:
"""Register a callback invoked whenever a new window starts.
The callback receives the newly-created ``WindowState``.
"""
self._callbacks.append(callback)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _fire_callbacks(self, window: WindowState) -> None:
for cb in self._callbacks:
try:
cb(window)
except Exception:
log.exception(
"window_change_callback_error",
asset=window.asset.value,
timeframe=window.timeframe.value,
)

7
src/risk/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Risk management modules."""
from src.risk.fee_calculator import FeeCalculator
from src.risk.position_sizer import PositionSizer
from src.risk.risk_manager import RiskManager
__all__ = ["PositionSizer", "RiskManager", "FeeCalculator"]

View File

@@ -0,0 +1,90 @@
"""Fee Calculator — computes trading fees for Polymarket CLOB.
Fee structure:
- 5-minute markets: max 1.56% taker fee
- 15-minute markets: max 3% taker fee
- Fee is applied to potential profit, not to cost basis
- Maker rebate program available for orders resting >= 3.5 seconds
"""
from __future__ import annotations
from src.config import FeesConfig
import structlog
log = structlog.get_logger()
class FeeCalculator:
"""Calculates expected fees for Polymarket trades."""
def __init__(self, fees_config: FeesConfig) -> None:
self.fees = fees_config
def taker_fee(self, timeframe: str, entry_price: float, size: int) -> float:
"""Calculate the taker fee for a trade.
The fee is applied to the potential profit (payout - cost),
not the cost itself.
Args:
timeframe: "5M" or "15M".
entry_price: Entry price per share (e.g., 0.50).
size: Number of shares.
Returns:
Fee in USD.
"""
fee_rate = self.fees.fee_for_timeframe(timeframe)
payout = size * 1.0 # $1 per share if winning
cost = entry_price * size
profit = payout - cost
return max(0, profit * fee_rate)
def breakeven_price(self, timeframe: str, entry_price: float) -> float:
"""Calculate the breakeven probability needed to cover fees.
If you buy at `entry_price`, you need the true probability
to exceed this value to be profitable after fees.
"""
fee_rate = self.fees.fee_for_timeframe(timeframe)
# Solving: prob * (1 - fee_rate) * (1 - entry_price) > entry_price * (1 - prob)
# prob > entry_price / (1 - fee_rate * (1 - entry_price))
denominator = 1.0 - fee_rate * (1.0 - entry_price)
if denominator <= 0:
return 1.0
return entry_price / denominator
def net_payout(self, timeframe: str, entry_price: float, size: int, won: bool) -> float:
"""Calculate net payout after fees.
Args:
timeframe: "5M" or "15M".
entry_price: Entry price per share.
size: Number of shares.
won: Whether the outcome was correct.
Returns:
Net payout in USD (can be negative for losses).
"""
cost = entry_price * size
if won:
gross_payout = size * 1.0
fee = self.taker_fee(timeframe, entry_price, size)
return gross_payout - cost - fee
else:
return -cost # Total loss, no fee on losing side
def expected_value(
self, timeframe: str, entry_price: float, estimated_prob: float, size: int
) -> float:
"""Calculate expected value of a trade.
EV = prob * net_win_payout + (1-prob) * net_loss
"""
win_payout = self.net_payout(timeframe, entry_price, size, won=True)
loss_payout = self.net_payout(timeframe, entry_price, size, won=False)
return estimated_prob * win_payout + (1 - estimated_prob) * loss_payout

View File

@@ -0,0 +1,90 @@
"""Kelly Criterion position sizer with risk adjustments."""
from __future__ import annotations
import math
from typing import Optional
import structlog
from src.config import FeesConfig, RiskConfig
log = structlog.get_logger()
class PositionSizer:
"""Determines optimal position size using Kelly Criterion
with multiple safety caps and adjustments.
"""
def __init__(self, risk_config: RiskConfig, fees_config: FeesConfig) -> None:
self.risk = risk_config
self.fees = fees_config
def calculate(
self,
estimated_prob: float,
poly_price: float,
timeframe: str,
balance: float,
current_exposure: float = 0.0,
) -> int:
"""Calculate position size in shares.
Args:
estimated_prob: Our estimated true probability (0-1).
poly_price: Polymarket entry price (0-1).
timeframe: "5M" or "15M" for fee lookup.
balance: Available balance in USD.
current_exposure: Current total exposure in USD.
Returns:
Number of shares to buy (0 if no trade).
"""
if poly_price <= 0 or poly_price >= 1.0 or estimated_prob <= 0:
return 0
fee_rate = self.fees.fee_for_timeframe(timeframe)
# Kelly Criterion: f* = (b*p - q) / b
b = (1.0 / poly_price) - 1.0 # Payout odds
p = estimated_prob
q = 1.0 - p
if b <= 0:
return 0
kelly_raw = (b * p - q) / b
if kelly_raw <= 0:
return 0
# Cap 1: Kelly fraction cap (default 25%)
kelly_adj = min(kelly_raw, self.risk.kelly_fraction_cap)
# Cap 2: Use half-Kelly for additional safety
kelly_adj *= 0.5
# Cap 3: Dollar size limits
dollar_size = balance * kelly_adj
dollar_size = min(dollar_size, self.risk.max_position_per_market_usd)
# Cap 4: Total exposure limit
remaining_capacity = self.risk.max_total_exposure_usd - current_exposure
if remaining_capacity <= 0:
return 0
dollar_size = min(dollar_size, remaining_capacity)
# Convert to shares
shares = int(dollar_size / poly_price)
log.debug(
"position_sized",
kelly_raw=round(kelly_raw, 4),
kelly_adj=round(kelly_adj, 4),
dollar_size=round(dollar_size, 2),
shares=shares,
prob=round(estimated_prob, 4),
price=poly_price,
)
return max(0, shares)

161
src/risk/risk_manager.py Normal file
View File

@@ -0,0 +1,161 @@
"""Risk Manager — enforces trading limits and circuit breakers.
Monitors exposure, daily PnL, and position counts to prevent
catastrophic losses.
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
import structlog
from src.config import RiskConfig
from src.data.db import TradeDB
from src.execution.position_tracker import PositionTracker
log = structlog.get_logger()
class RiskManager:
"""Central risk management.
Tracks daily PnL, total exposure, and position count.
Can halt trading if limits are breached.
"""
def __init__(
self,
risk_config: RiskConfig,
position_tracker: PositionTracker,
trade_db: TradeDB,
) -> None:
self.risk = risk_config
self.tracker = position_tracker
self.db = trade_db
self._halted = False
self._halt_reason: str = ""
self._daily_pnl_cache: float = 0.0
self._last_pnl_check: float = 0.0
@property
def is_halted(self) -> bool:
return self._halted
@property
def halt_reason(self) -> str:
return self._halt_reason
def check_all(self) -> bool:
"""Run all risk checks. Returns True if trading is allowed."""
if self._halted:
return False
if not self._check_daily_loss():
return False
if not self._check_total_exposure():
return False
if not self._check_position_count():
return False
return True
def can_open_position(self, additional_usd: float) -> bool:
"""Check if a new position of given size can be opened."""
if self._halted:
return False
if not self.tracker.is_exposure_ok(additional_usd):
log.warning(
"risk_exposure_limit",
current=round(self.tracker.total_exposure, 2),
additional=round(additional_usd, 2),
limit=self.risk.max_total_exposure_usd,
)
return False
if self.tracker.position_count >= self.risk.max_concurrent_positions:
log.warning(
"risk_position_limit",
current=self.tracker.position_count,
limit=self.risk.max_concurrent_positions,
)
return False
return self.check_all()
# ------------------------------------------------------------------
# Individual checks
# ------------------------------------------------------------------
def _check_daily_loss(self) -> bool:
"""Check if daily loss limit has been breached."""
now = time.time()
# Cache daily PnL check (recompute every 10 seconds)
if now - self._last_pnl_check > 10:
self._daily_pnl_cache = self.db.get_today_pnl()
self._last_pnl_check = now
daily_pnl = self._daily_pnl_cache + self.tracker.total_unrealized_pnl
if daily_pnl < -self.risk.max_daily_loss_usd:
self._halt("daily_loss_limit_breached",
f"Daily PnL ${daily_pnl:.2f} < -${self.risk.max_daily_loss_usd}")
return False
return True
def _check_total_exposure(self) -> bool:
"""Check if total exposure is within limits."""
exposure = self.tracker.total_exposure
if exposure > self.risk.max_total_exposure_usd:
self._halt("exposure_limit_breached",
f"Exposure ${exposure:.2f} > ${self.risk.max_total_exposure_usd}")
return False
return True
def _check_position_count(self) -> bool:
"""Check if position count is within limits."""
count = self.tracker.position_count
if count > self.risk.max_concurrent_positions:
log.warning("position_count_exceeded", count=count, limit=self.risk.max_concurrent_positions)
return False
return True
# ------------------------------------------------------------------
# Halt/resume
# ------------------------------------------------------------------
def _halt(self, halt_event: str, reason: str) -> None:
"""Halt all trading."""
self._halted = True
self._halt_reason = reason
log.critical("trading_halted", halt_event=halt_event, reason=reason)
def resume(self) -> None:
"""Manually resume trading (use with caution)."""
log.warning("trading_resumed", previous_halt_reason=self._halt_reason)
self._halted = False
self._halt_reason = ""
# ------------------------------------------------------------------
# Reporting
# ------------------------------------------------------------------
def get_risk_summary(self) -> dict:
"""Return current risk state for dashboard/logging."""
return {
"halted": self._halted,
"halt_reason": self._halt_reason,
"daily_pnl": round(self._daily_pnl_cache, 2),
"total_exposure": round(self.tracker.total_exposure, 2),
"exposure_pct": round(
self.tracker.total_exposure / self.risk.max_total_exposure_usd * 100, 1
),
"positions": self.tracker.position_count,
"max_positions": self.risk.max_concurrent_positions,
"daily_loss_limit": self.risk.max_daily_loss_usd,
"exposure_limit": self.risk.max_total_exposure_usd,
}

13
src/strategy/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""Strategy modules for the Polymarket Arbitrage Bot."""
from src.strategy.signal import SignalAggregator
from src.strategy.spread_capture import SpreadCaptureStrategy
from src.strategy.sum_to_one import SumToOneStrategy
from src.strategy.temporal_arb import TemporalArbStrategy
__all__ = [
"TemporalArbStrategy",
"SumToOneStrategy",
"SpreadCaptureStrategy",
"SignalAggregator",
]

196
src/strategy/signal.py Normal file
View File

@@ -0,0 +1,196 @@
"""Signal Aggregator — coordinates all strategies and manages signal flow.
Receives market data from feeds and window tracker, evaluates all active
strategies, and emits consolidated signals for the execution engine.
"""
from __future__ import annotations
import asyncio
import time
from typing import Callable, Optional
import structlog
from src.config import Config
from src.data.models import (
ActiveMarket,
Direction,
OrderBookSnapshot,
Signal,
WindowState,
)
from src.strategy.spread_capture import SpreadCaptureStrategy
from src.strategy.sum_to_one import SumToOneStrategy
from src.strategy.temporal_arb import TemporalArbStrategy
log = structlog.get_logger()
SignalCallback = Callable[[Signal], None]
class SignalAggregator:
"""Coordinates all strategies and emits trading signals.
Connects to the WindowTracker and PolymarketFeed to receive
real-time data, runs strategy evaluations, and dispatches
signals to registered handlers.
"""
def __init__(self, config: Config, balance: float = 10000.0) -> None:
self.config = config
self._callbacks: list[SignalCallback] = []
# Initialize strategies
self.temporal_arb = TemporalArbStrategy(
arb_config=config.temporal_arb,
risk_config=config.risk,
fees_config=config.fees,
balance=balance,
)
self.sum_to_one = SumToOneStrategy(
sto_config=config.sum_to_one,
risk_config=config.risk,
fees_config=config.fees,
balance=balance,
)
self.spread_capture = SpreadCaptureStrategy(
spread_config=config.spread_capture,
risk_config=config.risk,
fees_config=config.fees,
balance=balance,
)
# Rate limiting: don't evaluate the same market more than once per second
self._last_eval_time: dict[str, float] = {}
self._min_eval_interval = 0.5 # seconds
# Track recent signals to avoid duplicates
self._recent_signals: dict[str, float] = {}
self._signal_cooldown = 30.0 # seconds between signals for same market
def on_signal(self, callback: SignalCallback) -> None:
"""Register a callback for emitted signals."""
self._callbacks.append(callback)
def _emit(self, signal: Signal) -> None:
"""Dispatch a signal to all registered callbacks."""
# Dedup: don't emit same direction/asset/timeframe within cooldown
key = f"{signal.asset.value}:{signal.timeframe.value}:{signal.direction.value}"
now = time.time()
if key in self._recent_signals:
if now - self._recent_signals[key] < self._signal_cooldown:
log.debug("signal_deduplicated", key=key)
return
self._recent_signals[key] = now
for cb in self._callbacks:
try:
cb(signal)
except Exception:
log.exception("signal_callback_error")
async def on_price_tick(
self,
symbol: str,
cex_price: float,
window: Optional[WindowState],
orderbooks: dict[str, OrderBookSnapshot],
) -> None:
"""Called on each CEX price tick with current window and orderbook state.
Evaluates temporal arb and sum-to-one strategies.
"""
if window is None or window.market is None:
return
if window.start_price is None:
return
market = window.market
timeframe = window.timeframe.value
# Rate limiting
eval_key = f"{symbol}:{timeframe}"
now = time.time()
if eval_key in self._last_eval_time:
if now - self._last_eval_time[eval_key] < self._min_eval_interval:
return
self._last_eval_time[eval_key] = now
# Get Polymarket prices from orderbooks
up_book = orderbooks.get(market.up_token_id)
down_book = orderbooks.get(market.down_token_id)
poly_up_ask = up_book.best_ask if up_book else None
poly_down_ask = down_book.best_ask if down_book else None
# --- Temporal Arbitrage ---
if self.config.temporal_arb.enabled:
signal = await self.temporal_arb.evaluate(
symbol=symbol,
cex_price=cex_price,
window_start_price=window.start_price,
window_end_time=window.window_end_time,
poly_up_ask=poly_up_ask,
poly_down_ask=poly_down_ask,
up_token_id=market.up_token_id,
down_token_id=market.down_token_id,
timeframe=timeframe,
)
if signal:
self._emit(signal)
# --- Sum-to-One Arbitrage ---
if self.config.sum_to_one.enabled:
opp = await self.sum_to_one.evaluate(
asset=symbol,
timeframe=timeframe,
poly_up_ask=poly_up_ask,
poly_down_ask=poly_down_ask,
up_token_id=market.up_token_id,
down_token_id=market.down_token_id,
)
if opp:
up_sig, down_sig = self.sum_to_one.generate_signals(opp)
self._emit(up_sig)
self._emit(down_sig)
# --- Spread Capture ---
if self.config.spread_capture.enabled:
for token_id, book in [(market.up_token_id, up_book), (market.down_token_id, down_book)]:
if book:
quote = await self.spread_capture.evaluate(
asset=symbol,
timeframe=timeframe,
token_id=token_id,
orderbook=book,
)
# Spread quotes handled differently — not emitted as signals
def update_balance(self, balance: float) -> None:
"""Update balance across all strategies."""
self.temporal_arb.update_balance(balance)
self.sum_to_one.update_balance(balance)
self.spread_capture.update_balance(balance)
def get_stats(self) -> dict:
"""Return combined statistics from all strategies."""
return {
"temporal_arb": {
"evaluations": self.temporal_arb.stats.total_evaluations,
"signals": self.temporal_arb.stats.signals_generated,
"avg_edge": round(self.temporal_arb.stats.avg_edge, 4),
"by_asset": dict(self.temporal_arb.stats.signals_by_asset),
},
"sum_to_one": {
"evaluations": self.sum_to_one.stats.total_evaluations,
"opportunities": self.sum_to_one.stats.opportunities_found,
"total_net_profit": round(self.sum_to_one.stats.total_net_profit, 4),
},
"spread_capture": {
"evaluations": self.spread_capture.stats.total_evaluations,
"quotes": self.spread_capture.stats.quotes_generated,
"avg_spread": round(self.spread_capture.stats.avg_spread, 4),
},
}

View File

@@ -0,0 +1,164 @@
"""Spread Capture / Market Making Strategy.
Places limit orders on both sides of the bid-ask spread to capture
the spread as profit. Requires careful inventory management.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Optional
import structlog
from src.config import FeesConfig, RiskConfig, SpreadCaptureConfig
from src.data.models import (
Asset,
Direction,
OrderBookSnapshot,
Signal,
Timeframe,
)
log = structlog.get_logger()
@dataclass
class SpreadQuote:
"""A pair of limit orders to capture the spread."""
asset: Asset
timeframe: Timeframe
token_id: str
bid_price: float
ask_price: float
size: int
spread: float
timestamp: float = field(default_factory=time.time)
@dataclass
class SpreadStats:
total_evaluations: int = 0
quotes_generated: int = 0
avg_spread: float = 0.0
_total_spread: float = 0.0
class SpreadCaptureStrategy:
"""Market making strategy that places limit orders on both sides
of the spread. Profits from the bid-ask spread minus fees.
Note: Requires orders to rest for >=3.5 seconds for maker rebate eligibility.
"""
def __init__(
self,
spread_config: SpreadCaptureConfig,
risk_config: RiskConfig,
fees_config: FeesConfig,
balance: float = 10000.0,
) -> None:
self.spread = spread_config
self.risk = risk_config
self.fees = fees_config
self.balance = balance
self.stats = SpreadStats()
# Active quotes per token to avoid over-quoting
self._active_quotes: dict[str, SpreadQuote] = {}
async def evaluate(
self,
asset: str,
timeframe: str,
token_id: str,
orderbook: OrderBookSnapshot,
) -> Optional[SpreadQuote]:
"""Evaluate whether to place spread-capture quotes on this token.
Returns a SpreadQuote if the spread is wide enough to be profitable.
"""
self.stats.total_evaluations += 1
if not self.spread.enabled:
return None
if orderbook.best_bid is None or orderbook.best_ask is None:
return None
current_spread = orderbook.best_ask - orderbook.best_bid
if current_spread < self.spread.spread_target:
return None # Spread too tight
# Place our orders inside the current spread
# Improve by 1 cent on each side
our_bid = orderbook.best_bid + 0.01
our_ask = orderbook.best_ask - 0.01
our_spread = our_ask - our_bid
if our_spread <= 0:
return None
# Check profitability after fees (maker gets rebate, but conservative estimate)
taker_fee = self.fees.fee_for_timeframe(timeframe)
# As maker, fee is lower or zero (rebate), but we budget for taker as worst case
estimated_profit_per_share = our_spread - (taker_fee * 2)
if estimated_profit_per_share <= 0:
return None
# Size: conservative for market making
max_dollar = min(
self.balance * 0.10, # 10% of balance per quote
self.risk.max_position_per_market_usd * 0.5,
)
size = int(max_dollar / our_ask)
if size <= 0:
return None
# Check if we already have an active quote
if token_id in self._active_quotes:
old = self._active_quotes[token_id]
# Only update if spread changed significantly
if abs(old.bid_price - our_bid) < 0.005 and abs(old.ask_price - our_ask) < 0.005:
return None
quote = SpreadQuote(
asset=Asset(asset),
timeframe=Timeframe(timeframe),
token_id=token_id,
bid_price=our_bid,
ask_price=our_ask,
size=size,
spread=our_spread,
)
self._active_quotes[token_id] = quote
self.stats.quotes_generated += 1
self.stats._total_spread += our_spread
self.stats.avg_spread = self.stats._total_spread / self.stats.quotes_generated
log.info(
"spread_quote",
asset=asset,
timeframe=timeframe,
bid=our_bid,
ask=our_ask,
spread=round(our_spread, 4),
size=size,
)
return quote
def remove_quote(self, token_id: str) -> None:
"""Remove an active quote (e.g., after fill or cancel)."""
self._active_quotes.pop(token_id, None)
def get_active_quotes(self) -> dict[str, SpreadQuote]:
return dict(self._active_quotes)
def update_balance(self, new_balance: float) -> None:
self.balance = new_balance

178
src/strategy/sum_to_one.py Normal file
View File

@@ -0,0 +1,178 @@
"""Sum-to-One Arbitrage Strategy.
When YES + NO best ask prices sum to less than $1.00 (minus fees),
buy both sides for a guaranteed risk-free profit regardless of outcome.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Optional
import structlog
from src.config import FeesConfig, RiskConfig, SumToOneConfig
from src.data.models import Asset, Direction, Signal, Timeframe
log = structlog.get_logger()
@dataclass
class SumToOneOpportunity:
"""Represents a sum-to-one arbitrage opportunity."""
asset: Asset
timeframe: Timeframe
up_ask: float
down_ask: float
total_cost: float # up_ask + down_ask
gross_profit: float # 1.0 - total_cost
fee: float # Total fees for both sides
net_profit: float # gross_profit - fee
up_token_id: str
down_token_id: str
timestamp: float = field(default_factory=time.time)
@dataclass
class SumToOneStats:
total_evaluations: int = 0
opportunities_found: int = 0
total_net_profit: float = 0.0
class SumToOneStrategy:
"""Sum-to-One arbitrage: buy both YES and NO when their combined
ask price is less than $1.00 after fees."""
def __init__(
self,
sto_config: SumToOneConfig,
risk_config: RiskConfig,
fees_config: FeesConfig,
balance: float = 10000.0,
) -> None:
self.sto = sto_config
self.risk = risk_config
self.fees = fees_config
self.balance = balance
self.stats = SumToOneStats()
async def evaluate(
self,
asset: str,
timeframe: str,
poly_up_ask: Optional[float],
poly_down_ask: Optional[float],
up_token_id: str,
down_token_id: str,
) -> Optional[SumToOneOpportunity]:
"""Check if a sum-to-one arb opportunity exists.
Returns an opportunity if YES ask + NO ask + fees < $1.00.
"""
self.stats.total_evaluations += 1
if poly_up_ask is None or poly_down_ask is None:
return None
if poly_up_ask <= 0 or poly_down_ask <= 0:
return None
if poly_up_ask >= 1.0 or poly_down_ask >= 1.0:
return None
total_cost = poly_up_ask + poly_down_ask
# Already sums to >= $1.00 — no arb
if total_cost >= 1.0:
return None
gross_profit = 1.0 - total_cost
# Fee: taker fee applies to BOTH purchases
taker_fee = self.fees.fee_for_timeframe(timeframe)
# Fee is calculated on the payout, not the cost
# For each side, fee = taker_fee * (payout - cost) when it wins
# Simplified: total fee ≈ taker_fee * 1.0 (since one side pays out $1)
total_fee = taker_fee * 1.0
net_profit = gross_profit - total_fee
if net_profit < self.sto.min_spread_after_fee:
return None
opp = SumToOneOpportunity(
asset=Asset(asset),
timeframe=Timeframe(timeframe),
up_ask=poly_up_ask,
down_ask=poly_down_ask,
total_cost=total_cost,
gross_profit=gross_profit,
fee=total_fee,
net_profit=net_profit,
up_token_id=up_token_id,
down_token_id=down_token_id,
)
self.stats.opportunities_found += 1
self.stats.total_net_profit += net_profit
log.info(
"sum_to_one_opportunity",
asset=asset,
timeframe=timeframe,
up_ask=poly_up_ask,
down_ask=poly_down_ask,
total_cost=round(total_cost, 4),
net_profit=round(net_profit, 4),
)
return opp
def calculate_size(self, opportunity: SumToOneOpportunity) -> int:
"""Calculate position size for a sum-to-one trade.
Since this is risk-free, we can size more aggressively,
limited only by available liquidity and max position.
"""
# Max dollar exposure per side
max_per_side = min(
self.balance * 0.5, # Don't commit more than 50% of balance
self.risk.max_position_per_market_usd,
)
# Shares are constrained by the more expensive side
max_price = max(opportunity.up_ask, opportunity.down_ask)
shares = int(max_per_side / max_price)
return max(0, shares)
def generate_signals(self, opportunity: SumToOneOpportunity) -> tuple[Signal, Signal]:
"""Generate a pair of BUY signals (one for UP, one for DOWN)."""
size = self.calculate_size(opportunity)
up_signal = Signal(
direction=Direction.UP,
asset=opportunity.asset,
timeframe=opportunity.timeframe,
token_id=opportunity.up_token_id,
price=opportunity.up_ask,
size=size,
edge=opportunity.net_profit,
estimated_prob=0.5, # Direction-agnostic
)
down_signal = Signal(
direction=Direction.DOWN,
asset=opportunity.asset,
timeframe=opportunity.timeframe,
token_id=opportunity.down_token_id,
price=opportunity.down_ask,
size=size,
edge=opportunity.net_profit,
estimated_prob=0.5,
)
return up_signal, down_signal
def update_balance(self, new_balance: float) -> None:
self.balance = new_balance

View File

@@ -0,0 +1,297 @@
"""Temporal Arbitrage Strategy — core strategy module.
Exploits the delay between CEX price movements and Polymarket oracle updates.
When Binance price confirms a direction but Polymarket odds haven't caught up,
buy the confirmed direction at a discount.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import Optional
import structlog
from src.config import FeesConfig, RiskConfig, TemporalArbConfig
from src.data.models import Asset, Direction, Signal, Timeframe
log = structlog.get_logger()
@dataclass
class StrategyStats:
"""Running statistics for the strategy."""
total_evaluations: int = 0
signals_generated: int = 0
signals_by_asset: dict[str, int] = field(default_factory=lambda: {"BTC": 0, "ETH": 0, "SOL": 0})
total_edge: float = 0.0
@property
def avg_edge(self) -> float:
return self.total_edge / self.signals_generated if self.signals_generated > 0 else 0.0
class TemporalArbStrategy:
"""Core temporal arbitrage strategy.
Monitors CEX price vs Polymarket odds and generates buy signals when
the CEX price has confirmed a direction but Polymarket hasn't adjusted.
"""
def __init__(
self,
arb_config: TemporalArbConfig,
risk_config: RiskConfig,
fees_config: FeesConfig,
balance: float = 10000.0,
) -> None:
self.arb = arb_config
self.risk = risk_config
self.fees = fees_config
self.balance = balance
self.stats = StrategyStats()
# ------------------------------------------------------------------
# Core evaluation
# ------------------------------------------------------------------
async def evaluate(
self,
symbol: str,
cex_price: float,
window_start_price: float,
window_end_time: float,
poly_up_ask: Optional[float],
poly_down_ask: Optional[float],
up_token_id: str,
down_token_id: str,
timeframe: str,
) -> Optional[Signal]:
"""Evaluate whether to enter a temporal arb position.
Called on every CEX price tick. Returns a Signal if conditions are met,
otherwise None.
"""
self.stats.total_evaluations += 1
if window_start_price <= 0:
return None
# 1. Price direction & magnitude
price_change_pct = (cex_price - window_start_price) / window_start_price * 100
if abs(price_change_pct) < self.arb.min_price_move_pct:
return None # Not enough movement to confirm direction
# 2. Direction
direction = Direction.UP if price_change_pct > 0 else Direction.DOWN
# 3. Time remaining
now = time.time()
time_remaining = window_end_time - now
if time_remaining < self.arb.exit_before_resolution_sec:
return None # Too close to resolution
# 4. Polymarket price
poly_price = poly_up_ask if direction == Direction.UP else poly_down_ask
if poly_price is None or poly_price <= 0 or poly_price >= 1.0:
return None
if poly_price > self.arb.max_poly_entry_price:
return None # Risk/reward insufficient
# 5. Probability estimation
total_window = self._total_window_seconds(timeframe)
estimated_prob = self.estimate_probability(
price_change_pct, time_remaining, total_window
)
# 6. Edge calculation (after fees)
taker_fee = self.fees.fee_for_timeframe(timeframe)
edge = estimated_prob - poly_price - taker_fee
if edge < self.arb.min_edge:
return None # Insufficient edge
# 7. Position sizing
asset = Asset(symbol)
size = self.calculate_kelly_size(
edge=edge,
price=poly_price,
balance=self.balance,
max_size=self.risk.max_position_per_market_usd,
)
if size <= 0:
return None
# 8. Build signal
token_id = up_token_id if direction == Direction.UP else down_token_id
tf = Timeframe(timeframe)
signal = Signal(
direction=direction,
asset=asset,
timeframe=tf,
token_id=token_id,
price=poly_price,
size=size,
edge=edge,
estimated_prob=estimated_prob,
)
# Update stats
self.stats.signals_generated += 1
self.stats.signals_by_asset[symbol] = self.stats.signals_by_asset.get(symbol, 0) + 1
self.stats.total_edge += edge
log.info(
"signal_generated",
asset=symbol,
direction=direction.value,
timeframe=timeframe,
price_change_pct=round(price_change_pct, 4),
poly_price=poly_price,
estimated_prob=round(estimated_prob, 4),
edge=round(edge, 4),
size=size,
time_remaining=round(time_remaining, 1),
)
return signal
# ------------------------------------------------------------------
# Probability model
# ------------------------------------------------------------------
def estimate_probability(
self,
price_change_pct: float,
time_remaining: float,
total_window_sec: float,
) -> float:
"""Estimate the true probability that the current direction holds at resolution.
Multi-factor model:
1. Base probability from price magnitude
2. Time decay: as window nears end, momentum is more confirmed
3. Volatility: larger moves are harder to reverse
"""
abs_change = abs(price_change_pct)
# Factor 1: Base probability from price magnitude
# 0.15% → ~70%, 0.3% → ~82%, 0.5% → ~90%, 1.0%+ → ~95%
base_prob = 0.55 + abs_change * 1.0 # 1.0 scaling factor
base_prob = min(base_prob, 0.95)
# Factor 2: Time decay — more time elapsed = more confirmation
# If 80% of window has passed and price is still in this direction,
# the probability of reversal is lower
elapsed_fraction = max(0, 1.0 - (time_remaining / total_window_sec))
# Sigmoid-like boost: ramps up in the last 40% of the window
time_factor = 1.0 + 0.08 * max(0, elapsed_fraction - 0.6) / 0.4
time_factor = min(time_factor, 1.08)
# Factor 3: Volatility / momentum strength
# Very large moves (>0.5%) get extra confidence
if abs_change > 0.5:
vol_boost = min(0.05, (abs_change - 0.5) * 0.1)
else:
vol_boost = 0.0
final_prob = base_prob * time_factor + vol_boost
return min(0.95, max(0.50, final_prob))
# ------------------------------------------------------------------
# Position sizing
# ------------------------------------------------------------------
def calculate_kelly_size(
self,
edge: float,
price: float,
balance: float,
max_size: float,
) -> int:
"""Kelly Criterion position sizing.
f* = (b*p - q) / b
where b = (1/price - 1), p = estimated_prob, q = 1-p
"""
if price <= 0 or price >= 1.0:
return 0
b = (1.0 / price) - 1.0 # Payout odds
p = edge + price # estimated_prob (edge = prob - price - fee)
q = 1.0 - p
if b <= 0:
return 0
kelly_fraction = (b * p - q) / b
kelly_fraction = max(0.0, min(kelly_fraction, self.risk.kelly_fraction_cap))
dollar_size = min(balance * kelly_fraction, max_size)
shares = int(dollar_size / price)
return max(0, shares)
# ------------------------------------------------------------------
# Exit logic
# ------------------------------------------------------------------
def should_exit_early(
self,
entry_direction: Direction,
entry_price: float,
current_poly_price: float,
cex_price: float,
window_start_price: float,
time_remaining: float,
) -> bool:
"""Determine if we should exit a position early.
Exit if:
1. Price has reversed and edge has disappeared
2. Polymarket price has risen enough to take profit
3. Very close to resolution with negative PnL trajectory
"""
if window_start_price <= 0:
return False
current_change_pct = (cex_price - window_start_price) / window_start_price * 100
# Direction reversed significantly
if entry_direction == Direction.UP and current_change_pct < -0.05:
log.warning("exit_signal_reversal", direction="UP", change_pct=round(current_change_pct, 4))
return True
if entry_direction == Direction.DOWN and current_change_pct > 0.05:
log.warning("exit_signal_reversal", direction="DOWN", change_pct=round(current_change_pct, 4))
return True
# Take profit: price has moved significantly in our favor
if current_poly_price > 0.90 and current_poly_price > entry_price * 1.3:
log.info("exit_signal_take_profit", current_price=current_poly_price, entry_price=entry_price)
return True
# Close to resolution with thin margin
if time_remaining < 10 and abs(current_change_pct) < 0.05:
log.warning("exit_signal_thin_margin", time_remaining=round(time_remaining, 1))
return True
return False
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _total_window_seconds(timeframe: str) -> float:
"""Return total seconds for a given timeframe."""
return {"5M": 300.0, "15M": 900.0}.get(timeframe, 300.0)
def update_balance(self, new_balance: float) -> None:
"""Update available balance for position sizing."""
self.balance = new_balance

14
src/utils/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""Utility modules for the Polymarket Arbitrage Bot."""
from .logger import get_logger, log_timing, log_trade, setup_logging
from .metrics import MetricsCollector
from .telegram import TelegramNotifier
__all__ = [
"get_logger",
"log_timing",
"log_trade",
"setup_logging",
"MetricsCollector",
"TelegramNotifier",
]

153
src/utils/logger.py Normal file
View File

@@ -0,0 +1,153 @@
"""Structured logging setup using structlog for the Polymarket Arbitrage Bot.
Provides consistent, structured logging across all modules with support
for dev-friendly console output and production JSON rendering.
"""
from __future__ import annotations
import logging
import sys
import time
from contextlib import contextmanager
from typing import Any, Generator
import structlog
def setup_logging(log_level: str = "INFO") -> None:
"""Configure structlog with processors, renderers, and stdlib integration.
Parameters
----------
log_level:
Root log level (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``).
Also accepts lowercase variants.
"""
level = getattr(logging, log_level.upper(), logging.INFO)
# Shared processors applied to every log entry
shared_processors: list[structlog.types.Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.CallsiteParameterAdder(
[
structlog.processors.CallsiteParameter.FILENAME,
structlog.processors.CallsiteParameter.FUNC_NAME,
structlog.processors.CallsiteParameter.LINENO,
]
),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
# Choose renderer based on whether we're attached to a terminal (dev) or
# running headless / in production.
if sys.stderr.isatty():
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(
colors=True,
)
else:
renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=[
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# Configure stdlib root logger so third-party libraries also go through
# structlog's formatting pipeline.
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(level)
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
"""Return a bound structlog logger for *name*.
Parameters
----------
name:
Typically ``__name__`` of the calling module.
"""
return structlog.get_logger(name)
def log_trade(logger: structlog.stdlib.BoundLogger, trade_data: dict[str, Any]) -> None:
"""Emit a structured trade event.
Extracts key fields from *trade_data* and logs them as a single
structured event for easy querying and dashboarding.
Parameters
----------
logger:
A bound structlog logger.
trade_data:
Dictionary with trade details (e.g. ``id``, ``asset``, ``direction``,
``entry_price``, ``fill_price``, ``size``, ``pnl``, ``status``).
"""
logger.info(
"trade_event",
trade_id=trade_data.get("id"),
asset=trade_data.get("asset"),
direction=trade_data.get("direction"),
token_id=trade_data.get("token_id"),
entry_price=trade_data.get("entry_price"),
fill_price=trade_data.get("fill_price"),
size=trade_data.get("size"),
fee=trade_data.get("fee"),
pnl=trade_data.get("pnl"),
status=trade_data.get("status"),
edge=trade_data.get("edge"),
)
@contextmanager
def log_timing(
logger: structlog.stdlib.BoundLogger,
operation: str,
) -> Generator[None, None, None]:
"""Context manager that logs the elapsed wall-clock time of *operation*.
Usage::
with log_timing(logger, "fetch_orderbook"):
await fetch_orderbook()
Parameters
----------
logger:
A bound structlog logger.
operation:
Human-readable label for the timed block.
"""
start = time.perf_counter()
logger.debug("timing_start", operation=operation)
try:
yield
finally:
elapsed_ms = (time.perf_counter() - start) * 1000.0
logger.info(
"timing_end",
operation=operation,
elapsed_ms=round(elapsed_ms, 2),
)

116
src/utils/metrics.py Normal file
View File

@@ -0,0 +1,116 @@
"""Metrics collector for the performance dashboard."""
from __future__ import annotations
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class TradeMetric:
"""Single trade metric for time-series tracking."""
timestamp: float
asset: str
direction: str
timeframe: str
entry_price: float
size: int
edge: float
pnl: Optional[float] = None
won: Optional[bool] = None
class MetricsCollector:
"""Collects and aggregates trading metrics for the dashboard.
Maintains rolling windows and aggregated stats for real-time display.
"""
def __init__(self, max_history: int = 10000) -> None:
self._trades: deque[TradeMetric] = deque(maxlen=max_history)
self._pnl_series: deque[tuple[float, float]] = deque(maxlen=max_history)
self._start_time = time.time()
# Running aggregates
self.total_volume: float = 0.0
self.total_fees: float = 0.0
def record_trade(self, metric: TradeMetric) -> None:
self._trades.append(metric)
self.total_volume += metric.entry_price * metric.size
def record_pnl(self, pnl: float) -> None:
self._pnl_series.append((time.time(), pnl))
def record_fee(self, fee: float) -> None:
self.total_fees += fee
# ------------------------------------------------------------------
# Aggregations
# ------------------------------------------------------------------
def get_recent_trades(self, n: int = 50) -> list[TradeMetric]:
return list(self._trades)[-n:]
def get_pnl_series(self) -> list[tuple[float, float]]:
return list(self._pnl_series)
def get_hourly_stats(self) -> dict:
"""Aggregate stats for the last hour."""
cutoff = time.time() - 3600
recent = [t for t in self._trades if t.timestamp > cutoff]
wins = sum(1 for t in recent if t.won is True)
losses = sum(1 for t in recent if t.won is False)
total_pnl = sum(t.pnl for t in recent if t.pnl is not None)
return {
"trades": len(recent),
"wins": wins,
"losses": losses,
"win_rate": wins / (wins + losses) * 100 if (wins + losses) > 0 else 0,
"pnl": round(total_pnl, 2),
}
def get_asset_breakdown(self) -> dict[str, dict]:
"""PnL and trade count per asset."""
breakdown: dict[str, dict] = {}
for t in self._trades:
if t.asset not in breakdown:
breakdown[t.asset] = {"trades": 0, "wins": 0, "pnl": 0.0}
breakdown[t.asset]["trades"] += 1
if t.won is True:
breakdown[t.asset]["wins"] += 1
if t.pnl is not None:
breakdown[t.asset]["pnl"] += t.pnl
for asset in breakdown:
total = breakdown[asset]["trades"]
wins = breakdown[asset]["wins"]
breakdown[asset]["win_rate"] = round(wins / total * 100, 1) if total > 0 else 0
breakdown[asset]["pnl"] = round(breakdown[asset]["pnl"], 2)
return breakdown
def get_uptime(self) -> float:
"""Return uptime in seconds."""
return time.time() - self._start_time
def get_summary(self) -> dict:
total_trades = len(self._trades)
wins = sum(1 for t in self._trades if t.won is True)
losses = sum(1 for t in self._trades if t.won is False)
total_pnl = sum(t.pnl for t in self._trades if t.pnl is not None)
return {
"total_trades": total_trades,
"wins": wins,
"losses": losses,
"win_rate": round(wins / (wins + losses) * 100, 1) if (wins + losses) > 0 else 0,
"total_pnl": round(total_pnl, 2),
"total_volume": round(self.total_volume, 2),
"total_fees": round(self.total_fees, 2),
"uptime_hours": round(self.get_uptime() / 3600, 2),
}

152
src/utils/telegram.py Normal file
View File

@@ -0,0 +1,152 @@
"""Telegram notification bot for trade alerts and daily summaries."""
from __future__ import annotations
import asyncio
from typing import Optional
import structlog
from src.config import NotificationConfig
log = structlog.get_logger()
class TelegramNotifier:
"""Sends trading notifications via Telegram Bot API.
Uses aiohttp directly (no python-telegram-bot dependency for async)
for lightweight non-blocking notification delivery.
"""
BASE_URL = "https://api.telegram.org/bot{token}/sendMessage"
def __init__(self, config: NotificationConfig) -> None:
self.config = config
self._enabled = config.telegram_enabled and bool(config.telegram_token) and bool(config.telegram_chat_id)
self._session = None
if not self._enabled:
log.info("telegram_disabled", reason="missing token or chat_id")
async def _get_session(self):
if self._session is None:
import aiohttp
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session:
await self._session.close()
self._session = None
async def send(self, message: str, parse_mode: str = "HTML") -> bool:
"""Send a message to the configured Telegram chat."""
if not self._enabled:
return False
url = self.BASE_URL.format(token=self.config.telegram_token)
payload = {
"chat_id": self.config.telegram_chat_id,
"text": message,
"parse_mode": parse_mode,
}
try:
session = await self._get_session()
async with session.post(url, json=payload, timeout=10) as resp:
if resp.status == 200:
return True
else:
body = await resp.text()
log.warning("telegram_send_failed", status=resp.status, body=body[:200])
return False
except Exception:
log.exception("telegram_send_error")
return False
# ------------------------------------------------------------------
# Convenience methods
# ------------------------------------------------------------------
async def notify_trade(
self,
asset: str,
direction: str,
timeframe: str,
price: float,
size: int,
edge: float,
) -> None:
"""Send a trade notification."""
if not self.config.notify_on_trade:
return
msg = (
f"🔔 <b>Trade Signal</b>\n"
f"Asset: <b>{asset}</b> | {direction}\n"
f"Timeframe: {timeframe}\n"
f"Price: {price:.2f} | Size: {size}\n"
f"Edge: {edge:.2%}"
)
await self.send(msg)
async def notify_fill(
self,
asset: str,
direction: str,
fill_price: float,
fill_size: int,
trade_id: str,
) -> None:
"""Send a fill notification."""
if not self.config.notify_on_trade:
return
msg = (
f"✅ <b>Order Filled</b>\n"
f"Asset: {asset} | {direction}\n"
f"Fill: {fill_price:.2f} × {fill_size}\n"
f"Trade ID: <code>{trade_id}</code>"
)
await self.send(msg)
async def notify_daily_summary(
self,
date: str,
total_trades: int,
wins: int,
losses: int,
pnl: float,
fees: float,
volume: float,
) -> None:
"""Send daily summary."""
if not self.config.notify_on_daily_summary:
return
win_rate = wins / total_trades * 100 if total_trades > 0 else 0
emoji = "📈" if pnl >= 0 else "📉"
msg = (
f"{emoji} <b>Daily Summary — {date}</b>\n"
f"Trades: {total_trades} (W:{wins} / L:{losses})\n"
f"Win Rate: {win_rate:.1f}%\n"
f"PnL: <b>${pnl:+.2f}</b>\n"
f"Fees: ${fees:.2f}\n"
f"Volume: ${volume:,.0f}"
)
await self.send(msg)
async def notify_error(self, error_msg: str) -> None:
"""Send an error alert."""
if not self.config.notify_on_error:
return
msg = f"🚨 <b>Error Alert</b>\n<code>{error_msg[:500]}</code>"
await self.send(msg)
async def notify_halt(self, reason: str) -> None:
"""Send a trading halt alert."""
msg = f"🛑 <b>TRADING HALTED</b>\nReason: {reason}"
await self.send(msg)

0
tests/__init__.py Normal file
View File

251
tests/test_execution.py Normal file
View File

@@ -0,0 +1,251 @@
"""Tests for execution, position tracking, and risk management."""
from __future__ import annotations
import time
import pytest
from src.config import Config, RiskConfig, FeesConfig
from src.data.models import (
Asset,
Direction,
Position,
Signal,
Timeframe,
Trade,
TradeStatus,
)
from src.data.db import TradeDB
from src.execution.position_tracker import PositionTracker
from src.risk.risk_manager import RiskManager
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def config():
return Config()
@pytest.fixture
def position_tracker(config):
return PositionTracker(config)
@pytest.fixture
def trade_db():
return TradeDB(db_path=":memory:")
def _make_signal(
asset="BTC", direction=Direction.UP, price=0.50, size=100, edge=0.10
) -> Signal:
return Signal(
direction=direction,
asset=Asset(asset),
timeframe=Timeframe.FIVE_MIN,
token_id=f"token_{asset}_{direction.value}",
price=price,
size=size,
edge=edge,
estimated_prob=price + edge + 0.0156,
)
def _make_trade(
signal=None, fill_price=None, fill_size=None, status=TradeStatus.FILLED
) -> Trade:
if signal is None:
signal = _make_signal()
return Trade(
id="test_trade_1",
signal=signal,
order_id="order_1",
status=status,
fill_price=fill_price or signal.price,
fill_size=fill_size or signal.size,
fee=0.78,
)
# ---------------------------------------------------------------------------
# PositionTracker tests
# ---------------------------------------------------------------------------
class TestPositionTracker:
def test_open_position(self, position_tracker):
trade = _make_trade()
pos = position_tracker.open_position(trade)
assert pos.size == 100
assert pos.avg_price == 0.50
assert pos.asset == Asset.BTC
assert position_tracker.position_count == 1
def test_close_position_win(self, position_tracker):
trade = _make_trade()
position_tracker.open_position(trade)
pnl = position_tracker.close_position(trade.signal.token_id, 1.0)
assert pnl is not None
assert pnl == pytest.approx(50.0) # (1.0 - 0.50) * 100
assert position_tracker.position_count == 0
assert position_tracker.total_realized_pnl == pytest.approx(50.0)
def test_close_position_loss(self, position_tracker):
trade = _make_trade()
position_tracker.open_position(trade)
pnl = position_tracker.close_position(trade.signal.token_id, 0.0)
assert pnl is not None
assert pnl == pytest.approx(-50.0) # (0 - 0.50) * 100
assert position_tracker.total_realized_pnl == pytest.approx(-50.0)
def test_add_to_existing_position(self, position_tracker):
trade1 = _make_trade(signal=_make_signal(price=0.50, size=100))
trade2 = _make_trade(signal=_make_signal(price=0.60, size=50))
trade2.fill_price = 0.60
trade2.fill_size = 50
position_tracker.open_position(trade1)
pos = position_tracker.open_position(trade2)
assert pos.size == 150
expected_avg = (0.50 * 100 + 0.60 * 50) / 150
assert pos.avg_price == pytest.approx(expected_avg, abs=0.001)
def test_total_exposure(self, position_tracker):
trade = _make_trade()
position_tracker.open_position(trade)
assert position_tracker.total_exposure == pytest.approx(50.0)
def test_mark_to_market(self, position_tracker):
trade = _make_trade()
position_tracker.open_position(trade)
position_tracker.update_mark(trade.signal.token_id, 0.70)
pos = position_tracker.get_position(trade.signal.token_id)
assert pos.current_value == pytest.approx(70.0)
assert pos.unrealized_pnl == pytest.approx(20.0)
def test_win_rate(self, position_tracker):
# Open and close two positions, one win one loss
trade1 = _make_trade(signal=_make_signal(asset="BTC"))
trade1.id = "t1"
trade2 = _make_trade(signal=_make_signal(asset="ETH"))
trade2.id = "t2"
trade2.signal = _make_signal(asset="ETH")
position_tracker.open_position(trade1)
position_tracker.open_position(trade2)
position_tracker.close_position(trade1.signal.token_id, 1.0) # Win
position_tracker.close_position(trade2.signal.token_id, 0.0) # Loss
assert position_tracker.win_rate == pytest.approx(0.5)
def test_get_summary(self, position_tracker):
summary = position_tracker.get_summary()
assert "positions" in summary
assert "total_pnl" in summary
assert "win_rate" in summary
# ---------------------------------------------------------------------------
# RiskManager tests
# ---------------------------------------------------------------------------
class TestRiskManager:
def test_can_open_within_limits(self, config, position_tracker, trade_db):
risk_mgr = RiskManager(config.risk, position_tracker, trade_db)
assert risk_mgr.can_open_position(1000) is True
def test_rejects_over_exposure(self, position_tracker, trade_db):
risk_config = RiskConfig(max_total_exposure_usd=100)
risk_mgr = RiskManager(risk_config, position_tracker, trade_db)
# Fill up exposure
trade = _make_trade(signal=_make_signal(price=0.50, size=250))
position_tracker.open_position(trade)
# Exposure = 125, limit = 100
assert risk_mgr.check_all() is False
def test_rejects_over_position_count(self, position_tracker, trade_db):
risk_config = RiskConfig(max_concurrent_positions=1)
risk_mgr = RiskManager(risk_config, position_tracker, trade_db)
trade = _make_trade()
position_tracker.open_position(trade)
assert risk_mgr.can_open_position(50) is False
def test_halt_and_resume(self, config, position_tracker, trade_db):
risk_mgr = RiskManager(config.risk, position_tracker, trade_db)
assert risk_mgr.is_halted is False
risk_mgr._halt(halt_event="test", reason="manual test halt")
assert risk_mgr.is_halted is True
assert risk_mgr.check_all() is False
risk_mgr.resume()
assert risk_mgr.is_halted is False
def test_risk_summary(self, config, position_tracker, trade_db):
risk_mgr = RiskManager(config.risk, position_tracker, trade_db)
summary = risk_mgr.get_risk_summary()
assert "halted" in summary
assert "daily_pnl" in summary
assert "total_exposure" in summary
# ---------------------------------------------------------------------------
# TradeDB tests
# ---------------------------------------------------------------------------
class TestTradeDB:
def test_log_and_query_trade(self, trade_db):
trade = _make_trade()
trade_db.log_trade(trade)
count = trade_db.get_today_trade_count()
assert count == 1
def test_update_trade(self, trade_db):
trade = _make_trade()
trade_db.log_trade(trade)
trade_db.update_trade(trade.id, pnl=50.0, status="FILLED")
# No exception = success
def test_log_balance(self, trade_db):
trade_db.log_balance(10000, 0.0, event="start")
trade_db.log_balance(10050, 50.0, event="update")
bal = trade_db.get_latest_balance()
assert bal == pytest.approx(10050.0)
def test_today_pnl(self, trade_db):
trade = _make_trade()
trade.pnl = 25.0
trade_db.log_trade(trade)
pnl = trade_db.get_today_pnl()
assert pnl == pytest.approx(25.0)
def test_daily_summary(self, trade_db):
trade = _make_trade()
trade.pnl = 30.0
trade_db.log_trade(trade)
today = time.strftime("%Y-%m-%d", time.gmtime())
summary = trade_db.get_daily_summary(today)
assert summary["total_trades"] == 1
assert summary["total_pnl"] == pytest.approx(30.0)

336
tests/test_strategy.py Normal file
View File

@@ -0,0 +1,336 @@
"""Tests for temporal arbitrage strategy and related components."""
from __future__ import annotations
import asyncio
import time
import pytest
from src.config import (
FeesConfig,
RiskConfig,
TemporalArbConfig,
SumToOneConfig,
SpreadCaptureConfig,
)
from src.data.models import Asset, Direction, Timeframe, Signal, OrderBookLevel, OrderBookSnapshot
from src.strategy.temporal_arb import TemporalArbStrategy
from src.strategy.sum_to_one import SumToOneStrategy
from src.risk.fee_calculator import FeeCalculator
from src.risk.position_sizer import PositionSizer
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def arb_config():
return TemporalArbConfig(
enabled=True,
min_price_move_pct=0.03,
max_poly_entry_price=0.65,
min_edge=0.05,
exit_before_resolution_sec=5,
)
@pytest.fixture
def risk_config():
return RiskConfig(
max_position_per_market_usd=5000,
max_total_exposure_usd=20000,
max_daily_loss_usd=2000,
kelly_fraction_cap=0.25,
max_concurrent_positions=6,
)
@pytest.fixture
def fees_config():
return FeesConfig(taker_fee_5m=0.0156, taker_fee_15m=0.03)
@pytest.fixture
def strategy(arb_config, risk_config, fees_config):
return TemporalArbStrategy(
arb_config=arb_config,
risk_config=risk_config,
fees_config=fees_config,
balance=10000.0,
)
@pytest.fixture
def fee_calc(fees_config):
return FeeCalculator(fees_config)
# ---------------------------------------------------------------------------
# TemporalArbStrategy tests
# ---------------------------------------------------------------------------
class TestTemporalArbStrategy:
def test_no_signal_below_min_move(self, strategy):
"""No signal when price move is too small."""
result = asyncio.run(strategy.evaluate(
symbol="BTC",
cex_price=84010,
window_start_price=84000,
window_end_time=time.time() + 200,
poly_up_ask=0.50,
poly_down_ask=0.50,
up_token_id="up_1",
down_token_id="down_1",
timeframe="5M",
))
assert result is None
def test_signal_generated_on_sufficient_move(self, strategy):
"""Signal generated when price move and edge are sufficient."""
result = asyncio.run(strategy.evaluate(
symbol="BTC",
cex_price=84300, # +0.36% move
window_start_price=84000,
window_end_time=time.time() + 200,
poly_up_ask=0.50,
poly_down_ask=0.50,
up_token_id="up_1",
down_token_id="down_1",
timeframe="5M",
))
assert result is not None
assert result.direction == Direction.UP
assert result.asset == Asset.BTC
assert result.price == 0.50
assert result.edge > 0
assert result.size > 0
def test_down_signal(self, strategy):
"""Signal generated for DOWN direction."""
result = asyncio.run(strategy.evaluate(
symbol="ETH",
cex_price=2290, # -0.43% from 2300
window_start_price=2300,
window_end_time=time.time() + 200,
poly_up_ask=0.50,
poly_down_ask=0.48,
up_token_id="up_1",
down_token_id="down_1",
timeframe="15M",
))
assert result is not None
assert result.direction == Direction.DOWN
assert result.asset == Asset.ETH
def test_no_signal_when_poly_price_too_high(self, strategy):
"""No signal when Polymarket price exceeds max entry price."""
result = asyncio.run(strategy.evaluate(
symbol="BTC",
cex_price=84500,
window_start_price=84000,
window_end_time=time.time() + 200,
poly_up_ask=0.70, # Above max_poly_entry_price=0.65
poly_down_ask=0.30,
up_token_id="up_1",
down_token_id="down_1",
timeframe="5M",
))
assert result is None
def test_no_signal_too_close_to_resolution(self, strategy):
"""No signal when window is about to expire."""
result = asyncio.run(strategy.evaluate(
symbol="BTC",
cex_price=84500,
window_start_price=84000,
window_end_time=time.time() + 3, # Only 3 seconds left
poly_up_ask=0.50,
poly_down_ask=0.50,
up_token_id="up_1",
down_token_id="down_1",
timeframe="5M",
))
assert result is None
def test_probability_estimation(self, strategy):
"""Probability increases with price magnitude."""
prob_small = strategy.estimate_probability(0.1, 200, 300)
prob_medium = strategy.estimate_probability(0.3, 200, 300)
prob_large = strategy.estimate_probability(0.5, 200, 300)
assert prob_small < prob_medium < prob_large
assert prob_small >= 0.50
assert prob_large <= 0.95
def test_kelly_sizing_positive_edge(self, strategy):
"""Kelly sizing returns positive size for positive edge."""
size = strategy.calculate_kelly_size(
edge=0.10, price=0.50, balance=10000, max_size=5000
)
assert size > 0
assert size * 0.50 <= 5000 # Within max size
def test_kelly_sizing_zero_edge(self, strategy):
"""Kelly sizing returns 0 for zero or negative edge."""
size = strategy.calculate_kelly_size(
edge=-0.05, price=0.50, balance=10000, max_size=5000
)
assert size == 0
def test_should_exit_early_reversal(self, strategy):
"""Exit signal on price reversal."""
should_exit = strategy.should_exit_early(
entry_direction=Direction.UP,
entry_price=0.50,
current_poly_price=0.45,
cex_price=83800, # Price reversed down
window_start_price=84000,
time_remaining=100,
)
assert should_exit is True
def test_should_not_exit_when_direction_holds(self, strategy):
"""No exit when direction still holds."""
should_exit = strategy.should_exit_early(
entry_direction=Direction.UP,
entry_price=0.50,
current_poly_price=0.60,
cex_price=84200,
window_start_price=84000,
time_remaining=100,
)
assert should_exit is False
# ---------------------------------------------------------------------------
# FeeCalculator tests
# ---------------------------------------------------------------------------
class TestFeeCalculator:
def test_taker_fee_5m(self, fee_calc):
"""5M taker fee calculation."""
fee = fee_calc.taker_fee("5M", 0.50, 100)
# Profit = 100*1.0 - 100*0.50 = 50, fee = 50 * 0.0156 = 0.78
assert abs(fee - 0.78) < 0.01
def test_taker_fee_15m(self, fee_calc):
"""15M taker fee is higher."""
fee_5m = fee_calc.taker_fee("5M", 0.50, 100)
fee_15m = fee_calc.taker_fee("15M", 0.50, 100)
assert fee_15m > fee_5m
def test_net_payout_win(self, fee_calc):
"""Net payout on a win."""
payout = fee_calc.net_payout("5M", 0.50, 100, won=True)
assert payout > 0
assert payout < 50 # Less than gross profit due to fees
def test_net_payout_loss(self, fee_calc):
"""Net payout on a loss."""
payout = fee_calc.net_payout("5M", 0.50, 100, won=False)
assert payout == -50.0 # Total loss of cost basis
def test_breakeven_price(self, fee_calc):
"""Breakeven probability is higher than entry price."""
be = fee_calc.breakeven_price("5M", 0.50)
assert be > 0.50
assert be < 1.0
def test_expected_value_positive_edge(self, fee_calc):
"""EV is positive when estimated prob exceeds breakeven."""
ev = fee_calc.expected_value("5M", 0.50, 0.70, 100)
assert ev > 0
def test_expected_value_negative_edge(self, fee_calc):
"""EV is negative when estimated prob is below breakeven."""
ev = fee_calc.expected_value("5M", 0.50, 0.50, 100)
assert ev < 0
# ---------------------------------------------------------------------------
# Data models tests
# ---------------------------------------------------------------------------
class TestModels:
def test_orderbook_snapshot(self):
book = OrderBookSnapshot(
token_id="test",
bids=[OrderBookLevel(0.48, 100), OrderBookLevel(0.47, 200)],
asks=[OrderBookLevel(0.52, 100), OrderBookLevel(0.53, 200)],
)
assert book.best_bid == 0.48
assert book.best_ask == 0.52
assert book.spread == pytest.approx(0.04)
def test_empty_orderbook(self):
book = OrderBookSnapshot(token_id="test")
assert book.best_bid is None
assert book.best_ask is None
assert book.spread is None
def test_signal_timestamp(self):
sig = Signal(
direction=Direction.UP,
asset=Asset.BTC,
timeframe=Timeframe.FIVE_MIN,
token_id="test",
price=0.50,
size=100,
edge=0.10,
estimated_prob=0.65,
)
assert sig.timestamp > 0
assert sig.price == 0.50
# ---------------------------------------------------------------------------
# WindowTracker tests
# ---------------------------------------------------------------------------
class TestWindowTracker:
def test_window_creation_on_first_tick(self):
from src.market.window_tracker import WindowTracker
tracker = WindowTracker(
assets=[Asset.BTC],
timeframes=[Timeframe.FIVE_MIN],
)
changed = []
tracker.on_window_change(lambda w: changed.append(w))
tracker.update_price("BTC", 84000.0, time.time())
assert len(changed) == 1
assert changed[0].asset == Asset.BTC
assert changed[0].start_price == 84000.0
def test_get_window(self):
from src.market.window_tracker import WindowTracker
tracker = WindowTracker(
assets=[Asset.BTC],
timeframes=[Timeframe.FIVE_MIN],
)
tracker.update_price("BTC", 84000.0, time.time())
window = tracker.get_window("BTC", "5M")
assert window is not None
assert window.start_price == 84000.0
def test_price_update_within_window(self):
from src.market.window_tracker import WindowTracker
tracker = WindowTracker(
assets=[Asset.BTC],
timeframes=[Timeframe.FIVE_MIN],
)
now = time.time()
tracker.update_price("BTC", 84000.0, now)
tracker.update_price("BTC", 84100.0, now + 1)
window = tracker.get_window("BTC", "5M")
assert window.start_price == 84000.0
assert window.current_price == 84100.0