From b96d9e94b4e45cbd13b828a7984c43d62526c6ba Mon Sep 17 00:00:00 2001 From: Daniel Sapkota Date: Mon, 6 Jul 2026 14:48:18 -0400 Subject: [PATCH] Good enough to hand to the public --- config/config.toml | 15 ++- config/markets.toml | 19 +-- config/strategy.toml | 117 ++++++++++------- session/monitor.py | 118 +++++++++++++++++ session/watch.py | 42 ++++++ src/polymaker/catalog/gamma.py | 8 ++ src/polymaker/catalog/scoring.py | 40 ++++-- src/polymaker/catalog/store.py | 28 ++-- src/polymaker/config.py | 15 +++ src/polymaker/domain.py | 3 +- src/polymaker/engine.py | 191 +++++++++++++++++++++------- src/polymaker/marketdata/service.py | 8 ++ src/polymaker/state/store.py | 14 ++ src/polymaker/strategy/quoting.py | 43 ++++--- src/polymaker/strategy/regime.py | 6 +- tests/test_hardening2.py | 96 ++++++++++++++ 16 files changed, 610 insertions(+), 153 deletions(-) create mode 100644 session/monitor.py create mode 100644 session/watch.py diff --git a/config/config.toml b/config/config.toml index 038bd69..da2f69e 100644 --- a/config/config.toml +++ b/config/config.toml @@ -21,19 +21,20 @@ data_api_host = "https://data-api.polymarket.com" polygon_rpc = "https://polygon-bor-rpc.publicnode.com" [engine] -debounce_ms = 200 # min gap between quote recomputes per market -reconcile_interval_s = 30 # REST drift reconciliation cadence -catalog_refresh_s = 900 # market catalog rescan cadence (15 min) +debounce_ms = 250 # min gap between quote recomputes per market +quoter_tick_s = 60 # slow baseline refresh (reactions are event-driven); cool-off re-entry is precise +reconcile_interval_s = 20 # REST drift reconciliation cadence +catalog_refresh_s = 300 # market catalog rescan cadence (15 min) heartbeat = true # exchange dead-man switch heartbeat_interval_s = 5 journal = true # append raw WS/orders to journal/ for backtest loop = "uvloop" # "uvloop" | "asyncio" [risk] -max_total_exposure_usdc = 5000.0 # sum of |position notional| + open buy notional -max_event_group_loss_usdc = 1000.0 # neg-risk group worst-case loss cap -max_market_notional_usdc = 800.0 # per-market position+orders notional cap -daily_loss_kill_usdc = 250.0 # realized daily loss -> halt new quotes +max_total_exposure_usdc = 450.0 # sum of |position notional| + open buy notional +max_event_group_loss_usdc = 400.0 # neg-risk group worst-case loss cap +max_market_notional_usdc = 400.0 # per-market position+orders notional cap +daily_loss_kill_usdc = 40.0 # realized daily loss -> halt new quotes ws_stale_halt_s = 10.0 # no book updates for this long -> halt market user_ws_blind_halt_s = 15.0 # user WS down this long -> pull all quotes (can't see fills) heartbeat_halt_failures = 3 # consecutive heartbeat misses -> halt + resync diff --git a/config/markets.toml b/config/markets.toml index 04fa417..770663c 100644 --- a/config/markets.toml +++ b/config/markets.toml @@ -1,21 +1,10 @@ -# The trade list (replaces the v1 Selected Markets sheet). -# Each entry names a market by slug OR condition_id and a strategy profile. -# `polymaker markets add ` appends here; edit freely, hot-reloaded live. -# Start empty — populate with `polymaker scan` then `polymaker markets`. - -# Example (disabled) entry: -# [[markets]] -# slug = "will-the-democrats-win-the-2028-us-presidential-election" -# profile = "political-longdated" -# enabled = false -# q_max_usdc = 800 # optional per-market override of the profile value - +# Trade list (supervised MM session). [[markets]] slug = "will-gavin-newsom-win-the-2028-democratic-presidential-nomination-568" -profile = "political-longdated" +profile = "newsom-mm" enabled = true [[markets]] -slug = "will-jd-vance-win-the-2028-republican-presidential-nomination" -profile = "political-longdated" +slug = "will-alexandru-nazare-be-the-next-prime-minister-of-romania" +profile = "romania-pm" enabled = true diff --git a/config/strategy.toml b/config/strategy.toml index eefe25c..40605bf 100644 --- a/config/strategy.toml +++ b/config/strategy.toml @@ -1,66 +1,87 @@ -# Named strategy parameter profiles (replaces the v1 Hyperparameters sheet). -# markets.toml maps each market to one of these profiles. -# See the README. +# Strategy profiles. `newsom-mm` is tuned for the Gavin Newsom 2028 Dem-nomination +# market from a live microstructure sample (2026-07-06): price ~0.195, tick 0.001, +# 2-tick spread, thin touch (~$20-250), VERY quiet tape (~0.1 trades/min), +# rewards: min 100 shares/order, 5.5c band, ~$52/day rate, 25% maker-rebate pool. -[profiles.political-longdated] +[profiles.newsom-mm] # --- fair value --- -micro_levels = 3 # depth levels used for microprice -flow_ewma_halflife_s = 120 # signed-flow EWMA half-life +micro_levels = 3 +flow_ewma_halflife_s = 120 # --- spread / skew --- -gamma = 0.5 # inventory risk aversion (skew strength) -delta_min_ticks = 2 # minimum half-spread, in ticks -c_vol = 1.2 # half-spread added per unit short-horizon vol -c_tox = 2.0 # half-spread added per unit toxicity score -# --- vol horizons (seconds) --- +gamma = 0.6 # inventory skew; sized so full cap leans ~1-2 ticks +delta_min_ticks = 2 # half-spread floor = 2 ticks (market spread is 2) +c_vol = 1.5 +c_tox = 3.0 +# --- vol horizons --- vol_short_halflife_s = 10 vol_long_halflife_s = 900 -# --- sizing / inventory --- -base_size_usdc = 50.0 # notional per quote -q_max_usdc = 500.0 # hard inventory cap (notional) -q_soft_frac = 0.6 # soft cap as fraction of q_max -layers = 2 # price levels per side -layer_step_ticks = 2 # tick gap between layers +# --- sizing / inventory ($50 orders, $200 net max position) --- +base_size_usdc = 100.0 # per token side, split into `layers` orders +q_max_usdc = 200.0 # NET directional cap (YES minus NO exposure) +q_soft_frac = 0.6 # stop adding at net $120, exits get urgent +layers = 2 # 2 x $50 YES orders; NO auto-sizes to reward floor +reward_size_mult = 1.5 # bump reward-eligible orders to 1.5x the min (100 -> 150 sh) +layer_step_ticks = 2 # --- placement / churn --- -reprice_ticks = 2 # only reprice if target moves this many ticks -resize_frac = 0.15 # or if size drifts this fraction -min_edge_ticks = 1 # never quote inside (FV +/- this) * tick -# --- regime --- -event_cooloff_s = 60 -event_jump_ticks = 8 # FV jump over debounce that flags EVENT -event_sweep_levels = 3 # levels consumed in one print that flags EVENT -trend_flow_z = 1.5 # flow z-score that flags TRENDING -# --- lifecycle --- +reprice_ticks = 2 # ignore 1-tick FV flicker (hold queue position) +resize_frac = 0.2 +min_edge_ticks = 1 # never bid above FV - 1 tick +# --- regime (damped for a quiet tape: one 250-share print must not flap us) --- +event_cooloff_s = 30 # shorter: this is a deep, slow market +event_jump_ticks = 6 # 0.006 = ~3% jump -> pull quotes +event_sweep_mult = 6.0 # print must be >=6 order-sizes +event_sweep_frac = 1.0 # AND consume ~all near-touch depth to count as a sweep +trend_flow_z = 1.8 +# --- lifecycle (resolves 2028 — irrelevant here) --- end_date_taper_days = 7 reduce_only_hours = 24 halt_before_hours = 2 # --- exits --- -exit_urgency_s = 900 # time to walk exit from FV+delta to best-bid+tick -merge_min_size = 20.0 # min(YES,NO) shares to trigger a merge +exit_urgency_s = 900 +merge_min_size = 20.0 -[profiles.political-hot] -# tighter, defensive profile for high-volatility / event-prone markets + +# romania-pm: "Next PM of Romania" — Alexandru Nazare leg. Live sample 2026-07-06: +# price ~0.487/0.488 (near 50/50 -> max reward score, low extremity), tick 0.001, +# 1-tick spread, ~$4k/day tape, rewards: min 50 shares/order, 4.5c band, ~$257/day +# rate, 25% rebate. endDate is stale (past) but still accepting -> handled as no-end. +[profiles.romania-pm] micro_levels = 3 -flow_ewma_halflife_s = 60 -gamma = 0.9 -delta_min_ticks = 3 -c_vol = 1.8 +flow_ewma_halflife_s = 120 +# --- spread / skew --- +gamma = 0.6 +delta_min_ticks = 1 # tight market (1-tick spread); quote close to score +c_vol = 1.5 c_tox = 3.0 -vol_short_halflife_s = 8 -vol_long_halflife_s = 600 -base_size_usdc = 30.0 -q_max_usdc = 250.0 -q_soft_frac = 0.5 -layers = 2 -layer_step_ticks = 3 -reprice_ticks = 2 -resize_frac = 0.15 +# --- vol horizons --- +vol_short_halflife_s = 10 +vol_long_halflife_s = 900 +# --- sizing / inventory (MINIMUM reward size — thin/gap-prone book, so a fill +# must be small & disposable, not a directional bag. Reward < max, on purpose) --- +base_size_usdc = 22.0 # ~50 sh at these prices = the reward MIN, nothing more +q_max_usdc = 100.0 # ceiling (min-size orders make accumulation slow anyway) +q_soft_frac = 0.6 +layers = 1 +reward_size_mult = 1.0 # floor each order to exactly the 50-share reward min +layer_step_ticks = 2 +# --- placement / churn (STICKY: thin/noisy tape must not flap us out of the +# reward queue; we reprice only on real moves, not microprice jitter) --- +reprice_ticks = 3 # ignore <=3-tick FV jitter (hold queue position) +resize_frac = 0.6 # don't cancel/replace on a regime size-flip (154<->77) min_edge_ticks = 1 -event_cooloff_s = 120 -event_jump_ticks = 6 -event_sweep_levels = 2 -trend_flow_z = 1.2 +# --- regime: rest through noise (high trend threshold) but pull HARD on a real +# sweep/jump, since the gapped book makes a toxic fill costly --- +event_cooloff_s = 30 +event_jump_ticks = 5 # 0.005 jump from a thin book -> pull (gap protection) +event_sweep_mult = 3.0 # thinner book: a smaller print is a real sweep here +event_sweep_frac = 0.8 # consuming ~80% of near-touch depth counts +trend_flow_z = 2.6 # noisy flow z on a $4k/day tape -> raise the bar +trend_vol_ratio = 5.0 # ~1 trade/hr: microprice jitter must NOT halve size + # (real gaps still caught by event_jump_ticks) +# --- lifecycle (end date stale/None -> these are inert; acceptingOrders governs) --- end_date_taper_days = 7 reduce_only_hours = 24 halt_before_hours = 2 -exit_urgency_s = 600 +# --- exits --- +exit_urgency_s = 900 merge_min_size = 20.0 diff --git a/session/monitor.py b/session/monitor.py new file mode 100644 index 0000000..36ea79a --- /dev/null +++ b/session/monitor.py @@ -0,0 +1,118 @@ +"""One-shot health probe for the live MM session. Prints a compact status block +plus ALERT lines for anything that needs attention. Run each monitoring cycle.""" +from __future__ import annotations + +import asyncio +import json +import re +import subprocess +import httpx +from polymaker.config import Config +from polymaker.execution.gateway import ExecutionGateway + +LOG = "session/live.log" +NEWS_Y = "54533043819946592547517511176940999955633860128497669742211153063842200957669" +NEWS_N = "87854174148074652060467921081181402357467303721471806610111179101805869578687" +FUNDER = "0xb84ca5f197A73429F608842cD75ebbC7c578e169" + + +def logstats(cid: str) -> dict: + txt = open(LOG).read() + lines = [l for l in txt.splitlines() if f"cid={cid}" in l] + regimes = re.findall(rf"cid={cid}.*?regime=([A-Z_]+)", "\n".join(lines)) + cancels = sum(1 for l in lines if re.search(r"cancel=[1-9]", l)) + tox = [float(x) for x in re.findall(rf"cid={cid}.*?tox=([0-9.]+)", "\n".join(lines))] + return { + "reqs": len(lines), + "cancels": cancels, + "halts": sum(1 for r in regimes if r == "HALTED"), + "last_regime": regimes[-1] if regimes else "?", + "maxtox": max(tox) if tox else 0.0, + } + + +async def main() -> None: + alerts: list[str] = [] + engines = int(subprocess.run(["grep", "-c", "engine_started", LOG], + capture_output=True, text=True).stdout.strip() or 0) + procs = subprocess.run("pgrep -f 'polymaker run'", shell=True, + capture_output=True, text=True).stdout.split() + alive = len(procs) > 0 + if not alive: + alerts.append("BOT PROCESS DEAD") + + full = open(LOG).read() + errors = len(re.findall(r"Traceback|quoter_error|reconcile_error|task_died|divergence", full)) + fills = len(re.findall(r"\] fill ", full)) + if errors: + alerts.append(f"{errors} error/divergence lines in log") + + gw = ExecutionGateway(Config.load("config")) + await gw.connect() + naz = httpx.get("https://gamma-api.polymarket.com/markets", + params={"slug": "will-alexandru-nazare-be-the-next-prime-minister-of-romania"}, + timeout=15).json()[0] + NY, NN = json.loads(naz["clobTokenIds"]) + band = float(naz["rewardsMaxSpread"]) / 100.0 + + async def mid(tok: str) -> float: + b = httpx.get("https://clob.polymarket.com/book", params={"token_id": tok}, timeout=15).json() + bb = max((float(x["price"]) for x in b["bids"]), default=0) + ba = min((float(x["price"]) for x in b["asks"]), default=1) + return (bb + ba) / 2 + + naz_mid = await mid(NY) + news_mid = await mid(NEWS_Y) + oo = await gw.open_orders() + + def fmt(tok_yes, tok_no, m, label): + rows = [] + n_inband = 0 + for o in oo: + if o.token_id in (tok_yes, tok_no): + eff = o.price if o.token_id == tok_yes else round(1 - o.price, 3) + d = abs(eff - m) + inb = d <= band if label == "ROM" else d <= 0.055 + n_inband += inb + rows.append(f"{'Y' if o.token_id==tok_yes else 'N'}:{o.side.value[0]}{o.size:.0f}@{o.price}({d*100:.1f}c{'✓' if inb else '✗OUT'})") + return rows, n_inband + + rrows, rin = fmt(NY, NN, naz_mid, "ROM") + nrows, nin = fmt(NEWS_Y, NEWS_N, news_mid, "NEWS") + + # positions + pos = {} + try: + for p in httpx.get("https://data-api.polymarket.com/positions", + params={"user": FUNDER}, timeout=15).json(): + if p.get("asset") in (NEWS_Y, NY, NN): + pos[p["asset"]] = (float(p["size"]), float(p["avgPrice"]), float(p.get("curPrice", 0))) + except Exception: + pass + pusd = await gw.collateral_balance() + + rs, ns = logstats("0xabc341"), logstats("0x0f49db") + if not alive or engines != 1: + pass + if rs["halts"] > 0: + alerts.append(f"ROMANIA {rs['halts']} HALTs") + if rs["maxtox"] > 0.15 or ns["maxtox"] > 0.15: + alerts.append(f"TOXICITY spike ROM={rs['maxtox']} NEWS={ns['maxtox']}") + for rows, m, lbl in ((rrows, naz_mid, "ROM"), (nrows, news_mid, "NEWS")): + if any("OUT" in r for r in rows): + alerts.append(f"{lbl} order OUT OF BAND") + if NY in pos or NN in pos: + alerts.append(f"ROMANIA FILLED: {[(k[:6], round(v[0])) for k,v in pos.items() if k in (NY,NN)]}") + + ny = pos.get(NEWS_Y, (668, 0.1917, news_mid)) + news_unreal = ny[0] * (ny[2] - ny[1]) if ny[2] else 0.0 + + print(f"engines={engines} alive={alive} fills={fills} errors={errors} pUSD={pusd:.0f}") + print(f"NEWSOM mid={news_mid:.3f} pos={ny[0]:.0f}Y@{ny[1]:.4f} unreal=${news_unreal:+.2f} " + f"| {' '.join(nrows)} inband={nin} | reg={ns['last_regime']} reqs={ns['reqs']} cxl={ns['cancels']} halt={ns['halts']} tox={ns['maxtox']}") + print(f"ROMANIA mid={naz_mid:.3f} pos={'FILLED' if (NY in pos or NN in pos) else 'flat'} " + f"| {' '.join(rrows)} inband={rin} | reg={rs['last_regime']} reqs={rs['reqs']} cxl={rs['cancels']} halt={rs['halts']} tox={rs['maxtox']}") + print("ALERTS: " + (" || ".join(alerts) if alerts else "none — all nominal")) + + +asyncio.run(main()) diff --git a/session/watch.py b/session/watch.py new file mode 100644 index 0000000..ebbb934 --- /dev/null +++ b/session/watch.py @@ -0,0 +1,42 @@ +"""Live event watcher: follow live.log and return the INSTANT a significant +event fires (fill / regime escalation / toxicity / error), streaming benign +notes as they pass. Blocks up to `maxwait` seconds when the tape is quiet, so +control returns periodically even with no events. This is event-driven watching +— no polling gaps — not interval snapshots.""" +import re +import sys +import time + +LOG = "session/live.log" +# events that demand my immediate attention -> return NOW +CRIT = re.compile( + r"\] fill |Traceback|quoter_error|reconcile_error|task_died|divergence|" + r"quarantine|market_blind|inflight_expired|regime=HALTED|regime=EVENT|" + r"regime=REDUCE_ONLY|tox=0\.[1-9]|market_halted_by_meta|risk_halt|_kill" +) +# worth surfacing but not alarming -> print and keep watching +NOTE = re.compile( + r"meta_refreshed|user_ws_reconnected|market_ws_dropped|position_forced|" + r"untracked_positions|book_drift|market_halted|pagination" +) + +maxwait = float(sys.argv[1]) if len(sys.argv) > 1 else 540.0 +f = open(LOG) +f.seek(0, 2) # tail from end +start = time.time() +fired = False +while time.time() - start < maxwait: + line = f.readline() + if not line: + time.sleep(0.4) + continue + if "HTTP Request" in line or "heartbeats" in line: + continue + if CRIT.search(line): + print("!! CRIT ", line.strip()[:230], flush=True) + fired = True + break + if NOTE.search(line): + print(".. note ", line.strip()[:190], flush=True) +elapsed = int(time.time() - start) +print(f"[{'CRITICAL — reacting' if fired else f'quiet {elapsed}s — re-arming'}]") diff --git a/src/polymaker/catalog/gamma.py b/src/polymaker/catalog/gamma.py index be3f055..301290f 100644 --- a/src/polymaker/catalog/gamma.py +++ b/src/polymaker/catalog/gamma.py @@ -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")) diff --git a/src/polymaker/catalog/scoring.py b/src/polymaker/catalog/scoring.py index 8381160..66890d5 100644 --- a/src/polymaker/catalog/scoring.py +++ b/src/polymaker/catalog/scoring.py @@ -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), ) diff --git a/src/polymaker/catalog/store.py b/src/polymaker/catalog/store.py index a168943..a252e51 100644 --- a/src/polymaker/catalog/store.py +++ b/src/polymaker/catalog/store.py @@ -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) diff --git a/src/polymaker/config.py b/src/polymaker/config.py index 58b5d51..4ef448f 100644 --- a/src/polymaker/config.py +++ b/src/polymaker/config.py @@ -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 diff --git a/src/polymaker/domain.py b/src/polymaker/domain.py index 39935c7..cc482ae 100644 --- a/src/polymaker/domain.py +++ b/src/polymaker/domain.py @@ -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: diff --git a/src/polymaker/engine.py b/src/polymaker/engine.py index 6ac6cab..12a105d 100644 --- a/src/polymaker/engine.py +++ b/src/polymaker/engine.py @@ -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 diff --git a/src/polymaker/marketdata/service.py b/src/polymaker/marketdata/service.py index f69b086..9ffd17a 100644 --- a/src/polymaker/marketdata/service.py +++ b/src/polymaker/marketdata/service.py @@ -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() diff --git a/src/polymaker/state/store.py b/src/polymaker/state/store.py index 2663870..f55e30e 100644 --- a/src/polymaker/state/store.py +++ b/src/polymaker/state/store.py @@ -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) diff --git a/src/polymaker/strategy/quoting.py b/src/polymaker/strategy/quoting.py index 92f6a40..fadc7e8 100644 --- a/src/polymaker/strategy/quoting.py +++ b/src/polymaker/strategy/quoting.py @@ -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)) diff --git a/src/polymaker/strategy/regime.py b/src/polymaker/strategy/regime.py index 77ebf41..c9e25c2 100644 --- a/src/polymaker/strategy/regime.py +++ b/src/polymaker/strategy/regime.py @@ -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) diff --git a/tests/test_hardening2.py b/tests/test_hardening2.py index cfa3e5b..3fe5ba1 100644 --- a/tests/test_hardening2.py +++ b/tests/test_hardening2.py @@ -160,6 +160,102 @@ def test_open_orders_do_not_taper_quote_size(tmp_path, meta): store.close() +# ── operator positions in other markets must not leak into bot state ───── +def test_untracked_positions_are_dropped_and_filtered(tmp_path, meta): + """Manual UI bets in markets the bot doesn't trade must not enter state, + exposure caps, or PnL — neither from the DB (stale) nor from the API.""" + eng = _engine_with_market(tmp_path, meta) + # stale DB leak: a sports position from an earlier unscoped reconcile + eng.state.set_position("sports-token", 370.0, 0.54) + dropped = eng.state.drop_untracked_positions(set(eng._token_cid)) + assert dropped == ["sports-token"] + assert eng.state.position("sports-token").size == 0 + # API filter: only traded tokens survive _only_traded + api = {"sports-token": (370.0, 0.54), meta.yes.token_id: (10.0, 0.2)} + filtered = eng._only_traded(api) + assert "sports-token" not in filtered + assert meta.yes.token_id in filtered + eng.state.close() + eng.catalog.close() + + +def test_per_layer_reward_floor(meta, profile): + """Every resting ORDER must meet the rewards min size (scoring is per order). + NO at ~0.80 with $100 base -> layers bump to the 100-share floor.""" + from dataclasses import replace + + m = replace(meta, rewards_min_size=100.0) + p = profile.with_overrides({"base_size_usdc": 100.0, "layers": 2}) + tq = construct_quotes(QuoteInputs( + meta=m, regime=Regime.QUIET, fv=0.20, vol_short=0.0, toxicity=0.0, + yes_view=view(0.195, 0.197), no_view=view(0.802, 0.805), + pos_yes=Position("yes-token"), pos_no=Position("no-token"), + profile=p, now=1000.0, + )) + buys = [q for q in tq.quotes if q.side == Side.BUY] + assert buys + assert all(q.size >= 100.0 for q in buys), [q.size for q in buys] + + +# ── quoter wake cadence: slow baseline, precise cool-off re-entry ──────── +async def test_quoter_wake_cadence(tmp_path, meta): + from polymaker.domain import Fill + from polymaker.strategy.regime import RegimeInputs + + eng = _engine_with_market(tmp_path, meta) + # flat + QUIET -> slow baseline tick + assert eng._next_wake_s(meta.condition_id, 60.0) == 60.0 + # in an EVENT cool-off -> wake right when it ends, not a full minute later + p = eng.profiles[meta.condition_id] + eng.regime_m[meta.condition_id].decide( + RegimeInputs(now=time.time(), tick=0.001, fv=0.2, prev_fv=0.2, vol_ratio=1.0, + flow_z=0.0, inventory_util=0.0, hours_to_end=999.0, sweep_flagged=True), p) + w = eng._next_wake_s(meta.condition_id, 60.0) + assert 0 < w <= p.event_cooloff_s + 1 + # holding inventory -> fast tick to manage exits + eng.state.apply_fill(Fill(meta.yes.token_id, Side.BUY, 0.2, 50, "f")) + assert eng._next_wake_s(meta.condition_id, 60.0) <= 10.0 + eng.state.close() + eng.catalog.close() + + +# ── a quiet market with a live WS link must NOT false-halt ─────────────── +async def test_quiet_market_with_live_link_is_not_stale(tmp_path, meta): + """Thin/quiet markets go long stretches with no book mutation. Halting on + book-recency would zero their rewards. With the link up we must keep quoting; + only a genuinely DOWN link past the grace window halts.""" + eng = _engine_with_market(tmp_path, meta) + _feed_book(eng, meta) + eng.md.connected = True + eng.md.disconnected_since = 0.0 + # backdate the book so a book-recency check would (wrongly) read stale + eng.md.book(meta.yes.token_id).local_ts = time.time() - 9999 + eng.md.book(meta.no.token_id).local_ts = time.time() - 9999 + await eng._recompute(meta.condition_id) + assert len(eng.state.orders) > 0 # still quoting despite a silent book + # a genuinely dead link past the grace window DOES halt + eng.md.connected = False + eng.md.disconnected_since = time.time() - 9999 + await eng._recompute(meta.condition_id) + assert eng.state.orders == {} + eng.state.close() + eng.catalog.close() + + +# ── stale/past end-date must not halt a still-trading market ───────────── +def test_past_end_date_is_treated_as_unknown(): + """"Next PM" appointment markets carry a stale past endDate while still + accepting orders. A past date must read as None (unknown), not 0 hours, + else the regime machine HALTs a live market and never quotes.""" + from polymaker.engine import _hours_to_end + + now = time.time() + assert _hours_to_end("2020-01-01T00:00:00Z", now) is None # past -> unknown + assert _hours_to_end(None, now) is None + future = _hours_to_end("2099-01-01T00:00:00Z", now) + assert future is not None and future > 0 # genuine future still measured + + # ── T2: PnL snapshot + CSV export smoke ────────────────────────────────── def test_pnl_snapshot_and_wal(tmp_path): s = StateStore(tmp_path / "s.db")