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
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)