update 03-22 09:28

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

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

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

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

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

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

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

View File

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