Add paper liquidity-provision loop (lp_paper.py)
Simulates two-sided quoting on the screener's top low-vol markets against the live order book, tracking net = rewards accrued - adverse-selection bleed. Clean cash + mark-to-market accounting; fills modeled when midpoint crosses a resting quote (slightly pessimistic on fill rate); rewards accrue by score-share of each pool. Discord summaries + state persistence so it can run for days. This is the decisive, no-money test before any funded/hosted bot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,4 @@ edge_profitable.json
|
||||
copyable_77.csv
|
||||
lp_markets.csv
|
||||
follow_10.json
|
||||
lp_paper_state.json
|
||||
|
||||
@@ -20,6 +20,7 @@ live), and backtest the strategy. Zero dependencies — Python 3 stdlib only
|
||||
| `copytrade.py` | Copy-trade engine — mirror a watchlist (paper by default, live gated). |
|
||||
| `backtest.py` | Replay a watchlist over a recent window and mark outcomes. |
|
||||
| `lp_screener.py` | Rank reward-eligible markets by risk-adjusted LP yield (pool ÷ competition, penalized by volatility). |
|
||||
| `lp_paper.py` | Paper liquidity-provision loop — simulate quoting on the live book, track **net = rewards − adverse selection**. |
|
||||
|
||||
## Run the dashboard
|
||||
|
||||
@@ -233,5 +234,18 @@ correctly de-ranked: that's where you get picked off.
|
||||
**Caveats that still gate real money:** headline APRs are a *snapshot* — thin
|
||||
pools attract competitors and yield mean-reverts down; they're *gross*, ignoring
|
||||
inventory losses when you get filled; and we have not yet confirmed near-empty
|
||||
books actually pay the full pool. The paper LP loop (post near mid, requote on
|
||||
moves, track **net** = rewards − pick-off) is the next and decisive test.
|
||||
books actually pay the full pool.
|
||||
|
||||
`lp_paper.py` is the decisive test — no money, no host, no key. It picks the
|
||||
screener's top low-vol markets, simulates two-sided quotes against the **live**
|
||||
order book, and tracks **net = rewards accrued − adverse-selection bleed**:
|
||||
|
||||
```bash
|
||||
python3 lp_paper.py --capital 1000 --markets 6 --poll 20 # runs until stopped
|
||||
```
|
||||
|
||||
Fills are modeled when the midpoint crosses a resting quote (deliberately a bit
|
||||
pessimistic on fill rate); rewards accrue by score-share of each pool. Net P&L,
|
||||
per-market breakdown, and Discord summaries let it run for days to see whether
|
||||
the edge survives mean-reversion. **Only if net stays clearly positive does a
|
||||
real, funded, hosted bot make sense.**
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Paper liquidity-provision loop — measures NET reward yield without real money.
|
||||
|
||||
It simulates posting two-sided limit orders near the midpoint on the screener's
|
||||
top markets, against the LIVE order book, and tracks:
|
||||
|
||||
net P&L = rewards accrued - adverse-selection / inventory P&L
|
||||
|
||||
Reward model (per poll, per market):
|
||||
your_share = your_notional / (your_notional + competing_notional_near_mid)
|
||||
rewards += daily_pool * your_share * (seconds_elapsed / 86400)
|
||||
(matches Polymarket's score-share mechanic; assumes ~equal price-quality, which
|
||||
is conservative since we quote tight to mid.)
|
||||
|
||||
Fill / adverse-selection model:
|
||||
We rest a bid at mid-tick and an ask at mid+tick. Between polls, if the
|
||||
midpoint crosses a quote, that quote is assumed FILLED at its price, and we
|
||||
take on inventory marked at the NEW mid — so a price that runs through us
|
||||
books an immediate loss. This is the bleed that must stay below rewards.
|
||||
Each poll we "cancel and re-quote" around the new mid (a bot requoting every
|
||||
poll interval). Shorter --poll = less bleed, fewer missed requotes.
|
||||
|
||||
This is an APPROXIMATION (ignores queue position, partial fills, requote
|
||||
latency), deliberately a bit pessimistic on fills. Good enough to answer the
|
||||
one question that gates real money: is net positive, and how big?
|
||||
|
||||
python3 lp_paper.py --capital 1000 --markets 6 --poll 20
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from lp_screener import get, reward_markets, daily_rate, hours_to_end, realized_vol_cents, CLOB
|
||||
from copytrade import post_discord, load_json
|
||||
|
||||
STATE_PATH = "lp_paper_state.json"
|
||||
|
||||
|
||||
def screen_targets(n, min_rate, capital, max_vol):
|
||||
"""Pick the top-N low-volatility reward markets to make on."""
|
||||
mkts = [m for m in reward_markets()
|
||||
if m.get("active") and not m.get("closed") and daily_rate(m) >= min_rate]
|
||||
scored = []
|
||||
def assess(m):
|
||||
r = m.get("rewards") or {}
|
||||
ms = r.get("max_spread", 0) / 100.0
|
||||
toks = m.get("tokens") or []
|
||||
if not toks or ms <= 0:
|
||||
return None
|
||||
tok = toks[0]["token_id"]
|
||||
try:
|
||||
bk = get(f"{CLOB}/book?token_id={tok}")
|
||||
except Exception:
|
||||
return None
|
||||
bids = [(float(o["price"]), float(o["size"])) for o in bk.get("bids", [])]
|
||||
asks = [(float(o["price"]), float(o["size"])) for o in bk.get("asks", [])]
|
||||
if not bids or not asks:
|
||||
return None
|
||||
mid = (max(p for p, _ in bids) + min(p for p, _ in asks)) / 2
|
||||
comp = min(sum(p * s for p, s in bids if p >= mid - ms),
|
||||
sum((1 - p) * s for p, s in asks if p <= mid + ms))
|
||||
vol = realized_vol_cents(tok)
|
||||
hrs = hours_to_end(m)
|
||||
if vol is None or vol > max_vol: # skip toxic / unknown-vol
|
||||
return None
|
||||
if hrs is not None and hrs < 24: # skip imminent/live
|
||||
return None
|
||||
return {
|
||||
"token": tok, "question": m.get("question", "?")[:50],
|
||||
"pool": daily_rate(m), "max_spread": ms,
|
||||
"min_size": r.get("min_size", 0),
|
||||
"tick": float(bk.get("tick_size", 0.01)),
|
||||
"comp": comp, "mid": mid, "vol": vol,
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=16) as ex:
|
||||
for res in ex.map(assess, mkts):
|
||||
if res:
|
||||
scored.append(res)
|
||||
# rank by reward-yield / competition, low vol
|
||||
scored.sort(key=lambda x: x["pool"] / (x["comp"] + x["pool"]) / (1 + x["vol"]),
|
||||
reverse=True)
|
||||
return scored[:n]
|
||||
|
||||
|
||||
def fresh_market_state(t, per_market):
|
||||
# cash = cumulative cash flow from simulated trades (buys negative, sells
|
||||
# positive); inv = signed share position. Net trading P&L = cash + inv*mid.
|
||||
return {**t, "notional": per_market / 2, "bid": None, "ask": None,
|
||||
"inv": 0.0, "cash": 0.0, "rewards": 0.0, "fills": 0,
|
||||
"last_t": time.time()}
|
||||
|
||||
|
||||
def poll_market(s, max_inv_shares):
|
||||
"""One observe-fill-accrue-requote step against the live book."""
|
||||
try:
|
||||
bk = get(f"{CLOB}/book?token_id={s['token']}")
|
||||
except Exception:
|
||||
return
|
||||
bids = [(float(o["price"]), float(o["size"])) for o in bk.get("bids", [])]
|
||||
asks = [(float(o["price"]), float(o["size"])) for o in bk.get("asks", [])]
|
||||
if not bids or not asks:
|
||||
return
|
||||
mid = (max(p for p, _ in bids) + min(p for p, _ in asks)) / 2
|
||||
now = time.time()
|
||||
dt = now - s["last_t"]
|
||||
s["last_t"] = now
|
||||
size = s["notional"] / mid if mid > 0 else 0
|
||||
|
||||
# 1) fills: did the mid cross our resting quotes since last poll?
|
||||
# cash + mark-to-market captures the adverse-selection loss directly.
|
||||
if s["bid"] is not None and mid <= s["bid"]: # bought at our bid
|
||||
s["inv"] += size
|
||||
s["cash"] -= size * s["bid"]
|
||||
s["fills"] += 1
|
||||
if s["ask"] is not None and mid >= s["ask"]: # sold at our ask
|
||||
s["inv"] -= size
|
||||
s["cash"] += size * s["ask"]
|
||||
s["fills"] += 1
|
||||
|
||||
# 2) accrue rewards for the elapsed time
|
||||
comp = s["comp"]
|
||||
share = s["notional"] / (s["notional"] + comp) if (s["notional"] + comp) > 0 else 0
|
||||
s["rewards"] += s["pool"] * share * (dt / 86400.0)
|
||||
|
||||
# 3) re-quote around the new mid (within max_spread), respecting inventory cap
|
||||
s["mid"] = mid
|
||||
s["bid"] = mid - s["tick"] if s["inv"] < max_inv_shares else None # stop adding if long
|
||||
s["ask"] = mid + s["tick"] if s["inv"] > -max_inv_shares else None
|
||||
ms = s["max_spread"]
|
||||
s["comp"] = min(sum(p * sz for p, sz in bids if p >= mid - ms),
|
||||
sum((1 - p) * sz for p, sz in asks if p <= mid + ms))
|
||||
|
||||
|
||||
def net_pnl(s):
|
||||
return s["rewards"] + s["cash"] + s["inv"] * s["mid"]
|
||||
|
||||
|
||||
def summary(states, started, capital):
|
||||
rew = sum(s["rewards"] for s in states)
|
||||
trading = sum(s["cash"] + s["inv"] * s["mid"] for s in states)
|
||||
net = rew + trading
|
||||
hrs = (time.time() - started) / 3600 or 1e-9
|
||||
apr = net / capital / (hrs / 24) * 365 * 100 if capital else 0
|
||||
lines = [
|
||||
f"⏱ {hrs:.1f}h · capital ${capital:,.0f}",
|
||||
f" rewards accrued : +${rew:,.2f}",
|
||||
f" trading/inventory: {trading:+,.2f} (adverse-selection bleed)",
|
||||
f" ── NET : {net:+,.2f} (~{apr:,.0f}% APR if it holds)",
|
||||
]
|
||||
return net, "\n".join(lines)
|
||||
|
||||
|
||||
def run(args):
|
||||
cfg = load_json("config.json", {})
|
||||
webhook = cfg.get("discord_webhook", "")
|
||||
per_market = args.capital / args.markets
|
||||
print(f"[{time.strftime('%H:%M:%S')}] screening for {args.markets} low-vol markets...",
|
||||
flush=True)
|
||||
targets = screen_targets(args.markets, args.min_rate, args.capital, args.max_vol)
|
||||
if not targets:
|
||||
print("No suitable low-vol markets found right now.")
|
||||
return
|
||||
states = [fresh_market_state(t, per_market) for t in targets]
|
||||
started = time.time()
|
||||
print(f"[{time.strftime('%H:%M:%S')}] making markets on {len(states)} markets "
|
||||
f"(${per_market:,.0f} each, ${args.capital:,.0f} total):", flush=True)
|
||||
for s in states:
|
||||
print(f" ${s['pool']:>4.0f}/day vol {s['vol']:.1f}c comp ${s['comp']:,.0f}"
|
||||
f" {s['question']}", flush=True)
|
||||
if webhook:
|
||||
post_discord(webhook, f"📊 **Paper LP started** · {len(states)} markets · "
|
||||
f"${args.capital:,.0f} capital. Tracking net = rewards − bleed.")
|
||||
|
||||
max_inv_shares = (per_market / 2) / 0.5 * args.max_inv # rough share cap per market
|
||||
last_report = started
|
||||
next_rescreen = started + args.refresh
|
||||
try:
|
||||
while True:
|
||||
for s in states:
|
||||
poll_market(s, max_inv_shares)
|
||||
save_state(states, started, args.capital)
|
||||
now = time.time()
|
||||
if now - last_report >= args.report:
|
||||
net, txt = summary(states, started, args.capital)
|
||||
print(f"\n[{time.strftime('%H:%M:%S')}]\n{txt}", flush=True)
|
||||
if webhook:
|
||||
post_discord(webhook, "📊 **Paper LP update**\n" + txt)
|
||||
last_report = now
|
||||
if args.duration and (now - started) >= args.duration * 3600:
|
||||
break
|
||||
time.sleep(args.poll)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
net, txt = summary(states, started, args.capital)
|
||||
print(f"\n=== FINAL ===\n{txt}")
|
||||
print("\nPer-market:")
|
||||
for s in sorted(states, key=net_pnl, reverse=True):
|
||||
print(f" net {net_pnl(s):+8.2f} | rew +{s['rewards']:6.2f} | "
|
||||
f"fills {s['fills']:3d} | inv {s['inv']:+8.1f} | {s['question']}")
|
||||
|
||||
|
||||
def save_state(states, started, capital):
|
||||
slim = [{"question": s["question"], "pool": s["pool"],
|
||||
"rewards": round(s["rewards"], 2), "trading": round(s["cash"] + s["inv"] * s["mid"], 2),
|
||||
"inv": round(s["inv"], 1), "fills": s["fills"],
|
||||
"net": round(net_pnl(s), 2)} for s in states]
|
||||
net, _ = summary(states, started, capital)
|
||||
tmp = STATE_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"started": started, "capital": capital, "net": round(net, 2),
|
||||
"markets": slim}, f, indent=2)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--capital", type=float, default=1000)
|
||||
ap.add_argument("--markets", type=int, default=6)
|
||||
ap.add_argument("--poll", type=int, default=20, help="seconds between requotes")
|
||||
ap.add_argument("--report", type=int, default=900, help="seconds between summaries")
|
||||
ap.add_argument("--refresh", type=int, default=3600, help="seconds between re-screens")
|
||||
ap.add_argument("--min-rate", type=float, default=50)
|
||||
ap.add_argument("--max-vol", type=float, default=1.5, help="max 24h vol (cents) to qualify")
|
||||
ap.add_argument("--max-inv", type=float, default=1.0, help="inventory cap multiple")
|
||||
ap.add_argument("--duration", type=float, default=0, help="hours to run (0 = until killed)")
|
||||
run(ap.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user