From 7f45211276221b7fe3fd227bea6d44809af96a8d Mon Sep 17 00:00:00 2001 From: choijaewook Date: Sun, 22 Mar 2026 09:28:14 +0900 Subject: [PATCH] update 03-22 09:28 --- .env.example | 13 + .gitignore | 11 + backtest.py | 554 ++++++++++++++++++ config.toml | 52 ++ dashboard/app.py | 935 ++++++++++++++++++++++++++++++ deploy.sh | 770 ++++++++++++++++++++++++ docs/gap-analysis.md | 463 +++++++++++++++ paper_trade.py | 901 ++++++++++++++++++++++++++++ push.sh | 39 ++ requirements.txt | 14 + src/__init__.py | 0 src/config.py | 262 +++++++++ src/data/__init__.py | 5 + src/data/db.py | 323 +++++++++++ src/data/models.py | 136 +++++ src/execution/__init__.py | 7 + src/execution/clob_client.py | 235 ++++++++ src/execution/order_manager.py | 243 ++++++++ src/execution/position_tracker.py | 211 +++++++ src/feeds/__init__.py | 6 + src/feeds/binance_ws.py | 250 ++++++++ src/feeds/polymarket_ws.py | 303 ++++++++++ src/main.py | 762 ++++++++++++++++++++++++ src/market/__init__.py | 6 + src/market/discovery.py | 275 +++++++++ src/market/oracle.py | 186 ++++++ src/market/window_tracker.py | 193 ++++++ src/risk/__init__.py | 7 + src/risk/fee_calculator.py | 90 +++ src/risk/position_sizer.py | 90 +++ src/risk/risk_manager.py | 161 +++++ src/strategy/__init__.py | 13 + src/strategy/signal.py | 196 +++++++ src/strategy/spread_capture.py | 164 ++++++ src/strategy/sum_to_one.py | 178 ++++++ src/strategy/temporal_arb.py | 297 ++++++++++ src/utils/__init__.py | 14 + src/utils/logger.py | 153 +++++ src/utils/metrics.py | 116 ++++ src/utils/telegram.py | 152 +++++ tests/__init__.py | 0 tests/test_execution.py | 251 ++++++++ tests/test_strategy.py | 336 +++++++++++ 43 files changed, 9373 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backtest.py create mode 100644 config.toml create mode 100644 dashboard/app.py create mode 100644 deploy.sh create mode 100644 docs/gap-analysis.md create mode 100644 paper_trade.py create mode 100644 push.sh create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/config.py create mode 100644 src/data/__init__.py create mode 100644 src/data/db.py create mode 100644 src/data/models.py create mode 100644 src/execution/__init__.py create mode 100644 src/execution/clob_client.py create mode 100644 src/execution/order_manager.py create mode 100644 src/execution/position_tracker.py create mode 100644 src/feeds/__init__.py create mode 100644 src/feeds/binance_ws.py create mode 100644 src/feeds/polymarket_ws.py create mode 100644 src/main.py create mode 100644 src/market/__init__.py create mode 100644 src/market/discovery.py create mode 100644 src/market/oracle.py create mode 100644 src/market/window_tracker.py create mode 100644 src/risk/__init__.py create mode 100644 src/risk/fee_calculator.py create mode 100644 src/risk/position_sizer.py create mode 100644 src/risk/risk_manager.py create mode 100644 src/strategy/__init__.py create mode 100644 src/strategy/signal.py create mode 100644 src/strategy/spread_capture.py create mode 100644 src/strategy/sum_to_one.py create mode 100644 src/strategy/temporal_arb.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/logger.py create mode 100644 src/utils/metrics.py create mode 100644 src/utils/telegram.py create mode 100644 tests/__init__.py create mode 100644 tests/test_execution.py create mode 100644 tests/test_strategy.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4cbdacc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..105e4de --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +.env.local +*.pyc +__pycache__/ +*.db +*.sqlite +.venv/ +venv/ +dist/ +*.egg-info/ +.streamlit/ diff --git a/backtest.py b/backtest.py new file mode 100644 index 0000000..bc4498b --- /dev/null +++ b/backtest.py @@ -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()) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..7aefc37 --- /dev/null +++ b/config.toml @@ -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 diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..0ad4900 --- /dev/null +++ b/dashboard/app.py @@ -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(""" + +""", unsafe_allow_html=True) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def get_db_path() -> Path: + mode = st.sidebar.radio("Mode", ["Paper", "Live"], index=0, horizontal=True) + return PAPER_DB_PATH if mode == "Paper" else DB_PATH + + +def query_db(db_path: Path, sql: str) -> pd.DataFrame: + if not db_path.exists(): + return pd.DataFrame() + try: + conn = sqlite3.connect(str(db_path)) + df = pd.read_sql_query(sql, conn) + conn.close() + return df + except Exception: + return pd.DataFrame() + + +def fmt_price(price: float, asset: str = "") -> str: + if price >= 1000: + return f"${price:,.2f}" + return f"${price:.2f}" + + +def time_ago(ts: float) -> str: + diff = time.time() - ts + if diff < 60: + return f"{diff:.0f}s ago" + elif diff < 3600: + return f"{diff/60:.0f}m ago" + else: + return f"{diff/3600:.1f}h ago" + + +# ------------------------------------------------------------------ +# Layout +# ------------------------------------------------------------------ + +db_path = get_db_path() + +# Sidebar settings +st.sidebar.markdown("---") +st.sidebar.markdown('', unsafe_allow_html=True) +refresh_rate = st.sidebar.selectbox("Interval (sec)", [5, 10, 30], index=1, label_visibility="collapsed") +auto_refresh = st.sidebar.checkbox("Auto-refresh", value=True) + +# Load data +trades_df = query_db(db_path, "SELECT * FROM trades ORDER BY created_at DESC") +windows_df = query_db(db_path, "SELECT * FROM window_snapshots ORDER BY created_at DESC") +daily_df = query_db(db_path, "SELECT * FROM daily_summary ORDER BY date DESC") +balance_df = query_db(db_path, "SELECT * FROM balance_history ORDER BY timestamp ASC") +oracle_df = query_db(db_path, "SELECT * FROM oracle_snapshots ORDER BY timestamp DESC") + + +# ================================================================== +# Header +# ================================================================== + +# Use balance_history (every 30s) for liveness check instead of window_snapshots (every 5min) +_latest_ts = balance_df.iloc[-1]["timestamp"] if not balance_df.empty else 0 +has_recent = (time.time() - _latest_ts) < 120 +mode_label = "PAPER" if "paper" in str(db_path) else "LIVE" +status_class = "header-badge" if has_recent else "header-badge header-badge-warn" +status_text = "RUNNING" if has_recent else "OFFLINE" + +st.markdown(f""" +
+
+
Polymarket Temporal Arb
+
Real-time arbitrage monitoring • {mode_label} MODE
+
+
+ {status_text} + + {datetime.now(timezone.utc).strftime('%H:%M:%S UTC')} + +
+
+""", unsafe_allow_html=True) + + +# ================================================================== +# Section 1: Key Metrics +# ================================================================== + +if not balance_df.empty: + latest_bal = balance_df.iloc[-1] + current_balance = latest_bal["balance"] + current_pnl = latest_bal["pnl"] + starting_balance = balance_df.iloc[0]["balance"] + pnl_pct = (current_pnl / starting_balance * 100) if starting_balance > 0 else 0 + + total_trades = len(trades_df) if not trades_df.empty else 0 + # Only count resolved trades for win/loss (exclude pnl=0 which are still open) + resolved_df = trades_df[trades_df["pnl"] != 0] if not trades_df.empty and "pnl" in trades_df.columns else pd.DataFrame() + open_df = trades_df[trades_df["pnl"] == 0] if not trades_df.empty and "pnl" in trades_df.columns else pd.DataFrame() + wins = len(resolved_df[resolved_df["pnl"] > 0]) if not resolved_df.empty else 0 + losses = len(resolved_df[resolved_df["pnl"] < 0]) if not resolved_df.empty else 0 + resolved_count = wins + losses + win_rate = wins / resolved_count * 100 if resolved_count > 0 else 0 + total_fees = trades_df["fee"].sum() if not trades_df.empty and "fee" in trades_df.columns else 0 + avg_edge = trades_df["signal_edge"].mean() * 100 if not trades_df.empty and "signal_edge" in trades_df.columns else 0 + # Total bet volume (cost basis = fill_price * size) + total_bet = (trades_df["fill_price"] * trades_df["size"]).sum() if not trades_df.empty and "fill_price" in trades_df.columns else 0 + # Active position exposure + active_exposure = (open_df["fill_price"] * open_df["size"]).sum() if not open_df.empty else 0 + + pnl_color = "green" if current_pnl >= 0 else "red" + pnl_sign = "+" if current_pnl >= 0 else "" + + c1, c2, c3, c4, c5, c6 = st.columns(6) + + with c1: + st.markdown(f""" +
+
Balance
+
${current_balance:,.2f}
+
Start: ${starting_balance:,.2f}
+
+ """, unsafe_allow_html=True) + + with c2: + st.markdown(f""" +
+
Total PnL
+
{pnl_sign}${current_pnl:,.2f}
+
{pnl_sign}{pnl_pct:.2f}% return
+
+ """, unsafe_allow_html=True) + + with c3: + wr_color = "green" if win_rate >= 50 else "amber" if win_rate >= 40 else "red" + st.markdown(f""" +
+
Win Rate
+
{win_rate:.1f}%
+
{wins}W / {losses}L ({resolved_count} resolved)
+
+ """, unsafe_allow_html=True) + + with c4: + avg_bet = f"${total_bet/total_trades:,.0f}" if total_trades > 0 else "$0" + st.markdown(f""" +
+
Total Bets
+
${total_bet:,.0f}
+
{total_trades} trades • avg {avg_bet}/trade
+
+ """, unsafe_allow_html=True) + + with c5: + st.markdown(f""" +
+
Active Exposure
+
${active_exposure:,.0f}
+
{len(open_df)} open positions
+
+ """, unsafe_allow_html=True) + + with c6: + # PnL per resolved trade + pnl_per_trade = current_pnl / resolved_count if resolved_count > 0 else 0 + st.markdown(f""" +
+
PnL / Trade
+
{'+' if pnl_per_trade >= 0 else ''}${pnl_per_trade:.2f}
+
Avg edge: {avg_edge:.1f}%
+
+ """, unsafe_allow_html=True) + +else: + st.markdown(""" +
+
📡
+
Awaiting Balance Data
+
Bot is initializing. Balance snapshots will appear once trading begins.
+
+ """, unsafe_allow_html=True) + + +# ================================================================== +# Section 2: Live Prices + Connections +# ================================================================== + +st.markdown(""" +
+
📡
+ Market Data & Connections +
+""", unsafe_allow_html=True) + +latest_prices = {} +if not windows_df.empty: + for asset in ["BTC", "ETH", "SOL"]: + asset_rows = windows_df[windows_df["asset"] == asset] + if not asset_rows.empty: + row = asset_rows.iloc[0] + latest_prices[asset] = { + "price": row.get("end_price", row.get("start_price", 0)), + "start": row.get("start_price", 0), + "change_pct": row.get("price_change_pct", 0), + "updated": row.get("created_at", 0), + } + +col_conn, col_btc, col_eth, col_sol = st.columns([1.3, 1, 1, 1]) + +with col_conn: + binance_ok = has_recent # based on balance_history (30s interval) + poly_ok = not windows_df.empty and windows_df.iloc[0].get("market_condition_id") is not None + last_update = time_ago(windows_df.iloc[0]["created_at"]) if not windows_df.empty else "N/A" + + st.markdown(f""" +
+
+
+ Binance WebSocket + {'Live' if binance_ok else 'Reconnecting'} +
+
+
+ Polymarket CLOB + {'Active' if poly_ok else 'Scanning'} +
+
+
+ Chainlink Oracle + {'Polling' if not oracle_df.empty else 'Waiting'} +
+
+
+ SQLite Database + {len(windows_df)} snapshots +
+
+ Last update: {last_update} +
+
+ """, unsafe_allow_html=True) + +asset_icons = {"BTC": "₿", "ETH": "Ξ", "SOL": "◎"} +asset_colors = {"BTC": "#f7931a", "ETH": "#627eea", "SOL": "#9945ff"} + +for col, asset in [(col_btc, "BTC"), (col_eth, "ETH"), (col_sol, "SOL")]: + with col: + info = latest_prices.get(asset, {}) + price = info.get("price", 0) + change = info.get("change_pct", 0) + color = "green" if change >= 0 else "red" + arrow = "▲" if change > 0 else "▼" if change < 0 else "—" + st.markdown(f""" +
+
{asset_icons[asset]} {asset}/USDT
+
{fmt_price(price)}
+
{arrow} {change:+.4f}%
+
+ """, unsafe_allow_html=True) + + +# ================================================================== +# Section 2b: Chainlink Oracle vs Binance +# ================================================================== + +if not oracle_df.empty: + st.markdown(""" +
+
🔗
+ Chainlink Oracle vs Binance (Arb Gap) +
+ """, unsafe_allow_html=True) + + # Latest oracle data per asset + o_cols = st.columns(3) + for i, asset in enumerate(["BTC", "ETH", "SOL"]): + asset_oracle = oracle_df[oracle_df["asset"] == asset] + if asset_oracle.empty: + continue + latest = asset_oracle.iloc[0] + o_price = latest["oracle_price"] + c_price = latest["cex_price"] + dev = latest["deviation_pct"] + lag = latest["oracle_lag_sec"] + + dev_color = "amber" if abs(dev) > 0.1 else "green" if abs(dev) < 0.05 else "cyan" + dev_sign = "+" if dev >= 0 else "" + gap_label = "GAP!" if abs(dev) > 0.1 else "OK" + gap_color = "#fbbf24" if abs(dev) > 0.1 else "#4ade80" + + with o_cols[i]: + st.markdown(f""" +
+
+
{asset} Oracle Gap
+ {gap_label} +
+
{dev_sign}{dev:.4f}%
+
+
+
CHAINLINK
+
{fmt_price(o_price)}
+
+
+
BINANCE
+
{fmt_price(c_price)}
+
+
+
Oracle lag: {lag:.0f}s
+
+ """, unsafe_allow_html=True) + + # Deviation over time chart + if len(oracle_df) > 3: + st.markdown("**Oracle Deviation Over Time (%)**") + chart_oracle = oracle_df.copy() + chart_oracle["time"] = pd.to_datetime(chart_oracle["timestamp"], unit="s") + # Pivot by asset + pivot = chart_oracle.pivot_table(index="time", columns="asset", values="deviation_pct", aggfunc="last") + pivot = pivot.sort_index() + if not pivot.empty: + st.line_chart(pivot, color=["#f7931a", "#627eea", "#9945ff"]) + + +# ================================================================== +# Section 3: Window Tracker +# ================================================================== + +st.markdown(""" +
+
+ Active Windows +
+""", 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""" +
+
+ {asset} +
+ {tf} + {direction} +
+
+
+ Open: {fmt_price(start_p)} + Now: {fmt_price(end_p)} + {change:+.4f}% +
+
+
+
+
{remaining:.0f}s remaining
+
+ """, unsafe_allow_html=True) +else: + st.info("Waiting for first window data...") + + +# ================================================================== +# Section 4: Charts +# ================================================================== + +if not balance_df.empty and len(balance_df) > 1: + st.markdown(""" +
+
📈
+ Performance Charts +
+ """, 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(""" +
+
🎯
+ Active Positions +
+ """, 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(""" +
+
+ Trading Activity +
+""", unsafe_allow_html=True) + +if not trades_df.empty: + # Cumulative PnL chart (only resolved trades) + if "pnl" in trades_df.columns and "created_at" in trades_df.columns: + resolved_chart = trades_df[trades_df["pnl"] != 0].sort_values("created_at").copy() + if not resolved_chart.empty: + st.markdown("**Cumulative PnL (Resolved Trades)**") + resolved_chart["cumulative_pnl"] = resolved_chart["pnl"].cumsum() + resolved_chart["trade_num"] = range(1, len(resolved_chart) + 1) + st.line_chart(resolved_chart.set_index("trade_num")[["cumulative_pnl"]], color="#22d3ee") + + # Add bet amount column for display + display_df = trades_df.copy() + display_df["bet_amount"] = (display_df["fill_price"] * display_df["size"]).round(2) + + st.markdown("**Recent Trades**") + display_cols = [c for c in ["asset", "direction", "timeframe", "fill_price", + "size", "bet_amount", "pnl", "fee", "signal_edge", "status"] + if c in display_df.columns] + st.dataframe( + display_df[display_cols].head(50).rename(columns={ + "fill_price": "Price", + "bet_amount": "Bet ($)", + "signal_edge": "Edge", + }), + use_container_width=True, + hide_index=True, + ) +else: + st.markdown(""" +
+
+
Scanning for Opportunities
+
+ The bot is monitoring CEX prices and scanning Polymarket markets.
+ Trades will appear here once arbitrage opportunities are detected and executed. +
+
+ """, unsafe_allow_html=True) + + +# ================================================================== +# Section 6: Window History +# ================================================================== + +if not windows_df.empty: + st.markdown(""" +
+
🕐
+ Window History +
+ """, 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(""" +
+
📅
+ Daily Summary +
+ """, unsafe_allow_html=True) + st.dataframe(daily_df, use_container_width=True, hide_index=True) + + +# ================================================================== +# Sidebar stats +# ================================================================== + +st.sidebar.markdown("---") +st.sidebar.markdown('', unsafe_allow_html=True) +if not balance_df.empty: + sb_bal = balance_df.iloc[-1]["balance"] + sb_pnl = balance_df.iloc[-1]["pnl"] + sb_icon = "🟢" if sb_pnl >= 0 else "🔴" + st.sidebar.markdown(f'', unsafe_allow_html=True) + st.sidebar.markdown(f"{sb_icon} PnL: **${sb_pnl:+,.2f}**") +else: + st.sidebar.markdown("Balance: **—**") + +st.sidebar.markdown("---") +st.sidebar.markdown('', unsafe_allow_html=True) +st.sidebar.code(str(db_path.name), language=None) +st.sidebar.markdown(f"Windows: **{len(windows_df)}**") +st.sidebar.markdown(f"Trades: **{len(trades_df)}**") +st.sidebar.markdown(f"Snapshots: **{len(balance_df)}**") + + +# ------------------------------------------------------------------ +# Auto-refresh +# ------------------------------------------------------------------ + +if auto_refresh: + time.sleep(refresh_rate) + st.rerun() diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..cd5a4b3 --- /dev/null +++ b/deploy.sh @@ -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 diff --git a/docs/gap-analysis.md b/docs/gap-analysis.md new file mode 100644 index 0000000..73dbcc6 --- /dev/null +++ b/docs/gap-analysis.md @@ -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 diff --git a/paper_trade.py b/paper_trade.py new file mode 100644 index 0000000..3494d4a --- /dev/null +++ b/paper_trade.py @@ -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() diff --git a/push.sh b/push.sh new file mode 100644 index 0000000..1a521a2 --- /dev/null +++ b/push.sh @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a32a1b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..bbced6d --- /dev/null +++ b/src/config.py @@ -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 diff --git a/src/data/__init__.py b/src/data/__init__.py new file mode 100644 index 0000000..adbb032 --- /dev/null +++ b/src/data/__init__.py @@ -0,0 +1,5 @@ +"""Data layer for the Polymarket Arbitrage Bot.""" + +from .db import TradeDB + +__all__ = ["TradeDB"] diff --git a/src/data/db.py b/src/data/db.py new file mode 100644 index 0000000..3cfeef5 --- /dev/null +++ b/src/data/db.py @@ -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]) diff --git a/src/data/models.py b/src/data/models.py new file mode 100644 index 0000000..6d8eb40 --- /dev/null +++ b/src/data/models.py @@ -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 diff --git a/src/execution/__init__.py b/src/execution/__init__.py new file mode 100644 index 0000000..7ba95ec --- /dev/null +++ b/src/execution/__init__.py @@ -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"] diff --git a/src/execution/clob_client.py b/src/execution/clob_client.py new file mode 100644 index 0000000..8446823 --- /dev/null +++ b/src/execution/clob_client.py @@ -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 diff --git a/src/execution/order_manager.py b/src/execution/order_manager.py new file mode 100644 index 0000000..1e5607a --- /dev/null +++ b/src/execution/order_manager.py @@ -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) diff --git a/src/execution/position_tracker.py b/src/execution/position_tracker.py new file mode 100644 index 0000000..0a4a373 --- /dev/null +++ b/src/execution/position_tracker.py @@ -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), + } diff --git a/src/feeds/__init__.py b/src/feeds/__init__.py new file mode 100644 index 0000000..b05b469 --- /dev/null +++ b/src/feeds/__init__.py @@ -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"] diff --git a/src/feeds/binance_ws.py b/src/feeds/binance_ws.py new file mode 100644 index 0000000..12b7083 --- /dev/null +++ b/src/feeds/binance_ws.py @@ -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() diff --git a/src/feeds/polymarket_ws.py b/src/feeds/polymarket_ws.py new file mode 100644 index 0000000..87d0a79 --- /dev/null +++ b/src/feeds/polymarket_ws.py @@ -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() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..5a9f827 --- /dev/null +++ b/src/main.py @@ -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 '❌'} Position Resolved\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"🚀 Polymarket Arb Bot Started\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"🛑 Bot Stopped\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() diff --git a/src/market/__init__.py b/src/market/__init__.py new file mode 100644 index 0000000..09e77ac --- /dev/null +++ b/src/market/__init__.py @@ -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"] diff --git a/src/market/discovery.py b/src/market/discovery.py new file mode 100644 index 0000000..9b4c483 --- /dev/null +++ b/src/market/discovery.py @@ -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") diff --git a/src/market/oracle.py b/src/market/oracle.py new file mode 100644 index 0000000..6b7b392 --- /dev/null +++ b/src/market/oracle.py @@ -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 + } diff --git a/src/market/window_tracker.py b/src/market/window_tracker.py new file mode 100644 index 0000000..4d4da33 --- /dev/null +++ b/src/market/window_tracker.py @@ -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, + ) diff --git a/src/risk/__init__.py b/src/risk/__init__.py new file mode 100644 index 0000000..13fb234 --- /dev/null +++ b/src/risk/__init__.py @@ -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"] diff --git a/src/risk/fee_calculator.py b/src/risk/fee_calculator.py new file mode 100644 index 0000000..a85e4d2 --- /dev/null +++ b/src/risk/fee_calculator.py @@ -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 diff --git a/src/risk/position_sizer.py b/src/risk/position_sizer.py new file mode 100644 index 0000000..51d5ccb --- /dev/null +++ b/src/risk/position_sizer.py @@ -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) diff --git a/src/risk/risk_manager.py b/src/risk/risk_manager.py new file mode 100644 index 0000000..c8f8fbc --- /dev/null +++ b/src/risk/risk_manager.py @@ -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, + } diff --git a/src/strategy/__init__.py b/src/strategy/__init__.py new file mode 100644 index 0000000..1165b36 --- /dev/null +++ b/src/strategy/__init__.py @@ -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", +] diff --git a/src/strategy/signal.py b/src/strategy/signal.py new file mode 100644 index 0000000..8639a1d --- /dev/null +++ b/src/strategy/signal.py @@ -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), + }, + } diff --git a/src/strategy/spread_capture.py b/src/strategy/spread_capture.py new file mode 100644 index 0000000..9ad5188 --- /dev/null +++ b/src/strategy/spread_capture.py @@ -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 diff --git a/src/strategy/sum_to_one.py b/src/strategy/sum_to_one.py new file mode 100644 index 0000000..4c64075 --- /dev/null +++ b/src/strategy/sum_to_one.py @@ -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 diff --git a/src/strategy/temporal_arb.py b/src/strategy/temporal_arb.py new file mode 100644 index 0000000..db6a540 --- /dev/null +++ b/src/strategy/temporal_arb.py @@ -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 diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..af60a23 --- /dev/null +++ b/src/utils/__init__.py @@ -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", +] diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..4d5f0ea --- /dev/null +++ b/src/utils/logger.py @@ -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), + ) diff --git a/src/utils/metrics.py b/src/utils/metrics.py new file mode 100644 index 0000000..486741e --- /dev/null +++ b/src/utils/metrics.py @@ -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), + } diff --git a/src/utils/telegram.py b/src/utils/telegram.py new file mode 100644 index 0000000..2956424 --- /dev/null +++ b/src/utils/telegram.py @@ -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"🔔 Trade Signal\n" + f"Asset: {asset} | {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"✅ Order Filled\n" + f"Asset: {asset} | {direction}\n" + f"Fill: {fill_price:.2f} × {fill_size}\n" + f"Trade ID: {trade_id}" + ) + 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} Daily Summary — {date}\n" + f"Trades: {total_trades} (W:{wins} / L:{losses})\n" + f"Win Rate: {win_rate:.1f}%\n" + f"PnL: ${pnl:+.2f}\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"🚨 Error Alert\n{error_msg[:500]}" + await self.send(msg) + + async def notify_halt(self, reason: str) -> None: + """Send a trading halt alert.""" + msg = f"🛑 TRADING HALTED\nReason: {reason}" + await self.send(msg) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_execution.py b/tests/test_execution.py new file mode 100644 index 0000000..7be6bb2 --- /dev/null +++ b/tests/test_execution.py @@ -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) diff --git a/tests/test_strategy.py b/tests/test_strategy.py new file mode 100644 index 0000000..01b30c6 --- /dev/null +++ b/tests/test_strategy.py @@ -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