update 03-22 09:28
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
.env.local
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.db
|
||||
*.sqlite
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.streamlit/
|
||||
554
backtest.py
Normal file
554
backtest.py
Normal 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
52
config.toml
Normal 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
935
dashboard/app.py
Normal 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 • {mode_label} MODE</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; align-items:center">
|
||||
<span class="{status_class}">{status_text}</span>
|
||||
<span style="color:#64748b; font-size:0.78rem; font-family:'Inter',sans-serif">
|
||||
{datetime.now(timezone.utc).strftime('%H:%M:%S UTC')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 1: Key Metrics
|
||||
# ==================================================================
|
||||
|
||||
if not balance_df.empty:
|
||||
latest_bal = balance_df.iloc[-1]
|
||||
current_balance = latest_bal["balance"]
|
||||
current_pnl = latest_bal["pnl"]
|
||||
starting_balance = balance_df.iloc[0]["balance"]
|
||||
pnl_pct = (current_pnl / starting_balance * 100) if starting_balance > 0 else 0
|
||||
|
||||
total_trades = len(trades_df) if not trades_df.empty else 0
|
||||
# Only count resolved trades for win/loss (exclude pnl=0 which are still open)
|
||||
resolved_df = trades_df[trades_df["pnl"] != 0] if not trades_df.empty and "pnl" in trades_df.columns else pd.DataFrame()
|
||||
open_df = trades_df[trades_df["pnl"] == 0] if not trades_df.empty and "pnl" in trades_df.columns else pd.DataFrame()
|
||||
wins = len(resolved_df[resolved_df["pnl"] > 0]) if not resolved_df.empty else 0
|
||||
losses = len(resolved_df[resolved_df["pnl"] < 0]) if not resolved_df.empty else 0
|
||||
resolved_count = wins + losses
|
||||
win_rate = wins / resolved_count * 100 if resolved_count > 0 else 0
|
||||
total_fees = trades_df["fee"].sum() if not trades_df.empty and "fee" in trades_df.columns else 0
|
||||
avg_edge = trades_df["signal_edge"].mean() * 100 if not trades_df.empty and "signal_edge" in trades_df.columns else 0
|
||||
# Total bet volume (cost basis = fill_price * size)
|
||||
total_bet = (trades_df["fill_price"] * trades_df["size"]).sum() if not trades_df.empty and "fill_price" in trades_df.columns else 0
|
||||
# Active position exposure
|
||||
active_exposure = (open_df["fill_price"] * open_df["size"]).sum() if not open_df.empty else 0
|
||||
|
||||
pnl_color = "green" if current_pnl >= 0 else "red"
|
||||
pnl_sign = "+" if current_pnl >= 0 else ""
|
||||
|
||||
c1, c2, c3, c4, c5, c6 = st.columns(6)
|
||||
|
||||
with c1:
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-blue">
|
||||
<div class="metric-label">Balance</div>
|
||||
<div class="metric-value white">${current_balance:,.2f}</div>
|
||||
<div class="metric-sub">Start: ${starting_balance:,.2f}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with c2:
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-{'green' if current_pnl >= 0 else 'red'}">
|
||||
<div class="metric-label">Total PnL</div>
|
||||
<div class="metric-value {pnl_color}">{pnl_sign}${current_pnl:,.2f}</div>
|
||||
<div class="metric-sub">{pnl_sign}{pnl_pct:.2f}% return</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with c3:
|
||||
wr_color = "green" if win_rate >= 50 else "amber" if win_rate >= 40 else "red"
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-purple">
|
||||
<div class="metric-label">Win Rate</div>
|
||||
<div class="metric-value {wr_color}">{win_rate:.1f}%</div>
|
||||
<div class="metric-sub">{wins}W / {losses}L ({resolved_count} resolved)</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with c4:
|
||||
avg_bet = f"${total_bet/total_trades:,.0f}" if total_trades > 0 else "$0"
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-cyan">
|
||||
<div class="metric-label">Total Bets</div>
|
||||
<div class="metric-value white">${total_bet:,.0f}</div>
|
||||
<div class="metric-sub">{total_trades} trades • avg {avg_bet}/trade</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with c5:
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-amber">
|
||||
<div class="metric-label">Active Exposure</div>
|
||||
<div class="metric-value amber">${active_exposure:,.0f}</div>
|
||||
<div class="metric-sub">{len(open_df)} open positions</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with c6:
|
||||
# PnL per resolved trade
|
||||
pnl_per_trade = current_pnl / resolved_count if resolved_count > 0 else 0
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-blue">
|
||||
<div class="metric-label">PnL / Trade</div>
|
||||
<div class="metric-value {'green' if pnl_per_trade >= 0 else 'red'}">{'+' if pnl_per_trade >= 0 else ''}${pnl_per_trade:.2f}</div>
|
||||
<div class="metric-sub">Avg edge: {avg_edge:.1f}%</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
else:
|
||||
st.markdown("""
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<div class="empty-title">Awaiting Balance Data</div>
|
||||
<div class="empty-desc">Bot is initializing. Balance snapshots will appear once trading begins.</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 2: Live Prices + Connections
|
||||
# ==================================================================
|
||||
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon blue">📡</div>
|
||||
Market Data & Connections
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
latest_prices = {}
|
||||
if not windows_df.empty:
|
||||
for asset in ["BTC", "ETH", "SOL"]:
|
||||
asset_rows = windows_df[windows_df["asset"] == asset]
|
||||
if not asset_rows.empty:
|
||||
row = asset_rows.iloc[0]
|
||||
latest_prices[asset] = {
|
||||
"price": row.get("end_price", row.get("start_price", 0)),
|
||||
"start": row.get("start_price", 0),
|
||||
"change_pct": row.get("price_change_pct", 0),
|
||||
"updated": row.get("created_at", 0),
|
||||
}
|
||||
|
||||
col_conn, col_btc, col_eth, col_sol = st.columns([1.3, 1, 1, 1])
|
||||
|
||||
with col_conn:
|
||||
binance_ok = has_recent # based on balance_history (30s interval)
|
||||
poly_ok = not windows_df.empty and windows_df.iloc[0].get("market_condition_id") is not None
|
||||
last_update = time_ago(windows_df.iloc[0]["created_at"]) if not windows_df.empty else "N/A"
|
||||
|
||||
st.markdown(f"""
|
||||
<div class="price-card" style="text-align:left; padding:1rem 1.2rem">
|
||||
<div class="conn-item">
|
||||
<div class="conn-dot {'on' if binance_ok else 'warn'}"></div>
|
||||
<span class="conn-name">Binance WebSocket</span>
|
||||
<span class="conn-status">{'Live' if binance_ok else 'Reconnecting'}</span>
|
||||
</div>
|
||||
<div class="conn-item">
|
||||
<div class="conn-dot {'on' if poly_ok else 'warn'}"></div>
|
||||
<span class="conn-name">Polymarket CLOB</span>
|
||||
<span class="conn-status">{'Active' if poly_ok else 'Scanning'}</span>
|
||||
</div>
|
||||
<div class="conn-item">
|
||||
<div class="conn-dot {'on' if not oracle_df.empty else 'warn'}"></div>
|
||||
<span class="conn-name">Chainlink Oracle</span>
|
||||
<span class="conn-status">{'Polling' if not oracle_df.empty else 'Waiting'}</span>
|
||||
</div>
|
||||
<div class="conn-item">
|
||||
<div class="conn-dot on"></div>
|
||||
<span class="conn-name">SQLite Database</span>
|
||||
<span class="conn-status">{len(windows_df)} snapshots</span>
|
||||
</div>
|
||||
<div style="margin-top:8px; font-size:0.75rem; color:#475569; font-family:'Inter',sans-serif">
|
||||
Last update: {last_update}
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
asset_icons = {"BTC": "₿", "ETH": "Ξ", "SOL": "◎"}
|
||||
asset_colors = {"BTC": "#f7931a", "ETH": "#627eea", "SOL": "#9945ff"}
|
||||
|
||||
for col, asset in [(col_btc, "BTC"), (col_eth, "ETH"), (col_sol, "SOL")]:
|
||||
with col:
|
||||
info = latest_prices.get(asset, {})
|
||||
price = info.get("price", 0)
|
||||
change = info.get("change_pct", 0)
|
||||
color = "green" if change >= 0 else "red"
|
||||
arrow = "▲" if change > 0 else "▼" if change < 0 else "—"
|
||||
st.markdown(f"""
|
||||
<div class="price-card">
|
||||
<div class="price-asset">{asset_icons[asset]} {asset}/USDT</div>
|
||||
<div class="price-value">{fmt_price(price)}</div>
|
||||
<div class="price-change {color}">{arrow} {change:+.4f}%</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 2b: Chainlink Oracle vs Binance
|
||||
# ==================================================================
|
||||
|
||||
if not oracle_df.empty:
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon purple">🔗</div>
|
||||
Chainlink Oracle vs Binance (Arb Gap)
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Latest oracle data per asset
|
||||
o_cols = st.columns(3)
|
||||
for i, asset in enumerate(["BTC", "ETH", "SOL"]):
|
||||
asset_oracle = oracle_df[oracle_df["asset"] == asset]
|
||||
if asset_oracle.empty:
|
||||
continue
|
||||
latest = asset_oracle.iloc[0]
|
||||
o_price = latest["oracle_price"]
|
||||
c_price = latest["cex_price"]
|
||||
dev = latest["deviation_pct"]
|
||||
lag = latest["oracle_lag_sec"]
|
||||
|
||||
dev_color = "amber" if abs(dev) > 0.1 else "green" if abs(dev) < 0.05 else "cyan"
|
||||
dev_sign = "+" if dev >= 0 else ""
|
||||
gap_label = "GAP!" if abs(dev) > 0.1 else "OK"
|
||||
gap_color = "#fbbf24" if abs(dev) > 0.1 else "#4ade80"
|
||||
|
||||
with o_cols[i]:
|
||||
st.markdown(f"""
|
||||
<div class="metric-card accent-purple">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||||
<div class="metric-label">{asset} Oracle Gap</div>
|
||||
<span style="color:{gap_color}; font-size:0.7rem; font-weight:700; font-family:'Inter',sans-serif">{gap_label}</span>
|
||||
</div>
|
||||
<div class="metric-value {dev_color}">{dev_sign}{dev:.4f}%</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-top:8px">
|
||||
<div>
|
||||
<div style="font-size:0.65rem; color:#475569; font-family:'Inter',sans-serif">CHAINLINK</div>
|
||||
<div style="font-family:'JetBrains Mono',monospace; font-size:0.85rem; color:#a78bfa">{fmt_price(o_price)}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:0.65rem; color:#475569; font-family:'Inter',sans-serif">BINANCE</div>
|
||||
<div style="font-family:'JetBrains Mono',monospace; font-size:0.85rem; color:#60a5fa">{fmt_price(c_price)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-sub">Oracle lag: {lag:.0f}s</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Deviation over time chart
|
||||
if len(oracle_df) > 3:
|
||||
st.markdown("**Oracle Deviation Over Time (%)**")
|
||||
chart_oracle = oracle_df.copy()
|
||||
chart_oracle["time"] = pd.to_datetime(chart_oracle["timestamp"], unit="s")
|
||||
# Pivot by asset
|
||||
pivot = chart_oracle.pivot_table(index="time", columns="asset", values="deviation_pct", aggfunc="last")
|
||||
pivot = pivot.sort_index()
|
||||
if not pivot.empty:
|
||||
st.line_chart(pivot, color=["#f7931a", "#627eea", "#9945ff"])
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 3: Window Tracker
|
||||
# ==================================================================
|
||||
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon green">⏱</div>
|
||||
Active Windows
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
if not windows_df.empty:
|
||||
seen = set()
|
||||
window_rows = []
|
||||
for _, row in windows_df.iterrows():
|
||||
key = f"{row['asset']}_{row['timeframe']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
window_rows.append(row)
|
||||
if len(seen) >= 6:
|
||||
break
|
||||
|
||||
w_cols = st.columns(3)
|
||||
|
||||
for i, row in enumerate(window_rows):
|
||||
asset = row["asset"]
|
||||
tf = row["timeframe"]
|
||||
start_p = row.get("start_price", 0)
|
||||
end_p = row.get("end_price", start_p)
|
||||
change = row.get("price_change_pct", 0)
|
||||
w_start = row.get("window_start", 0)
|
||||
w_end = row.get("window_end", 0)
|
||||
remaining = max(0, w_end - time.time())
|
||||
|
||||
color = "green" if change >= 0 else "red"
|
||||
direction = "▲ UP" if change > 0 else "▼ DOWN" if change < 0 else "— FLAT"
|
||||
badge_class = "badge-5m" if tf == "5M" else "badge-15m"
|
||||
|
||||
total_window = w_end - w_start
|
||||
elapsed_frac = max(0, min(1, 1 - (remaining / total_window))) if total_window > 0 else 0
|
||||
bar_width = int(elapsed_frac * 100)
|
||||
bar_color = "#4ade80" if change >= 0 else "#f87171"
|
||||
|
||||
with w_cols[i % 3]:
|
||||
st.markdown(f"""
|
||||
<div class="window-card">
|
||||
<div class="window-header">
|
||||
<span style="font-family:'JetBrains Mono',monospace; font-weight:700; color:#e2e8f0; font-size:0.95rem">{asset}</span>
|
||||
<div style="display:flex; gap:8px; align-items:center">
|
||||
<span class="window-asset-badge {badge_class}">{tf}</span>
|
||||
<span class="{color}" style="font-family:'JetBrains Mono',monospace; font-weight:600; font-size:0.85rem">{direction}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-prices">
|
||||
<span>Open: {fmt_price(start_p)}</span>
|
||||
<span>Now: {fmt_price(end_p)}</span>
|
||||
<span class="{color}" style="font-weight:600">{change:+.4f}%</span>
|
||||
</div>
|
||||
<div class="window-bar-bg">
|
||||
<div class="window-bar-fill" style="width:{bar_width}%; background:{bar_color}"></div>
|
||||
</div>
|
||||
<div style="text-align:right; margin-top:4px; font-family:'JetBrains Mono',monospace; font-size:0.72rem; color:#64748b">{remaining:.0f}s remaining</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
else:
|
||||
st.info("Waiting for first window data...")
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 4: Charts
|
||||
# ==================================================================
|
||||
|
||||
if not balance_df.empty and len(balance_df) > 1:
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon purple">📈</div>
|
||||
Performance Charts
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
chart_col1, chart_col2 = st.columns(2)
|
||||
|
||||
with chart_col1:
|
||||
st.markdown("**Balance Over Time**")
|
||||
chart_bal = balance_df.copy()
|
||||
chart_bal["time"] = pd.to_datetime(chart_bal["timestamp"], unit="s")
|
||||
chart_bal = chart_bal.set_index("time")[["balance"]]
|
||||
st.area_chart(chart_bal, color="#818cf8")
|
||||
|
||||
with chart_col2:
|
||||
st.markdown("**PnL Curve**")
|
||||
chart_pnl = balance_df.copy()
|
||||
chart_pnl["time"] = pd.to_datetime(chart_pnl["timestamp"], unit="s")
|
||||
chart_pnl = chart_pnl.set_index("time")[["pnl"]]
|
||||
st.area_chart(chart_pnl, color="#4ade80")
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 5: Trades
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Active Positions (open trades with pnl=0)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if not trades_df.empty and "pnl" in trades_df.columns:
|
||||
open_trades = trades_df[trades_df["pnl"] == 0].copy()
|
||||
if not open_trades.empty:
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon green">🎯</div>
|
||||
Active Positions
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
open_trades["bet_amount"] = (open_trades["fill_price"] * open_trades["size"]).round(2)
|
||||
open_trades["max_payout"] = open_trades["size"].apply(lambda s: round(s * 1.0, 2))
|
||||
open_trades["potential_profit"] = (open_trades["max_payout"] - open_trades["bet_amount"]).round(2)
|
||||
open_trades["edge_pct"] = (open_trades["signal_edge"] * 100).round(1)
|
||||
|
||||
pos_cols = [c for c in ["asset", "direction", "timeframe", "fill_price",
|
||||
"size", "bet_amount", "potential_profit", "edge_pct"]
|
||||
if c in open_trades.columns]
|
||||
st.dataframe(
|
||||
open_trades[pos_cols].head(20).rename(columns={
|
||||
"fill_price": "Entry Price",
|
||||
"size": "Shares",
|
||||
"bet_amount": "Bet ($)",
|
||||
"potential_profit": "Max Profit ($)",
|
||||
"edge_pct": "Edge %",
|
||||
"asset": "Asset",
|
||||
"direction": "Dir",
|
||||
"timeframe": "TF",
|
||||
}),
|
||||
use_container_width=True,
|
||||
hide_index=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trading Activity (resolved trades)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon amber">⚡</div>
|
||||
Trading Activity
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
if not trades_df.empty:
|
||||
# Cumulative PnL chart (only resolved trades)
|
||||
if "pnl" in trades_df.columns and "created_at" in trades_df.columns:
|
||||
resolved_chart = trades_df[trades_df["pnl"] != 0].sort_values("created_at").copy()
|
||||
if not resolved_chart.empty:
|
||||
st.markdown("**Cumulative PnL (Resolved Trades)**")
|
||||
resolved_chart["cumulative_pnl"] = resolved_chart["pnl"].cumsum()
|
||||
resolved_chart["trade_num"] = range(1, len(resolved_chart) + 1)
|
||||
st.line_chart(resolved_chart.set_index("trade_num")[["cumulative_pnl"]], color="#22d3ee")
|
||||
|
||||
# Add bet amount column for display
|
||||
display_df = trades_df.copy()
|
||||
display_df["bet_amount"] = (display_df["fill_price"] * display_df["size"]).round(2)
|
||||
|
||||
st.markdown("**Recent Trades**")
|
||||
display_cols = [c for c in ["asset", "direction", "timeframe", "fill_price",
|
||||
"size", "bet_amount", "pnl", "fee", "signal_edge", "status"]
|
||||
if c in display_df.columns]
|
||||
st.dataframe(
|
||||
display_df[display_cols].head(50).rename(columns={
|
||||
"fill_price": "Price",
|
||||
"bet_amount": "Bet ($)",
|
||||
"signal_edge": "Edge",
|
||||
}),
|
||||
use_container_width=True,
|
||||
hide_index=True,
|
||||
)
|
||||
else:
|
||||
st.markdown("""
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚡</div>
|
||||
<div class="empty-title">Scanning for Opportunities</div>
|
||||
<div class="empty-desc">
|
||||
The bot is monitoring CEX prices and scanning Polymarket markets.<br>
|
||||
Trades will appear here once arbitrage opportunities are detected and executed.
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 6: Window History
|
||||
# ==================================================================
|
||||
|
||||
if not windows_df.empty:
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon blue">🕐</div>
|
||||
Window History
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
col_wh1, col_wh2 = st.columns([2, 1])
|
||||
|
||||
with col_wh1:
|
||||
hist_df = windows_df.copy()
|
||||
hist_df["time"] = pd.to_datetime(hist_df["created_at"], unit="s").dt.strftime("%H:%M:%S")
|
||||
hist_df["change"] = hist_df["price_change_pct"].apply(lambda x: f"{x:+.4f}%")
|
||||
hist_df["start"] = hist_df["start_price"].apply(lambda x: f"${x:,.2f}")
|
||||
hist_df["end"] = hist_df["end_price"].apply(lambda x: f"${x:,.2f}")
|
||||
hist_df["market"] = hist_df["market_condition_id"].apply(lambda x: x[:12] + "..." if x else "—")
|
||||
|
||||
display = hist_df[["time", "asset", "timeframe", "start", "end", "change", "market"]].head(30)
|
||||
st.dataframe(display, use_container_width=True, hide_index=True)
|
||||
|
||||
with col_wh2:
|
||||
st.markdown("**Avg Price Change by Asset**")
|
||||
chart_data = windows_df[["asset", "price_change_pct"]].copy()
|
||||
chart_data["abs_change"] = chart_data["price_change_pct"].abs()
|
||||
pivot = chart_data.groupby("asset")["abs_change"].mean()
|
||||
st.bar_chart(pivot, color="#a78bfa")
|
||||
|
||||
st.markdown("**Windows by Asset**")
|
||||
counts = windows_df["asset"].value_counts()
|
||||
st.bar_chart(counts, color="#60a5fa")
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Section 7: Daily Summary
|
||||
# ==================================================================
|
||||
|
||||
if not daily_df.empty:
|
||||
st.markdown("""
|
||||
<div class="section-header">
|
||||
<div class="section-icon green">📅</div>
|
||||
Daily Summary
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.dataframe(daily_df, use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Sidebar stats
|
||||
# ==================================================================
|
||||
|
||||
st.sidebar.markdown("---")
|
||||
st.sidebar.markdown('<div class="sidebar-label">ACCOUNT</div>', unsafe_allow_html=True)
|
||||
if not balance_df.empty:
|
||||
sb_bal = balance_df.iloc[-1]["balance"]
|
||||
sb_pnl = balance_df.iloc[-1]["pnl"]
|
||||
sb_icon = "🟢" if sb_pnl >= 0 else "🔴"
|
||||
st.sidebar.markdown(f'<div class="sidebar-value">${sb_bal:,.2f}</div>', unsafe_allow_html=True)
|
||||
st.sidebar.markdown(f"{sb_icon} PnL: **${sb_pnl:+,.2f}**")
|
||||
else:
|
||||
st.sidebar.markdown("Balance: **—**")
|
||||
|
||||
st.sidebar.markdown("---")
|
||||
st.sidebar.markdown('<div class="sidebar-label">DATABASE</div>', unsafe_allow_html=True)
|
||||
st.sidebar.code(str(db_path.name), language=None)
|
||||
st.sidebar.markdown(f"Windows: **{len(windows_df)}**")
|
||||
st.sidebar.markdown(f"Trades: **{len(trades_df)}**")
|
||||
st.sidebar.markdown(f"Snapshots: **{len(balance_df)}**")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-refresh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if auto_refresh:
|
||||
time.sleep(refresh_rate)
|
||||
st.rerun()
|
||||
770
deploy.sh
Normal file
770
deploy.sh
Normal 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
463
docs/gap-analysis.md
Normal 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
901
paper_trade.py
Normal 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
39
push.sh
Normal 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
14
requirements.txt
Normal 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
0
src/__init__.py
Normal file
262
src/config.py
Normal file
262
src/config.py
Normal 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
5
src/data/__init__.py
Normal 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
323
src/data/db.py
Normal 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
136
src/data/models.py
Normal 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
|
||||
7
src/execution/__init__.py
Normal file
7
src/execution/__init__.py
Normal 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"]
|
||||
235
src/execution/clob_client.py
Normal file
235
src/execution/clob_client.py
Normal 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
|
||||
243
src/execution/order_manager.py
Normal file
243
src/execution/order_manager.py
Normal 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)
|
||||
211
src/execution/position_tracker.py
Normal file
211
src/execution/position_tracker.py
Normal 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
6
src/feeds/__init__.py
Normal 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
250
src/feeds/binance_ws.py
Normal 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
303
src/feeds/polymarket_ws.py
Normal 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
762
src/main.py
Normal 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
6
src/market/__init__.py
Normal 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
275
src/market/discovery.py
Normal 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
186
src/market/oracle.py
Normal 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
|
||||
}
|
||||
193
src/market/window_tracker.py
Normal file
193
src/market/window_tracker.py
Normal 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
7
src/risk/__init__.py
Normal 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"]
|
||||
90
src/risk/fee_calculator.py
Normal file
90
src/risk/fee_calculator.py
Normal 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
|
||||
90
src/risk/position_sizer.py
Normal file
90
src/risk/position_sizer.py
Normal 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
161
src/risk/risk_manager.py
Normal 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
13
src/strategy/__init__.py
Normal 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
196
src/strategy/signal.py
Normal 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),
|
||||
},
|
||||
}
|
||||
164
src/strategy/spread_capture.py
Normal file
164
src/strategy/spread_capture.py
Normal 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
178
src/strategy/sum_to_one.py
Normal 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
|
||||
297
src/strategy/temporal_arb.py
Normal file
297
src/strategy/temporal_arb.py
Normal 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
14
src/utils/__init__.py
Normal 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
153
src/utils/logger.py
Normal 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
116
src/utils/metrics.py
Normal 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
152
src/utils/telegram.py
Normal 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
0
tests/__init__.py
Normal file
251
tests/test_execution.py
Normal file
251
tests/test_execution.py
Normal 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
336
tests/test_strategy.py
Normal 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
|
||||
Reference in New Issue
Block a user