Good enough to hand to the public

This commit is contained in:
Daniel Sapkota
2026-07-06 14:48:18 -04:00
parent a18e1ac7fb
commit b96d9e94b4
16 changed files with 610 additions and 153 deletions
+8 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+118
View File
@@ -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())
+42
View File
@@ -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'}]")
+8
View File
@@ -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"))
+28 -12
View File
@@ -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),
)
+20 -8
View File
@@ -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)
+15
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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()
+14
View File
@@ -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)
+28 -15
View File
@@ -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))
+5 -1
View File
@@ -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)
+96
View File
@@ -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")