Good enough to hand to the public
This commit is contained in:
+8
-7
@@ -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
|
||||
|
||||
+4
-15
@@ -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 <slug>` 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
|
||||
|
||||
+69
-48
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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'}]")
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user