AJAY KASU commited on
Commit ·
81146df
1
Parent(s): 27279c5
Add tests, alerts, and blockchain tracker
Browse files- src/alerts.py +62 -0
- src/blockchain.py +89 -0
- tests/__init__.py +0 -0
- tests/__pycache__/__init__.cpython-39.pyc +0 -0
- tests/__pycache__/test_arbitrage.cpython-39-pytest-7.1.1.pyc +0 -0
- tests/__pycache__/test_bayesian.cpython-39-pytest-7.1.1.pyc +0 -0
- tests/__pycache__/test_kalman.cpython-39-pytest-7.1.1.pyc +0 -0
- tests/test_arbitrage.py +44 -0
- tests/test_bayesian.py +35 -0
- tests/test_kalman.py +27 -0
src/alerts.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class AlertManager:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
# Read Webhook URLs from environment
|
| 10 |
+
self.discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL")
|
| 11 |
+
self.slack_webhook = os.environ.get("SLACK_WEBHOOK_URL")
|
| 12 |
+
|
| 13 |
+
def send_discord_alert(self, title: str, message: str, color: int = 5814783):
|
| 14 |
+
"""Send a rich embed to Discord."""
|
| 15 |
+
if not self.discord_webhook:
|
| 16 |
+
logger.debug("Discord webhook not configured.")
|
| 17 |
+
return
|
| 18 |
+
|
| 19 |
+
payload = {
|
| 20 |
+
"embeds": [{
|
| 21 |
+
"title": title,
|
| 22 |
+
"description": message,
|
| 23 |
+
"color": color
|
| 24 |
+
}]
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
response = requests.post(self.discord_webhook, json=payload)
|
| 29 |
+
if response.status_code != 204:
|
| 30 |
+
logger.error(f"Failed to send Discord alert: {response.status_code}")
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.error(f"Error sending Discord alert: {e}")
|
| 33 |
+
|
| 34 |
+
def send_slack_alert(self, title: str, message: str):
|
| 35 |
+
"""Send a message to Slack."""
|
| 36 |
+
if not self.slack_webhook:
|
| 37 |
+
logger.debug("Slack webhook not configured.")
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
payload = {
|
| 41 |
+
"text": f"*{title}*\n{message}"
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
response = requests.post(self.slack_webhook, json=payload)
|
| 46 |
+
if response.status_code != 200:
|
| 47 |
+
logger.error(f"Failed to send Slack alert: {response.status_code}")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"Error sending Slack alert: {e}")
|
| 50 |
+
|
| 51 |
+
def alert_arbitrage(self, opp: dict):
|
| 52 |
+
"""Format and send an arbitrage opportunity alert."""
|
| 53 |
+
title = "🚨 New Arbitrage Opportunity Detected!"
|
| 54 |
+
msg = f"**{opp['event_name']}**\n"
|
| 55 |
+
msg += f"Buy on {opp['buy_platform']} @ ${opp['buy_price']}\n"
|
| 56 |
+
msg += f"Sell on {opp['sell_platform']} @ ${opp['sell_price']}\n"
|
| 57 |
+
msg += f"**Net Edge:** {opp['expected_profit_margin']*100:.2f}%\n"
|
| 58 |
+
msg += f"**Max Size:** ${opp['buy_size']:.2f}"
|
| 59 |
+
|
| 60 |
+
# Color: Greenish for profit
|
| 61 |
+
self.send_discord_alert(title, msg, color=5763719)
|
| 62 |
+
self.send_slack_alert(title, msg)
|
src/blockchain.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from web3 import Web3
|
| 4 |
+
from typing import Callable, Optional
|
| 5 |
+
import asyncio
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class BlockchainTracker:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
"""
|
| 12 |
+
Track whale movements on Polygon (Polymarket's native chain).
|
| 13 |
+
"""
|
| 14 |
+
# Alchemy or Infura RPC URL for Polygon Mainnet
|
| 15 |
+
self.rpc_url = os.environ.get("POLYGON_RPC_URL", "https://polygon-rpc.com")
|
| 16 |
+
self.w3 = Web3(Web3.HTTPProvider(self.rpc_url))
|
| 17 |
+
|
| 18 |
+
if self.w3.is_connected():
|
| 19 |
+
logger.info(f"Connected to Polygon Network via {self.rpc_url}")
|
| 20 |
+
else:
|
| 21 |
+
logger.error("Failed to connect to Polygon Network")
|
| 22 |
+
|
| 23 |
+
# USDC Token Contract on Polygon
|
| 24 |
+
self.usdc_address = self.w3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
|
| 25 |
+
|
| 26 |
+
# Polymarket CTF Exchange Contract
|
| 27 |
+
self.poly_exchange = self.w3.to_checksum_address("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E")
|
| 28 |
+
|
| 29 |
+
# Standard ERC20 Transfer ABI (reduced form)
|
| 30 |
+
self.erc20_abi = [
|
| 31 |
+
{
|
| 32 |
+
"anonymous": False,
|
| 33 |
+
"inputs": [
|
| 34 |
+
{"indexed": True, "name": "from", "type": "address"},
|
| 35 |
+
{"indexed": True, "name": "to", "type": "address"},
|
| 36 |
+
{"indexed": False, "name": "value", "type": "uint256"}
|
| 37 |
+
],
|
| 38 |
+
"name": "Transfer",
|
| 39 |
+
"type": "event"
|
| 40 |
+
}
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
self.usdc_contract = self.w3.eth.contract(address=self.usdc_address, abi=self.erc20_abi)
|
| 44 |
+
|
| 45 |
+
async def watch_whale_deposits(self, threshold_usdc: float = 100000.0, callback: Optional[Callable] = None):
|
| 46 |
+
"""
|
| 47 |
+
Poll for large USDC transfers (whales) flowing into Polymarket.
|
| 48 |
+
Threshold defaults to 100k USDC (USDC has 6 decimals on Polygon).
|
| 49 |
+
"""
|
| 50 |
+
threshold_wei = int(threshold_usdc * (10 ** 6))
|
| 51 |
+
|
| 52 |
+
# Create a filter for Transfer events to the Polymarket exchange
|
| 53 |
+
# Note: In production, relying on direct get_logs polling can be rate limited.
|
| 54 |
+
# WebSockets (WSS) and async Web3 is preferred.
|
| 55 |
+
|
| 56 |
+
filter_params = {
|
| 57 |
+
'address': self.usdc_address,
|
| 58 |
+
'topics': [
|
| 59 |
+
self.w3.keccak(text="Transfer(address,address,uint256)").hex(),
|
| 60 |
+
None, # from any
|
| 61 |
+
"0x000000000000000000000000" + self.poly_exchange[2:] # to polymarket
|
| 62 |
+
]
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
latest_block = self.w3.eth.block_number
|
| 66 |
+
|
| 67 |
+
while True:
|
| 68 |
+
try:
|
| 69 |
+
# Fetch logs from latest block
|
| 70 |
+
logs = self.w3.eth.get_logs({**filter_params, 'fromBlock': latest_block, 'toBlock': 'latest'})
|
| 71 |
+
|
| 72 |
+
for log in logs:
|
| 73 |
+
# value is not indexed, so it's in the data field
|
| 74 |
+
value = int(log['data'].hex(), 16)
|
| 75 |
+
|
| 76 |
+
if value >= threshold_wei:
|
| 77 |
+
usdc_amount = value / (10 ** 6)
|
| 78 |
+
sender = "0x" + log['topics'][1].hex()[-40:]
|
| 79 |
+
logger.info(f"🐋 WHALE ALERT: {usdc_amount} USDC deposited to Polymarket by {sender}")
|
| 80 |
+
|
| 81 |
+
if callback:
|
| 82 |
+
callback(f"🐋 WHALE ALERT", f"${usdc_amount:,.2f} deposited to Polymarket by `{sender}`")
|
| 83 |
+
|
| 84 |
+
latest_block = self.w3.eth.block_number + 1
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"Blockchain poll error: {e}")
|
| 88 |
+
|
| 89 |
+
await asyncio.sleep(15) # Poll every 15 seconds (Polygon block time is ~2s)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/__pycache__/__init__.cpython-39.pyc
ADDED
|
Binary file (143 Bytes). View file
|
|
|
tests/__pycache__/test_arbitrage.cpython-39-pytest-7.1.1.pyc
ADDED
|
Binary file (4.33 kB). View file
|
|
|
tests/__pycache__/test_bayesian.cpython-39-pytest-7.1.1.pyc
ADDED
|
Binary file (3.46 kB). View file
|
|
|
tests/__pycache__/test_kalman.cpython-39-pytest-7.1.1.pyc
ADDED
|
Binary file (2.5 kB). View file
|
|
|
tests/test_arbitrage.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from src.strategies.arbitrage import CrossPlatformArbitrage, IntraMarketArbitrage
|
| 3 |
+
|
| 4 |
+
def test_cross_platform_arbitrage_profitable():
|
| 5 |
+
scanner = CrossPlatformArbitrage(min_profit_threshold=0.01)
|
| 6 |
+
|
| 7 |
+
# State where Arb exists (Polymarket cheaper than Kalshi)
|
| 8 |
+
scanner.update_state('polymarket', '0x217...', bid=0.50, bid_size=1000, ask=0.52, ask_size=1000)
|
| 9 |
+
scanner.update_state('kalshi', 'KXUS2024', bid=0.55, bid_size=1500, ask=0.57, ask_size=500)
|
| 10 |
+
|
| 11 |
+
opps = scanner.scan_opportunities()
|
| 12 |
+
|
| 13 |
+
assert len(opps) == 1
|
| 14 |
+
opp = opps[0]
|
| 15 |
+
|
| 16 |
+
assert opp.buy_platform == 'polymarket'
|
| 17 |
+
assert opp.sell_platform == 'kalshi'
|
| 18 |
+
assert opp.buy_price == 0.52
|
| 19 |
+
assert opp.sell_price == 0.55
|
| 20 |
+
assert opp.buy_size == 1000 # min of 1000 and 1500
|
| 21 |
+
assert opp.expected_profit_margin > 0.01 # Expect profit > min threshold
|
| 22 |
+
|
| 23 |
+
def test_cross_platform_arbitrage_no_opportunity():
|
| 24 |
+
scanner = CrossPlatformArbitrage(min_profit_threshold=0.01)
|
| 25 |
+
|
| 26 |
+
# Efficient market state
|
| 27 |
+
scanner.update_state('polymarket', '0x217...', bid=0.53, bid_size=1000, ask=0.54, ask_size=1000)
|
| 28 |
+
scanner.update_state('kalshi', 'KXUS2024', bid=0.53, bid_size=1500, ask=0.54, ask_size=500)
|
| 29 |
+
|
| 30 |
+
opps = scanner.scan_opportunities()
|
| 31 |
+
assert len(opps) == 0
|
| 32 |
+
|
| 33 |
+
def test_intra_market_arbitrage():
|
| 34 |
+
scanner = IntraMarketArbitrage(min_profit_threshold=0.01)
|
| 35 |
+
|
| 36 |
+
# Parity violation: P(Yes) + P(No) = 0.45 + 0.45 = 0.90 < 1.0 (Margin = 0.10)
|
| 37 |
+
margin = scanner.check_parity_violation(ask_yes=0.45, ask_no=0.45)
|
| 38 |
+
|
| 39 |
+
assert margin is not None
|
| 40 |
+
assert round(margin, 2) == 0.10
|
| 41 |
+
|
| 42 |
+
# Efficient market: P(Yes) + P(No) = 0.52 + 0.52 = 1.04 > 1.0 (No Arb)
|
| 43 |
+
margin_eff = scanner.check_parity_violation(ask_yes=0.52, ask_no=0.52)
|
| 44 |
+
assert margin_eff is None
|
tests/test_bayesian.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from src.models.bayesian import BayesianFairValue
|
| 3 |
+
|
| 4 |
+
def test_bayesian_fair_value_initialization():
|
| 5 |
+
model = BayesianFairValue(prior_prob=0.5, prior_confidence=10.0)
|
| 6 |
+
|
| 7 |
+
assert model.get_fair_value() == 0.5
|
| 8 |
+
assert model.alpha == 5.0
|
| 9 |
+
assert model.beta == 5.0
|
| 10 |
+
|
| 11 |
+
def test_bayesian_update():
|
| 12 |
+
model = BayesianFairValue(prior_prob=0.5, prior_confidence=10.0)
|
| 13 |
+
|
| 14 |
+
# We observe a lot of volume trading at 0.70 implied probability
|
| 15 |
+
model.update(market_implied_prob=0.70, trade_volume=100.0, noise_factor=0.1)
|
| 16 |
+
|
| 17 |
+
# FV should shift towards 0.70
|
| 18 |
+
fv = model.get_fair_value()
|
| 19 |
+
assert fv > 0.5
|
| 20 |
+
assert fv < 0.70
|
| 21 |
+
|
| 22 |
+
def test_evaluate_opportunity():
|
| 23 |
+
model = BayesianFairValue(prior_prob=0.6, prior_confidence=100.0) # Highly confident at 0.6
|
| 24 |
+
|
| 25 |
+
# Market asks are way below FV (Underpriced)
|
| 26 |
+
opp_buy = model.evaluate_opportunity(current_ask=0.40, current_bid=0.38)
|
| 27 |
+
assert opp_buy is not None
|
| 28 |
+
assert opp_buy["action"] == "BUY_YES"
|
| 29 |
+
assert opp_buy["edge"] > 0.15 # 0.60 - 0.40 = 0.20ish edge
|
| 30 |
+
|
| 31 |
+
# Market bids are way above FV (Overpriced)
|
| 32 |
+
opp_sell = model.evaluate_opportunity(current_ask=0.82, current_bid=0.80)
|
| 33 |
+
assert opp_sell is not None
|
| 34 |
+
assert opp_sell["action"] == "SELL_YES"
|
| 35 |
+
assert opp_sell["edge"] > 0.15 # 0.80 - 0.60 = 0.20ish edge
|
tests/test_kalman.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import numpy as np
|
| 3 |
+
from src.models.kalman import KalmanPriceSmoother
|
| 4 |
+
|
| 5 |
+
def test_kalman_filter_initialization():
|
| 6 |
+
filter = KalmanPriceSmoother()
|
| 7 |
+
assert filter.x is None
|
| 8 |
+
|
| 9 |
+
filter.initialize(0.50)
|
| 10 |
+
assert filter.x == 0.50
|
| 11 |
+
assert filter.P == 1.0
|
| 12 |
+
|
| 13 |
+
def test_kalman_filter_smoothing():
|
| 14 |
+
filter = KalmanPriceSmoother(process_variance=1e-4, measurement_variance=1e-2)
|
| 15 |
+
|
| 16 |
+
# Simulate a stable price with a sudden noisy spike
|
| 17 |
+
prices = [0.50, 0.505, 0.495, 0.50, 0.51, 0.65, 0.50, 0.505]
|
| 18 |
+
|
| 19 |
+
smoothed = filter.batch_smooth(np.array(prices))
|
| 20 |
+
|
| 21 |
+
# The smoothed value at index 5 (0.65 spike) should be significantly lower than 0.65
|
| 22 |
+
# because the filter heavily trusts its prior state (0.50) and dampens the noise.
|
| 23 |
+
assert smoothed[5] < 0.65
|
| 24 |
+
assert smoothed[5] > 0.50 # It should move up slightly
|
| 25 |
+
|
| 26 |
+
# The final smoothed value should quickly return near 0.50
|
| 27 |
+
assert abs(smoothed[-1] - 0.50) < 0.05
|