Good enough to hand to the public
This commit is contained in:
@@ -103,6 +103,11 @@ class GammaClient:
|
||||
params["volume_num_min"] = min_volume_24hr
|
||||
|
||||
r = await self._client.get("/markets", params=params)
|
||||
# Gamma returns 422 (not an empty page) once the offset runs past the
|
||||
# last result — treat that as the natural end of pagination.
|
||||
if r.status_code in (400, 422):
|
||||
log.info("pagination_end", offset=offset, status=r.status_code)
|
||||
return
|
||||
r.raise_for_status()
|
||||
batch = r.json()
|
||||
if not batch:
|
||||
@@ -158,6 +163,9 @@ def parse_market(raw: dict[str, Any], reward_rates: dict[str, float] | None = No
|
||||
best_ask=float(raw.get("bestAsk", 0) or 0),
|
||||
liquidity_num=float(raw.get("liquidityNum", 0) or 0),
|
||||
volume_num=float(raw.get("volumeNum", 0) or 0),
|
||||
# prefer CLOB 24h volume (the taker flow that generates fees);
|
||||
# fall back to total 24h volume
|
||||
volume_24hr=float(raw.get("volume24hrClob") or raw.get("volume24hr") or 0),
|
||||
)
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
log.warning("parse_market_failed", err=str(exc), slug=raw.get("slug"))
|
||||
|
||||
@@ -44,15 +44,25 @@ def reward_density(m: MarketMeta, quote_size_usdc: float = 100.0) -> float:
|
||||
|
||||
|
||||
def rebate_potential(m: MarketMeta) -> float:
|
||||
"""Est. daily maker-rebate pool: taker_fee_rate * rebate_rate * daily volume."""
|
||||
"""Estimated daily maker-rebate POOL for the market, using the exact V2 fee
|
||||
formula (per-market rate + rebate rate, no hardcoding).
|
||||
|
||||
Per-share taker fee = fee_rate * p*(1-p) (py_clob_client_v2/fees.py).
|
||||
Daily taker shares ~ vol_24h / mid, so:
|
||||
daily fees = (vol/mid) * fee_rate * mid*(1-mid) = vol * fee_rate * (1-mid)
|
||||
rebate pool = daily fees * rebate_rate
|
||||
This is the whole-market pool; your take is (your maker-fill share) x pool.
|
||||
It's a trailing-volume estimate — actual depends on future flow + fill share.
|
||||
"""
|
||||
if not m.fees_enabled or m.rebate_rate <= 0 or m.taker_fee_bps <= 0:
|
||||
return 0.0
|
||||
daily_vol = m.volume_num # best proxy available from catalog; refined live
|
||||
taker_rate = m.taker_fee_bps / 10000.0
|
||||
# taker fee peaks at p*(1-p); use mid as the representative point
|
||||
vol24 = m.volume_24hr
|
||||
if vol24 <= 0:
|
||||
return 0.0
|
||||
fee_rate = m.taker_fee_bps / 10000.0
|
||||
mid = _mid(m)
|
||||
fee_factor = mid * (1.0 - mid)
|
||||
return daily_vol * taker_rate * fee_factor * m.rebate_rate * 0.01 # 1% daily-vol proxy
|
||||
daily_fees = vol24 * fee_rate * (1.0 - mid)
|
||||
return round(daily_fees * m.rebate_rate, 2)
|
||||
|
||||
|
||||
def extremity(m: MarketMeta) -> float:
|
||||
@@ -62,19 +72,25 @@ def extremity(m: MarketMeta) -> float:
|
||||
|
||||
|
||||
def score_market(m: MarketMeta) -> MarketScore:
|
||||
rd = reward_density(m)
|
||||
rp = rebate_potential(m)
|
||||
rd = reward_density(m) # our estimated reward income (share-adjusted)
|
||||
rp = rebate_potential(m) # total daily rebate POOL (for display)
|
||||
ext = extremity(m)
|
||||
spread = max(0.0, m.best_ask - m.best_bid) if (m.best_bid and m.best_ask) else 1.0
|
||||
|
||||
# income terms are additive; extremity and wide spreads discount the score
|
||||
income = rd + rp
|
||||
# our estimated income = reward share + (rebate pool * our fill/liquidity share);
|
||||
# extremity and wide spreads discount the score
|
||||
ref = 100.0
|
||||
our_share = min(0.5, ref / max(m.liquidity_num, ref)) # you won't own a whole pool
|
||||
income = rd + rp * our_share
|
||||
penalty = (1.0 - 0.5 * ext) * (1.0 / (1.0 + spread * 20.0))
|
||||
# viability: a market needs real book depth to actually quote — otherwise a
|
||||
# near-zero-liquidity market games "our share" to the top of the ranking
|
||||
viability = min(1.0, m.liquidity_num / 2000.0)
|
||||
return MarketScore(
|
||||
condition_id=m.condition_id,
|
||||
reward_density=round(rd, 3),
|
||||
rebate_potential=round(rp, 3),
|
||||
rebate_potential=round(rp, 3), # the market's total daily rebate pool
|
||||
spread=round(spread, 4),
|
||||
extremity=round(ext, 3),
|
||||
score=round(income * penalty, 4),
|
||||
score=round(income * penalty * viability, 4),
|
||||
)
|
||||
|
||||
@@ -89,9 +89,20 @@ class CatalogStore:
|
||||
).fetchone()
|
||||
return _load_meta(row["meta_json"]) if row else None
|
||||
|
||||
def top(self, limit: int = 50) -> list[tuple[MarketMeta, MarketScore]]:
|
||||
def top(self, limit: int = 50, fresh_s: float = 3600.0) -> list[tuple[MarketMeta, MarketScore]]:
|
||||
"""Top markets by score, restricted to the most recent scan.
|
||||
|
||||
The markets table accumulates rows across scans; without a freshness gate
|
||||
a stale row (scored by an older formula, or a market that has since
|
||||
resolved / dropped out of the tag) can surface at the top. We keep only
|
||||
rows scanned within `fresh_s` of the newest row.
|
||||
"""
|
||||
newest = self._conn.execute("SELECT MAX(scanned_ts) AS t FROM markets").fetchone()
|
||||
cutoff = (newest["t"] or 0.0) - fresh_s
|
||||
rows = self._conn.execute(
|
||||
"SELECT meta_json, score_json FROM markets ORDER BY score DESC LIMIT ?", (limit,)
|
||||
"SELECT meta_json, score_json FROM markets WHERE scanned_ts >= ? "
|
||||
"ORDER BY score DESC LIMIT ?",
|
||||
(cutoff, limit),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
@@ -109,9 +120,10 @@ class CatalogStore:
|
||||
"""
|
||||
rows = self.top(limit)
|
||||
fields = [
|
||||
"score", "reward_per_day", "rebate_per_day", "spread", "best_bid", "best_ask",
|
||||
"tick", "min_size", "neg_risk", "taker_fee_bps", "rewards_max_spread",
|
||||
"liquidity", "volume", "end_date", "question", "slug", "condition_id",
|
||||
"score", "reward_pool_per_day", "rebate_pool_per_day", "spread",
|
||||
"best_bid", "best_ask", "tick", "min_size", "neg_risk", "taker_fee_pct",
|
||||
"rebate_pct", "rewards_max_spread", "liquidity", "volume_24h",
|
||||
"end_date", "question", "slug", "condition_id",
|
||||
]
|
||||
with open(path, "w", newline="") as fh:
|
||||
w = csv.writer(fh)
|
||||
@@ -120,9 +132,9 @@ class CatalogStore:
|
||||
w.writerow([
|
||||
f"{sc.score:.3f}", f"{m.rewards_daily_rate:.2f}", f"{sc.rebate_potential:.2f}",
|
||||
f"{sc.spread:.4f}", m.best_bid, m.best_ask, f"{m.tick_size:g}",
|
||||
f"{m.min_order_size:g}", int(m.neg_risk), m.taker_fee_bps,
|
||||
m.rewards_max_spread, f"{m.liquidity_num:.0f}", f"{m.volume_num:.0f}",
|
||||
m.end_date_iso or "", m.question, m.slug, m.condition_id,
|
||||
f"{m.min_order_size:g}", int(m.neg_risk), f"{m.taker_fee_bps / 100:.1f}",
|
||||
f"{m.rebate_rate * 100:.0f}", m.rewards_max_spread, f"{m.liquidity_num:.0f}",
|
||||
f"{m.volume_24hr:.0f}", m.end_date_iso or "", m.question, m.slug, m.condition_id,
|
||||
])
|
||||
return len(rows)
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ class WalletConfig(BaseModel):
|
||||
|
||||
class EngineConfig(BaseModel):
|
||||
debounce_ms: int = 200
|
||||
# baseline periodic re-quote (book reactions are event-driven & instant; this
|
||||
# is just a slow refresh for cool-off re-entry / exit-urgency updates). A
|
||||
# precise wake is also scheduled for the exact moment an EVENT cool-off ends.
|
||||
quoter_tick_s: float = 60.0
|
||||
reconcile_interval_s: float = 30.0
|
||||
catalog_refresh_s: float = 900.0
|
||||
heartbeat: bool = True
|
||||
@@ -86,6 +90,9 @@ class StrategyProfile(BaseModel):
|
||||
q_soft_frac: float = 0.6
|
||||
layers: int = 2
|
||||
layer_step_ticks: int = 2
|
||||
# multiplier on the market's reward min-size that reward-eligible orders are
|
||||
# bumped to (margin above the scoring floor). 1.5 => 100-share min -> 150.
|
||||
reward_size_mult: float = 1.0
|
||||
# placement / churn
|
||||
reprice_ticks: int = 2
|
||||
resize_frac: float = 0.15
|
||||
@@ -94,7 +101,15 @@ class StrategyProfile(BaseModel):
|
||||
event_cooloff_s: float = 60.0
|
||||
event_jump_ticks: int = 8
|
||||
event_sweep_levels: int = 3
|
||||
# sweep = a print >= event_sweep_mult order-sizes AND >= event_sweep_frac of
|
||||
# the near-touch depth it consumed (both must hold to flag a toxic sweep)
|
||||
event_sweep_mult: float = 4.0
|
||||
event_sweep_frac: float = 0.8
|
||||
trend_flow_z: float = 1.5
|
||||
# short/long realized-vol ratio that trips TRENDING (half size). On a thin
|
||||
# book microprice jitter inflates this without real trade flow, so raise it
|
||||
# for reward-farming markets that trade rarely.
|
||||
trend_vol_ratio: float = 2.0
|
||||
# lifecycle
|
||||
end_date_taper_days: float = 7.0
|
||||
reduce_only_hours: float = 24.0
|
||||
|
||||
@@ -92,7 +92,8 @@ class MarketMeta:
|
||||
best_bid: float = 0.0
|
||||
best_ask: float = 0.0
|
||||
liquidity_num: float = 0.0
|
||||
volume_num: float = 0.0
|
||||
volume_num: float = 0.0 # lifetime
|
||||
volume_24hr: float = 0.0 # trailing 24h CLOB volume (drives rebate estimate)
|
||||
|
||||
@property
|
||||
def yes(self) -> TokenMeta:
|
||||
|
||||
+145
-46
@@ -20,7 +20,7 @@ from polymaker.alerts import Alerter
|
||||
from polymaker.catalog.gamma import GammaClient, fetch_reward_rates, parse_market
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
from polymaker.config import Config, StrategyProfile
|
||||
from polymaker.domain import Fill, MarketMeta, Regime
|
||||
from polymaker.domain import Fill, MarketMeta, Regime, Side
|
||||
from polymaker.execution.gateway import ExecutionGateway
|
||||
from polymaker.execution.reconciler import reconcile
|
||||
from polymaker.journal import Journal
|
||||
@@ -94,6 +94,9 @@ class Engine:
|
||||
await self._resolve_markets()
|
||||
if not self.metas:
|
||||
log.warning("no_markets_selected", hint="add markets to config/markets.toml, run `polymaker scan`")
|
||||
# freshen reward/fee/end-date params from live Gamma BEFORE quoting so a
|
||||
# stale catalog (e.g. old reward min-size) can't mis-size our orders
|
||||
await self.refresh_market_metadata()
|
||||
await self._startup_reconcile()
|
||||
|
||||
# subscribe feeds
|
||||
@@ -237,11 +240,20 @@ class Engine:
|
||||
log.error("startup_orders_stuck", n=len(still))
|
||||
self.alerter.alert("startup_orders_stuck",
|
||||
f"{len(still)} orders survived cancel-all", critical=True)
|
||||
positions = await self.gateway.positions()
|
||||
# purge positions that leaked in for markets we don't trade (manual UI
|
||||
# bets etc.) so they can't distort exposure caps or PnL
|
||||
self.state.drop_untracked_positions(set(self._token_cid))
|
||||
positions = self._only_traded(await self.gateway.positions())
|
||||
if positions:
|
||||
self.state.reconcile_positions(positions)
|
||||
log.info("startup_positions", n=len(positions))
|
||||
|
||||
def _only_traded(self, positions: dict[str, tuple[float, float]]) -> dict[str, tuple[float, float]]:
|
||||
"""Scope account positions to tokens WE trade. Manual/UI positions in
|
||||
other markets are the operator's business — they must not enter our
|
||||
state, exposure caps, or PnL."""
|
||||
return {t: v for t, v in positions.items() if t in self._token_cid}
|
||||
|
||||
# ── callbacks ───────────────────────────────────────────────────────
|
||||
def _on_dirty(self, condition_id: str, token_id: str) -> None:
|
||||
ev = self._dirty.get(condition_id)
|
||||
@@ -267,10 +279,29 @@ class Engine:
|
||||
cid = self._token_cid.get(tp.asset_id)
|
||||
if cid is None:
|
||||
return
|
||||
p = self.profiles[cid]
|
||||
self.est[cid].flow.update(tp.aggressor, tp.size, tp.ts)
|
||||
# crude sweep flag: a single print larger than 3x base size
|
||||
base = self.profiles[cid].base_size_usdc / max(tp.price, 0.01)
|
||||
if tp.size >= 3 * base:
|
||||
# A trade only flags a SWEEP (-> pull quotes) if it's genuinely toxic:
|
||||
# large in absolute terms AND large relative to the resting depth it
|
||||
# consumed (i.e. it actually ate through the book). A big trade absorbed
|
||||
# by a deep book doesn't move the price and isn't toxic — for a liquid
|
||||
# market the FV-jump detector is the real event signal. event_sweep_mult
|
||||
# sets how many order-sizes big the print must be to even be considered.
|
||||
base = p.base_size_usdc / max(tp.price, 0.01)
|
||||
if tp.size < p.event_sweep_mult * base:
|
||||
return
|
||||
book = self.md.book(tp.asset_id)
|
||||
if book is None:
|
||||
return
|
||||
bb, ba = book.best_bid(), book.best_ask()
|
||||
if bb is None or ba is None:
|
||||
return
|
||||
# aggressor BUY lifts asks; SELL hits bids — measure the side it consumed
|
||||
if tp.aggressor is Side.BUY:
|
||||
consumed = book.depth_within(Side.SELL, ba.price, ba.price + 3 * book.tick_size)
|
||||
else:
|
||||
consumed = book.depth_within(Side.BUY, bb.price - 3 * book.tick_size, bb.price)
|
||||
if consumed > 0 and tp.size >= p.event_sweep_frac * consumed:
|
||||
self._sweep[cid] = True
|
||||
|
||||
def _on_fill(self, fill: Fill) -> None:
|
||||
@@ -286,11 +317,20 @@ class Engine:
|
||||
# ── quoter ──────────────────────────────────────────────────────────
|
||||
async def _quoter(self, cid: str) -> None:
|
||||
debounce = self.cfg.engine.debounce_ms / 1000.0
|
||||
base_tick = self.cfg.engine.quoter_tick_s
|
||||
ev = self._dirty[cid]
|
||||
while self._running:
|
||||
try:
|
||||
await ev.wait()
|
||||
await asyncio.sleep(debounce) # coalesce a burst of book updates
|
||||
# Book/fill events wake us instantly. Otherwise we refresh on a
|
||||
# slow baseline tick, EXCEPT: if an EVENT cool-off is active,
|
||||
# wake precisely when it ends (re-enter promptly, not up to a
|
||||
# minute late); if we're holding inventory, tick faster to walk
|
||||
# exit urgency.
|
||||
timeout = self._next_wake_s(cid, base_tick)
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ev.wait(), timeout=timeout)
|
||||
if ev.is_set():
|
||||
await asyncio.sleep(debounce) # coalesce a burst of updates
|
||||
ev.clear()
|
||||
await self._recompute(cid)
|
||||
except asyncio.CancelledError:
|
||||
@@ -299,6 +339,21 @@ class Engine:
|
||||
log.error("quoter_error", cid=cid[:8], err=str(exc))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
def _next_wake_s(self, cid: str, base_tick: float) -> float:
|
||||
now = time.time()
|
||||
wake = base_tick
|
||||
rm = self.regime_m.get(cid)
|
||||
if rm is not None:
|
||||
cd = rm.cooloff_remaining(now)
|
||||
if cd > 0:
|
||||
wake = min(wake, cd + 0.5) # re-enter right when cool-off ends
|
||||
meta = self.metas.get(cid)
|
||||
if meta is not None: # holding inventory -> tick faster to manage exits
|
||||
held = self.state.position(meta.yes.token_id).size + self.state.position(meta.no.token_id).size
|
||||
if held >= meta.min_order_size:
|
||||
wake = min(wake, 10.0)
|
||||
return max(1.0, wake)
|
||||
|
||||
async def _recompute(self, cid: str) -> None:
|
||||
lock = self._locks.get(cid)
|
||||
if lock is None:
|
||||
@@ -338,9 +393,15 @@ class Engine:
|
||||
inv_util = abs(pos_yes.size - pos_no.size) * fv / q_max if q_max > 0 else 0.0
|
||||
hours_to_end = _hours_to_end(meta.end_date_iso, now)
|
||||
|
||||
# ── blind/stale conditions: all use LOCAL receive time (skew-proof) ──
|
||||
# ── blind/stale conditions ──────────────────────────────────────────
|
||||
# A QUIET market with a live WS link is NOT stale — the CLOB WS pings
|
||||
# every 5s (pong-timeout 10s), so a dead link flips `connected` within
|
||||
# ~15s. Gating on the connection (not book-mutation recency) stops a
|
||||
# legitimately-quiet thin market from false-halting into zero rewards.
|
||||
market_stale = (
|
||||
(now - self.md.last_local_ts(meta.yes.token_id)) > self.cfg.risk.ws_stale_halt_s
|
||||
not self.md.connected
|
||||
and self.md.disconnected_since > 0.0
|
||||
and (now - self.md.disconnected_since) > self.cfg.risk.ws_stale_halt_s
|
||||
)
|
||||
user_blind = (
|
||||
self._user_started
|
||||
@@ -436,7 +497,8 @@ class Engine:
|
||||
self._last_quote_fv[cid] = fv
|
||||
log.info("requote", cid=cid[:8], regime=regime.value, fv=round(fv, 4),
|
||||
place=placed_n, cancel=len(plan.to_cancel),
|
||||
pos_yes=round(pos_yes.size, 1), pos_no=round(pos_no.size, 1))
|
||||
pos_yes=round(pos_yes.size, 1), pos_no=round(pos_no.size, 1),
|
||||
tox=round(est.markout.toxicity, 3), flowz=round(est.flow.z, 2))
|
||||
self._maybe_merge(cid, meta, p, pos_yes.size, pos_no.size)
|
||||
|
||||
async def _quarantine(self, meta: MarketMeta, reason: str) -> None:
|
||||
@@ -526,7 +588,7 @@ class Engine:
|
||||
self.alerter.alert("inflight_expired",
|
||||
f"{len(expired)} stuck in-flight guards cleared")
|
||||
|
||||
positions = await self.gateway.positions()
|
||||
positions = self._only_traded(await self.gateway.positions())
|
||||
if positions:
|
||||
self.state.reconcile_positions(positions)
|
||||
live = await self.gateway.open_orders()
|
||||
@@ -587,44 +649,67 @@ class Engine:
|
||||
if cid:
|
||||
self._wake_cid(cid)
|
||||
|
||||
async def _metadata_refresh_loop(self) -> None:
|
||||
"""Refresh market metadata from Gamma: halt markets that have closed /
|
||||
resolved / stopped accepting orders, and pick up updated end dates."""
|
||||
async def refresh_market_metadata(self) -> None:
|
||||
"""Pull fresh metadata from Gamma for all traded markets: halt on
|
||||
closed/not-accepting, and freshen reward/fee/end-date params so we quote
|
||||
at the CURRENT reward minimum, band, and fees (these change over time —
|
||||
e.g. the reward min-size jumping 50->100 shares). Called at startup and
|
||||
periodically. Safe to await."""
|
||||
if not self.metas:
|
||||
return
|
||||
try:
|
||||
async with GammaClient(self.cfg.wallet.gamma_host) as gamma:
|
||||
raws = await gamma.markets_by_condition(list(self.metas))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("metadata_refresh_error", err=str(exc))
|
||||
return
|
||||
for cid, raw in raws.items():
|
||||
if cid not in self.metas:
|
||||
continue
|
||||
accepting = bool(raw.get("acceptingOrders", True))
|
||||
closed = bool(raw.get("closed", False))
|
||||
if closed or not accepting:
|
||||
if cid not in self._halted:
|
||||
self._halted.add(cid)
|
||||
log.critical("market_halted_by_meta", cid=cid[:8], closed=closed,
|
||||
accepting=accepting)
|
||||
self.alerter.alert(f"halted:{cid[:8]}",
|
||||
f"{self.metas[cid].question[:40]} closed/not-accepting",
|
||||
critical=True)
|
||||
meta = self.metas[cid]
|
||||
for tok in (meta.yes.token_id, meta.no.token_id):
|
||||
with contextlib.suppress(Exception):
|
||||
await self.gateway.cancel_asset(tok)
|
||||
self._wake_cid(cid)
|
||||
continue
|
||||
self._halted.discard(cid)
|
||||
self._apply_meta_refresh(cid, raw)
|
||||
|
||||
def _apply_meta_refresh(self, cid: str, raw: dict[str, Any]) -> None:
|
||||
import dataclasses
|
||||
|
||||
old = self.metas[cid]
|
||||
fee = raw.get("feeSchedule") or {}
|
||||
rate = _fnum(fee.get("rate"))
|
||||
candidates: dict[str, Any] = {
|
||||
"rewards_min_size": _fnum(raw.get("rewardsMinSize")),
|
||||
"rewards_max_spread": _fnum(raw.get("rewardsMaxSpread")),
|
||||
"taker_fee_bps": int(round(rate * 10000)) if rate is not None else None,
|
||||
"rebate_rate": _fnum(fee.get("rebateRate")),
|
||||
"end_date_iso": raw.get("endDate"),
|
||||
"min_order_size": _fnum(raw.get("orderMinSize")),
|
||||
}
|
||||
updates = {k: v for k, v in candidates.items()
|
||||
if v is not None and getattr(old, k) != v}
|
||||
if updates:
|
||||
self.metas[cid] = dataclasses.replace(old, **updates)
|
||||
log.info("meta_refreshed", cid=cid[:8], **updates)
|
||||
self._wake_cid(cid)
|
||||
|
||||
async def _metadata_refresh_loop(self) -> None:
|
||||
while self._running:
|
||||
await asyncio.sleep(self.cfg.engine.catalog_refresh_s)
|
||||
if not self.metas:
|
||||
continue
|
||||
try:
|
||||
async with GammaClient(self.cfg.wallet.gamma_host) as gamma:
|
||||
raws = await gamma.markets_by_condition(list(self.metas))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("metadata_refresh_error", err=str(exc))
|
||||
continue
|
||||
for cid, raw in raws.items():
|
||||
if cid not in self.metas:
|
||||
continue
|
||||
accepting = bool(raw.get("acceptingOrders", True))
|
||||
closed = bool(raw.get("closed", False))
|
||||
if closed or not accepting:
|
||||
if cid not in self._halted:
|
||||
self._halted.add(cid)
|
||||
log.critical("market_halted_by_meta", cid=cid[:8], closed=closed,
|
||||
accepting=accepting)
|
||||
self.alerter.alert(f"halted:{cid[:8]}",
|
||||
f"{self.metas[cid].question[:40]} closed/not-accepting",
|
||||
critical=True)
|
||||
meta = self.metas[cid]
|
||||
for tok in (meta.yes.token_id, meta.no.token_id):
|
||||
with contextlib.suppress(Exception):
|
||||
await self.gateway.cancel_asset(tok)
|
||||
self._wake_cid(cid)
|
||||
else:
|
||||
self._halted.discard(cid)
|
||||
new_end = raw.get("endDate")
|
||||
if new_end and new_end != self.metas[cid].end_date_iso:
|
||||
self.metas[cid] = dataclasses.replace(self.metas[cid], end_date_iso=new_end)
|
||||
await self.refresh_market_metadata()
|
||||
|
||||
async def _maintenance_loop(self) -> None:
|
||||
"""Periodic REST book refresh to catch any silently-missed WS deltas."""
|
||||
@@ -681,12 +766,26 @@ class Engine:
|
||||
return cost
|
||||
|
||||
|
||||
def _fnum(v: object) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v) # type: ignore[arg-type]
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hours_to_end(end_date_iso: str | None, now: float) -> float | None:
|
||||
if not end_date_iso:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(end_date_iso.replace("Z", "+00:00"))
|
||||
return max(0.0, (dt.timestamp() - now) / 3600.0)
|
||||
hrs = (dt.timestamp() - now) / 3600.0
|
||||
# A past end date on a still-trading market is a stale/placeholder date
|
||||
# (common for "next X" appointment markets) — treat as unknown so we
|
||||
# don't wrongly HALT. The true end is signalled by acceptingOrders=False,
|
||||
# which the metadata refresh already halts on.
|
||||
return hrs if hrs > 0.0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -56,6 +57,11 @@ class MarketDataService:
|
||||
self._ws: Any = None
|
||||
self._stop = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
# wall-clock when the link last went down (0 until the first run). Used
|
||||
# for staleness: a QUIET market with a live link is NOT stale — only a
|
||||
# genuinely down connection is. Book-mutation recency can't tell the two
|
||||
# apart on a thin market, so we gate on connection liveness instead.
|
||||
self.disconnected_since: float = 0.0
|
||||
|
||||
# ── subscription management ─────────────────────────────────────────
|
||||
def set_markets(self, markets: list[tuple[str, list[str]]]) -> None:
|
||||
@@ -87,6 +93,7 @@ class MarketDataService:
|
||||
# ── run loop ────────────────────────────────────────────────────────
|
||||
async def run(self) -> None:
|
||||
backoff = 1.0
|
||||
self.disconnected_since = time.time() # start the grace clock for first connect
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await self._connect_and_listen()
|
||||
@@ -120,6 +127,7 @@ class MarketDataService:
|
||||
self._handle(raw)
|
||||
finally:
|
||||
self.connected = False
|
||||
self.disconnected_since = time.time()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
@@ -220,6 +220,20 @@ class StateStore:
|
||||
row["token_id"], row["size"], row["avg_price"]
|
||||
)
|
||||
|
||||
def drop_untracked_positions(self, tracked: set[str]) -> list[str]:
|
||||
"""Remove positions for tokens we don't trade (e.g. the operator's manual
|
||||
UI bets that leaked in via an earlier unscoped reconcile). They must not
|
||||
count toward exposure caps or PnL. Returns the dropped token ids."""
|
||||
dropped = [t for t in self.positions if t not in tracked]
|
||||
for t in dropped:
|
||||
self.positions.pop(t, None)
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
self._conn.execute("DELETE FROM positions WHERE token_id=?", (t,))
|
||||
if dropped:
|
||||
self._conn.commit()
|
||||
log.info("untracked_positions_dropped", n=len(dropped))
|
||||
return dropped
|
||||
|
||||
def force_set_position(self, token_id: str, size: float, avg_price: float, source: str) -> None:
|
||||
"""Overwrite a position unconditionally (used when on-chain is truth)."""
|
||||
prev = self.positions.get(token_id)
|
||||
|
||||
@@ -74,6 +74,7 @@ def construct_quotes(inp: QuoteInputs) -> TargetQuotes:
|
||||
net_shares = inp.pos_yes.size - inp.pos_no.size
|
||||
q_max_shares = p.q_max_usdc / max(inp.fv, tick)
|
||||
u = _clamp(net_shares / q_max_shares, -1.0, 1.0) if q_max_shares > 0 else 0.0
|
||||
reward_floor = m.rewards_min_size * p.reward_size_mult # scoring size w/ margin
|
||||
|
||||
skew = p.gamma * inp.vol_short * u
|
||||
|
||||
@@ -104,7 +105,8 @@ def construct_quotes(inp: QuoteInputs) -> TargetQuotes:
|
||||
if price is not None:
|
||||
_add_layers(quotes, m.yes.token_id, Side.BUY, price, tick, dec,
|
||||
_size_shares(p.base_size_usdc, price, common_scale * (1 - max(u, 0.0)), m),
|
||||
p.layers, p.layer_step_ticks, down=True)
|
||||
p.layers, p.layer_step_ticks, down=True,
|
||||
exchange_min=m.min_order_size, reward_floor=reward_floor)
|
||||
|
||||
# entry: BUY NO
|
||||
if add_no:
|
||||
@@ -113,7 +115,8 @@ def construct_quotes(inp: QuoteInputs) -> TargetQuotes:
|
||||
if price is not None:
|
||||
_add_layers(quotes, m.no.token_id, Side.BUY, price, tick, dec,
|
||||
_size_shares(p.base_size_usdc, price, common_scale * (1 - max(-u, 0.0)), m),
|
||||
p.layers, p.layer_step_ticks, down=True)
|
||||
p.layers, p.layer_step_ticks, down=True,
|
||||
exchange_min=m.min_order_size, reward_floor=reward_floor)
|
||||
|
||||
# ── exits: SELL held inventory (maker, never cross) ─────────────────
|
||||
_maybe_exit(quotes, m.yes.token_id, inp.pos_yes, inp.fv, delta, inp.yes_view, tick, dec,
|
||||
@@ -151,34 +154,44 @@ def _place_bid(
|
||||
|
||||
|
||||
def _size_shares(base_usdc: float, price: float, scale: float, m: MarketMeta) -> float:
|
||||
"""USDC-notional sizing -> shares, honoring exchange & reward minimums."""
|
||||
"""USDC-notional sizing -> shares. Per-order minimums applied in _add_layers
|
||||
(reward scoring is per ORDER, so the floor must hold per layer, not per total)."""
|
||||
shares = (base_usdc / max(price, m.tick_size)) * max(scale, 0.0)
|
||||
if shares <= 0:
|
||||
return 0.0
|
||||
floor = max(m.min_order_size, m.rewards_min_size)
|
||||
# round up small-but-real sizes to the reward min so they actually score
|
||||
if 0.5 * floor <= shares < floor:
|
||||
shares = floor
|
||||
return round(shares, 2) if shares >= m.min_order_size else 0.0
|
||||
return round(shares, 2) if shares > 0 else 0.0
|
||||
|
||||
|
||||
def _add_layers(
|
||||
quotes: list[Quote], token_id: str, side: Side, top_price: float, tick: float, dec: int,
|
||||
total_size: float, layers: int, step_ticks: int, *, down: bool,
|
||||
exchange_min: float = 0.0, reward_floor: float = 0.0,
|
||||
) -> None:
|
||||
"""Split size across `layers` price levels stepping away from the touch."""
|
||||
"""Split size across `layers` price levels stepping away from the touch.
|
||||
|
||||
Each ORDER must meet the exchange min and, when within reach (>=50% of it),
|
||||
is bumped to `reward_floor` (the reward min-size × the profile margin) so it
|
||||
actually scores — the program scores per order, so a floor applied to the
|
||||
total is worthless. Layers that can't reach the floor are consolidated into
|
||||
fewer, larger orders rather than resting unscoring dust.
|
||||
"""
|
||||
if total_size <= 0:
|
||||
return
|
||||
layers = max(1, layers)
|
||||
reward_floor = max(reward_floor, exchange_min)
|
||||
per = round(total_size / layers, 2)
|
||||
if per <= 0:
|
||||
per = total_size
|
||||
layers = 1
|
||||
# consolidate: if a split layer would fall below half the reward floor,
|
||||
# use fewer layers so each resting order can still score
|
||||
while layers > 1 and reward_floor > 0 and per < 0.5 * reward_floor:
|
||||
layers -= 1
|
||||
per = round(total_size / layers, 2)
|
||||
if reward_floor > 0 and 0.5 * reward_floor <= per < reward_floor:
|
||||
per = reward_floor # bump each order up to scoring size
|
||||
if per < exchange_min or per <= 0:
|
||||
return
|
||||
for i in range(layers):
|
||||
offset = i * step_ticks * tick
|
||||
price = top_price - offset if down else top_price + offset
|
||||
price = round(price, dec)
|
||||
if 0 < price < 1 and per > 0:
|
||||
if 0 < price < 1:
|
||||
quotes.append(Quote(token_id, side, price, per))
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class RegimeMachine:
|
||||
return Regime.REDUCE_ONLY
|
||||
|
||||
# 4. trending
|
||||
if abs(inp.flow_z) >= p.trend_flow_z or inp.vol_ratio >= 2.0:
|
||||
if abs(inp.flow_z) >= p.trend_flow_z or inp.vol_ratio >= p.trend_vol_ratio:
|
||||
return Regime.TRENDING
|
||||
|
||||
# 5. default
|
||||
@@ -72,3 +72,7 @@ class RegimeMachine:
|
||||
@property
|
||||
def in_cooloff(self) -> bool:
|
||||
return self._event_until > 0.0
|
||||
|
||||
def cooloff_remaining(self, now: float) -> float:
|
||||
"""Seconds until the EVENT cool-off expires (0 if not cooling off)."""
|
||||
return max(0.0, self._event_until - now)
|
||||
|
||||
Reference in New Issue
Block a user