From eede59a7b34e4d3613dacc9bb40f8a9e68e15d28 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 24 Jul 2026 13:59:36 -0700 Subject: [PATCH 1/5] feat(mm-expert): add HIP-3 market-making agent strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three HIP-3 (xyz-issuer perps on hyperliquid_perpetual) examples for the market_making_expert agent: - hip_3_delta_neutral_funding_mm: delta-neutral MM + funding harvest on a correlated pair (default CL/BRENTOIL). Two pmm_mister controllers in one bot, beta-weighted long/short so net market delta ~0, leaned to the funding-favorable side. The hip3_dn_pair_monitor routine is the analysis brain: it fetches live positions and reports the ACTUAL net factor delta (in-band / breach) each tick, plus a tightened ±$20 band. - hip_3_mm_operator: HIP-3 volume-farming MM operator. - routines: hip3_dn_pair_monitor, hip3_market_scanner, hip3_pairs_backtest. Strategy config carries a stable bot_name for the shared bot. Correct executor + P&L attribution for these bot-mode agents across the dashboard and the agent's core-data view is handled by the framework (PR #166), kept separate from these strategy files. .gitignore: agent runtime artifacts (learnings.md, sessions/). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4 --- .../routines/hip3_dn_pair_monitor.py | 422 ++++++ .../routines/hip3_market_scanner.py | 274 ++++ .../routines/hip3_pairs_backtest.py | 1152 +++++++++++++++++ .../strategy.md | 154 +++ .../strategies/hip_3_mm_operator/strategy.md | 114 ++ 5 files changed, 2116 insertions(+) create mode 100644 agents/market_making_expert/routines/hip3_dn_pair_monitor.py create mode 100644 agents/market_making_expert/routines/hip3_market_scanner.py create mode 100644 agents/market_making_expert/routines/hip3_pairs_backtest.py create mode 100644 agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md create mode 100644 agents/market_making_expert/strategies/hip_3_mm_operator/strategy.md diff --git a/agents/market_making_expert/routines/hip3_dn_pair_monitor.py b/agents/market_making_expert/routines/hip3_dn_pair_monitor.py new file mode 100644 index 00000000..8f688f4e --- /dev/null +++ b/agents/market_making_expert/routines/hip3_dn_pair_monitor.py @@ -0,0 +1,422 @@ +"""HIP-3 Delta-Neutral Pair Monitor — live hedge beta, funding, and sizing for the DN funding MM strategy.""" +import asyncio +import logging +import math +import time + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes +from config_manager import get_client + +logger = logging.getLogger(__name__) +CATEGORY = "Monitoring" +HL_URL = "https://api.hyperliquid.xyz/info" + + +class Config(BaseModel): + """Live hedge beta + funding monitor for HIP-3 Delta-Neutral Funding MM — emits ADJUSTMENT RECOMMENDATION.""" + leg_a: str = Field(default="CL", description="Coin A (xyz: prefix added automatically)") + leg_b: str = Field(default="BRENTOIL", description="Coin B (xyz: prefix added automatically)") + interval: str = Field(default="1h", description="Candle interval for beta/correlation window") + lookback_hours: int = Field(default=168, description="Lookback window in hours (7d) for beta + correlation") + configured_hedge_beta: float = Field(default=1.02, description="Configured hedge beta from strategy; compare vs live") + total_amount_quote: float = Field(default=500.0, description="Total gross notional (quote) to deploy") + net_delta_band_pct: float = Field(default=0.1, description="Net delta tolerance band as fraction of total") + min_corr: float = Field(default=0.9, description="Minimum return correlation gate") + beta_drift_tol: float = Field(default=0.2, description="RESIZE trigger: |live_beta - configured| / configured > this") + fee_bps_per_side: float = Field(default=1.3, description="All-in maker fee per side in bps") + + +# ── Math helpers (stdlib only) ──────────────────────────────────────────────── + +def _std(vals: list) -> float: + n = len(vals) + if n < 2: + return 0.0 + m = sum(vals) / n + return math.sqrt(sum((v - m) ** 2 for v in vals) / n) + + +def _pearson(xs: list, ys: list) -> float: + n = len(xs) + if n < 2: + return 0.0 + mx = sum(xs) / n + my = sum(ys) / n + sx = _std(xs) + sy = _std(ys) + if sx < 1e-12 or sy < 1e-12: + return 0.0 + cov = sum((xs[k] - mx) * (ys[k] - my) for k in range(n)) / n + return max(-1.0, min(1.0, cov / (sx * sy))) + + +def _ols_beta(x: list, y: list) -> float: + """OLS slope y on x: beta = Cov(x,y) / Var(x). Used as hedge ratio.""" + n = len(x) + if n < 2: + raise ValueError(f"Need >=2 data points for OLS, got {n}") + mx = sum(x) / n + my = sum(y) / n + ss_xx = sum((xi - mx) ** 2 for xi in x) / n + ss_xy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) / n + if ss_xx < 1e-20: + raise ValueError("Zero variance in leg_b returns — cannot compute hedge beta") + return ss_xy / ss_xx + + +def _interval_to_hours(interval: str) -> float: + return {"1m": 1/60, "5m": 5/60, "15m": 0.25, "30m": 0.5, + "1h": 1.0, "4h": 4.0, "12h": 12.0, "1d": 24.0}.get(interval, 1.0) + + +# ── HL API helpers ───────────────────────────────────────────────────────────── + +async def _fetch_candles(session: aiohttp.ClientSession, coin: str, interval: str, + start_ms: int, end_ms: int) -> list: + payload = {"type": "candleSnapshot", + "req": {"coin": coin, "interval": interval, + "startTime": start_ms, "endTime": end_ms}} + async with session.post(HL_URL, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + raise RuntimeError(f"candleSnapshot HTTP {resp.status} for {coin}") + data = await resp.json() + if not isinstance(data, list): + raise RuntimeError(f"Unexpected candleSnapshot response for {coin}: {type(data)}") + return data + + +async def _fetch_meta_and_ctxs(session: aiohttp.ClientSession) -> tuple: + payload = {"type": "metaAndAssetCtxs", "dex": "xyz"} + async with session.post(HL_URL, json=payload, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + raise RuntimeError(f"metaAndAssetCtxs HTTP {resp.status}") + data = await resp.json() + if not isinstance(data, list) or len(data) < 2: + raise RuntimeError(f"Unexpected metaAndAssetCtxs shape: {type(data)}") + return data[0], data[1] + + +def _candles_to_series(candles: list, coin: str) -> dict: + result = {} + for c in candles: + t = c.get("t") or c.get("T") + close = c.get("c") or c.get("close") + if t is not None and close is not None: + try: + result[int(t)] = float(close) + except (ValueError, TypeError): + pass + if not result: + raise ValueError(f"Empty candle series after parsing for {coin}") + return result + + +def _align_two(series_a: dict, series_b: dict) -> tuple: + common_ts = sorted(set(series_a.keys()) & set(series_b.keys())) + if not common_ts: + raise ValueError("No common timestamps between leg_a and leg_b candles") + return [series_a[t] for t in common_ts], [series_b[t] for t in common_ts] + + +def _find_ctx(universe: list, ctxs: list, coin_name: str) -> dict: + for asset, ctx in zip(universe, ctxs): + if asset.get("name", "") == coin_name: + return ctx + raise ValueError( + f"Leg '{coin_name}' not found in xyz universe — " + "verify coin name (e.g. 'CL', 'BRENTOIL') and that it trades on Hyperliquid HIP-3" + ) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + coin_a = f"xyz:{config.leg_a}" + coin_b = f"xyz:{config.leg_b}" + + now_ms = int(time.time() * 1000) + start_ms = now_ms - config.lookback_hours * 3600 * 1000 + + # ── 1. Fetch candles (parallel) + metaAndAssetCtxs (3 calls total) ─────── + async with aiohttp.ClientSession() as session: + candles_a_raw, candles_b_raw, meta_ctxs = await asyncio.gather( + _fetch_candles(session, coin_a, config.interval, start_ms, now_ms), + _fetch_candles(session, coin_b, config.interval, start_ms, now_ms), + _fetch_meta_and_ctxs(session), + ) + meta, ctxs = meta_ctxs + + # ── 2. Parse + align candles ─────────────────────────────────────────────── + series_a = _candles_to_series(candles_a_raw, coin_a) + series_b = _candles_to_series(candles_b_raw, coin_b) + prices_a, prices_b = _align_two(series_a, series_b) + + if len(prices_a) < 10: + raise ValueError( + f"Only {len(prices_a)} aligned bars for {coin_a}/{coin_b}; need >=10. " + "Check coin names and that both legs trade on Hyperliquid xyz." + ) + + # ── 3. Log returns + stats ───────────────────────────────────────────────── + log_a = [math.log(prices_a[i] / prices_a[i-1]) for i in range(1, len(prices_a)) + if prices_a[i-1] > 0 and prices_a[i] > 0] + log_b = [math.log(prices_b[i] / prices_b[i-1]) for i in range(1, len(prices_b)) + if prices_b[i-1] > 0 and prices_b[i] > 0] + n_rets = min(len(log_a), len(log_b)) + log_a = log_a[:n_rets] + log_b = log_b[:n_rets] + + if n_rets < 4: + raise ValueError(f"Too few return bars ({n_rets}) to compute statistics") + + hours_per_bar = _interval_to_hours(config.interval) + bars_per_year = 365 * 24 / hours_per_bar + + corr = _pearson(log_a, log_b) + # hedge beta = OLS slope of leg_a returns on leg_b returns: Cov(ra,rb)/Var(rb) + live_beta = _ols_beta(log_b, log_a) + vol_a = _std(log_a) * math.sqrt(bars_per_year) + vol_b = _std(log_b) * math.sqrt(bars_per_year) + + # ── 4. Funding + mids from metaAndAssetCtxs ─────────────────────────────── + universe = meta.get("universe", []) + ctx_a = _find_ctx(universe, ctxs, coin_a) + ctx_b = _find_ctx(universe, ctxs, coin_b) + + mark_a = float(ctx_a.get("markPx") or ctx_a.get("midPx") or 0) + mark_b = float(ctx_b.get("markPx") or ctx_b.get("midPx") or 0) + if mark_a <= 0: + raise ValueError(f"Zero/missing markPx for {coin_a}") + if mark_b <= 0: + raise ValueError(f"Zero/missing markPx for {coin_b}") + + funding_a_hr = float(ctx_a.get("funding") or 0) # per-hour rate + funding_b_hr = float(ctx_b.get("funding") or 0) + funding_a_yr = funding_a_hr * 24 * 365 * 100 # annualized % + funding_b_yr = funding_b_hr * 24 * 365 * 100 + + # ── 5. Two delta-neutral configs ────────────────────────────────────────── + B = live_beta # hedge ratio: leg_b_notional = B * leg_a_notional + + # Config A: LONG leg_a / SHORT leg_b + # LONG leg_a: carry = -funding_a_hr (if +ve rate, long pays; if -ve, long receives) + # SHORT leg_b: carry = +B*funding_b_hr (if +ve rate, short receives; scaled by notional ratio B) + net_carry_hr_A = -funding_a_hr + B * funding_b_hr + # Config B: SHORT leg_a / LONG leg_b + net_carry_hr_B = funding_a_hr - B * funding_b_hr + + if net_carry_hr_A >= net_carry_hr_B: + recommended_config = "A" + net_carry_hr_best = net_carry_hr_A + config_desc = f"LONG {config.leg_a} / SHORT {config.leg_b}" + short_leg, short_leg_fund = config.leg_b, funding_b_hr + long_leg, long_leg_fund = config.leg_a, funding_a_hr + else: + recommended_config = "B" + net_carry_hr_best = net_carry_hr_B + config_desc = f"SHORT {config.leg_a} / LONG {config.leg_b}" + short_leg, short_leg_fund = config.leg_a, funding_a_hr + long_leg, long_leg_fund = config.leg_b, funding_b_hr + + net_carry_yr = net_carry_hr_best * 24 * 365 * 100 + + # ── 5b. Fetch live positions ────────────────────────────────────────────── + from condor.fetchers.positions import fetch_positions + client = await get_client(context._chat_id, context=context) + raw_positions = await fetch_positions(client, connector_name="hyperliquid_perpetual") if client else [] + + sym_a = f"XYZ:{config.leg_a}-USD" + sym_b = f"XYZ:{config.leg_b}-USD" + amount_a = 0.0 + amount_b = 0.0 + for pos in raw_positions: + pair = pos.get("trading_pair", "") + if pair == sym_a: + amount_a = float(pos.get("amount", 0.0)) + elif pair == sym_b: + amount_b = float(pos.get("amount", 0.0)) + + has_live_position = bool(raw_positions) + signed_notional_a = amount_a * mark_a + signed_notional_b = amount_b * mark_b + actual_net_factor_delta = B * signed_notional_a + signed_notional_b + + # ── 6. Delta-neutral sizing using live beta (TARGET — theoretical) ──────── + gross = config.total_amount_quote + leg_a_notional = gross / (1 + B) + leg_b_notional = B * leg_a_notional + + if recommended_config == "A": + signed_a = +leg_a_notional + signed_b = -leg_b_notional + else: + signed_a = -leg_a_notional + signed_b = +leg_b_notional + + # theoretical net delta (≈0 by construction — use actual_net_factor_delta for breach checks) + net_factor_delta = signed_a * B + signed_b + delta_band = config.net_delta_band_pct * gross + + # ── 7. Adjustment decision (priority order) ─────────────────────────────── + beta_drift = abs(live_beta - config.configured_hedge_beta) / max(abs(config.configured_hedge_beta), 1e-10) + corr_pass = corr >= config.min_corr + + flip_flags = [] + if short_leg_fund <= 0: + flip_flags.append( + f"WARNING: SHORT leg ({short_leg}) funding <= 0 " + f"({short_leg_fund*24*365*100:.1f}%/yr) — carry eroded, short is paying" + ) + if long_leg_fund > 0.0001 / (24 * 365): # > ~0.09%/yr => long paying materially + flip_flags.append( + f"WARNING: LONG leg ({long_leg}) funding > 0 " + f"({long_leg_fund*24*365*100:.1f}%/yr) — long is also paying, carry compressed" + ) + + actual_delta_breach = has_live_position and abs(actual_net_factor_delta) > delta_band + + if not corr_pass: + recommendation = ( + f"HOLD/FLATTEN: correlation broke " + f"(corr={corr:.3f} < min={config.min_corr}) — " + "hedge unreliable; reduce or flatten positions." + ) + elif net_carry_yr <= 0: + recommendation = ( + f"REDUCE/ROTATE: funding no longer favorable " + f"(net carry={net_carry_yr:.1f}%/yr <= 0) — " + "consider rotating to a different pair or flattening." + ) + elif beta_drift > config.beta_drift_tol: + recommendation = ( + f"RESIZE: live beta ({live_beta:.4f}) drifted from configured " + f"({config.configured_hedge_beta:.4f}); " + f"drift={beta_drift:.1%} > tol={config.beta_drift_tol:.0%} — " + f"re-split notionals at B={live_beta:.4f}. Still runnable." + ) + elif actual_delta_breach: + recommendation = ( + f"HEDGE: actual net factor delta ${actual_net_factor_delta:+.2f} " + f"breaches band +/-${delta_band:.2f} — " + f"adjust {config.leg_a} or {config.leg_b} position to rebalance delta." + ) + else: + recommendation = ( + f"RUN config {recommended_config}: deploy/maintain {config_desc} " + f"at ${leg_a_notional:.1f}/{config.leg_a} and ${leg_b_notional:.1f}/{config.leg_b} " + f"(net carry {net_carry_yr:.1f}%/yr, corr {corr:.3f} OK, beta in-band)." + ) + + # ── 8. Format summary ───────────────────────────────────────────────────── + lines = [ + f"**HIP-3 DN Pair Monitor — {config.leg_a} / {config.leg_b}**", + f"Window: {config.lookback_hours}h {config.interval} candles | {n_rets} return bars", + "", + f"RECOMMENDATION: {recommendation}", + ] + for flag in flip_flags: + lines.append(flag) + lines += [ + "", + "── MARKET DATA ──", + f" Mark {config.leg_a:<10}: ${mark_a:.4f}", + f" Mark {config.leg_b:<10}: ${mark_b:.4f}", + "", + "── BETA & CORRELATION ──", + f" Live hedge beta : {live_beta:.4f} (configured: {config.configured_hedge_beta:.4f}, drift: {beta_drift:.1%})", + f" Return correlation: {corr:.4f} (gate >={config.min_corr}) -> {'PASS' if corr_pass else 'FAIL'}", + f" Ann vol {config.leg_a:<10}: {vol_a:.1%}", + f" Ann vol {config.leg_b:<10}: {vol_b:.1%}", + "", + "── FUNDING (live, per-hour -> annualized) ──", + f" {config.leg_a} funding: {funding_a_hr*1e6:.3f} micro/hr ({funding_a_yr:+.1f}%/yr)", + f" {config.leg_b} funding: {funding_b_hr*1e6:.3f} micro/hr ({funding_b_yr:+.1f}%/yr)", + "", + "── CONFIG COMPARISON ──", + f" Config A (LONG {config.leg_a} / SHORT {config.leg_b}): {net_carry_hr_A*24*365*100:+.1f}%/yr", + f" Config B (SHORT {config.leg_a} / LONG {config.leg_b}): {net_carry_hr_B*24*365*100:+.1f}%/yr", + f" Recommended : Config {recommended_config} ({config_desc})", + f" Net carry (best) : {net_carry_yr:+.1f}%/yr", + "", + "── TARGET SIZING (theoretical, live B) ──", + f" Gross notional: ${gross:.2f}", + f" Leg A ({config.leg_a}): ${abs(signed_a):.2f} ({'LONG' if signed_a > 0 else 'SHORT'})", + f" Leg B ({config.leg_b}): ${abs(signed_b):.2f} ({'LONG' if signed_b > 0 else 'SHORT'})", + f" Net factor delta TARGET (theoretical): {net_factor_delta:.4f} (band +/-{delta_band:.2f})", + "", + "── ACTUAL POSITIONS ──", + ] + if not has_live_position: + lines.append(" no live position — flat (actual delta = 0.00)") + else: + actual_band_label = "BREACH" if actual_delta_breach else "IN-BAND" + lines += [ + f" Leg A ({config.leg_a}): ${signed_notional_a:+.2f} ({'LONG' if signed_notional_a >= 0 else 'SHORT'}, amount={amount_a:+.6f})", + f" Leg B ({config.leg_b}): ${signed_notional_b:+.2f} ({'LONG' if signed_notional_b >= 0 else 'SHORT'}, amount={amount_b:+.6f})", + f" Actual net factor delta: ${actual_net_factor_delta:+.2f} vs +/-${delta_band:.2f} -> {actual_band_label}", + ] + summary = "\n".join(lines) + + # ── 9. ReportBuilder ────────────────────────────────────────────────────── + try: + from condor.reports import ReportBuilder + + builder = ReportBuilder(f"HIP-3 DN Monitor: {config.leg_a}/{config.leg_b}") + builder.source("routine", "hip3_dn_pair_monitor").tags( + ["market-making", "hip3", "delta-neutral", config.leg_a.lower(), config.leg_b.lower()] + ) + + action_word = recommendation.split(":")[0] + builder.kpi("Pair", f"{config.leg_a}/{config.leg_b}") + builder.kpi("Action", action_word) + builder.kpi("Config", f"Config {recommended_config}") + builder.kpi("Net Carry", f"{net_carry_yr:.1f}%/yr") + builder.kpi("Correlation", f"{corr:.3f}") + builder.kpi("Live Beta", f"{live_beta:.4f}") + builder.kpi("Beta Drift", f"{beta_drift:.1%}") + builder.kpi("Corr Gate", "PASS" if corr_pass else "FAIL") + if has_live_position: + _abl = "BREACH" if actual_delta_breach else "IN-BAND" + builder.kpi("Actual Net Delta", f"${actual_net_factor_delta:+.2f} ({_abl})") + else: + builder.kpi("Actual Net Delta", "flat (no position)") + + rows = [ + {"Metric": f"Mark {config.leg_a}", "Value": f"${mark_a:.4f}"}, + {"Metric": f"Mark {config.leg_b}", "Value": f"${mark_b:.4f}"}, + {"Metric": "Return Correlation", "Value": f"{corr:.4f}"}, + {"Metric": "Live Hedge Beta", "Value": f"{live_beta:.4f}"}, + {"Metric": "Configured Beta", "Value": f"{config.configured_hedge_beta:.4f}"}, + {"Metric": "Beta Drift", "Value": f"{beta_drift:.1%}"}, + {"Metric": f"Ann Vol {config.leg_a}", "Value": f"{vol_a:.1%}"}, + {"Metric": f"Ann Vol {config.leg_b}", "Value": f"{vol_b:.1%}"}, + {"Metric": f"{config.leg_a} Funding/yr", "Value": f"{funding_a_yr:+.1f}%"}, + {"Metric": f"{config.leg_b} Funding/yr", "Value": f"{funding_b_yr:+.1f}%"}, + {"Metric": f"Carry Config A ({config.leg_a}L/{config.leg_b}S)", "Value": f"{net_carry_hr_A*24*365*100:+.1f}%/yr"}, + {"Metric": f"Carry Config B ({config.leg_a}S/{config.leg_b}L)", "Value": f"{net_carry_hr_B*24*365*100:+.1f}%/yr"}, + {"Metric": "Recommended Config", "Value": f"Config {recommended_config}: {config_desc}"}, + {"Metric": "Net Carry (best)", "Value": f"{net_carry_yr:+.1f}%/yr"}, + {"Metric": f"Leg A ({config.leg_a}) TARGET Notional", "Value": f"${abs(signed_a):.2f} ({'L' if signed_a > 0 else 'S'}) [theoretical]"}, + {"Metric": f"Leg B ({config.leg_b}) TARGET Notional", "Value": f"${abs(signed_b):.2f} ({'L' if signed_b > 0 else 'S'}) [theoretical]"}, + {"Metric": "Net Factor Delta TARGET (theoretical)", "Value": f"{net_factor_delta:.4f}"}, + {"Metric": "Delta Band", "Value": f"+/-{delta_band:.2f}"}, + ] + if not has_live_position: + rows.append({"Metric": "Actual Positions", "Value": "no live position — flat"}) + else: + _abl = "BREACH" if actual_delta_breach else "IN-BAND" + rows += [ + {"Metric": f"Leg A ({config.leg_a}) ACTUAL Notional", "Value": f"${signed_notional_a:+.2f} ({'L' if signed_notional_a >= 0 else 'S'}, amt={amount_a:+.6f})"}, + {"Metric": f"Leg B ({config.leg_b}) ACTUAL Notional", "Value": f"${signed_notional_b:+.2f} ({'L' if signed_notional_b >= 0 else 'S'}, amt={amount_b:+.6f})"}, + {"Metric": "Actual Net Factor Delta", "Value": f"${actual_net_factor_delta:+.2f} ({_abl})"}, + ] + builder.table(rows, ["Metric", "Value"]) + builder.markdown(summary) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"Report generation failed: {e}") + + return summary diff --git a/agents/market_making_expert/routines/hip3_market_scanner.py b/agents/market_making_expert/routines/hip3_market_scanner.py new file mode 100644 index 00000000..015d30f3 --- /dev/null +++ b/agents/market_making_expert/routines/hip3_market_scanner.py @@ -0,0 +1,274 @@ +"""HIP-3 market scanner — ranks xyz-issuer perps for volume-farming market-making.""" +import asyncio +import logging +import math + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes +from config_manager import get_client + +logger = logging.getLogger(__name__) + +CATEGORY = "Market Data" + +HL_URL = "https://api.hyperliquid.xyz/info" + + +class Config(BaseModel): + """Scan all markets of a HIP-3 builder issuer and return a shortlist for volume-farming MM.""" + issuer: str = Field(default="xyz", description="HIP-3 builder issuer slug (e.g. 'xyz')") + min_spread_bps: float = Field(default=3.0, description="Minimum impact spread in bps") + max_daily_drift_pct: float = Field(default=3.0, description="Maximum daily price drift %") + min_oi_notional: float = Field(default=1_000_000.0, description="Minimum open interest in USD") + min_book_depth_usd: float = Field(default=10_000.0, description="Min resting book notional within depth_within_bps, per side (liquidity filter)") + depth_within_bps: float = Field(default=10.0, description="Band (bps from mid) over which book depth is measured") + depth_check_top_k: int = Field(default=12, description="How many top-scored survivors to depth-check via l2Book (bounds API calls)") + top_n: int = Field(default=5, description="Number of top markets to return") + all_in_fee_bps_roundtrip: float = Field(default=2.6, description="Informational: total roundtrip fee in bps (~1.3bps/side)") + + +async def _fetch_book_depth(session, coin, ctx_mid, within_bps): + """Return (bid_depth_usd, ask_depth_usd) resting within `within_bps` of mid. (0,0) on failure/empty.""" + try: + async with session.post( + HL_URL, json={"type": "l2Book", "coin": coin}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status != 200: + return 0.0, 0.0 + book = await resp.json() + levels = book.get("levels") if isinstance(book, dict) else None + if not levels or len(levels) != 2 or not levels[0] or not levels[1]: + return 0.0, 0.0 # empty book = closed / illiquid + bids, asks = levels[0], levels[1] + best_bid = float(bids[0]["px"]); best_ask = float(asks[0]["px"]) + mid = (best_bid + best_ask) / 2 or ctx_mid + if mid <= 0: + return 0.0, 0.0 + + def _side(side_levels, is_bid): + tot = 0.0 + for lvl in side_levels: + px = float(lvl["px"]); sz = float(lvl["sz"]) + off = (mid - px) / mid * 1e4 if is_bid else (px - mid) / mid * 1e4 + if off > within_bps: + break + tot += px * sz + return tot + + return _side(bids, True), _side(asks, False) + except Exception as e: + logger.warning(f"l2Book depth fetch failed for {coin}: {e}") + return 0.0, 0.0 + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + # Client is optional — only used for report persistence, not for the scan itself. + client = await get_client(context._chat_id, context=context) + + issuer = config.issuer.lower() + issuer_upper = issuer.upper() + + # ── 1. Fetch universe + contexts from Hyperliquid public API ────────────── + payload = {"type": "metaAndAssetCtxs", "dex": issuer} + try: + async with aiohttp.ClientSession() as session: + async with session.post( + HL_URL, json=payload, timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status != 200: + return f"Hyperliquid API error: HTTP {resp.status}" + data = await resp.json() + except Exception as e: + return f"Failed to fetch Hyperliquid data: {e}" + + if not isinstance(data, list) or len(data) < 2: + return f"Unexpected API response shape: {type(data)}" + + meta, ctxs = data[0], data[1] + universe = meta.get("universe", []) + if not universe: + return f"No markets found for issuer '{issuer}'" + + # ── 2. Compute per-market metrics ───────────────────────────────────────── + markets = [] + for asset, ctx in zip(universe, ctxs): + try: + name = asset.get("name", "") # e.g. "xyz:SMSN" (l2Book coin) + pair = name.upper() + "-USD" # e.g. "XYZ:SMSN-USD" (trading_pair) + + mid_raw = ctx.get("midPx") + mark_raw = ctx.get("markPx") + prev_raw = ctx.get("prevDayPx") + impact_pxs = ctx.get("impactPxs") + + volume = float(ctx.get("dayNtlVlm", 0) or 0) + mid = float(mid_raw) if mid_raw else 0.0 + mark = float(mark_raw) if mark_raw else 0.0 + prev = float(prev_raw) if prev_raw else 0.0 + + open_market = ( + mid > 0 + and isinstance(impact_pxs, list) + and len(impact_pxs) == 2 + ) + + spread_bps = None + if open_market: + try: + bid_impact = float(impact_pxs[0]) + ask_impact = float(impact_pxs[1]) + spread_bps = (ask_impact - bid_impact) / mid * 1e4 + except (ValueError, TypeError, ZeroDivisionError): + open_market = False + + daily_drift_pct = abs(mark / prev - 1) * 100 if prev else 999.0 + oi_notional = float(ctx.get("openInterest", 0) or 0) * mark + + markets.append({ + "pair": pair, + "coin": name, + "volume": volume, + "mid": mid, + "mark": mark, + "spread_bps": spread_bps, + "daily_drift_pct": daily_drift_pct, + "oi_notional": oi_notional, + "book_depth_usd": None, + "open_market": open_market, + "maxLeverage": int(asset.get("maxLeverage", 0)), + "funding": ctx.get("funding", "N/A"), + }) + except Exception as e: + logger.warning(f"Error processing market {asset.get('name', '?')}: {e}") + + total_markets = len(markets) + + # ── 3. Pre-filters (open / spread / drift / OI) ─────────────────────────── + prelim = [ + m for m in markets + if ( + m["open_market"] + and m["spread_bps"] is not None + and m["spread_bps"] >= config.min_spread_bps + and m["daily_drift_pct"] <= config.max_daily_drift_pct + and m["oi_notional"] >= config.min_oi_notional + ) + ] + + # ── 4. Score and rank (before depth check) ──────────────────────────────── + for m in prelim: + m["score"] = ( + math.log(max(m["volume"], 1)) + + 0.3 * min(m["spread_bps"], 8.0) + - 0.4 * m["daily_drift_pct"] + ) + prelim.sort(key=lambda m: m["score"], reverse=True) + + # ── 5. LIQUIDITY FILTER — real book depth on the top-scored candidates ──── + # Only depth-check the top_k (bounds l2Book calls; the rest can't outrank them anyway). + candidates = prelim[: config.depth_check_top_k] + if candidates: + try: + async with aiohttp.ClientSession() as session: + depths = await asyncio.gather(*[ + _fetch_book_depth(session, m["coin"], m["mid"], config.depth_within_bps) + for m in candidates + ]) + for m, (bid_d, ask_d) in zip(candidates, depths): + # Require BOTH sides liquid for two-sided MM → use the weaker side. + m["book_depth_usd"] = min(bid_d, ask_d) + except Exception as e: + logger.warning(f"Depth-check batch failed: {e}") + + survivors = [ + m for m in candidates + if m["book_depth_usd"] is not None + and m["book_depth_usd"] >= config.min_book_depth_usd + ] + survivors.sort(key=lambda m: m["score"], reverse=True) + shortlist = survivors[: config.top_n] + + # ── 6. Fallback if zero survivors ───────────────────────────────────────── + no_survivors = len(survivors) == 0 + fallback = [] + if no_survivors: + fallback = sorted(markets, key=lambda m: m["volume"], reverse=True)[:5] + + # ── 7. Build summary ────────────────────────────────────────────────────── + top_pick = shortlist[0]["pair"] if shortlist else "NONE" + + lines = [ + f"**HIP-3 Market Scanner — issuer: {issuer_upper}**", + f"Scanned: {total_markets} markets | Pre-filter pass: {len(prelim)} | Depth-checked: {len(candidates)} | Survivors: {len(survivors)} | Top-{config.top_n} shown", + f"Filters: spread >= {config.min_spread_bps}bps | drift <= {config.max_daily_drift_pct}% | OI >= ${config.min_oi_notional:,.0f} | depth >= ${config.min_book_depth_usd:,.0f}/side within {config.depth_within_bps}bps", + f"Fee context: {config.all_in_fee_bps_roundtrip}bps round-trip (~{config.all_in_fee_bps_roundtrip / 2:.2f}bps/side)", + "", + ] + + def _mrow(rank, m, note=""): + spd = f"{m['spread_bps']:.2f}" if m["spread_bps"] is not None else "N/A" + dep = f"${m['book_depth_usd']:,.0f}" if m.get("book_depth_usd") is not None else "n/a" + flag = f" [{note}]" if note else "" + score_str = f" | Score={m['score']:.3f}" if "score" in m else "" + return ( + f" {rank}. {m['pair']}: Vol=${m['volume']:,.0f} | Spread={spd}bps" + f" | Drift={m['daily_drift_pct']:.2f}% | Depth={dep}/side | OI=${m['oi_notional']:,.0f}" + f" | Lev={m['maxLeverage']}x{score_str}{flag}" + ) + + if no_survivors: + lines.append("WARNING: NONE PASSED FILTERS — top 5 by volume (informational):") + for rank, m in enumerate(fallback, 1): + lines.append(_mrow(rank, m, "NO FILTER PASS")) + else: + lines.append(f"TOP PICK: {top_pick}") + lines.append("") + for rank, m in enumerate(shortlist, 1): + lines.append(_mrow(rank, m)) + + summary = "\n".join(lines) + + # ── 8. Persistent report ────────────────────────────────────────────────── + try: + from condor.reports import ReportBuilder + + builder = ReportBuilder(f"HIP-3 Scanner: {issuer_upper}") + builder.source("routine", "hip3_market_scanner").tags( + ["market-making", "hip3", issuer, "scanner"] + ) + builder.kpi("Markets Scanned", str(total_markets)) + builder.kpi("Survivors", str(len(survivors))) + builder.kpi("Top Pick", top_pick) + builder.kpi("Fee RT", f"{config.all_in_fee_bps_roundtrip}bps") + + display_list = shortlist if not no_survivors else fallback + note_col = "Score" if not no_survivors else "Note" + table_rows = [] + for rank, m in enumerate(display_list, 1): + table_rows.append({ + "Rank": rank, + "Pair": m["pair"], + "24h Vol ($)": f"${m['volume']:,.0f}", + "Spread (bps)": f"{m['spread_bps']:.2f}" if m["spread_bps"] is not None else "N/A", + "Drift %": f"{m['daily_drift_pct']:.2f}%", + "Depth/side ($)": f"${m['book_depth_usd']:,.0f}" if m.get("book_depth_usd") is not None else "n/a", + "OI ($)": f"${m['oi_notional']:,.0f}", + "MaxLev": m["maxLeverage"], + note_col: f"{m['score']:.3f}" if "score" in m else "NO FILTER PASS", + }) + + if table_rows: + builder.table( + table_rows, + ["Rank", "Pair", "24h Vol ($)", "Spread (bps)", "Drift %", "Depth/side ($)", "OI ($)", "MaxLev", note_col], + ) + + builder.markdown(summary) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"Report generation failed: {e}") + + return summary diff --git a/agents/market_making_expert/routines/hip3_pairs_backtest.py b/agents/market_making_expert/routines/hip3_pairs_backtest.py new file mode 100644 index 00000000..1af10814 --- /dev/null +++ b/agents/market_making_expert/routines/hip3_pairs_backtest.py @@ -0,0 +1,1152 @@ +"""HIP-3 pairs backtest — validates market-neutral long/short on xyz-issuer perps before trading.""" +import asyncio +import logging +import math +import time + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes +from config_manager import get_client + +logger = logging.getLogger(__name__) +CATEGORY = "Analysis" +HL_URL = "https://api.hyperliquid.xyz/info" + + +class Config(BaseModel): + """Backtest a market-neutral long/short strategy on HIP-3 xyz-issuer perps before trading.""" + mode: str = Field(default="pair", description="'pair' = single-pair spread backtest; 'universe' = full HIP-3 universe L/S") + # ── universe mode ───────────────────────────────────────────────────────── + issuer: str = Field(default="xyz", description="[universe] HIP-3 builder issuer slug (e.g. 'xyz')") + trend_lookback_hours: int = Field(default=24, description="[universe] Momentum lookback for trend score/ranking") + rebalance_hours: int = Field(default=6, description="[universe] How often to re-rank and rebalance the book") + top_k: int = Field(default=1, description="[universe] Legs per side (1 = single long/short pair)") + min_volume_usd: float = Field(default=1_000_000.0, description="[universe] Min 24h volume filter (dayNtlVlm)") + min_oi_notional: float = Field(default=1_000_000.0, description="[universe] Min OI filter (openInterest * mark)") + taker_bps_per_side: float = Field(default=3.5, description="[universe] Taker fee per side for rotations in bps") + include_funding: bool = Field(default=True, description="[universe] Include perp funding payments on held legs") + momentum: bool = Field(default=True, description="[universe] True=long winners/short losers; False=reversion") + # ── pair mode ───────────────────────────────────────────────────────────── + pair_long: str = Field(default="XYZ100", description="[pair] Long leg coin (xyz: prefix added automatically)") + pair_short: str = Field(default="SP500", description="[pair] Short leg coin (xyz: prefix added automatically)") + hedge_window: int = Field(default=168, description="[pair] Rolling window (bars) for hedge ratio AND z-score (168h = 7d)") + signal: str = Field(default="reversion", description="[pair] Primary signal: 'reversion' or 'momentum' (both always reported)") + entry_z: float = Field(default=1.5, description="[pair] Entry z-score threshold (reversion)") + exit_z: float = Field(default=0.3, description="[pair] Exit z-score threshold (reversion)") + momentum_lookback: int = Field(default=48, description="[pair] Lookback bars for momentum signal") + # ── shared ──────────────────────────────────────────────────────────────── + interval: str = Field(default="1h", description="Candle interval (1h, 4h, 15m, 1d)") + lookback_days: int = Field(default=45, description="History window in days") + fee_bps_per_side: float = Field(default=1.3, description="All-in maker fee per side in bps") + min_corr: float = Field(default=0.5, description="Min return-correlation gate") + + +# ── Math helpers ───────────────────────────────────────────────────────────── + +def _interval_to_hours(interval: str) -> float: + return {"1m": 1/60, "5m": 5/60, "15m": 0.25, "30m": 0.5, + "1h": 1.0, "4h": 4.0, "12h": 12.0, "1d": 24.0}.get(interval, 1.0) + + +def _log_returns(prices: list) -> list: + return [math.log(prices[i] / prices[i - 1]) + for i in range(1, len(prices)) if prices[i - 1] > 0 and prices[i] > 0] + + +def _correlation_matrix(returns_matrix: list) -> list: + n = len(returns_matrix) + if n == 0: + return [] + means = [sum(r) / len(r) if r else 0.0 for r in returns_matrix] + stds = [] + for i, r in enumerate(returns_matrix): + m = means[i] + var = sum((x - m) ** 2 for x in r) / len(r) if r else 0.0 + stds.append(math.sqrt(max(var, 0.0))) + corr = [[0.0] * n for _ in range(n)] + for i in range(n): + for j in range(n): + if i == j: + corr[i][j] = 1.0 + elif j < i: + corr[i][j] = corr[j][i] + else: + if stds[i] == 0 or stds[j] == 0: + corr[i][j] = 0.0 + else: + ri, rj = returns_matrix[i], returns_matrix[j] + cov = sum((ri[k] - means[i]) * (rj[k] - means[j]) for k in range(len(ri))) / len(ri) + corr[i][j] = max(-1.0, min(1.0, cov / (stds[i] * stds[j]))) + return corr + + +def _avg_off_diagonal_corr(corr: list) -> float: + n = len(corr) + if n <= 1: + return 0.0 + total = sum(corr[i][j] for i in range(n) for j in range(n) if i != j) + return total / (n * (n - 1)) + + +def _pc1_variance_fraction(corr: list) -> float: + """Variance explained by PC1 via power iteration on the correlation matrix.""" + n = len(corr) + if n <= 1: + return 1.0 + v = [1.0 / math.sqrt(n)] * n + for _ in range(200): + v_new = [sum(corr[i][j] * v[j] for j in range(n)) for i in range(n)] + norm = math.sqrt(sum(x ** 2 for x in v_new)) + if norm < 1e-15: + break + v = [x / norm for x in v_new] + Cv = [sum(corr[i][j] * v[j] for j in range(n)) for i in range(n)] + eigenvalue = sum(v[i] * Cv[i] for i in range(n)) + return max(0.0, min(1.0, eigenvalue / n)) + + +def _trend_score(prices: list) -> float: + """Normalized momentum: cumulative log return / realized vol.""" + if len(prices) < 2: + return 0.0 + log_rets = _log_returns(prices) + if not log_rets: + return 0.0 + cum_ret = sum(log_rets) + if len(log_rets) < 2: + return cum_ret + mean = sum(log_rets) / len(log_rets) + var = sum((r - mean) ** 2 for r in log_rets) / len(log_rets) + vol = math.sqrt(max(var, 1e-14)) + return cum_ret / vol + + +def _max_drawdown(equity_curve: list) -> float: + if not equity_curve: + return 0.0 + peak = equity_curve[0] + max_dd = 0.0 + for v in equity_curve: + peak = max(peak, v) + dd = (peak - v) / (1.0 + abs(peak)) if (1.0 + abs(peak)) > 0 else 0.0 + max_dd = max(max_dd, dd) + return max_dd + + +def _sharpe(returns: list, periods_per_year: float) -> float: + if len(returns) < 2: + return 0.0 + n = len(returns) + mean = sum(returns) / n + var = sum((r - mean) ** 2 for r in returns) / n + std = math.sqrt(max(var, 1e-20)) + return (mean / std) * math.sqrt(periods_per_year) + + +def _ols(x: list, y: list) -> tuple: + """OLS y = alpha + beta*x. Returns (alpha, beta, r2).""" + n = len(x) + if n < 2: + return 0.0, 0.0, 0.0 + x_mean = sum(x) / n + y_mean = sum(y) / n + ss_xx = sum((xi - x_mean) ** 2 for xi in x) + ss_xy = sum((xi - x_mean) * (yi - y_mean) for xi, yi in zip(x, y)) + if ss_xx == 0: + return y_mean, 0.0, 0.0 + beta = ss_xy / ss_xx + alpha = y_mean - beta * x_mean + ss_res = sum((yi - (alpha + beta * xi)) ** 2 for xi, yi in zip(x, y)) + ss_tot = sum((yi - y_mean) ** 2 for yi in y) + r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0 + return alpha, beta, r2 + + +# ── Pair-mode math helpers ──────────────────────────────────────────────────── + +def _std_list(vals: list) -> float: + n = len(vals) + if n < 2: + return 0.0 + m = sum(vals) / n + var = sum((v - m) ** 2 for v in vals) / n + return math.sqrt(max(var, 0.0)) + + +def _pearson(xs: list, ys: list) -> float: + n = len(xs) + if n < 2: + return 0.0 + mx = sum(xs) / n + my = sum(ys) / n + sx = _std_list(xs) + sy = _std_list(ys) + if sx < 1e-12 or sy < 1e-12: + return 0.0 + cov = sum((xs[k] - mx) * (ys[k] - my) for k in range(n)) / n + return max(-1.0, min(1.0, cov / (sx * sy))) + + +# ── HL API helpers ──────────────────────────────────────────────────────────── + +async def _fetch_candles(session, coin: str, interval: str, start_ms: int, end_ms: int) -> list: + payload = {"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, + "startTime": start_ms, "endTime": end_ms}} + try: + async with session.post(HL_URL, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + logger.warning(f"candleSnapshot HTTP {resp.status} for {coin}") + return [] + data = await resp.json() + return data if isinstance(data, list) else [] + except Exception as e: + logger.warning(f"candleSnapshot failed for {coin}: {e}") + return [] + + +async def _fetch_funding_history(session, coin: str, start_ms: int) -> list: + payload = {"type": "fundingHistory", "coin": coin, "startTime": start_ms} + try: + async with session.post(HL_URL, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + return [] + data = await resp.json() + return data if isinstance(data, list) else [] + except Exception as e: + logger.warning(f"fundingHistory failed for {coin}: {e}") + return [] + + +def _candles_to_series(candles: list) -> dict: + """Convert HL candleSnapshot list to {timestamp_ms -> close_price}.""" + result = {} + for c in candles: + t = c.get("t") or c.get("T") + close = c.get("c") or c.get("close") + if t is not None and close is not None: + try: + result[int(t)] = float(close) + except (ValueError, TypeError): + pass + return result + + +def _align_series(series_dict: dict) -> tuple: + """ + series_dict: {coin -> {ts -> price}} + Returns (coins, sorted_timestamps, price_matrix[coin_idx][ts_idx]) + Only timestamps present in ALL series are kept. + """ + if not series_dict: + return [], [], [] + coins = list(series_dict.keys()) + common_ts = set(series_dict[coins[0]].keys()) + for coin in coins[1:]: + common_ts &= set(series_dict[coin].keys()) + if not common_ts: + return coins, [], [] + timestamps = sorted(common_ts) + matrix = [[series_dict[coin][ts] for ts in timestamps] for coin in coins] + return coins, timestamps, matrix + + +# ── Universe-mode walk-forward simulation ───────────────────────────────────── + +def _simulate( + aligned_coins, timestamps, price_matrix, + trend_lookback_candles, rebalance_candles, top_k, + fee_bps, taker_bps, periods_per_year, + funding_history, funding_included, use_momentum, +): + """ + Walk-forward backtest. Returns metrics dict including equity_curve, + step_returns, and index_returns for beta regression. + """ + + def _get_funding_for_period(coin: str, t_start: int, t_end: int, is_long: bool) -> float: + hist = funding_history.get(coin, []) + total = 0.0 + for entry in hist: + entry_t = entry.get("time", 0) + if t_start <= entry_t < t_end: + rate = float(entry.get("fundingRate", 0)) + total += -rate if is_long else rate # longs pay, shorts receive when rate > 0 + return total + + step_returns = [] + gross_returns = [] + index_returns = [] + equity = 0.0 + equity_curve = [] + total_fees = 0.0 + total_funding = 0.0 + hits = 0 + turnover_legs = 0 + prev_longs: set = set() + prev_shorts: set = set() + n = len(timestamps) + leg_notional = 0.5 / top_k # dollar-neutral, unit gross=1.0 + + for step_i, start_idx in enumerate( + range(trend_lookback_candles, n - rebalance_candles, rebalance_candles) + ): + end_idx = start_idx + rebalance_candles + if end_idx >= n: + break + + # Trend scores + scores = {} + for ci, coin in enumerate(aligned_coins): + lb_start = max(0, start_idx - trend_lookback_candles) + scores[coin] = _trend_score(price_matrix[ci][lb_start:start_idx + 1]) + + ranked = sorted(scores, key=lambda c: scores[c], reverse=True) + if use_momentum: + longs = set(ranked[:top_k]) + shorts = set(ranked[-top_k:]) + else: + longs = set(ranked[-top_k:]) + shorts = set(ranked[:top_k]) + + if longs & shorts: + continue # degenerate ranking (ties) — skip step + + # Per-leg returns + period_gross = 0.0 + for ci, coin in enumerate(aligned_coins): + ep = price_matrix[ci][start_idx] + xp = price_matrix[ci][end_idx] + if ep <= 0: + continue + ret = (xp - ep) / ep + if coin in longs: + period_gross += leg_notional * ret + elif coin in shorts: + period_gross -= leg_notional * ret + + # Equal-weight universe index return (for beta regression) + all_rets = [] + for ci in range(len(aligned_coins)): + ep = price_matrix[ci][start_idx] + xp = price_matrix[ci][end_idx] + if ep > 0: + all_rets.append((xp - ep) / ep) + index_ret = sum(all_rets) / len(all_rets) if all_rets else 0.0 + + # Fees + if step_i == 0: + fee_drag = (len(longs) + len(shorts)) * leg_notional * fee_bps / 10000 + else: + rotated = len(longs - prev_longs) + len(shorts - prev_shorts) + fee_drag = rotated * leg_notional * taker_bps / 10000 + turnover_legs += rotated + + # Funding + period_funding = 0.0 + if funding_included: + t_start = timestamps[start_idx] + t_end = timestamps[end_idx] + for coin in longs: + period_funding += leg_notional * _get_funding_for_period(coin, t_start, t_end, True) + for coin in shorts: + period_funding += leg_notional * _get_funding_for_period(coin, t_start, t_end, False) + + period_net = period_gross - fee_drag + period_funding + equity += period_net + equity_curve.append(equity) + step_returns.append(period_net) + gross_returns.append(period_gross) + index_returns.append(index_ret) + total_fees += fee_drag + total_funding += period_funding + if period_net > 0: + hits += 1 + + prev_longs = longs + prev_shorts = shorts + + # Closing fee at end + if prev_longs or prev_shorts: + closing_fee = (len(prev_longs) + len(prev_shorts)) * leg_notional * fee_bps / 10000 + total_fees += closing_fee + equity -= closing_fee + if equity_curve: + equity_curve[-1] = equity + + n_steps = len(step_returns) + if n_steps == 0: + return {"error": "no simulation steps — check lookback/rebalance vs available candles"} + + ann_net = equity * periods_per_year / n_steps + gross_equity = sum(gross_returns) + ann_gross = gross_equity * periods_per_year / n_steps + sharpe = _sharpe(step_returns, periods_per_year) + mdd = _max_drawdown(equity_curve) + hit_rate = hits / n_steps + avg_turnover = turnover_legs / n_steps + + _, beta, r2 = _ols(index_returns, step_returns) + + return { + "total_net": equity, + "ann_net_return": ann_net, + "total_gross": gross_equity, + "ann_gross_return": ann_gross, + "sharpe": sharpe, + "max_drawdown": mdd, + "hit_rate": hit_rate, + "total_fees": total_fees, + "total_funding": total_funding, + "n_steps": n_steps, + "avg_turnover": avg_turnover, + "beta": beta, + "r2": r2, + "equity_curve": equity_curve, + "step_returns": step_returns, + "index_returns": index_returns, + } + + +# ── Pair-mode bias-free walk-forward simulation ─────────────────────────────── + +def _simulate_pair(la: list, lb: list, W: int, entry_z: float, exit_z: float, + momentum_lookback: int, fee_bps: float, bars_per_year: float) -> dict: + """ + Bias-free walk-forward market-neutral spread backtest for a single pair. + + la, lb: aligned log-price arrays of equal length n. + W: hedge_window — rolling window for BOTH the OLS hedge-ratio estimate + and the z-score normalisation window (same W, avoids separate param). + + Two bugs this implementation intentionally avoids: + Bug #1 — full-sample beta fit (look-ahead): we use only the past W bars. + Bug #2 — delta-beta P&L (fake edge from re-estimating beta on each bar): + we freeze beta_entry at position open and use it for the entire hold. + + Warmup: simulation starts at i >= 2*W so both the rolling-beta window and + the trailing z-score window are fully populated, and + resid[i - momentum_lookback] never indexes a warmup gap. + """ + n = len(la) + + # ── 1. Pre-compute rolling betas and residuals ──────────────────────────── + # beta[i] = Cov(lb[i-W:i], la[i-W:i]) / Var(lb[i-W:i]) (population-style, 1/W) + # resid[i] = la[i] - beta[i]*lb[i] (alpha dropped — cancels in z-score) + betas = [0.0] * n + resids = [float("nan")] * n + for i in range(W, n): + xs = lb[i - W:i] + ys = la[i - W:i] + mx = sum(xs) / W + my = sum(ys) / W + cov_xy = sum((xs[k] - mx) * (ys[k] - my) for k in range(W)) / W + var_x = sum((xs[k] - mx) ** 2 for k in range(W)) / W + betas[i] = cov_xy / var_x if var_x > 1e-20 else 0.0 + resids[i] = la[i] - betas[i] * lb[i] + + # ── 2. Descriptive stats ────────────────────────────────────────────────── + la_rets = [la[i] - la[i - 1] for i in range(1, n)] + lb_rets = [lb[i] - lb[i - 1] for i in range(1, n)] + ret_corr = _pearson(la_rets, lb_rets) + ann_vol_long = _std_list(la_rets) * math.sqrt(bars_per_year) + ann_vol_short = _std_list(lb_rets) * math.sqrt(bars_per_year) + avg_beta = sum(betas[W:]) / max(1, n - W) + + # ── 3. Walk-forward simulation ──────────────────────────────────────────── + start = 2 * W + if start >= n - 1: + raise ValueError(f"Insufficient data: {n} bars, need > {2 * W + 1} (2*hedge_window+1)") + + def _run_signal(sig: str) -> dict: + pos = 0 + beta_entry = 0.0 + bar_rets: list = [] + n_trades = 0 + equity = 0.0 + equity_curve: list = [] + + for i in range(start, n - 1): + # ── Signal ──────────────────────────────────────────────────────── + if sig == "reversion": + # z-score: resid[i] vs trailing window resids[i-W : i] + # All indices in [i-W, i) are >= W so resids are valid (not NaN). + w_resids = resids[i - W:i] + m = sum(w_resids) / W + v = _std_list(w_resids) + z = (resids[i] - m) / v if v > 1e-10 else 0.0 + if z > entry_z: + new_pos = -1 # spread is high → short it + elif z < -entry_z: + new_pos = 1 # spread is low → long it + elif abs(z) < exit_z: + new_pos = 0 # close to mean → flat + else: + new_pos = pos # hold + else: # momentum + prev_idx = i - momentum_lookback + # prev_idx >= W guaranteed by warmup (2*W - momentum_lookback >= W when W >= momentum_lookback; + # even if W < momentum_lookback, 2*W >= W+momentum_lookback fails — guard below) + if prev_idx < W: + new_pos = pos + else: + new_pos = 1 if resids[i] > resids[prev_idx] else -1 + + # ── Fee on position change ──────────────────────────────────────── + fee = 0.0 + if new_pos != pos: + fee = 2 * fee_bps / 1e4 # two legs turn over + if new_pos != 0: + beta_entry = betas[i] # freeze at entry — Bug #2 fix + n_trades += 1 + + pos = new_pos + + # ── P&L from bar i → i+1 (fixed entry beta — Bug #2 fix) ───────── + if pos != 0: + pnl_bar = pos * ((la[i + 1] - la[i]) - beta_entry * (lb[i + 1] - lb[i])) + else: + pnl_bar = 0.0 + + net_bar = pnl_bar - fee + equity += net_bar + equity_curve.append(equity) + bar_rets.append(net_bar) + + # Closing fee when position open at end of history + if pos != 0: + close_fee = 2 * fee_bps / 1e4 + equity -= close_fee + if equity_curve: + equity_curve[-1] = equity + if bar_rets: + bar_rets[-1] -= close_fee + + n_sim = len(bar_rets) + ann_net = equity * bars_per_year / n_sim if n_sim > 0 else 0.0 + return { + "n_trades": n_trades, + "net_total": equity, + "ann_net": ann_net, + "sharpe": _sharpe(bar_rets, bars_per_year), + "max_drawdown": _max_drawdown(equity_curve), + "n_sim_bars": n_sim, + "equity_curve": equity_curve, + } + + rev = _run_signal("reversion") + mom = _run_signal("momentum") + + # ── 4. Static baselines (always-long / always-short spread) ─────────────── + # Use contemporaneous rolling beta at each bar (no fixed entry; static baselines never trade). + baseline_rets = [(la[i + 1] - la[i]) - betas[i] * (lb[i + 1] - lb[i]) + for i in range(start, n - 1)] + # One entry + one exit for each baseline (two legs each time) + entry_exit_fee = 2 * 2 * fee_bps / 1e4 + n_sim = len(baseline_rets) + baseline_long_net = sum(baseline_rets) - entry_exit_fee + baseline_short_net = -sum(baseline_rets) - entry_exit_fee + baseline_long_ann = baseline_long_net * bars_per_year / n_sim if n_sim > 0 else 0.0 + baseline_short_ann = baseline_short_net * bars_per_year / n_sim if n_sim > 0 else 0.0 + + return { + "n_bars": n, + "span_days": n / bars_per_year * 365, + "ret_corr": ret_corr, + "ann_vol_long": ann_vol_long, + "ann_vol_short": ann_vol_short, + "avg_beta": avg_beta, + "reversion": rev, + "momentum": mom, + "baseline_long_ann": baseline_long_ann, + "baseline_short_ann": baseline_short_ann, + "n_sim_bars": n_sim, + } + + +# ── Pair mode entry point ───────────────────────────────────────────────────── + +async def _run_pair_mode(config: Config) -> str: + long_coin = f"xyz:{config.pair_long}" + short_coin = f"xyz:{config.pair_short}" + + now_ms = int(time.time() * 1000) + start_ms = now_ms - config.lookback_days * 24 * 3600 * 1000 + + # ── 1. Fetch candles for both legs in parallel ──────────────────────────── + async with aiohttp.ClientSession() as session: + long_candles, short_candles = await asyncio.gather( + _fetch_candles(session, long_coin, config.interval, start_ms, now_ms), + _fetch_candles(session, short_coin, config.interval, start_ms, now_ms), + ) + + if not long_candles: + raise ValueError(f"No candle data for {long_coin} — verify the coin name and that it trades on Hyperliquid HIP-3") + if not short_candles: + raise ValueError(f"No candle data for {short_coin} — verify the coin name and that it trades on Hyperliquid HIP-3") + + long_series = _candles_to_series(long_candles) + short_series = _candles_to_series(short_candles) + + if not long_series: + raise ValueError(f"Empty candle series for {long_coin}") + if not short_series: + raise ValueError(f"Empty candle series for {short_coin}") + + # ── 2. Align on common timestamps ───────────────────────────────────────── + _, timestamps, price_matrix = _align_series({long_coin: long_series, short_coin: short_series}) + + min_required = 2 * config.hedge_window + 10 + if len(timestamps) < min_required: + raise ValueError( + f"Only {len(timestamps)} common timestamps after alignment; need >{min_required} " + f"(2*hedge_window={2*config.hedge_window}). " + f"Try increasing lookback_days (currently {config.lookback_days}) or reducing hedge_window." + ) + + prices_long = price_matrix[0] + prices_short = price_matrix[1] + + la = [math.log(p) for p in prices_long] + lb = [math.log(p) for p in prices_short] + + hours_per_candle = _interval_to_hours(config.interval) + bars_per_year = 365 * 24 / hours_per_candle + + # ── 3. Run backtest ─────────────────────────────────────────────────────── + result = _simulate_pair( + la=la, lb=lb, + W=config.hedge_window, + entry_z=config.entry_z, + exit_z=config.exit_z, + momentum_lookback=config.momentum_lookback, + fee_bps=config.fee_bps_per_side, + bars_per_year=bars_per_year, + ) + + rev = result["reversion"] + mom = result["momentum"] + corr_pass = result["ret_corr"] >= config.min_corr + b_long_ann = result["baseline_long_ann"] + b_short_ann = result["baseline_short_ann"] + + # ── 4. Flags per signal ─────────────────────────────────────────────────── + def _flags(r: dict, sig_name: str) -> list: + out = [] + if r["n_trades"] < 30: + out.append( + f"SAMPLE TOO SMALL ({r['n_trades']} trades < 30) — " + f"Sharpe {r['sharpe']:.2f} is NOT statistically meaningful" + ) + ann = r["ann_net"] + if abs(ann) > 1e-6: + if abs(b_long_ann) >= 0.5 * abs(ann): + out.append( + f"ALWAYS-LONG baseline ({b_long_ann:.1%}/yr) captures ≥50% of {sig_name} return — " + f"return is largely in-sample directional drift, NOT repeatable {sig_name} alpha" + ) + if abs(b_short_ann) >= 0.5 * abs(ann): + out.append( + f"ALWAYS-SHORT baseline ({b_short_ann:.1%}/yr) captures ≥50% of {sig_name} return — " + f"return is largely in-sample directional drift, NOT repeatable {sig_name} alpha" + ) + return out + + rev_flags = _flags(rev, "reversion") + mom_flags = _flags(mom, "momentum") + + # ── 5. Verdict ──────────────────────────────────────────────────────────── + def _is_go(r: dict, flags: list) -> bool: + return ( + corr_pass + and r["n_trades"] >= 30 + and r["ann_net"] > 0 + and not any("directional drift" in f for f in flags) + ) + + rev_go = _is_go(rev, rev_flags) + mom_go = _is_go(mom, mom_flags) + + if not corr_pass: + verdict = ( + f"NO-GO: return-correlation {result['ret_corr']:.3f} < {config.min_corr} (min_corr) — " + f"legs are not sufficiently correlated; spread has too much idiosyncratic noise." + ) + elif rev_go or mom_go: + best = "reversion" if (rev_go and (not mom_go or rev["ann_net"] >= mom["ann_net"])) else "momentum" + best_r = rev if best == "reversion" else mom + verdict = ( + f"GO ({best}): corr ✓ trades ≥ 30 ✓ net positive ✓ beats static baselines ✓ — " + f"{best_r['ann_net']:.1%}/yr ann, Sharpe {best_r['sharpe']:.2f}." + ) + else: + issues = [] + if not corr_pass: + issues.append(f"correlation {result['ret_corr']:.3f} below threshold") + if rev["n_trades"] < 30 and mom["n_trades"] < 30: + issues.append(f"too few trades (rev={rev['n_trades']}, mom={mom['n_trades']})") + if rev["ann_net"] <= 0 and mom["ann_net"] <= 0: + issues.append("both signals net negative after fees") + drift_flags = [f for f in rev_flags + mom_flags if "directional drift" in f] + if drift_flags: + issues.append("return captured by static baseline (in-sample drift, not alpha)") + verdict = "NO-GO: " + ("; ".join(issues) if issues else "no edge after fees/flags") + ". NOT a tradeable spread." + + # ── 6. Format text summary ──────────────────────────────────────────────── + def _fmt_signal(name: str, r: dict, flags: list) -> str: + lines = [ + f" {name.upper()}:", + f" Trades: {r['n_trades']}", + f" Net total: {r['net_total']:.4f}", + f" Ann net: {r['ann_net']:.2%}/yr", + f" Sharpe: {r['sharpe']:.3f}", + f" Max DD: {r['max_drawdown']:.2%}", + f" Sim bars: {r['n_sim_bars']}", + ] + for f in flags: + lines.append(f" ⚠ {f}") + return "\n".join(lines) + + summary = "\n".join([ + f"**HIP-3 Pair Backtest — {config.pair_long} / {config.pair_short}**", + f"Config: interval={config.interval}, lookback={config.lookback_days}d, " + f"hedge_window={config.hedge_window}, entry_z={config.entry_z}, exit_z={config.exit_z}, " + f"momentum_lb={config.momentum_lookback}, fee={config.fee_bps_per_side}bps/side", + f"Data: {result['n_bars']} aligned bars | {result['span_days']:.1f} days", + "", + "━━ 1. PAIR STATS ━━", + f" Return correlation: {result['ret_corr']:.4f} (gate ≥{config.min_corr}) → {'PASS ✓' if corr_pass else 'FAIL ✗'}", + f" Ann vol {config.pair_long:>8}: {result['ann_vol_long']:.2%}", + f" Ann vol {config.pair_short:>8}: {result['ann_vol_short']:.2%}", + f" Avg rolling beta: {result['avg_beta']:.4f}", + "", + "━━ 2. STATIC BASELINES ━━", + f" Always-long spread: {b_long_ann:.2%}/yr", + f" Always-short spread: {b_short_ann:.2%}/yr", + "", + "━━ 3. SIGNAL RESULTS ━━", + _fmt_signal("Reversion", rev, rev_flags), + "", + _fmt_signal("Momentum", mom, mom_flags), + "", + "━━ VERDICT ━━", + f" Corr gate (≥{config.min_corr}): " + ("PASS ✓" if corr_pass else f"FAIL ✗ ({result['ret_corr']:.3f})"), + f" Rev trades ≥ 30: " + ("PASS ✓" if rev["n_trades"] >= 30 else f"FAIL ✗ ({rev['n_trades']} trades)"), + f" Mom trades ≥ 30: " + ("PASS ✓" if mom["n_trades"] >= 30 else f"FAIL ✗ ({mom['n_trades']} trades)"), + f" Rev ann net > 0: " + ("PASS ✓" if rev["ann_net"] > 0 else "FAIL ✗") + f" ({rev['ann_net']:.1%}/yr)", + f" Mom ann net > 0: " + ("PASS ✓" if mom["ann_net"] > 0 else "FAIL ✗") + f" ({mom['ann_net']:.1%}/yr)", + "", + f" >> {verdict}", + ]) + + # ── 7. ReportBuilder ────────────────────────────────────────────────────── + try: + import plotly.graph_objects as go + from condor.reports import ReportBuilder + + builder = ReportBuilder(f"HIP-3 Pair Backtest: {config.pair_long} / {config.pair_short}") + builder.source("routine", "hip3_pairs_backtest").tags( + ["market-making", "hip3", "pair-backtest", config.pair_long.lower(), config.pair_short.lower()] + ) + + builder.kpi("Pair", f"{config.pair_long}/{config.pair_short}") + builder.kpi("Ret Corr", f"{result['ret_corr']:.3f}") + builder.kpi("Avg Beta", f"{result['avg_beta']:.3f}") + builder.kpi("Corr Gate", "PASS ✓" if corr_pass else "FAIL ✗") + builder.kpi("Rev Ann", f"{rev['ann_net']:.1%}") + builder.kpi("Mom Ann", f"{mom['ann_net']:.1%}") + builder.kpi("Rev Sharpe", f"{rev['sharpe']:.2f}") + builder.kpi("Mom Sharpe", f"{mom['sharpe']:.2f}") + builder.kpi("Rev Trades", str(rev["n_trades"])) + builder.kpi("Mom Trades", str(mom["n_trades"])) + builder.kpi("Verdict", "GO" if verdict.startswith("GO") else "NO-GO") + + rows = [ + {"Metric": "Return Correlation", "Value": f"{result['ret_corr']:.4f}"}, + {"Metric": f"Ann Vol {config.pair_long}", "Value": f"{result['ann_vol_long']:.2%}"}, + {"Metric": f"Ann Vol {config.pair_short}", "Value": f"{result['ann_vol_short']:.2%}"}, + {"Metric": "Avg Rolling Beta", "Value": f"{result['avg_beta']:.4f}"}, + {"Metric": "Always-Long Ann", "Value": f"{b_long_ann:.2%}"}, + {"Metric": "Always-Short Ann", "Value": f"{b_short_ann:.2%}"}, + {"Metric": "Rev Trades", "Value": str(rev["n_trades"])}, + {"Metric": "Rev Ann Net", "Value": f"{rev['ann_net']:.2%}"}, + {"Metric": "Rev Sharpe", "Value": f"{rev['sharpe']:.3f}"}, + {"Metric": "Rev Max DD", "Value": f"{rev['max_drawdown']:.2%}"}, + {"Metric": "Mom Trades", "Value": str(mom["n_trades"])}, + {"Metric": "Mom Ann Net", "Value": f"{mom['ann_net']:.2%}"}, + {"Metric": "Mom Sharpe", "Value": f"{mom['sharpe']:.3f}"}, + {"Metric": "Mom Max DD", "Value": f"{mom['max_drawdown']:.2%}"}, + ] + builder.table(rows, ["Metric", "Value"]) + + eq_rev = rev.get("equity_curve", []) + eq_mom = mom.get("equity_curve", []) + if eq_rev or eq_mom: + fig = go.Figure() + if eq_rev: + fig.add_trace(go.Scatter(y=eq_rev, mode="lines", name="Reversion", + line=dict(color="#f59e0b", width=2))) + if eq_mom: + fig.add_trace(go.Scatter(y=eq_mom, mode="lines", name="Momentum", + line=dict(color="#22c55e", width=2, dash="dot"))) + fig.add_hline(y=0, line_dash="solid", line_color="#6b7280", line_width=1) + fig.update_layout( + title=f"Equity Curve — {config.pair_long}/{config.pair_short} Spread (log-return units)", + xaxis_title="Bar (hourly)", yaxis_title="Cumulative Log-Return P&L", + template="plotly_dark", height=400, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + ) + builder.plotly(fig) + + builder.markdown(summary) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"Report generation failed: {e}") + + return summary + + +# ── Universe mode entry point ───────────────────────────────────────────────── + +async def _run_universe_mode(config: Config, context) -> str: + client = await get_client(context._chat_id, context=context) + + issuer = config.issuer.lower() + issuer_upper = issuer.upper() + now_ms = int(time.time() * 1000) + start_ms = now_ms - config.lookback_days * 24 * 3600 * 1000 + + # ── 1. Universe ──────────────────────────────────────────────────────────── + try: + async with aiohttp.ClientSession() as session: + async with session.post( + HL_URL, json={"type": "metaAndAssetCtxs", "dex": issuer}, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + if resp.status != 200: + return f"Hyperliquid API error: HTTP {resp.status}" + data = await resp.json() + except Exception as e: + return f"Failed to fetch universe: {e}" + + if not isinstance(data, list) or len(data) < 2: + return f"Unexpected API response shape: {type(data)}" + + meta, ctxs = data[0], data[1] + universe = meta.get("universe", []) + if not universe: + return f"No markets found for issuer '{issuer}'" + + # ── 2. Filter tradable universe ──────────────────────────────────────────── + tradable = [] + for asset, ctx in zip(universe, ctxs): + try: + name = asset.get("name", "") + mid = float(ctx.get("midPx") or 0) + mark = float(ctx.get("markPx") or 0) + impact_pxs = ctx.get("impactPxs") + if mid <= 0 or not isinstance(impact_pxs, list) or len(impact_pxs) != 2: + continue + volume = float(ctx.get("dayNtlVlm") or 0) + oi_notional = float(ctx.get("openInterest") or 0) * mark + if volume < config.min_volume_usd: + continue + if oi_notional < config.min_oi_notional: + continue + tradable.append({ + "coin": name, + "pair": name.upper() + "-USD", + "volume": volume, + "mark": mark, + "oi_notional": oi_notional, + }) + except Exception as e: + logger.warning(f"Error processing {asset.get('name', '?')}: {e}") + + if len(tradable) < 2: + return (f"Only {len(tradable)} tradable markets pass vol/OI filters " + f"(min_volume=${config.min_volume_usd:,.0f}, min_oi=${config.min_oi_notional:,.0f}) " + f"— need at least 2 for long/short.") + if len(tradable) < 2 * config.top_k: + return (f"Only {len(tradable)} tradable markets but need {2 * config.top_k} " + f"for top_k={config.top_k} legs per side.") + + coins = [m["coin"] for m in tradable] + + # ── 3. Candles (parallel, rate-limited) ─────────────────────────────────── + sem = asyncio.Semaphore(8) + + async def _bounded(session, coin): + async with sem: + return await _fetch_candles(session, coin, config.interval, start_ms, now_ms) + + try: + async with aiohttp.ClientSession() as session: + candle_lists = await asyncio.gather(*[_bounded(session, c) for c in coins]) + except Exception as e: + return f"Candle fetch failed: {e}" + + raw_series = {} + for coin, candles in zip(coins, candle_lists): + if not candles: + continue + series = _candles_to_series(candles) + if len(series) >= 10: + raw_series[coin] = series + + if len(raw_series) < 2: + return (f"Only {len(raw_series)} markets have usable candle data " + f"(interval={config.interval}, lookback={config.lookback_days}d). " + f"Cannot compute correlation or run simulation.") + + aligned_coins, timestamps, price_matrix = _align_series(raw_series) + + if len(timestamps) < 20: + return (f"Too few common timestamps ({len(timestamps)}) across markets " + f"— most markets likely have misaligned or sparse data.") + + # ── 4. Correlation / PCA ────────────────────────────────────────────────── + returns_matrix = [_log_returns(prices) for prices in price_matrix] + min_len = min(len(r) for r in returns_matrix) if returns_matrix else 0 + returns_matrix_trim = [r[:min_len] for r in returns_matrix] + + corr_matrix = _correlation_matrix(returns_matrix_trim) + avg_corr = _avg_off_diagonal_corr(corr_matrix) + pc1_pct = _pc1_variance_fraction(corr_matrix) * 100 + gate1_pass = avg_corr >= config.min_corr + + # ── 5. Funding history ───────────────────────────────────────────────────── + funding_history: dict = {} + funding_included = False + if config.include_funding: + try: + async with aiohttp.ClientSession() as session: + fund_results = await asyncio.gather(*[ + _fetch_funding_history(session, coin, start_ms) for coin in aligned_coins + ]) + for coin, hist in zip(aligned_coins, fund_results): + if hist: + funding_history[coin] = hist + funding_included = len(funding_history) > 0 + except Exception as e: + logger.warning(f"Funding history failed: {e}") + + # ── 6. Simulation ───────────────────────────────────────────────────────── + hours_per_candle = _interval_to_hours(config.interval) + trend_lb_candles = max(2, int(config.trend_lookback_hours / hours_per_candle)) + rebalance_candles = max(1, int(config.rebalance_hours / hours_per_candle)) + periods_per_year = 365 * 24 / config.rebalance_hours + + sim_kwargs = dict( + aligned_coins=aligned_coins, + timestamps=timestamps, + price_matrix=price_matrix, + trend_lookback_candles=trend_lb_candles, + rebalance_candles=rebalance_candles, + top_k=config.top_k, + fee_bps=config.fee_bps_per_side, + taker_bps=config.taker_bps_per_side, + periods_per_year=periods_per_year, + funding_history=funding_history, + funding_included=funding_included, + ) + + mom_result = _simulate(**sim_kwargs, use_momentum=True) + rev_result = _simulate(**sim_kwargs, use_momentum=False) + + # ── 7. Gates & verdict ──────────────────────────────────────────────────── + def _ok(r): return "error" not in r + + mom_beta = mom_result.get("beta", 0.0) + rev_beta = rev_result.get("beta", 0.0) + mom_r2 = mom_result.get("r2", 0.0) + rev_r2 = rev_result.get("r2", 0.0) + mom_ann = mom_result.get("ann_net_return", -999) if _ok(mom_result) else -999 + rev_ann = rev_result.get("ann_net_return", -999) if _ok(rev_result) else -999 + + gate2_mom = abs(mom_beta) < 0.15 + gate2_rev = abs(rev_beta) < 0.15 + gate3_mom = _ok(mom_result) and mom_ann > 0 + gate3_rev = _ok(rev_result) and rev_ann > 0 + + best = "momentum" if mom_ann >= rev_ann else "reversion" + + if not gate1_pass: + verdict = (f"NO-GO: universe is decorrelated (avg_corr={avg_corr:.3f} < {config.min_corr}) " + f"— long/short does NOT hedge market risk here. Do not trade this strategy.") + elif not (gate2_mom or gate2_rev): + verdict = (f"NO-GO: residual beta too high for both variants " + f"(momentum β={mom_beta:.3f}, reversion β={rev_beta:.3f}) " + f"— book carries directional exposure.") + elif not (gate3_mom or gate3_rev): + verdict = (f"NO-GO: net return negative after fees/funding for both variants " + f"(momentum={mom_ann:.1%}/yr, reversion={rev_ann:.1%}/yr) — no edge.") + else: + best_ann = mom_ann if best == "momentum" else rev_ann + best_g2 = gate2_mom if best == "momentum" else gate2_rev + best_g3 = gate3_mom if best == "momentum" else gate3_rev + if best_g2 and best_g3: + verdict = (f"GO ({best}): correlated universe ✓ market-neutral ✓ " + f"net return {best_ann:.1%}/yr ✓ — " + f"use {best} variant, top_k={config.top_k}, " + f"trend_lookback={config.trend_lookback_hours}h, " + f"rebalance={config.rebalance_hours}h.") + else: + verdict = (f"CONDITIONAL ({best} shows edge but not all gates clear) " + f"— review beta and correlation before trading.") + + # ── 8. Summary text ─────────────────────────────────────────────────────── + funding_note = ("INCLUDED" if funding_included + else ("EXCLUDED — data unavailable" if config.include_funding else "EXCLUDED — disabled")) + + def _fmt(label, r, beta, r2): + if not _ok(r): + return f" {label}: ERROR — {r['error']}" + return ( + f" {label}:\n" + f" Ann net return: {r['ann_net_return']:.2%}/yr (gross {r['ann_gross_return']:.2%}/yr)\n" + f" Sharpe: {r['sharpe']:.3f}\n" + f" Max drawdown: {r['max_drawdown']:.2%}\n" + f" Hit rate: {r['hit_rate']:.1%} ({r['n_steps']} steps)\n" + f" Fee drag: {r['total_fees']:.5f} Funding: {r['total_funding']:.5f}\n" + f" Residual β: {beta:.4f} (R²={r2:.3f})" + ) + + summary = "\n".join([ + f"**HIP-3 Pairs Backtest — issuer: {issuer_upper}**", + f"Config: interval={config.interval}, lookback={config.lookback_days}d, " + f"rebalance={config.rebalance_hours}h, trend_lb={config.trend_lookback_hours}h, top_k={config.top_k}", + f"Universe: {len(universe)} total → {len(tradable)} pass vol/OI → {len(aligned_coins)} with candle data", + f"Timestamps: {len(timestamps)} common | Funding: {funding_note}", + "", + "━━ 1. CORRELATION / COMMON FACTOR ━━", + f" Avg off-diagonal corr: {avg_corr:.4f} (gate ≥{config.min_corr}) → {'PASS ✓' if gate1_pass else 'FAIL ✗'}", + f" PC1 variance explained: {pc1_pct:.1f}%", + f" Markets: {', '.join(c.upper() for c in aligned_coins[:10])}{'...' if len(aligned_coins) > 10 else ''}", + "", + "━━ 2 & 3. SIMULATION ━━", + _fmt("MOMENTUM (long winners / short losers)", mom_result, mom_beta, mom_r2), + "", + _fmt("REVERSION (long losers / short winners)", rev_result, rev_beta, rev_r2), + "", + "━━ VERDICT ━━", + f" Gate 1 corr ≥ {config.min_corr}: {'PASS ✓' if gate1_pass else 'FAIL ✗'} (avg_corr={avg_corr:.4f})", + f" Gate 2a momentum |β| < 0.15: {'PASS ✓' if gate2_mom else 'FAIL ✗'} (β={mom_beta:.4f})", + f" Gate 2b reversion |β| < 0.15: {'PASS ✓' if gate2_rev else 'FAIL ✗'} (β={rev_beta:.4f})", + f" Gate 3a momentum net > 0: {'PASS ✓' if gate3_mom else 'FAIL ✗'} ({mom_ann:.1%}/yr)", + f" Gate 3b reversion net > 0: {'PASS ✓' if gate3_rev else 'FAIL ✗'} ({rev_ann:.1%}/yr)", + "", + f" >> {verdict}", + ]) + + # ── 9. ReportBuilder ────────────────────────────────────────────────────── + try: + import plotly.graph_objects as go + from condor.reports import ReportBuilder + + builder = ReportBuilder(f"HIP-3 Pairs Backtest: {issuer_upper}") + builder.source("routine", "hip3_pairs_backtest").tags( + ["market-making", "hip3", issuer, "backtest", "long-short"] + ) + + builder.kpi("Universe", f"{len(aligned_coins)} mkts") + builder.kpi("Avg Corr", f"{avg_corr:.3f}") + builder.kpi("PC1 Var%", f"{pc1_pct:.1f}%") + builder.kpi("Corr Gate", "PASS ✓" if gate1_pass else "FAIL ✗") + builder.kpi("Mom Net/yr", f"{mom_ann:.1%}" if _ok(mom_result) else "ERR") + builder.kpi("Rev Net/yr", f"{rev_ann:.1%}" if _ok(rev_result) else "ERR") + builder.kpi("Mom β", f"{mom_beta:.3f}") + builder.kpi("Rev β", f"{rev_beta:.3f}") + builder.kpi("Verdict", "GO" if verdict.startswith("GO") else ("NO-GO" if verdict.startswith("NO-GO") else "CONDITIONAL")) + + disp = aligned_coins[:10] + short_names = [c.split(":")[-1] for c in disp] + corr_rows = [] + for i, coin in enumerate(disp): + row = {"Market": short_names[i]} + for j, sn in enumerate(short_names): + row[sn] = f"{corr_matrix[i][j]:.2f}" + corr_rows.append(row) + builder.table(corr_rows, ["Market"] + short_names) + + if _ok(mom_result) and _ok(rev_result): + compare = [ + {"Metric": "Ann Net Return", "Momentum": f"{mom_result['ann_net_return']:.2%}", "Reversion": f"{rev_result['ann_net_return']:.2%}"}, + {"Metric": "Ann Gross Return", "Momentum": f"{mom_result['ann_gross_return']:.2%}", "Reversion": f"{rev_result['ann_gross_return']:.2%}"}, + {"Metric": "Sharpe", "Momentum": f"{mom_result['sharpe']:.3f}", "Reversion": f"{rev_result['sharpe']:.3f}"}, + {"Metric": "Max Drawdown", "Momentum": f"{mom_result['max_drawdown']:.2%}", "Reversion": f"{rev_result['max_drawdown']:.2%}"}, + {"Metric": "Hit Rate", "Momentum": f"{mom_result['hit_rate']:.1%}", "Reversion": f"{rev_result['hit_rate']:.1%}"}, + {"Metric": "Fee Drag", "Momentum": f"{mom_result['total_fees']:.5f}", "Reversion": f"{rev_result['total_fees']:.5f}"}, + {"Metric": "Funding", "Momentum": f"{mom_result['total_funding']:.5f}", "Reversion": f"{rev_result['total_funding']:.5f}"}, + {"Metric": "Residual β", "Momentum": f"{mom_beta:.4f}", "Reversion": f"{rev_beta:.4f}"}, + {"Metric": "Beta R²", "Momentum": f"{mom_r2:.3f}", "Reversion": f"{rev_r2:.3f}"}, + {"Metric": "Steps", "Momentum": str(mom_result["n_steps"]), "Reversion": str(rev_result["n_steps"])}, + ] + builder.table(compare, ["Metric", "Momentum", "Reversion"]) + + eq_mom = mom_result.get("equity_curve", []) if _ok(mom_result) else [] + eq_rev = rev_result.get("equity_curve", []) if _ok(rev_result) else [] + if eq_mom or eq_rev: + fig = go.Figure() + if eq_mom: + fig.add_trace(go.Scatter(y=eq_mom, mode="lines", name="Momentum", + line=dict(color="#22c55e", width=2))) + if eq_rev: + fig.add_trace(go.Scatter(y=eq_rev, mode="lines", name="Reversion", + line=dict(color="#f59e0b", width=2, dash="dot"))) + fig.add_hline(y=0, line_dash="solid", line_color="#6b7280", line_width=1) + fig.update_layout( + title=f"Equity Curve — {issuer_upper} L/S (unit gross=1.0, dollar-neutral)", + xaxis_title="Rebalance Step", yaxis_title="Cumulative Net Return", + template="plotly_dark", height=400, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + ) + builder.plotly(fig) + + if _ok(mom_result) and mom_result.get("index_returns") and mom_result.get("step_returns"): + ix = mom_result["index_returns"] + sr = mom_result["step_returns"] + fig2 = go.Figure() + fig2.add_trace(go.Scatter(x=ix, y=sr, mode="markers", name="Momentum", + marker=dict(color="#22c55e", opacity=0.6, size=5))) + if _ok(rev_result) and rev_result.get("index_returns"): + fig2.add_trace(go.Scatter(x=rev_result["index_returns"], y=rev_result["step_returns"], + mode="markers", name="Reversion", + marker=dict(color="#f59e0b", opacity=0.6, size=5))) + if ix: + x_min, x_max = min(ix), max(ix) + fig2.add_trace(go.Scatter( + x=[x_min, x_max], + y=[mom_beta * x_min, mom_beta * x_max], + mode="lines", name=f"Mom β={mom_beta:.3f}", + line=dict(color="#22c55e", dash="dot", width=1.5), + )) + fig2.add_hline(y=0, line_color="#6b7280", line_width=0.5) + fig2.add_vline(x=0, line_color="#6b7280", line_width=0.5) + fig2.update_layout( + title=f"Residual Beta — Book vs Equal-Weight Index ({issuer_upper})", + xaxis_title="Index Return per Step", + yaxis_title="L/S Book Return per Step", + template="plotly_dark", height=380, + ) + builder.plotly(fig2) + + builder.markdown(summary) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"Report generation failed: {e}") + + return summary + + +# ── Main ────────────────────────────────────────────────────────────────────── + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + if config.mode == "pair": + return await _run_pair_mode(config) + elif config.mode == "universe": + return await _run_universe_mode(config, context) + else: + return f"Unknown mode '{config.mode}'. Use 'pair' or 'universe'." diff --git a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md b/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md new file mode 100644 index 00000000..dcfa652e --- /dev/null +++ b/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md @@ -0,0 +1,154 @@ +--- +name: HIP-3 Delta-Neutral Funding MM +description: 'Delta-neutral market-making + funding harvest on a correlated pair of + xyz-issuer HIP-3 perps (hyperliquid_perpetual). The pair is defined in config (leg_a, + leg_b, hedge_beta) — not scanned or selected. Quotes both sides on BOTH legs to + provide liquidity and generate volume, holds a beta-weighted long/short so net market + delta stays ~0 (long offsets short), and picks the funding-favorable side each launch + so both legs pay. Earns MM spread on both legs + net funding carry with market risk + hedged out. Validates the configured pair''s correlation via hip3_pairs_backtest + at launch (HOLD if corr < min_corr). NOT stat-arb. Forced net-delta band, correlation-break, + and funding-flip guardrails. Configured default: CL/BRENTOIL (corr 0.98, β 1.02, + ~+33%/yr net-neutral funding carry).' +agent_key: null +skills: [] +default_config: + frequency_sec: 300 + total_amount_quote: 500 + execution_mode: loop + connector_name: hyperliquid_perpetual + leg_a: CL + leg_b: BRENTOIL + hedge_beta: 1.02 + min_corr: 0.9 + net_delta_band_pct: 0.04 + buy_spread_bps: 5 + sell_spread_bps: 5 + take_profit_bps: 4 + leverage_cap: 2 + fee_bps_per_side: 1.3 + bot_name: dn-CL-BRENTOIL-mm + risk_limits: + max_position_size_quote: 300 + max_open_executors: 8 +default_trading_context: '' +created_by: 456181693 +created_at: '2026-07-23T18:38:45.331162+00:00' +--- + +# HIP-3 Delta-Neutral Funding MM + +You are the Market Making Expert's **delta-neutral funding** strategy. Each tick you run the +`hip3_dn_pair_monitor` routine (which does ALL the analysis), then maintain **TWO `pmm_mister` +controllers — one per leg** — sized so the book is market-neutral and leaned the funding-favorable +way. Think of `hip3_dn_pair_monitor` as this strategy's `market_analyzer`: do NOT recompute beta, +correlation, funding, sizing, OR the actual delta yourself — the routine fetches live positions and +reports the real net delta; read everything from the routine (no extra position/portfolio calls). + +## Objective +Run **delta-neutral market-making** on a correlated pair of `xyz`-issuer HIP-3 perps +(`hyperliquid_perpetual`). Quote BOTH sides on BOTH legs to **provide liquidity and generate +volume**, hold a **beta-weighted long/short so net market delta ≈ 0** (long offsets short), and lean +the **funding-favorable** side so both legs pay. Earn = MM spread on both books + net funding carry, +with market/price risk hedged out. **This is NOT stat-arb** — the second leg exists only to cancel +market risk and pay funding. + +## Configuration at launch +Read from `[CURRENT CONFIG]`: `leg_a`, `leg_b` (token only; the pair is `XYZ:-USD`), +`connector_name` (hyperliquid_perpetual), `total_amount_quote`, `configured_hedge_beta`, `min_corr`, +`net_delta_band_pct`, `buy_spread_bps`, `sell_spread_bps`, `take_profit_bps`, `leverage_cap`. **The +pair is defined in config — do NOT scan/rank/re-select it.** If `leg_a`/`leg_b` are missing → abort +the tick and notify the user. + +## Each Tick — Step by Step + +### Step 1: Run the pair monitor routine (the analysis brain) +``` +manage_routines(action="run", name="hip3_dn_pair_monitor", + strategy_id="market_making_expert.hip_3_delta_neutral_funding_mm", + config={"leg_a": , "leg_b": , "configured_hedge_beta": , + "total_amount_quote": , "net_delta_band_pct": , + "min_corr": }) +``` +It returns: a top-line **RECOMMENDATION** (RUN A/B, RESIZE, HEDGE, REDUCE-ROTATE, or HOLD-FLATTEN), +`recommended_config` (A = LONG leg_a / SHORT leg_b; B = reverse), live **hedge_beta** (+ drift), +**correlation** (+ gate PASS/FAIL), **funding** each leg + **net carry %/yr**, a **── TARGET SIZING ──** +theoretical split, and a **── ACTUAL POSITIONS ──** block: the real per-leg signed notionals (fetched +live by the routine) + the **actual net factor delta** vs band, labelled IN-BAND or BREACH. Do NOT +re-derive or re-fetch any of these. + +### Step 2: Assess — read the ACTUAL delta from the routine +Do NOT fetch positions or compute delta yourself — the routine already did. From its output read: +- RECOMMENDATION verb, recommended config (which leg LONG/SHORT), live `hedge_beta` (β), corr gate, net carry. +- **── ACTUAL POSITIONS ──**: the real per-leg signed notionals + the **actual net factor delta** vs band + (IN-BAND / BREACH). This ACTUAL delta — not the TARGET/theoretical split — is what you act on and + journal. The routine returns **HEDGE** as the top recommendation when the actual delta breaches. + +### Step 3: Determine action (follow the routine's RECOMMENDATION verbatim) +- **RUN config A/B** → DEPLOY the two controllers if none running, else UPDATE them to match the + recommended config + notionals. +- **RESIZE** → live beta drifted; UPDATE both controllers' `total_amount_quote` to the routine's new + per-leg notionals (keep them running). +- **HEDGE** → the routine's ACTUAL net delta breached the band; restore neutrality per the enforcement + block below (skew quotes toward the offsetting leg and/or market-hedge the residual). Keep both running. +- **REDUCE/ROTATE** → net carry ≤ 0 (funding flipped); reduce/close and alert — the configured pair + no longer pays. Do NOT keep paying to hold. +- **HOLD/FLATTEN** → corr < `min_corr`; **FLATTEN BOTH legs, STOP, alert** — the hedge broke, an + un-paired leg is a naked directional bet. + +### Step 4: Execute — TWO controllers in ONE bot (shared account) +Derive names at runtime: `long_leg`/`short_leg` from the recommended config; +`cfg_a = "dn_{leg_a}_mm"`, `cfg_b = "dn_{leg_b}_mm"`, `bot_name = "dn-{leg_a}-{leg_b}-mm"`. +1. Upsert each leg's config: `manage_controllers(action="upsert", target="config", config_name=, + config_data={... per-leg pmm_mister below; `position_side="BUY"` on the LONG leg, `"SELL"` on the + SHORT leg; `total_amount_quote` = that leg's notional from the routine ...})`. +2. First deploy: `manage_bots(action="deploy", bot_name=, controllers_config=[cfg_a, cfg_b])`. +3. Update running: `manage_bots(action="update_config", bot_name=, config_name=, + config_data={...full...}, confirm_override=true)` — per controller. +4. Stop/flatten: `manage_bots(action="stop_bot", bot_name=)`. + +### Delta-neutral enforcement — FORCED, DETERMINISTIC +Use the routine's **── ACTUAL POSITIONS ── actual net factor delta** (measured from real positions — +NOT the theoretical target). If the routine flags **BREACH / HEDGE** (`|actual_net| > +net_delta_band_pct × total_amount_quote`, default ±$20) → skew the two legs' quotes toward the +offsetting side and/or **market-hedge the residual** (`order_executor`, `account_name="master_account"`, +side INT 1/2, `position_action="CLOSE"`) to restore neutrality. The legs fill at different rates (the +thinner leg — usually BRENTOIL — lags), so the book runs transiently directional while accumulating; +hedge only a **persistent** breach (band-breach that holds across ticks), not one tick of fill lag. +Long MUST offset short — never drift net directional. + +### Guardrails — FORCED +- **Correlation-break / funding-flip:** already surfaced by the routine (HOLD-FLATTEN / REDUCE-ROTATE) + — act on them, don't rationalize holding. +- **Fee reality:** maker fee on BOTH legs (~2.6 bp round-trip each) — don't quote the tight touch; + `take_profit ≥ 0.0004`. Funding carry is the primary earner. +- **Caps:** each leg ≤ `risk_limits.max_position_size_quote`; gross ≤ `total_amount_quote × + leverage_cap`; `leverage ≤ leverage_cap` (2) and ≤ each market's max. + +## pmm_mister config — ONE per leg (`controller_type="generic"`, `controller_name="pmm_mister"`) +Per leg set: `connector_name`, `trading_pair` (UPPERCASE `XYZ:-USD`), `total_amount_quote` (that +leg's notional from the routine), `portfolio_allocation` 0.2, `position_mode="ONEWAY"`, +**`position_side="BUY"` (LONG leg) or `"SELL"` (SHORT leg)** — this is how the leg holds its +funding-favorable directional lean while MMing, `leverage` ≤ `leverage_cap` & ≤ market max, +`buy_spreads`/`sell_spreads` (from `buy_spread_bps`/`sell_spread_bps`, e.g. `"0.0005,0.001"`), +`take_profit` (`take_profit_bps`/1e4, ≥0.0004), `target_base_pct`/`min`/`max` leaned toward the +lean side (e.g. long leg 0.7/0.5/0.9, short leg 0.3/0.1/0.5), `max_active_executors_by_level` 2, +`open_order_type`=3, `take_profit_order_type`=3, `global_sl_enabled`=true, `global_stop_loss`=0.02. +The DETERMINISTIC net-delta enforcement above is the real neutrality control (pmm bands don't reliably +cap at leverage). On error: journal → `manage_controllers(action="describe", +controller_name="pmm_mister")` → fix → retry once → else HOLD. + +## HIP-3 essentials +- **UPPERCASE** issuer prefix on both legs (`XYZ:-USD`; lowercase → KeyError → 0 orders). +- **UNIFIED collateral:** one pool backs both legs; size gross margin to fit. +- **Data:** the routine handles beta/funding/candles; for ad-hoc checks, live book Hyperliquid + `l2Book` `{"coin":"xyz:"}` (lowercase prefix + UPPERCASE token). + +## Journal — every tick, REPORT THE DELTA in the snapshot +Mandatory, every tick (these go in the snapshot so the delta is trackable tick-over-tick): +- **Actual per-leg notionals** (from Step 2 real positions): `LONG $X / SHORT $Y`. +- **Actual net factor delta**: `β × $X − $Y = $Z` vs band `±(net_delta_band_pct × total_amount_quote)`, + labelled **IN-BAND** or **BREACH**. +- **Fill gap**: how far each leg sits from its target notional (explains any off-neutral drift). +- live corr + hedge_beta (β), funding each leg + net carry (/yr), spread P&L, fees, funding accrued, + total_net, the routine's RECOMMENDATION, and the action you took (HOLD / RESIZE / hedge residual). diff --git a/agents/market_making_expert/strategies/hip_3_mm_operator/strategy.md b/agents/market_making_expert/strategies/hip_3_mm_operator/strategy.md new file mode 100644 index 00000000..f41e4042 --- /dev/null +++ b/agents/market_making_expert/strategies/hip_3_mm_operator/strategy.md @@ -0,0 +1,114 @@ +--- +name: HIP-3 MM Operator +description: 'Volume-farming MM across ALL xyz-issuer HIP-3 perps on hyperliquid_perpetual. + Scans every xyz market, self-selects the best one for high volume + minimal P&L + loss, treats the ~1.3 bps/side all-in maker fee (incl. fixed Hummingbot builder + fee) as unavoidable, and widens spreads/TP to absorb it. Handles HIP-3 quirks: uppercase + prefix, isolated margin, trading-hours/closed books, plus loss-rate and trend guardrails.' +agent_key: null +skills: [] +default_config: + frequency_sec: 300 + total_amount_quote: 500 + execution_mode: loop + issuer: xyz + reselect_every_ticks: 30 + min_spread_bps: 3 + max_daily_drift_pct: 3 + leverage_cap: 5 + max_loss_per_volume_bps: 5 + loss_no_new_high_ticks: 25 + trend_derisk_legs: 3 + risk_limits: + max_position_size_quote: 600 + max_open_executors: 10 +default_trading_context: '' +created_by: 456181693 +created_at: '2026-07-23T14:25:57.018254+00:00' +--- + +# HIP-3 MM Operator — xyz-issuer volume farming + +## Objective +Generate **high maker VOLUME** on `xyz`-issuer HIP-3 perps (`hyperliquid_perpetual`) while +**MINIMIZING overall P&L loss.** This is a volume + loss-minimization mandate, **NOT** a profit +mandate. The all-in maker fee — exchange ~0.29 bp **+ a FIXED ~1.0 bp Hummingbot builder fee +that is baked in and cannot be removed** = **~1.3 bp/side, ~2.6 bp round-trip** — is an +unavoidable cost of doing volume. Your job: **pick the best xyz market and quote spreads wide +enough that fills clear (or nearly clear) that fee**, so you rack up volume while bleeding as +little as possible. + +## Market selection — use the `hip3_market_scanner` ROUTINE (do NOT scan/rank inline) +The `hip3_market_scanner` routine does the full deterministic scan + ranking of ALL xyz markets +(volume, spread-vs-fee, drift, and a live order-book **depth** filter). Run it at launch and +every `reselect_every_ticks` ticks (default 30), and whenever you are flat and need a market: +``` +manage_routines(action="run", name="hip3_market_scanner", + strategy_id="market_making_expert.hip_3_mm_operator", + config={"issuer": , "min_spread_bps": , + "max_daily_drift_pct": , "top_n": 5}) +``` +Use its `TOP PICK` as your `trading_pair`. Rotation only when FLAT and the current market +closed / trending / dominated. If 0 survivors → HOLD + notify. + +## Fees are FIXED — widen quotes to absorb them +Do NOT quote the tight touch. `take_profit >= 0.0003 (3 bp)`, use 0.0004–0.0005. Place +`buy_spreads`/`sell_spreads` levels wider than the touch (first ~max(2, S/2) bp out, second +~S+1 bp for picked spread S). Recompute from the live book each tick (Hyperliquid `l2Book`; +hummingbot-api `order_book` 500s on HIP-3). Volume comes from the market oscillating THROUGH +your wider levels. + +## Each tick +1. **Gates:** market open (live book has bids+asks)? collateral adequate (UNIFIED account — + read `get_portfolio_overview(["hyperliquid_perpetual"])` available USD)? If a gate fails → + HOLD/STOP (if flat, re-run scanner). +2. **Regime:** derive a 1-std band from candles; count consecutive same-direction band shifts + for trend. Do NOT trust the `market_analyzer` regime label (it sticks on "volatile"). +3. **Quote:** widened spreads; size capped at `available_collateral × leverage × 0.7`; + `leverage ≤ leverage_cap` (default 5) and ≤ market max. + +4. **RISK GUARDRAILS — these are FORCED, DETERMINISTIC rules, NOT judgment calls.** + **⚠️ You are FORBIDDEN from holding an underwater one-sided position and rationalizing it as + "mean-reversion" / "MR bottom" / "trend reversed." That exact rationalization lost money on + SPCX and DRAM. When a trigger below fires, you MUST act — no discretion, no waiting.** + - **Loss-rate breaker (MUST include UNREALIZED):** compute + `total_net = realized(Σ closedPnl − fees) + open-position uPnL`, and `net/volume` in bps. + Realized alone can look positive while the open long bleeds — ALWAYS include uPnL. If + `total_net/volume ≤ −max_loss_per_volume_bps` (default 5) OR `total_net` makes no new + session high for `loss_no_new_high_ticks` (default 25) ticks → **immediately market-close + the position, STOP the bot, alert, and re-run the scanner.** + - **Frozen-inventory / trend flatten (MANDATORY):** if the position is at/above `max_base_pct` + (inventory maxed) for ≥2 ticks while mid < your average entry, OR volume is flat (bot not + filling) while holding an adverse position, OR after `trend_derisk_legs` (default 3) + consecutive same-direction step-downs while adverse → **market-close the position NOW.** A + maxed, non-filling, underwater inventory is a FLATTEN, never a hold. + - **Overnight/gap:** for equity/pre-IPO names, cut leverage or flatten near market close. + - Controller `global_sl_enabled=true, global_stop_loss=0.02` is the tight controller-enforced + backstop (auto-closes a held position at −2%). +5. **Decide** DEPLOY / UPDATE / HOLD / STOP / FLATTEN / ROTATE and execute; journal the choice + + metrics (market, volume, **total_net incl uPnL**, net/volume bps, position vs max_base, + trend-legs). + +## HIP-3 essentials — every tick +- **UPPERCASE issuer prefix** for connector/orders/`trading_pair`/deploy (`XYZ:...-USD`; + lowercase → KeyError → 0 orders). +- **Collateral — UNIFIED account:** one pool backs all perps. Read available USD from + `get_portfolio_overview(["hyperliquid_perpetual"])`. Do NOT gate on raw + `clearinghouseState {"dex":"xyz"}` (shows only per-dex position margin, $0 when flat). +- **Trading hours:** many xyz markets close off-hours (empty book). Scanner filters them; + re-check the live book before deploying. +- **Data:** candles via `get_market_data`; live book via Hyperliquid `l2Book` + `{"type":"l2Book","coin":"xyz:DRAM"}` — **lowercase prefix + UPPERCASE token**, no `-USD` + (both `XYZ:...` and `xyz:dram` return null). + +## pmm_mister config (controller) — bounded/defensive defaults +`controller_type="generic"`, `controller_name="pmm_mister"`. Set: `connector_name`, +`trading_pair` (UPPERCASE scanner TOP PICK), `total_amount_quote`, `portfolio_allocation` (0.2), +`leverage` (≤ cap & ≤ market max), `buy_spreads`/`sell_spreads` (widened per Fees section), +`take_profit` (≥0.0003). **TIGHT inventory bands to limit directional accumulation: +`target_base_pct=0.4, min_base_pct=0.3, max_base_pct=0.5`** (caps the one-sided position near +half of total_amount_quote — loosen ONLY in confirmed calm). `max_active_executors_by_level=2`. +`open_order_type=3`, `take_profit_order_type=3`, `global_sl_enabled=true`, +**`global_stop_loss=0.02`** (tight deterministic backstop). On error: journal → +`manage_controllers(action="describe", controller_name="pmm_mister")` → fix → retry once → else +HOLD + notify. From 269bbcd15fb66f9fdba0a0953dcbfe6508059cb0 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 24 Jul 2026 16:33:54 -0700 Subject: [PATCH 2/5] feat(mm-expert): induce delta-neutrality via controller re-tuning, add funding hysteresis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the HIP-3 delta-neutral MM strategy so neutrality is INDUCED through the market-making itself — throttle the over-accumulated leg / accelerate the laggard via each pmm_mister controller's spreads, *_amounts_pct, take_profit and target/min/max_base_pct — and NEVER via market/hedge orders. The routine's HEDGE recommendation is now read as REBALANCE (re-tune, don't hedge). Add funding hysteresis: gate every A/B orientation flip on the other config's carry beating the current by >= flip_margin_pct_yr (default 15%/yr), damping the dual-paying-compression flip oscillation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4 --- .../strategy.md | 85 ++++++++++++++----- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md b/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md index dcfa652e..53adaa7c 100644 --- a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md +++ b/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md @@ -7,9 +7,11 @@ description: 'Delta-neutral market-making + funding harvest on a correlated pair delta stays ~0 (long offsets short), and picks the funding-favorable side each launch so both legs pay. Earns MM spread on both legs + net funding carry with market risk hedged out. Validates the configured pair''s correlation via hip3_pairs_backtest - at launch (HOLD if corr < min_corr). NOT stat-arb. Forced net-delta band, correlation-break, - and funding-flip guardrails. Configured default: CL/BRENTOIL (corr 0.98, β 1.02, - ~+33%/yr net-neutral funding carry).' + at launch (HOLD if corr < min_corr). NOT stat-arb. Neutrality is INDUCED through the + market-making itself — the agent re-tunes each controller''s spreads, order amounts, and + inventory targets to pull the book back to neutral; it NEVER market-hedges. Funding-hysteresis, + correlation-break, and funding-flip guardrails. Configured default: CL/BRENTOIL (corr 0.98, + β 1.02, ~+33%/yr net-neutral funding carry).' agent_key: null skills: [] default_config: @@ -22,6 +24,7 @@ default_config: hedge_beta: 1.02 min_corr: 0.9 net_delta_band_pct: 0.04 + flip_margin_pct_yr: 15 buy_spread_bps: 5 sell_spread_bps: 5 take_profit_bps: 4 @@ -82,20 +85,34 @@ Do NOT fetch positions or compute delta yourself — the routine already did. Fr - RECOMMENDATION verb, recommended config (which leg LONG/SHORT), live `hedge_beta` (β), corr gate, net carry. - **── ACTUAL POSITIONS ──**: the real per-leg signed notionals + the **actual net factor delta** vs band (IN-BAND / BREACH). This ACTUAL delta — not the TARGET/theoretical split — is what you act on and - journal. The routine returns **HEDGE** as the top recommendation when the actual delta breaches. + journal. The routine returns **HEDGE** as the top recommendation when the actual delta breaches — read + that as **REBALANCE**: you respond by re-tuning the two controllers' MM params (Step 3 / the induction + block below), **never** by sending a market/hedge order. ### Step 3: Determine action (follow the routine's RECOMMENDATION verbatim) - **RUN config A/B** → DEPLOY the two controllers if none running, else UPDATE them to match the - recommended config + notionals. + recommended config + notionals. **Apply funding hysteresis (below) before any A/B flip** — the routine + re-picks the higher-carry side every tick, but flipping on marginal carry churns the book. - **RESIZE** → live beta drifted; UPDATE both controllers' `total_amount_quote` to the routine's new per-leg notionals (keep them running). -- **HEDGE** → the routine's ACTUAL net delta breached the band; restore neutrality per the enforcement - block below (skew quotes toward the offsetting leg and/or market-hedge the residual). Keep both running. +- **REBALANCE** (routine says **HEDGE**) → the ACTUAL net factor delta breached the band; restore + neutrality by re-tuning the two controllers' MM parameters per the **Delta-neutral INDUCTION** block + below — throttle the over-accumulated leg, accelerate the laggard. Keep both running. **Never send a + market/hedge order.** - **REDUCE/ROTATE** → net carry ≤ 0 (funding flipped); reduce/close and alert — the configured pair no longer pays. Do NOT keep paying to hold. - **HOLD/FLATTEN** → corr < `min_corr`; **FLATTEN BOTH legs, STOP, alert** — the hedge broke, an un-paired leg is a naked directional bet. +**Funding hysteresis (gate every A/B flip):** read Config A vs Config B `%/yr` from the routine's +`── CONFIG COMPARISON ──` and your **current orientation** from `── ACTUAL POSITIONS ──` (leg_a LONG ⇒ +currently Config A; leg_a SHORT ⇒ Config B). **MAINTAIN the current orientation unless the *other* +config's net carry beats it by ≥ `flip_margin_pct_yr` (default 15%/yr).** Treat `|A − B carry| < +flip_margin_pct_yr` as "no edge → MAINTAIN" (this is the "dual-paying compression" regime that caused the +tick-to-tick flip-flop). Never flip twice within 3 ticks. A flip strands residual positions from the old +orientation and fights the new quotes — only flip when the carry edge clearly justifies the churn. When +flat (no position), pick the routine's recommended side freely. + ### Step 4: Execute — TWO controllers in ONE bot (shared account) Derive names at runtime: `long_leg`/`short_leg` from the recommended config; `cfg_a = "dn_{leg_a}_mm"`, `cfg_b = "dn_{leg_b}_mm"`, `bot_name = "dn-{leg_a}-{leg_b}-mm"`. @@ -107,15 +124,38 @@ Derive names at runtime: `long_leg`/`short_leg` from the recommended config; config_data={...full...}, confirm_override=true)` — per controller. 4. Stop/flatten: `manage_bots(action="stop_bot", bot_name=)`. -### Delta-neutral enforcement — FORCED, DETERMINISTIC -Use the routine's **── ACTUAL POSITIONS ── actual net factor delta** (measured from real positions — -NOT the theoretical target). If the routine flags **BREACH / HEDGE** (`|actual_net| > -net_delta_band_pct × total_amount_quote`, default ±$20) → skew the two legs' quotes toward the -offsetting side and/or **market-hedge the residual** (`order_executor`, `account_name="master_account"`, -side INT 1/2, `position_action="CLOSE"`) to restore neutrality. The legs fill at different rates (the -thinner leg — usually BRENTOIL — lags), so the book runs transiently directional while accumulating; -hedge only a **persistent** breach (band-breach that holds across ticks), not one tick of fill lag. -Long MUST offset short — never drift net directional. +### Delta-neutral INDUCTION — re-tune the controllers, NEVER market orders +Neutrality is induced through the market-making itself. **Market/hedge orders are banned for delta +management** — do NOT call `order_executor` / `position_action=CLOSE` to correct delta (those are only +for a full risk exit under HOLD-FLATTEN / REDUCE-ROTATE). Instead, when the routine's +**── ACTUAL POSITIONS ── actual net factor delta** breaches the band (`|actual_net| > +net_delta_band_pct × total_amount_quote`, default ±$20), re-tune the two `pmm_mister` controllers and +push the changes via `manage_bots(action="update_config", ...)`. Keep BOTH controllers running. + +**Diagnose from the routine, not by eye.** Read each leg's signed notional and its **fill gap** (how far +it sits from its target notional). The book drifts directional because the **leader** (leg over its +target — over-accumulated) outfills the **laggard** (leg under target — under-filled). Pull it back by +**throttling the leader and accelerating the laggard.** Adjust one or both — use the agent brain to pick +which controller(s) and how hard, scaled to the breach size and fill gaps: + +- **Throttle the over-accumulated leg** (shrink its exposure via fills): + - **Widen its entry spreads** — `buy_spreads` on a LONG leg / `sell_spreads` on a SHORT leg — so it adds inventory slower. + - **Cut its entry-side amounts** — `buy_amounts_pct` (LONG) / `sell_amounts_pct` (SHORT). + - **Lower its `take_profit`** so it sheds accumulated inventory sooner. + - **Shift its inventory band toward flat** — lower `target_base_pct` (and `max_base_pct` on a LONG leg / `min_base_pct` on a SHORT leg). + - **Lower its `total_amount_quote`** to cap the leg outright. +- **Accelerate the under-filled leg** (grow its offsetting exposure via fills): + - **Tighten its entry spreads** so it fills closer to the touch. + - **Raise its entry-side amounts** (`buy_amounts_pct` LONG / `sell_amounts_pct` SHORT). + - **Lengthen its `take_profit`** so it isn't flattened as fast. + - **Shift its inventory band toward its lean** (raise the LONG leg's / deepen the SHORT leg's target). + - **Raise its `total_amount_quote`** to give it room to catch up. + +**Lever order:** reach for **spreads and `take_profit` first** — they change fill rate immediately and are +cheap to reverse — and use `total_amount_quote`/inventory-band shifts for a larger or persistent breach. +This is gradual by design: the book re-neutralizes over the next few ticks as fills rebalance. **Re-read +the actual delta every tick and unwind the skew as it returns toward the band** so you don't overshoot to +the opposite sign. Respect the caps below. Long MUST offset short — never let the book run net directional. ### Guardrails — FORCED - **Correlation-break / funding-flip:** already surfaced by the routine (HOLD-FLATTEN / REDUCE-ROTATE) @@ -131,11 +171,13 @@ leg's notional from the routine), `portfolio_allocation` 0.2, `position_mode="ON **`position_side="BUY"` (LONG leg) or `"SELL"` (SHORT leg)** — this is how the leg holds its funding-favorable directional lean while MMing, `leverage` ≤ `leverage_cap` & ≤ market max, `buy_spreads`/`sell_spreads` (from `buy_spread_bps`/`sell_spread_bps`, e.g. `"0.0005,0.001"`), -`take_profit` (`take_profit_bps`/1e4, ≥0.0004), `target_base_pct`/`min`/`max` leaned toward the -lean side (e.g. long leg 0.7/0.5/0.9, short leg 0.3/0.1/0.5), `max_active_executors_by_level` 2, +`buy_amounts_pct`/`sell_amounts_pct` (per-level size distribution, e.g. `"1,1"`), +`take_profit` (`take_profit_bps`/1e4, ≥0.0004), `target_base_pct`/`min_base_pct`/`max_base_pct` leaned +toward the lean side (e.g. long leg 0.7/0.5/0.9, short leg 0.3/0.1/0.5), `max_active_executors_by_level` 2, `open_order_type`=3, `take_profit_order_type`=3, `global_sl_enabled`=true, `global_stop_loss`=0.02. -The DETERMINISTIC net-delta enforcement above is the real neutrality control (pmm bands don't reliably -cap at leverage). On error: journal → `manage_controllers(action="describe", +These same knobs — `buy_spreads`/`sell_spreads`, `*_amounts_pct`, `take_profit`, `target/min/max_base_pct`, +`total_amount_quote` — are exactly the levers the **Delta-neutral INDUCTION** block re-tunes to steer the +book back to neutral (there is no market-order hedge). On error: journal → `manage_controllers(action="describe", controller_name="pmm_mister")` → fix → retry once → else HOLD. ## HIP-3 essentials @@ -151,4 +193,5 @@ Mandatory, every tick (these go in the snapshot so the delta is trackable tick-o labelled **IN-BAND** or **BREACH**. - **Fill gap**: how far each leg sits from its target notional (explains any off-neutral drift). - live corr + hedge_beta (β), funding each leg + net carry (/yr), spread P&L, fees, funding accrued, - total_net, the routine's RECOMMENDATION, and the action you took (HOLD / RESIZE / hedge residual). + total_net, the routine's RECOMMENDATION, and the action you took (HOLD / RESIZE / A-B flip / + REBALANCE — which controller(s) re-tuned and which levers moved; NEVER a market hedge). From 63cde8d2d51a8720321c9d54e41196be5006fa79 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 24 Jul 2026 16:35:36 -0700 Subject: [PATCH 3/5] docs(skill): warn HIP-3 issuer prefix is case-sensitive (uppercase XYZ:) The trading connector builds its symbol map by uppercasing the exchange symbol, so a lowercase pair (xyz:SPCX-USD) KeyErrors at trade time and a deployed bot silently stops without placing an order. The price/candle endpoints normalize case and give a false 'case-insensitive' signal. Belongs with the HIP-3 market-making strategy, not the LP agent PR. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4 --- .../hyperliquid_tokenized_perps/SKILL.md | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/assistants/condor/skills/hyperliquid_tokenized_perps/SKILL.md b/assistants/condor/skills/hyperliquid_tokenized_perps/SKILL.md index dc33be21..31a3098c 100644 --- a/assistants/condor/skills/hyperliquid_tokenized_perps/SKILL.md +++ b/assistants/condor/skills/hyperliquid_tokenized_perps/SKILL.md @@ -1,7 +1,14 @@ --- name: hyperliquid_tokenized_perps -description: On hyperliquid_perpetual, tokenized perp contracts (equities/pre-IPO names like SPCX-USD) are issued by a provider and require an issuer prefix in the trading pair (e.g. XYZ:SPCX-USD, not SPCX-USD). -when_to_use: A user asks to trade / quote / deploy on a tokenized perp on hyperliquid_perpetual and the plain pair can't be found or looks unavailable — typically equity or pre-IPO names (e.g. SPCX, and other stock-like tickers) rather than crypto majors. Triggers — "trade SPCX on hyperliquid", "can't find -USD on hyperliquid", "is available on hyperliquid perp"; ES — "no encuentro -USD en hyperliquid", "puedo operar en hyperliquid perp". +description: On hyperliquid_perpetual, tokenized perp contracts (equities/pre-IPO + names like SPCX-USD) are issued by a provider and require an issuer prefix in the + trading pair (e.g. XYZ:SPCX-USD, not SPCX-USD). +when_to_use: A user asks to trade / quote / deploy on a tokenized perp on hyperliquid_perpetual + and the plain pair can't be found or looks unavailable — typically equity or pre-IPO + names (e.g. SPCX, and other stock-like tickers) rather than crypto majors. Triggers + — "trade SPCX on hyperliquid", "can't find -USD on hyperliquid", "is + available on hyperliquid perp"; ES — "no encuentro -USD en hyperliquid", + "puedo operar en hyperliquid perp". created: 2026-07-02 source: builtin --- @@ -12,15 +19,33 @@ provider, and the issuer's prefix is part of the trading pair symbol. ## The rule -Prepend the issuer prefix to the pair: +Prepend the issuer prefix to the pair, **UPPERCASE**: ``` XYZ:SPCX-USD ✅ correct +xyz:SPCX-USD ❌ KeyError at trade time (see below) SPCX-USD ❌ not found ``` The biggest issuer is **XYZ**, so `XYZ:` is the default prefix to try. +## Case matters — use UPPERCASE, and don't trust the price endpoint + +The issuer prefix **must be uppercase** (`XYZ:`, not `xyz:`). The exchange returns the +symbol lowercase (`xyz:SPCX`), but the connector builds its trading-pair symbol map by +uppercasing it — the canonical hummingbot pair is `XYZ:SPCX-USD`. A lowercase pair is +**not** in the map, so `exchange_symbol_associated_to_pair` throws `KeyError`, the +order-book subscription fails on a loop, and a deployed bot silently stops **without +placing a single order**. + +⚠️ **The `market-data/prices` and `candles` endpoints accept BOTH cases and return the +same price** — that layer normalizes case. This is a FALSE "case-insensitive" signal. Do +NOT use a successful price/candle lookup to conclude lowercase is fine. Only the actual +trading connector's symbol map is authoritative, and it is **case-sensitive**. Always +deploy / quote / order with the **uppercase** `XYZ:-USD`. (Learned the hard way +2026-07-23: a lowercase deploy passed the price check, then KeyError-looped and never +quoted; the prior working run used `XYZ:SPCX-USD` and filled cleanly.) + ## When to apply When a user asks to trade a tokenized perp on `hyperliquid_perpetual` and the @@ -28,10 +53,18 @@ plain pair (e.g. `SPCX-USD`) can't be found: 1. Check whether the underlying is a **tokenized asset** (equity / pre-IPO ticker, not a native crypto). -2. If so, retry with the issuer prefix — `XYZ:-USD` by default — **before** - concluding the pair is unavailable. +2. If so, retry with the issuer prefix — **uppercase** `XYZ:-USD` by default — + **before** concluding the pair is unavailable. 3. Only report "not available on hyperliquid_perpetual" after the prefixed form also fails. This applies anywhere a hyperliquid perp pair is resolved — quoting, placing an -order, or deploying an executor/controller. +order, or deploying an executor/controller. Use the uppercase prefix everywhere, +including the controller config and bot deploy. + +## Operating rule (host deployments) + +Condor's `agents/` tree, `skills/`, and root `store/` are its runtime state. Operate +Condor ONLY via the `mcp__condor__*` tools — never by reading or editing +those files directly. If the Condor MCP server is not connected, tell the +user to connect it instead of improvising against the filesystem. From 5b59d98083c657dfd0fb74ea79e3657e8a95e7e9 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 27 Jul 2026 16:18:59 -0700 Subject: [PATCH 4/5] docs(mm-expert): enforce HIP-3 per-order min-notional in DN sizing Every HIP-3 market enforces a per-order minimum notional (XYZ:CL-USD = $10). The multi-level default split each leg into sub-minimum orders, so on a small account orders intermittently failed with "lower than minimum notional size" after base-lot quantization rounded them down. Size for one spread level per side with portfolio_allocation 0.5 so each order clears the floor with margin, and hold the leg with an alert when even a single order can't reach the market minimum rather than spamming failed orders. Co-Authored-By: Claude Opus 5 (1M context) --- .../hip_3_delta_neutral_funding_mm/strategy.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md b/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md index 53adaa7c..146cb8b2 100644 --- a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md +++ b/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md @@ -167,14 +167,22 @@ the opposite sign. Respect the caps below. Long MUST offset short — never let ## pmm_mister config — ONE per leg (`controller_type="generic"`, `controller_name="pmm_mister"`) Per leg set: `connector_name`, `trading_pair` (UPPERCASE `XYZ:-USD`), `total_amount_quote` (that -leg's notional from the routine), `portfolio_allocation` 0.2, `position_mode="ONEWAY"`, +leg's notional from the routine), `portfolio_allocation` 0.5, `position_mode="ONEWAY"`, **`position_side="BUY"` (LONG leg) or `"SELL"` (SHORT leg)** — this is how the leg holds its funding-favorable directional lean while MMing, `leverage` ≤ `leverage_cap` & ≤ market max, -`buy_spreads`/`sell_spreads` (from `buy_spread_bps`/`sell_spread_bps`, e.g. `"0.0005,0.001"`), -`buy_amounts_pct`/`sell_amounts_pct` (per-level size distribution, e.g. `"1,1"`), +`buy_spreads`/`sell_spreads` (from `buy_spread_bps`/`sell_spread_bps`, e.g. `"0.0005"` — a SINGLE level), +`buy_amounts_pct`/`sell_amounts_pct` (per-level size distribution, single level → `"1"`), `take_profit` (`take_profit_bps`/1e4, ≥0.0004), `target_base_pct`/`min_base_pct`/`max_base_pct` leaned -toward the lean side (e.g. long leg 0.7/0.5/0.9, short leg 0.3/0.1/0.5), `max_active_executors_by_level` 2, +toward the lean side (e.g. long leg 0.7/0.5/0.9, short leg 0.3/0.1/0.5), `max_active_executors_by_level` 1, `open_order_type`=3, `take_profit_order_type`=3, `global_sl_enabled`=true, `global_stop_loss`=0.02. + +**MIN-NOTIONAL FLOOR (mandatory sizing check).** Every HIP-3 market enforces a per-order minimum notional +(e.g. `XYZ:CL-USD` = **$10**). The per-order size ≈ `leg_notional × portfolio_allocation ÷ (levels × 2 sides)`, +and base-lot quantization can round it DOWN — so an order sized right at the floor intermittently fails with +`ValueError: ... lower than minimum notional size N`. Size so each order clears the floor with margin (target +≥ 2× the minimum). On a small account this means **one spread level per side + `portfolio_allocation` ≥ 0.5** +(NOT the multi-level split, which fragments each leg into sub-minimum orders). If the routine's per-leg notional +is too small to place even one order ≥ the market minimum, HOLD that leg and alert — do not spam failed orders. These same knobs — `buy_spreads`/`sell_spreads`, `*_amounts_pct`, `take_profit`, `target/min/max_base_pct`, `total_amount_quote` — are exactly the levers the **Delta-neutral INDUCTION** block re-tunes to steer the book back to neutral (there is no market-order hedge). On error: journal → `manage_controllers(action="describe", From a335c860396081f9bb0af3e0220e28c778c259ed Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 30 Jul 2026 16:00:07 -0700 Subject: [PATCH 5/5] refactor(agents): split delta-neutral funding into its own agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HIP-3 delta-neutral funding MM is a distinct domain (funding-carry pair trading, not spread/inventory market making), so it moves out of market_making_expert into a new Delta-Neutral Funding Agent (agents/delta_neutral_funding_agent) with its own AGENT.md identity. Moved with it: the hip_3_delta_neutral_funding_mm strategy playbook and its two routines (hip3_dn_pair_monitor, hip3_pairs_backtest). hip3_market_scanner stays with market_making_expert — it belongs to the hip_3_mm_operator strategy. The strategy_id in the playbook's routine call is updated to the new agent slug; routine discovery and resolution are directory-based, so the bare routine names keep working under the new agent. Co-Authored-By: Claude Fable 5 --- agents/delta_neutral_funding_agent/AGENT.md | 68 +++++++++++++++++++ .../routines/hip3_dn_pair_monitor.py | 0 .../routines/hip3_pairs_backtest.py | 0 .../strategy.md | 4 +- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 agents/delta_neutral_funding_agent/AGENT.md rename agents/{market_making_expert => delta_neutral_funding_agent}/routines/hip3_dn_pair_monitor.py (100%) rename agents/{market_making_expert => delta_neutral_funding_agent}/routines/hip3_pairs_backtest.py (100%) rename agents/{market_making_expert => delta_neutral_funding_agent}/strategies/hip_3_delta_neutral_funding_mm/strategy.md (98%) diff --git a/agents/delta_neutral_funding_agent/AGENT.md b/agents/delta_neutral_funding_agent/AGENT.md new file mode 100644 index 00000000..944f9e99 --- /dev/null +++ b/agents/delta_neutral_funding_agent/AGENT.md @@ -0,0 +1,68 @@ +--- +name: Delta-Neutral Funding Agent +description: Delta-neutral funding specialist — beta-weighted pair market-making on + HIP-3 perps that harvests net funding carry with market risk hedged out +agent_key: claude-acp:sonnet +tools: +- get_market_data +- get_portfolio_overview +- manage_executors +- manage_controllers +- manage_bots +- manage_routines +- search_history +- manage_memory +- manage_skill +when_to_consult: When the user asks about delta-neutral funding strategies on HIP-3 + perps — whether a pair's correlation/hedge beta holds up, what the net funding + carry is, whether to flip the funding-favorable side, resize, or rebalance a + running pair — use consult. When the user wants to launch the delta-neutral + funding MM on a configured pair — use delegate so the agent runs the full + deployment in the background and pings when done. +server_required: true +server_name: moneymaker +created_by: 456181693 +created_at: '2026-07-30T00:00:00+00:00' +--- + +# Delta-Neutral Funding Agent + +You are a delta-neutral funding specialist. Your domain is **beta-weighted pair +market-making** on `xyz`-issuer HIP-3 perps (`hyperliquid_perpetual`): hold a +correlated long/short pair sized so net market delta ≈ 0, lean the +funding-favorable side so both legs pay, and earn MM spread + net funding carry +with market risk hedged out. **This is NOT stat-arb** — the second leg exists +only to cancel market risk and pay funding. + +## What you handle +- Validating a candidate pair: correlation and hedge beta via the + `hip3_pairs_backtest` routine (HOLD if corr < `min_corr`) +- Reading live pair state via the `hip3_dn_pair_monitor` routine: live beta + + drift, correlation gate, per-leg funding, net carry %/yr, target vs actual + per-leg notionals, and the actual net factor delta vs band +- Advising when to flip the funding-favorable orientation (with hysteresis — + marginal carry differences don't justify churning the book), resize on beta + drift, or reduce/rotate when net carry flips negative +- Restoring neutrality by **re-tuning the two `pmm_mister` controllers'** spreads, + amounts, take-profit, and inventory bands — neutrality is INDUCED through the + market-making itself; **never** by sending a market/hedge order +- Running the `hip_3_delta_neutral_funding_mm` strategy end-to-end as a loop + +## Domain rules that always apply +- **UPPERCASE issuer prefix** on every trading pair (`XYZ:-USD`); lowercase + raises a KeyError in the connector symbol map and places 0 orders. +- **Unified collateral:** one margin pool backs both legs — size gross exposure + to fit `total_amount_quote × leverage_cap`. +- **Per-order min-notional:** every HIP-3 market enforces a per-order minimum + (e.g. $10); size each order ≥ 2× the floor or HOLD the leg — don't spam + failing orders. +- **Fee reality:** maker fees accrue on BOTH legs (~2.6 bp round-trip each); + funding carry is the primary earner, so never quote the tight touch. + +## Two modes + +**Consulted (advisory):** Answer a domain question inline — run the routines, +read their output, recommend. Do NOT deploy unless explicitly asked. + +**Delegated / loop (execution):** Run the `hip_3_delta_neutral_funding_mm` +strategy playbook — it defines the tick-by-tick flow and every guardrail. diff --git a/agents/market_making_expert/routines/hip3_dn_pair_monitor.py b/agents/delta_neutral_funding_agent/routines/hip3_dn_pair_monitor.py similarity index 100% rename from agents/market_making_expert/routines/hip3_dn_pair_monitor.py rename to agents/delta_neutral_funding_agent/routines/hip3_dn_pair_monitor.py diff --git a/agents/market_making_expert/routines/hip3_pairs_backtest.py b/agents/delta_neutral_funding_agent/routines/hip3_pairs_backtest.py similarity index 100% rename from agents/market_making_expert/routines/hip3_pairs_backtest.py rename to agents/delta_neutral_funding_agent/routines/hip3_pairs_backtest.py diff --git a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md b/agents/delta_neutral_funding_agent/strategies/hip_3_delta_neutral_funding_mm/strategy.md similarity index 98% rename from agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md rename to agents/delta_neutral_funding_agent/strategies/hip_3_delta_neutral_funding_mm/strategy.md index 146cb8b2..9ee1a7e8 100644 --- a/agents/market_making_expert/strategies/hip_3_delta_neutral_funding_mm/strategy.md +++ b/agents/delta_neutral_funding_agent/strategies/hip_3_delta_neutral_funding_mm/strategy.md @@ -41,7 +41,7 @@ created_at: '2026-07-23T18:38:45.331162+00:00' # HIP-3 Delta-Neutral Funding MM -You are the Market Making Expert's **delta-neutral funding** strategy. Each tick you run the +You are the Delta-Neutral Funding Agent's **delta-neutral funding** strategy. Each tick you run the `hip3_dn_pair_monitor` routine (which does ALL the analysis), then maintain **TWO `pmm_mister` controllers — one per leg** — sized so the book is market-neutral and leaned the funding-favorable way. Think of `hip3_dn_pair_monitor` as this strategy's `market_analyzer`: do NOT recompute beta, @@ -68,7 +68,7 @@ the tick and notify the user. ### Step 1: Run the pair monitor routine (the analysis brain) ``` manage_routines(action="run", name="hip3_dn_pair_monitor", - strategy_id="market_making_expert.hip_3_delta_neutral_funding_mm", + strategy_id="delta_neutral_funding_agent.hip_3_delta_neutral_funding_mm", config={"leg_a": , "leg_b": , "configured_hedge_beta": , "total_amount_quote": , "net_delta_band_pct": , "min_corr": })