diff --git a/live/node.py b/live/node.py index 69924e0..5ef19c6 100644 --- a/live/node.py +++ b/live/node.py @@ -38,7 +38,8 @@ STRATEGIES = { "Avellaneda-Stoikov": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000230,"fee_paid":0.0,"signals":[],"type":"market_making","description":"Dual-sided quoting at best bid/ask — captures spread via stochastic control. Places both sides simultaneously."}, "Momentum Breakout": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0005,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Bollinger Band (1.2σ) breakout on ETH — enters when price breaks bands."}, "Mean Reversion": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0005,"fee_paid":0.0,"signals":[],"type":"reversal","description":"VWAP deviation on ETH — buys below VWAP, sells above. Higher vol = more reversion."}, - "Kalman Pairs": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.005,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"Kalman-filter adaptive hedge ratio — tracks evolving BTC/ETH beta with every tick."} + "Kalman Pairs": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.005,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"Kalman-filter adaptive hedge ratio — tracks evolving BTC/ETH beta with every tick."}, + "Hurst VPIN": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000240,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Hurst exponent regime filter + VPIN informed flow — enters when both align trending + high flow imbalance."} } trades_log: list[dict] = [] @@ -182,15 +183,33 @@ def compute_signals(): if eth_cur > sma+1.2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(eth_cur-sma-1.2*std)/std}) elif eth_cur < sma-1.2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-1.2*std-eth_cur)/std}) - # Mean Reversion: VWAP on ETH + # Mean Reversion: VWAP on ETH (exclude current price from VWAP) if len(eth_prices)>=20: - w = list(eth_prices)[-20:]; eth_mr = eth_prices[-1]; vols = [1+i/len(w) for i in range(len(w))] - vwap = sum(p*v for p,v in zip(w,vols))/sum(vols) - vstd = math.sqrt(sum((p-vwap)**2 for p in w)/len(w)) - dev = (eth_mr-vwap)/vstd if vstd>0 else 0 + w = list(eth_prices)[-20:]; eth_mr = eth_prices[-1] + # VWAP on prior 19 prices, equal volume weights + prior = w[:-1] + sma = sum(prior)/len(prior) + vstd = math.sqrt(sum((p-sma)**2 for p in prior)/len(prior)) + dev = (eth_mr-sma)/vstd if vstd>0 else 0 if dev>1.0: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev}) elif dev<-1.0: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)}) + # Hurst/VPIN: feed BTC price into dollar bars + if len(btc_prices)>=3: + try: + from strategies.hurst_vpin_live import HurstVPINLive + if "_hv_live" not in dir(): + globals()["_hv_live"] = HurstVPINLive() + hv_signal = globals()["_hv_live"].feed_price(btc) + if hv_signal: + STRATEGIES["Hurst VPIN"]["signals"].append({ + "time":time.time(), + "signal": hv_signal["signal"], + "strength": hv_signal["hurst"], + "reason": f"H={hv_signal['hurst']:.2f}_V={hv_signal['vpin']:.2f}" + }) + except: pass + # Trim signals for s in STRATEGIES.values(): s["signals"] = s["signals"][-20:] @@ -260,7 +279,7 @@ async def main(): log.info(f" BTC: bid=${btc_bid:,.0f} ask=${btc_ask:,.0f} (spread=${btc_ask-btc_bid:.1f})") log.info(f" ETH: bid=${eth_bid:,.0f} ask=${eth_ask:,.0f} (spread=${eth_ask-eth_bid:.1f})") log.info(f" Mode: POST-ONLY at best bid/ask | Maker: 0.02%") - log.info(f" 7 strategies | A-S is DUAL-SIDED quoting") + log.info(f" {len(STRATEGIES)} strategies | A-S is DUAL-SIDED quoting") log.info(f" Dashboard: https://ftdt.io/cv") log.info("="*60) @@ -273,7 +292,7 @@ async def main(): except: pass log.info(f"Cleared {len(open_ords)} stale orders") - existing = get_fills(addr) + existing = get_fills(addr) or [] for f in existing: seen_fills.add(f.get("tid",0)) log.info(f"Tracking {len(seen_fills)} existing fills") @@ -285,160 +304,236 @@ async def main(): try: while True: - tick+=1 + try: + tick += 1 - prices = get_mark_prices() - btc = prices.get("BTC",0); eth = prices.get("ETH",0) - if btc>0: btc_prices.append(btc) - if eth>0: eth_prices.append(eth) + prices = get_mark_prices() + btc = prices.get("BTC", 0) + eth = prices.get("ETH", 0) + if btc > 0: + btc_prices.append(btc) + if eth > 0: + eth_prices.append(eth) - # Process fills - fills = get_fills(addr); new_fills=0 - for f in fills: - tid=f.get("tid",0) - if tid in seen_fills: continue - seen_fills.add(tid) - side=f.get("side",""); sz=float(f.get("sz",0)); px=float(f.get("px",0)) - closed_pnl=float(f.get("closedPnl",0)); fee=float(f.get("fee","0")) + # Process fills + fills = get_fills(addr) + new_fills = 0 + for f in fills: + tid = f.get("tid", 0) + if tid in seen_fills: + continue + seen_fills.add(tid) + side = f.get("side", "") + sz = float(f.get("sz", 0)) + px = float(f.get("px", 0)) + closed_pnl = float(f.get("closedPnl", 0)) + fee = float(f.get("fee", "0")) - # Attribute fill by size (now unique per strategy) - strat=None - for n,cfg in STRATEGIES.items(): - if abs(sz-cfg["size"])<0.000001: - strat=n - break - if not strat: continue - - net=closed_pnl-abs(fee) - STRATEGIES[strat]["pnl"]+=net; STRATEGIES[strat]["trades_today"]+=1 - STRATEGIES[strat]["fee_paid"]+=abs(fee) - if closed_pnl>0: STRATEGIES[strat]["wins"]+=1 - STRATEGIES[strat]["pnl_pct"]=STRATEGIES[strat]["pnl"]/STRATEGIES[strat]["allocation"]*100 - strategy_equity[strat].append({"t":time.time(),"v":STRATEGIES[strat]["allocation"]+STRATEGIES[strat]["pnl"]}) - trades_log.append({"time":datetime.now().strftime("%H:%M:%S"),"strategy":strat,"side":"BUY" if side=="B" else "SELL","size":sz,"price":px,"pnl":round(net,4),"fee":round(abs(fee),4)}) - new_fills+=1 - - # Signals every 5 ticks - if tick%5==0: compute_signals() - - # Execute ALL strategies every 4 seconds - if tick>=3 and tick%4==0: - btc_bid, btc_ask, btc_mid = get_orderbook("BTC") - try: - eth_bid, eth_ask, eth_mid = get_orderbook("ETH") - except Exception as e: - eth_bid = eth_ask = eth_mid = 0 - if btc_bid<=0 or btc_ask<=0: continue - - for name in names: - cfg=STRATEGIES[name] - coin="BTC" if "BTC" in cfg["instrument"] else "ETH" - perp=btc_perp if coin=="BTC" else eth_perp - bid=btc_bid if coin=="BTC" else eth_bid - ask=btc_ask if coin=="BTC" else eth_ask - mid=btc_mid if coin=="BTC" else eth_mid - if bid<=0 or ask<=0: continue - - # Check if this strategy has a position; skip if already filled - has_position = name in active_cloids and tick - active_cloids_times.get(name,0) < 60 - - # Determine signal - signal=None - if cfg["signals"]: - latest = cfg["signals"][-1] - # Only use recent signals (< 10 seconds old) - if time.time() - latest["time"] < 10: - signal=latest["signal"] - - # Close on opposing signal - if has_position and signal: - prev_signal = active_cloids.get(name,"") - if ("BUY" in str(signal).upper() and "SELL" in str(prev_signal).upper()) or ("SELL" in str(signal).upper() and "BUY" in str(prev_signal).upper()): - try: - client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name])) - except: pass - del active_cloids[name] - has_position = False - - # Take-profit: close if price moved 2x fee in our favor - if has_position: - entry_px = active_cloids_px.get(name, 0) - if entry_px > 0: - if "BUY" in str(active_cloids[name]).upper() and mid > entry_px * 1.001: - try: - client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name])) - except: pass - del active_cloids[name] - has_position = False - elif "SELL" in str(active_cloids[name]).upper() and mid < entry_px * 0.999: - try: - client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name])) - except: pass - del active_cloids[name] - has_position = False - - if has_position: continue # Don't replace existing orders - - # Avellaneda-Stoikov: DUAL-SIDED (always active) - if name=="Avellaneda-Stoikov": - cid_bid=ClientOrderId(str(UUID4())); cid_ask=ClientOrderId(str(UUID4())) - try: - client.submit_order(instrument_id=perp.id,client_order_id=cid_bid,order_side=OrderSide.BUY,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(bid))),time_in_force=TimeInForce.GTC,post_only=True) - client.submit_order(instrument_id=perp.id,client_order_id=cid_ask,order_side=OrderSide.SELL,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(ask))),time_in_force=TimeInForce.GTC,post_only=True) - if tick%60==0: - log.info(f"[Avel] DUAL: BID {cfg['size']} @ ${int(bid):,} | ASK {cfg['size']} @ ${int(ask):,}") - active_cloids[name]=str(cid_bid) - active_cloids_times[name]=tick - active_cloids_px[name]=bid - except Exception as e: pass + # Attribute fill by size (now unique per strategy) + strat = None + for n, cfg in STRATEGIES.items(): + if abs(sz - cfg["size"]) < 0.000001: + strat = n + break + if not strat: continue - # For signal-driven strategies: use aggressive offset - if signal: - side=OrderSide.SELL if "SELL" in str(signal).upper() else OrderSide.BUY - # Aggressive: 0.03% inside the spread for higher fill probability - offset = int(mid * 0.0003) - px_level = ask - offset if side==OrderSide.SELL else bid + offset - px_level = max(px_level, 1) + net = closed_pnl - abs(fee) + STRATEGIES[strat]["pnl"] += net + STRATEGIES[strat]["trades_today"] += 1 + STRATEGIES[strat]["fee_paid"] += abs(fee) + if closed_pnl > 0: + STRATEGIES[strat]["wins"] += 1 + # Track position for AS model + if side == "B": + STRATEGIES[strat]["position"] = STRATEGIES[strat].get("position", 0.0) + sz else: - # No signal/default: skip (don't random-trade) + STRATEGIES[strat]["position"] = STRATEGIES[strat].get("position", 0.0) - sz + STRATEGIES[strat]["pnl_pct"] = STRATEGIES[strat]["pnl"] / STRATEGIES[strat]["allocation"] * 100 + strategy_equity[strat].append({"t": time.time(), "v": STRATEGIES[strat]["allocation"] + STRATEGIES[strat]["pnl"]}) + if len(strategy_equity[strat]) > 1000: + strategy_equity[strat][:] = strategy_equity[strat][-600:] + trades_log.append({"time": datetime.now().strftime("%H:%M:%S"), "strategy": strat, "side": "BUY" if side == "B" else "SELL", "size": sz, "price": px, "pnl": round(net, 4), "fee": round(abs(fee), 4)}) + new_fills += 1 + + # Signals every 5 ticks + if tick % 5 == 0: + compute_signals() + + # Execute ALL strategies every 4 seconds + if tick >= 3 and tick % 4 == 0: + btc_bid, btc_ask, btc_mid = get_orderbook("BTC") + try: + eth_bid, eth_ask, eth_mid = get_orderbook("ETH") + except Exception: + eth_bid = eth_ask = eth_mid = 0 + if btc_bid <= 0 or btc_ask <= 0: continue - if px_level<=0: continue + for name in names: + cfg = STRATEGIES[name] + coin = "BTC" if "BTC" in cfg["instrument"] else "ETH" + perp = btc_perp if coin == "BTC" else eth_perp + bid = btc_bid if coin == "BTC" else eth_bid + ask = btc_ask if coin == "BTC" else eth_ask + mid = btc_mid if coin == "BTC" else eth_mid + if bid <= 0 or ask <= 0: + continue - cid=ClientOrderId(str(UUID4())) - try: - client.submit_order(instrument_id=perp.id,client_order_id=cid,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.GTC,post_only=True) - if tick%60==0: - side_str="BUY" if side==OrderSide.BUY else "SELL" - log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} @ ${int(px_level):,} ({'best bid '+str(int(bid)) if side==OrderSide.BUY else 'best ask '+str(int(ask))})") - active_cloids[name]=str(cid) - active_cloids_times[name]=tick - active_cloids_px[name]=px_level - except Exception as e: - err=str(e) - if "would have immediately matched" in err or "cross" in err.lower(): - cid2=ClientOrderId(str(UUID4())) + # Check if this strategy has a position; skip if already filled + has_position = name in active_cloids and tick - active_cloids_times.get(name, 0) < 60 + + # Determine signal + signal = None + if cfg["signals"]: + latest = cfg["signals"][-1] + # Only use recent signals (< 10 seconds old) + if time.time() - latest["time"] < 10: + signal = latest["signal"] + + # Close on opposing signal + if has_position and signal: + prev_signal = active_cloids.get(name, "") + if ("BUY" in str(signal).upper() and "SELL" in str(prev_signal).upper()) or \ + ("SELL" in str(signal).upper() and "BUY" in str(prev_signal).upper()): + try: + client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name])) + except Exception: + pass + del active_cloids[name] + has_position = False + + # Take-profit: close if price moved 2x fee in our favor + if has_position: + entry_px = active_cloids_px.get(name, 0) + if entry_px > 0: + if "BUY" in str(active_cloids[name]).upper() and mid > entry_px * 1.001: + try: + client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name])) + except Exception: + pass + del active_cloids[name] + has_position = False + elif "SELL" in str(active_cloids[name]).upper() and mid < entry_px * 0.999: + try: + client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name])) + except Exception: + pass + del active_cloids[name] + has_position = False + + if has_position: + continue # Don't replace existing orders + + # Avellaneda-Stoikov: proper optimal control (reservation price + spread) + if name == "Avellaneda-Stoikov": try: - client.submit_order(instrument_id=perp.id,client_order_id=cid2,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.IOC) - active_cloids[name]=str(cid2) - active_cloids_times[name]=tick - active_cloids_px[name]=px_level - except: pass + from strategies.as_quoter import ASQuoter + if "_as_quoter" not in dir(): + globals()["_as_quoter"] = ASQuoter( + gamma=0.1, k=1.5, tau=1.0, + min_spread=0.0001, max_inventory=cfg["size"] * 5, + ) + q = ASQuoter + asq = globals()["_as_quoter"] + asq.observe(mid) - # Equity - tp=sum(s["pnl"] for s in STRATEGIES.values()) - if tick%2==0: equity_history.append({"t":time.time(),"v":TOTAL_EQUITY+tp}) - write_metrics(addr) + # Get A-S inventory from position tracking + as_inv = STRATEGIES[name].get("position", 0.0) + elapsed = (tick * 1.0) % (asq.tau * 3600) / 3600.0 # 1-hour virtual sessions - if tick%20==0: - tp=sum(s["pnl"] for s in STRATEGIES.values()) - tr=sum(s["trades_today"] for s in STRATEGIES.values()) - tf=sum(s["fee_paid"] for s in STRATEGIES.values()) - log.info(f"Tick {tick:4d} | PnL: ${tp:+.2f} | Trades: {tr:3d} | Fees: ${tf:.4f} | New fills: {new_fills}") + result = asq.quotes(mid, as_inv, elapsed) + if result is None: + continue # Circuit breaker active — skip this tick - await asyncio.sleep(1) - except KeyboardInterrupt: log.info("Stopping...") + r_price = result["reservation"] + as_bid = int(result["bid"]) + as_ask = int(result["ask"]) + # Clamp: never cross the market + as_bid = min(as_bid, int(bid)) + as_ask = max(as_ask, int(ask)) + + cid_bid = ClientOrderId(str(UUID4())) + cid_ask = ClientOrderId(str(UUID4())) + try: + client.submit_order(instrument_id=perp.id, client_order_id=cid_bid, order_side=OrderSide.BUY, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(as_bid)), time_in_force=TimeInForce.GTC, post_only=True) + client.submit_order(instrument_id=perp.id, client_order_id=cid_ask, order_side=OrderSide.SELL, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(as_ask)), time_in_force=TimeInForce.GTC, post_only=True) + if tick % 60 == 0: + log.info(f"[AS] r={r_price:.1f} σ={asq.sigma*100:.2f}% BID {cfg['size']} @ ${as_bid:,} | ASK {cfg['size']} @ ${as_ask:,} (spread ${as_ask - as_bid:,})") + active_cloids[name] = str(cid_bid) + active_cloids_times[name] = tick + active_cloids_px[name] = as_bid + except Exception: + pass + except Exception: + # Fallback: best bid/ask if module unavailable + cid_bid = ClientOrderId(str(UUID4())) + cid_ask = ClientOrderId(str(UUID4())) + try: + client.submit_order(instrument_id=perp.id, client_order_id=cid_bid, order_side=OrderSide.BUY, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(bid))), time_in_force=TimeInForce.GTC, post_only=True) + client.submit_order(instrument_id=perp.id, client_order_id=cid_ask, order_side=OrderSide.SELL, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(ask))), time_in_force=TimeInForce.GTC, post_only=True) + active_cloids[name] = str(cid_bid) + active_cloids_times[name] = tick + active_cloids_px[name] = bid + except Exception: + pass + continue + + # For signal-driven strategies: use aggressive offset + if signal: + side = OrderSide.SELL if "SELL" in str(signal).upper() else OrderSide.BUY + # Aggressive: 0.03% inside the spread for higher fill probability + offset = int(mid * 0.0003) + px_level = ask - offset if side == OrderSide.SELL else bid + offset + px_level = max(px_level, 1) + else: + # No signal/default: skip (don't random-trade) + continue + + if px_level <= 0: + continue + + cid = ClientOrderId(str(UUID4())) + try: + client.submit_order(instrument_id=perp.id, client_order_id=cid, order_side=side, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(px_level))), time_in_force=TimeInForce.GTC, post_only=True) + if tick % 60 == 0: + side_str = "BUY" if side == OrderSide.BUY else "SELL" + log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} @ ${int(px_level):,} ({'best bid ' + str(int(bid)) if side == OrderSide.BUY else 'best ask ' + str(int(ask))})") + active_cloids[name] = str(cid) + active_cloids_times[name] = tick + active_cloids_px[name] = px_level + except Exception as e: + err = str(e) + if "would have immediately matched" in err or "cross" in err.lower(): + cid2 = ClientOrderId(str(UUID4())) + try: + client.submit_order(instrument_id=perp.id, client_order_id=cid2, order_side=side, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(px_level))), time_in_force=TimeInForce.IOC) + active_cloids[name] = str(cid2) + active_cloids_times[name] = tick + active_cloids_px[name] = px_level + except Exception: + pass + + # Equity + tp = sum(s["pnl"] for s in STRATEGIES.values()) + if tick % 2 == 0: + equity_history.append({"t": time.time(), "v": TOTAL_EQUITY + tp}) + if len(equity_history) > 1000: + equity_history[:] = equity_history[-600:] + write_metrics(addr) + + if tick % 20 == 0: + tp = sum(s["pnl"] for s in STRATEGIES.values()) + tr = sum(s["trades_today"] for s in STRATEGIES.values()) + tf = sum(s["fee_paid"] for s in STRATEGIES.values()) + log.info(f"Tick {tick:4d} | PnL: ${tp:+.2f} | Trades: {tr:3d} | Fees: ${tf:.4f} | New fills: {new_fills}") + + await asyncio.sleep(1) + except Exception as loop_err: + log.error(f"Loop error (tick {tick}): {loop_err}") + await asyncio.sleep(5) # back off and retry + except KeyboardInterrupt: + log.info("Stopping...") # Cancel all open_ords = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=10).json() diff --git a/strategies/as_quoter.py b/strategies/as_quoter.py new file mode 100644 index 0000000..81d29aa --- /dev/null +++ b/strategies/as_quoter.py @@ -0,0 +1,117 @@ +""" +Proper Avellaneda-Stoikov market making for the live node. + +Key formulas (Avellaneda & Stoikov, 2008): + Reservation price: r = s - q * gamma * sigma^2 * tau + Optimal spread: spread = gamma * sigma^2 * tau + (2/gamma) * ln(1 + gamma/k) + Bid = r - spread/2 Ask = r + spread/2 + +Where: + s = mid price, q = inventory, gamma = risk aversion + sigma = volatility, tau = remaining session time, k = order intensity + +Production adaptations: + - Rolling volatility estimation (5-min window) + - Circuit breaker: pause quoting when price jump exceeds 3σ + - Inventory bounds: stop quoting on over-exposed side + - Virtual session clock: 1-hour windows since crypto is 24/7 +""" + +import math +from collections import deque + + +class ASQuoter: + """Stateless per-tick quote generator using A-S optimal control.""" + + def __init__( + self, + gamma: float = 0.1, # Risk aversion — higher = more aggressive inventory redux + k: float = 1.5, # Order flow sensitivity — higher = tighter market + tau: float = 1.0, # Virtual session length (hours, for 24/7 crypto) + min_spread: float = 0.0001, # 1 bp minimum spread + max_inventory: float = 0.001, # Max position before stopping one side + vol_window: int = 300, # Number of price ticks for rolling vol (5 min @ 1s) + cb_mult: float = 3.0, # Circuit breaker multiplier (3σ jump threshold) + ): + self.gamma = gamma + self.k = k + self.tau = tau + self.min_spread = min_spread + self.max_inventory = max_inventory + self.vol_window = vol_window + self.cb_mult = cb_mult + + self._mid_prices: deque[float] = deque(maxlen=vol_window) + self._current_sigma: float = 0.02 # fallback: ~32% annualized for crypto + self._session_start: float = 0.0 + + def observe(self, mid: float) -> None: + """Feed a new mid-price observation. Updates rolling volatility.""" + self._mid_prices.append(mid) + if len(self._mid_prices) >= 2: + prices = list(self._mid_prices) + returns = [ + (prices[i] - prices[i - 1]) / prices[i - 1] + for i in range(1, len(prices)) + ] + mu = sum(returns) / len(returns) + var = sum((r - mu) ** 2 for r in returns) / len(returns) + sigma = math.sqrt(var) if var > 0 else 0.02 + self._current_sigma = sigma + + @property + def sigma(self) -> float: + return self._current_sigma + + def circuit_breaker(self) -> bool: + """Check if recent price jump exceeds threshold. If true, pause quoting.""" + if len(self._mid_prices) < 5: + return False + recent = list(self._mid_prices)[-5:] + move_pct = abs(recent[-1] - recent[0]) / recent[0] + threshold = self.cb_mult * self._current_sigma * math.sqrt(5) + return move_pct > threshold + + def quotes(self, mid: float, inventory: float, t: float) -> dict | None: + """ + Generate bid/ask quotes given current state. + + Args: + mid: current mid-price + inventory: current net position (positive = long) + t: elapsed session time in hours (0 to tau) + + Returns: + {"bid": ..., "ask": ..., "reservation": ..., "spread": ...} or None if paused + """ + self.observe(mid) + + if self.circuit_breaker(): + return None # Pause quoting — price jump in progress + + # Reservation price: skew center by inventory risk + tau_remaining = max(self.tau - t, 0.01) + reservation = mid - inventory * self.gamma * (self._current_sigma ** 2) * tau_remaining + + # Optimal spread: balance risk compensation vs flow capture + try: + log_term = math.log(1.0 + self.gamma / self.k) + except ValueError: + log_term = 0.0 + spread = ( + self.gamma * (self._current_sigma ** 2) * tau_remaining + + (2.0 / max(self.gamma, 0.001)) * log_term + ) + spread = max(spread, self.min_spread) + + half = spread / 2.0 + bid = reservation - half + ask = reservation + half + + return { + "bid": max(bid, 1.0), # Never negative/zero + "ask": max(ask, 1.0), + "reservation": reservation, + "spread": spread, + }