update 03-22 09:28
This commit is contained in:
6
src/market/__init__.py
Normal file
6
src/market/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Market discovery and window tracking."""
|
||||
|
||||
from src.market.discovery import MarketDiscovery
|
||||
from src.market.window_tracker import WindowTracker
|
||||
|
||||
__all__ = ["MarketDiscovery", "WindowTracker"]
|
||||
275
src/market/discovery.py
Normal file
275
src/market/discovery.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Active 5min/15min Up/Down crypto market discovery via Gamma API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Callable, Optional
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from src.data.models import ActiveMarket, Asset, Timeframe
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
GAMMA_API_URL = "https://gamma-api.polymarket.com/events"
|
||||
GAMMA_QUERY_PARAMS = {
|
||||
"tag": "crypto",
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"limit": "100",
|
||||
}
|
||||
|
||||
SUPPORTED_ASSETS: dict[str, Asset] = {
|
||||
"BTC": Asset.BTC,
|
||||
"ETH": Asset.ETH,
|
||||
"SOL": Asset.SOL,
|
||||
}
|
||||
|
||||
TIMEFRAME_PATTERNS: dict[str, Timeframe] = {
|
||||
"5 Min": Timeframe.FIVE_MIN,
|
||||
"5 min": Timeframe.FIVE_MIN,
|
||||
"5Min": Timeframe.FIVE_MIN,
|
||||
"15 Min": Timeframe.FIFTEEN_MIN,
|
||||
"15 min": Timeframe.FIFTEEN_MIN,
|
||||
"15Min": Timeframe.FIFTEEN_MIN,
|
||||
}
|
||||
|
||||
|
||||
def _extract_asset(title: str) -> Optional[Asset]:
|
||||
"""Extract the crypto asset from an event/market title."""
|
||||
for keyword, asset in SUPPORTED_ASSETS.items():
|
||||
if keyword in title:
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
def _extract_timeframe(title: str) -> Optional[Timeframe]:
|
||||
"""Extract the timeframe from an event/market title."""
|
||||
for pattern, timeframe in TIMEFRAME_PATTERNS.items():
|
||||
if pattern in title:
|
||||
return timeframe
|
||||
return None
|
||||
|
||||
|
||||
def _extract_token_ids(market: dict) -> tuple[str, str] | None:
|
||||
"""Extract (up_token_id, down_token_id) from a market dict.
|
||||
|
||||
The Gamma API returns token IDs as a JSON-encoded list or a
|
||||
``clobTokenIds`` field. The first token is conventionally the
|
||||
"Up" outcome, the second is "Down". We also inspect ``outcomes``
|
||||
to verify ordering when available.
|
||||
"""
|
||||
tokens: list[str] = []
|
||||
outcomes: list[str] = []
|
||||
|
||||
# Token IDs
|
||||
raw_tokens = market.get("clobTokenIds")
|
||||
if isinstance(raw_tokens, list):
|
||||
tokens = [str(t) for t in raw_tokens]
|
||||
elif isinstance(raw_tokens, str):
|
||||
# Sometimes returned as JSON-encoded string "[\"0xabc\",\"0xdef\"]"
|
||||
try:
|
||||
import json
|
||||
tokens = [str(t) for t in json.loads(raw_tokens)]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# Outcomes
|
||||
raw_outcomes = market.get("outcomes")
|
||||
if isinstance(raw_outcomes, list):
|
||||
outcomes = [str(o).upper().strip() for o in raw_outcomes]
|
||||
elif isinstance(raw_outcomes, str):
|
||||
try:
|
||||
import json
|
||||
outcomes = [str(o).upper().strip() for o in json.loads(raw_outcomes)]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if len(tokens) < 2:
|
||||
return None
|
||||
|
||||
# Determine which token is Up and which is Down
|
||||
up_token: str = tokens[0]
|
||||
down_token: str = tokens[1]
|
||||
|
||||
if len(outcomes) >= 2:
|
||||
if outcomes[0] == "DOWN" and outcomes[1] == "UP":
|
||||
up_token, down_token = tokens[1], tokens[0]
|
||||
|
||||
return up_token, down_token
|
||||
|
||||
|
||||
class MarketDiscovery:
|
||||
"""Discovers active 5-min and 15-min Up/Down crypto markets on Polymarket.
|
||||
|
||||
Uses the Gamma API to fetch events tagged as crypto, then filters for
|
||||
short-duration binary Up/Down markets on BTC, ETH, or SOL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Optional[aiohttp.ClientSession] = None,
|
||||
on_new_markets: Optional[Callable[[list[ActiveMarket]], None]] = None,
|
||||
) -> None:
|
||||
self._external_session = session
|
||||
self._session: Optional[aiohttp.ClientSession] = session
|
||||
self._on_new_markets = on_new_markets
|
||||
self._seen_condition_ids: set[str] = set()
|
||||
self._log = logger.bind(component="MarketDiscovery")
|
||||
|
||||
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def _close_session_if_owned(self) -> None:
|
||||
"""Close the HTTP session only if we created it ourselves."""
|
||||
if self._external_session is None and self._session is not None and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def discover(self) -> list[ActiveMarket]:
|
||||
"""Fetch events from the Gamma API and return matching ActiveMarket instances."""
|
||||
session = await self._ensure_session()
|
||||
self._log.info("gamma_api_fetch_start")
|
||||
|
||||
try:
|
||||
async with session.get(GAMMA_API_URL, params=GAMMA_QUERY_PARAMS, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status == 429:
|
||||
retry_after = float(resp.headers.get("Retry-After", "5"))
|
||||
self._log.warning("gamma_api_rate_limited", retry_after=retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
return []
|
||||
|
||||
if resp.status != 200:
|
||||
self._log.error("gamma_api_http_error", status=resp.status, reason=resp.reason)
|
||||
return []
|
||||
|
||||
try:
|
||||
data = await resp.json()
|
||||
except (aiohttp.ContentTypeError, ValueError) as exc:
|
||||
self._log.error("gamma_api_json_parse_error", error=str(exc))
|
||||
return []
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
self._log.error("gamma_api_timeout")
|
||||
return []
|
||||
except aiohttp.ClientError as exc:
|
||||
self._log.error("gamma_api_client_error", error=str(exc))
|
||||
return []
|
||||
|
||||
events: list[dict] = data if isinstance(data, list) else []
|
||||
markets = self._filter_and_parse(events)
|
||||
self._log.info("gamma_api_fetch_done", total_events=len(events), matched_markets=len(markets))
|
||||
return markets
|
||||
|
||||
def _filter_and_parse(self, events: list[dict]) -> list[ActiveMarket]:
|
||||
"""Filter events and their sub-markets, returning ActiveMarket instances."""
|
||||
results: list[ActiveMarket] = []
|
||||
|
||||
for event in events:
|
||||
title: str = event.get("title", "")
|
||||
|
||||
# Must be an Up or Down style market
|
||||
if not re.search(r"[Uu]p\s+or\s+[Dd]own", title):
|
||||
continue
|
||||
|
||||
# Must match a supported timeframe
|
||||
timeframe = _extract_timeframe(title)
|
||||
if timeframe is None:
|
||||
continue
|
||||
|
||||
# Must be for a supported asset
|
||||
asset = _extract_asset(title)
|
||||
if asset is None:
|
||||
continue
|
||||
|
||||
# Process each sub-market within the event
|
||||
sub_markets: list[dict] = event.get("markets", [])
|
||||
if not sub_markets:
|
||||
# Some API shapes embed market data at the event level
|
||||
sub_markets = [event]
|
||||
|
||||
for mkt in sub_markets:
|
||||
if not mkt.get("active", False):
|
||||
continue
|
||||
if not mkt.get("enableOrderBook", False):
|
||||
continue
|
||||
|
||||
condition_id: str = mkt.get("conditionId", "") or mkt.get("condition_id", "")
|
||||
if not condition_id:
|
||||
continue
|
||||
|
||||
token_pair = _extract_token_ids(mkt)
|
||||
if token_pair is None:
|
||||
self._log.debug("skip_market_missing_tokens", condition_id=condition_id)
|
||||
continue
|
||||
|
||||
up_token, down_token = token_pair
|
||||
end_date: str = mkt.get("endDate", "") or mkt.get("end_date_iso", "") or ""
|
||||
|
||||
results.append(
|
||||
ActiveMarket(
|
||||
condition_id=condition_id,
|
||||
up_token_id=up_token,
|
||||
down_token_id=down_token,
|
||||
asset=asset,
|
||||
timeframe=timeframe,
|
||||
end_date=end_date,
|
||||
question=mkt.get("question", title),
|
||||
description=mkt.get("description", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Continuous discovery loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def discover_loop(self, interval_sec: float = 30) -> None:
|
||||
"""Continuously discover markets and invoke the callback with new ones.
|
||||
|
||||
Runs indefinitely. Each iteration sleeps for *interval_sec* seconds
|
||||
after completing a fetch cycle. Previously seen ``condition_id`` values
|
||||
are cached so the callback only receives genuinely new markets.
|
||||
"""
|
||||
self._log.info("discover_loop_start", interval_sec=interval_sec)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
all_markets = await self.discover()
|
||||
new_markets = [
|
||||
m for m in all_markets
|
||||
if m.condition_id not in self._seen_condition_ids
|
||||
]
|
||||
|
||||
for m in new_markets:
|
||||
self._seen_condition_ids.add(m.condition_id)
|
||||
|
||||
if new_markets:
|
||||
self._log.info("new_markets_found", count=len(new_markets))
|
||||
if self._on_new_markets is not None:
|
||||
self._on_new_markets(new_markets)
|
||||
|
||||
except Exception:
|
||||
self._log.exception("discover_loop_iteration_error")
|
||||
|
||||
await asyncio.sleep(interval_sec)
|
||||
finally:
|
||||
await self._close_session_if_owned()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def reset_cache(self) -> None:
|
||||
"""Clear the set of previously seen condition IDs."""
|
||||
self._seen_condition_ids.clear()
|
||||
self._log.info("cache_cleared")
|
||||
186
src/market/oracle.py
Normal file
186
src/market/oracle.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Chainlink Oracle monitor — tracks oracle update latency via web3.
|
||||
|
||||
Monitors the delay between real CEX prices and Chainlink oracle updates
|
||||
on Polygon, which is the core edge in temporal arbitrage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
# Chainlink AggregatorV3Interface ABI (latestRoundData only)
|
||||
AGGREGATOR_ABI = [
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "latestRoundData",
|
||||
"outputs": [
|
||||
{"name": "roundId", "type": "uint80"},
|
||||
{"name": "answer", "type": "int256"},
|
||||
{"name": "startedAt", "type": "uint256"},
|
||||
{"name": "updatedAt", "type": "uint256"},
|
||||
{"name": "answeredInRound", "type": "uint80"},
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function",
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [{"name": "", "type": "uint8"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class OracleMonitor:
|
||||
"""Monitors Chainlink oracle update frequency and latency.
|
||||
|
||||
The oracle typically updates every ~10-30 seconds or on 0.5% deviation.
|
||||
Tracking this helps calibrate the temporal arbitrage opportunity window.
|
||||
"""
|
||||
|
||||
# Chainlink price feed addresses on Polygon
|
||||
FEEDS = {
|
||||
"BTC": "0xc907E116054Ad103354f2D350FD2514433D57F6f",
|
||||
"ETH": "0xF9680D99D6C9589e2a93a78A04A279e509205945",
|
||||
"SOL": "0x10C8264C0935b3B9870013e057f330Ff3e9C56dC",
|
||||
}
|
||||
|
||||
def __init__(self, rpc_url: Optional[str] = None) -> None:
|
||||
self._rpc_url = rpc_url or "https://polygon.drpc.org"
|
||||
self._w3 = None
|
||||
self._contracts: dict[str, object] = {}
|
||||
self._decimals: dict[str, int] = {}
|
||||
self._last_oracle_prices: dict[str, float] = {}
|
||||
self._last_oracle_timestamps: dict[str, float] = {}
|
||||
self._last_oracle_round_ids: dict[str, int] = {}
|
||||
self._update_intervals: dict[str, list[float]] = {
|
||||
"BTC": [], "ETH": [], "SOL": []
|
||||
}
|
||||
self._initialized = False
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize web3 connection and contract instances."""
|
||||
try:
|
||||
from web3 import Web3
|
||||
self._w3 = Web3(Web3.HTTPProvider(self._rpc_url))
|
||||
|
||||
if not self._w3.is_connected():
|
||||
log.error("oracle_web3_not_connected", rpc_url=self._rpc_url)
|
||||
return False
|
||||
|
||||
for asset, address in self.FEEDS.items():
|
||||
checksum = self._w3.to_checksum_address(address)
|
||||
contract = self._w3.eth.contract(
|
||||
address=checksum, abi=AGGREGATOR_ABI
|
||||
)
|
||||
self._contracts[asset] = contract
|
||||
self._decimals[asset] = contract.functions.decimals().call()
|
||||
|
||||
self._initialized = True
|
||||
log.info(
|
||||
"oracle_initialized",
|
||||
rpc_url=self._rpc_url,
|
||||
assets=list(self._contracts.keys()),
|
||||
)
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
log.warning("oracle_web3_not_installed", msg="pip install web3")
|
||||
return False
|
||||
except Exception:
|
||||
log.exception("oracle_init_failed")
|
||||
return False
|
||||
|
||||
async def get_oracle_price(self, asset: str) -> Optional[float]:
|
||||
"""Fetch the latest oracle price for an asset from Chainlink."""
|
||||
if not self._initialized or asset not in self._contracts:
|
||||
return self._last_oracle_prices.get(asset)
|
||||
|
||||
try:
|
||||
contract = self._contracts[asset]
|
||||
result = contract.functions.latestRoundData().call()
|
||||
|
||||
round_id, answer, started_at, updated_at, answered_in_round = result
|
||||
decimals = self._decimals.get(asset, 8)
|
||||
price = answer / (10 ** decimals)
|
||||
|
||||
# Record the update
|
||||
self.record_oracle_update(asset, price, updated_at)
|
||||
self._last_oracle_round_ids[asset] = round_id
|
||||
|
||||
return price
|
||||
|
||||
except Exception:
|
||||
log.exception("oracle_fetch_failed", asset=asset)
|
||||
return self._last_oracle_prices.get(asset)
|
||||
|
||||
def record_oracle_update(self, asset: str, price: float, timestamp: float) -> None:
|
||||
"""Record an observed oracle price update."""
|
||||
prev_ts = self._last_oracle_timestamps.get(asset)
|
||||
if prev_ts is not None and timestamp > prev_ts:
|
||||
interval = timestamp - prev_ts
|
||||
intervals = self._update_intervals[asset]
|
||||
intervals.append(interval)
|
||||
# Keep last 100 intervals
|
||||
if len(intervals) > 100:
|
||||
intervals.pop(0)
|
||||
|
||||
self._last_oracle_prices[asset] = price
|
||||
self._last_oracle_timestamps[asset] = timestamp
|
||||
|
||||
def get_avg_update_interval(self, asset: str) -> Optional[float]:
|
||||
"""Get average oracle update interval in seconds."""
|
||||
intervals = self._update_intervals.get(asset, [])
|
||||
if not intervals:
|
||||
return None
|
||||
return sum(intervals) / len(intervals)
|
||||
|
||||
def get_estimated_lag(self, asset: str) -> Optional[float]:
|
||||
"""Estimate current oracle lag (time since last known update)."""
|
||||
last_ts = self._last_oracle_timestamps.get(asset)
|
||||
if last_ts is None:
|
||||
return None
|
||||
return time.time() - last_ts
|
||||
|
||||
def get_oracle_vs_cex_deviation(
|
||||
self, asset: str, cex_price: float
|
||||
) -> Optional[float]:
|
||||
"""Calculate percentage deviation between oracle and CEX price."""
|
||||
oracle_price = self._last_oracle_prices.get(asset)
|
||||
if oracle_price is None or oracle_price <= 0:
|
||||
return None
|
||||
return (cex_price - oracle_price) / oracle_price * 100
|
||||
|
||||
async def poll_loop(self, interval: float = 5.0) -> None:
|
||||
"""Continuously poll oracle prices."""
|
||||
if not self._initialized:
|
||||
log.warning("oracle_poll_not_initialized")
|
||||
return
|
||||
|
||||
while True:
|
||||
for asset in self.FEEDS:
|
||||
try:
|
||||
await self.get_oracle_price(asset)
|
||||
except Exception:
|
||||
log.exception("oracle_poll_error", asset=asset)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
return {
|
||||
asset: {
|
||||
"last_price": self._last_oracle_prices.get(asset),
|
||||
"last_round_id": self._last_oracle_round_ids.get(asset),
|
||||
"avg_interval": round(avg, 2) if (avg := self.get_avg_update_interval(asset)) else None,
|
||||
"estimated_lag": round(lag, 2) if (lag := self.get_estimated_lag(asset)) else None,
|
||||
"initialized": self._initialized,
|
||||
}
|
||||
for asset in self.FEEDS
|
||||
}
|
||||
193
src/market/window_tracker.py
Normal file
193
src/market/window_tracker.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Track 5-minute and 15-minute price windows for BTC, ETH, SOL.
|
||||
|
||||
Maintains six simultaneous windows (3 assets x 2 timeframes), capturing the
|
||||
start price from the first CEX tick after each window opens and tracking the
|
||||
current price throughout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Callable, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.data.models import ActiveMarket, Asset, Timeframe, WindowState
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# Timeframe durations in seconds.
|
||||
_TIMEFRAME_SECONDS: dict[Timeframe, int] = {
|
||||
Timeframe.FIVE_MIN: 5 * 60,
|
||||
Timeframe.FIFTEEN_MIN: 15 * 60,
|
||||
}
|
||||
|
||||
|
||||
def _window_bounds(timestamp: float, timeframe: Timeframe) -> tuple[float, float]:
|
||||
"""Return (start, end) UTC-aligned window boundaries for *timestamp*."""
|
||||
interval = _TIMEFRAME_SECONDS[timeframe]
|
||||
start = math.floor(timestamp / interval) * interval
|
||||
return float(start), float(start + interval)
|
||||
|
||||
|
||||
class WindowTracker:
|
||||
"""Track clock-aligned price windows for multiple assets and timeframes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
assets:
|
||||
Assets to track. Defaults to BTC, ETH, SOL.
|
||||
timeframes:
|
||||
Timeframes to track. Defaults to 5M and 15M.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
assets: Optional[list[Asset]] = None,
|
||||
timeframes: Optional[list[Timeframe]] = None,
|
||||
) -> None:
|
||||
self._assets: list[Asset] = assets or list(Asset)
|
||||
self._timeframes: list[Timeframe] = timeframes or list(Timeframe)
|
||||
|
||||
# Keyed by (asset, timeframe).
|
||||
self._windows: dict[tuple[Asset, Timeframe], WindowState] = {}
|
||||
self._callbacks: list[Callable[[WindowState], None]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_price(self, asset: str, price: float, timestamp: float) -> None:
|
||||
"""Process an incoming CEX price tick.
|
||||
|
||||
If *timestamp* falls within a new window that we haven't initialised
|
||||
yet (or a different window from the one we're tracking), a fresh
|
||||
``WindowState`` is created and any registered callbacks are fired.
|
||||
|
||||
The first tick inside a window sets ``start_price``. Every tick
|
||||
updates ``current_price``.
|
||||
"""
|
||||
asset_enum = Asset(asset)
|
||||
|
||||
for tf in self._timeframes:
|
||||
key = (asset_enum, tf)
|
||||
win_start, win_end = _window_bounds(timestamp, tf)
|
||||
|
||||
existing = self._windows.get(key)
|
||||
|
||||
# Detect window transition (or first-ever window).
|
||||
if existing is None or existing.window_start_time != win_start:
|
||||
# Carry over any linked market from the previous window.
|
||||
linked_market = existing.market if existing else None
|
||||
|
||||
new_window = WindowState(
|
||||
asset=asset_enum,
|
||||
timeframe=tf,
|
||||
window_start_time=win_start,
|
||||
window_end_time=win_end,
|
||||
start_price=price,
|
||||
current_price=price,
|
||||
market=linked_market,
|
||||
)
|
||||
self._windows[key] = new_window
|
||||
|
||||
log.info(
|
||||
"window_transition",
|
||||
asset=asset_enum.value,
|
||||
timeframe=tf.value,
|
||||
window_start=win_start,
|
||||
window_end=win_end,
|
||||
start_price=price,
|
||||
)
|
||||
|
||||
# Fire callbacks with the COMPLETED window so that
|
||||
# subscribers can read its final price_change_pct.
|
||||
# On the very first window there is no completed window
|
||||
# to report, so fire with the new one instead.
|
||||
completed = existing if existing is not None else new_window
|
||||
self._fire_callbacks(completed)
|
||||
else:
|
||||
# Same window — update current price. If start_price was
|
||||
# somehow not captured (shouldn't happen with this logic,
|
||||
# but defensive), fill it with the first available price.
|
||||
if existing.start_price is None:
|
||||
existing.start_price = price
|
||||
log.info(
|
||||
"late_start_price",
|
||||
asset=asset_enum.value,
|
||||
timeframe=tf.value,
|
||||
price=price,
|
||||
)
|
||||
existing.current_price = price
|
||||
|
||||
def get_window(self, asset: str, timeframe: str) -> Optional[WindowState]:
|
||||
"""Return the current window state for an asset/timeframe pair."""
|
||||
key = (Asset(asset), Timeframe(timeframe))
|
||||
return self._windows.get(key)
|
||||
|
||||
def get_all_active_windows(self) -> list[WindowState]:
|
||||
"""Return every currently tracked window."""
|
||||
return list(self._windows.values())
|
||||
|
||||
def is_window_expired(self, asset: str, timeframe: str) -> bool:
|
||||
"""Check whether the tracked window has already ended.
|
||||
|
||||
Returns ``True`` when the window end time is in the past **or**
|
||||
when no window has been initialised for the given pair.
|
||||
"""
|
||||
import time
|
||||
|
||||
key = (Asset(asset), Timeframe(timeframe))
|
||||
window = self._windows.get(key)
|
||||
if window is None:
|
||||
return True
|
||||
return time.time() >= window.window_end_time
|
||||
|
||||
def link_market(
|
||||
self, asset: str, timeframe: str, market: ActiveMarket
|
||||
) -> None:
|
||||
"""Associate a Polymarket market with the current window.
|
||||
|
||||
If no window exists yet for the pair, one is **not** created — the
|
||||
market will be linked once the first price tick arrives and triggers
|
||||
a window transition.
|
||||
"""
|
||||
key = (Asset(asset), Timeframe(timeframe))
|
||||
window = self._windows.get(key)
|
||||
if window is not None:
|
||||
window.market = market
|
||||
log.info(
|
||||
"market_linked",
|
||||
asset=asset,
|
||||
timeframe=timeframe,
|
||||
condition_id=market.condition_id,
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"link_market_no_window",
|
||||
asset=asset,
|
||||
timeframe=timeframe,
|
||||
condition_id=market.condition_id,
|
||||
)
|
||||
|
||||
def on_window_change(self, callback: Callable[[WindowState], None]) -> None:
|
||||
"""Register a callback invoked whenever a new window starts.
|
||||
|
||||
The callback receives the newly-created ``WindowState``.
|
||||
"""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fire_callbacks(self, window: WindowState) -> None:
|
||||
for cb in self._callbacks:
|
||||
try:
|
||||
cb(window)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"window_change_callback_error",
|
||||
asset=window.asset.value,
|
||||
timeframe=window.timeframe.value,
|
||||
)
|
||||
Reference in New Issue
Block a user