diff --git a/agents/adaptive_grid_trader/AGENT.md b/agents/adaptive_grid_trader/AGENT.md index e7e264b24..b5a1c6eb1 100644 --- a/agents/adaptive_grid_trader/AGENT.md +++ b/agents/adaptive_grid_trader/AGENT.md @@ -2,7 +2,7 @@ name: Adaptive Grid Trader description: Expert in multi-timeframe adaptive grid trading with safety-first order sizing, a configurable untraded reserve, and strict risk management -agent_key: claude-acp:opus +agent_key: claude-acp:sonnet tools: - get_market_data - get_portfolio_overview diff --git a/agents/adaptive_grid_trader/routines/kalman_grid_operator.py b/agents/adaptive_grid_trader/routines/kalman_grid_operator.py new file mode 100644 index 000000000..8db0653e9 --- /dev/null +++ b/agents/adaptive_grid_trader/routines/kalman_grid_operator.py @@ -0,0 +1,611 @@ +import asyncio +import logging +import time + +import numpy as np +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + +from condor.fetchers.executors import extract_executors_list +from condor.reports import LiveReport +from config_manager import get_client + +logger = logging.getLogger(__name__) + +CONTINUOUS = True +CATEGORY = "Analysis" + + +class Config(BaseModel): + """Kalman adaptive grid operator — 1m regime check every minute, tunes grid on bitget_perpetual.""" + + connector_name: str = Field( + default="bitget_perpetual", description="Exchange connector" + ) + trading_pair: str = Field(default="BTC-USDT", description="Trading pair") + budget_usdt: float = Field(default=100.0, description="Total USDT budget") + reserve_pct: float = Field( + default=0.10, description="Fraction held back (never traded)" + ) + max_leverage: int = Field(default=5, description="Max leverage") + max_loss_pct: float = Field( + default=0.10, description="Max loss per grid as fraction of budget" + ) + min_order_size: float = Field(default=5.0, description="Min order size in USDT") + interval_sec: int = Field(default=60, description="Tick interval in seconds") + lookback_candles: int = Field( + default=60, description="1m candles (60 = last 1 hour)" + ) + q_level: float = Field( + default=100.0, description="Kalman process noise: level variance" + ) + q_slope: float = Field( + default=1.0, description="Kalman process noise: slope variance" + ) + r_obs: float = Field(default=40.0, description="Kalman observation noise variance") + snr_trend_threshold: float = Field( + default=0.3, description="SNR >= this → deploy directional grid" + ) + target_grid_hours: float = Field( + default=8.0, description="Target grid lifetime (hours) for D sizing" + ) + retune_d_pct: float = Field( + default=0.20, description="Retune grid if Kalman D shifts by this fraction" + ) + min_grid_age_min: int = Field( + default=180, description="Min grid age (min) before directional flip" + ) + profit_take_pct: float = Field( + default=0.02, description="Take profit at this fraction of budget ($2 on $100)" + ) + stale_ticks: int = Field( + default=10, description="Recycle grid if fills stagnant for this many ticks" + ) + + +# ── Kalman filter ───────────────────────────────────────────────────────────── + + +def _kalman_filter(prices, q_level, q_slope, r_obs): + """Local linear trend Kalman filter. Returns (slopes, innovations).""" + F = np.array([[1.0, 1.0], [0.0, 1.0]]) + H = np.array([[1.0, 0.0]]) + Q = np.diag([q_level, q_slope]) + R = np.array([[r_obs]]) + x = np.array([prices[0], 0.0]) + P = np.eye(2) * 1e6 + slopes, innovations = [], [] + for price in prices: + x_pred = F @ x + P_pred = F @ P @ F.T + Q + inn = price - float((H @ x_pred)[0]) + S = float((H @ P_pred @ H.T + R)[0, 0]) + K = (P_pred @ H.T / S).flatten() + x = x_pred + K * inn + P = (np.eye(2) - np.outer(K, H)) @ P_pred + slopes.append(float(x[1])) + innovations.append(float(inn)) + return np.array(slopes), np.array(innovations) + + +def _get_signal(slopes, innovations, prices, config): + """Return (regime, profile, snr, slope_pct, sigma_inn, grid_D, current_price).""" + n = len(prices) + window = min(24, max(n // 6, 4)) + recent_slope = slopes[-1] + current_price = float(prices[-1]) + rms_inn = float(np.sqrt(np.mean(innovations[-window:] ** 2))) + sigma_inn = max(rms_inn, 1.0) + slope_pct = recent_slope / current_price * 100 + snr = abs(recent_slope) / sigma_inn + # D scales so grid width is the same regardless of candle timeframe (1m candles here) + grid_D = sigma_inn * float(np.sqrt(config.target_grid_hours * 60)) + if n < 30: + return "UNCERTAIN", "HOLD", snr, slope_pct, sigma_inn, grid_D, current_price + if snr >= config.snr_trend_threshold: + regime = "TRENDING_UP" if recent_slope > 0 else "TRENDING_DOWN" + profile = "LONG" if recent_slope > 0 else "SHORT" + else: + regime = "RANGING/UNCERTAIN" + profile = "HOLD" + return regime, profile, snr, slope_pct, sigma_inn, grid_D, current_price + + +# ── Grid config builder ─────────────────────────────────────────────────────── + + +def _grid_config(direction, price, grid_D, config): + trade_budget = config.budget_usdt * (1 - config.reserve_pct) + if direction == "LONG": + start = round(price - grid_D, 1) + end = round(price + 3 * grid_D, 1) + limit = round(price - 1.5 * grid_D, 1) + side = "BUY" + else: + start = round(price - 3 * grid_D, 1) + end = round(price + grid_D, 1) + limit = round(price + 1.5 * grid_D, 1) + side = "SELL" + n_levels = max(2, min(20, int(trade_budget / config.min_order_size))) + return { + "type": "grid_executor", + "timestamp": time.time(), + "connector_name": config.connector_name, + "trading_pair": config.trading_pair, + "start_price": start, + "end_price": end, + "limit_price": limit, + "total_amount_quote": trade_budget, + "n_levels": n_levels, + "min_spread_between_orders": 0.001, + "min_order_amount_quote": config.min_order_size, + "leverage": config.max_leverage, + "side": side, + "time_limit": 86400, + "keep_position": False, + "triple_barrier_config": { + "take_profit": 0.003, + "stop_loss": 0.02, + "stop_loss_order_type": "MARKET", + }, + } + + +# ── Executor helpers ────────────────────────────────────────────────────────── + + +def _controller_id(config) -> str: + """A durable handle for everything this run creates. + + The id returned by ``create_executor`` is the only reference this routine has + to a grid, and it does not exist until that call returns. Cancellation landing + mid-POST — after the server has created the grid, before the id comes back — + would otherwise leave a 5x leveraged grid running its full 24-hour lifetime + with nothing left that knows its id. Tagging every create with a controller id + chosen *before* the first one gives the server a handle that survives that + window, and ``search_executors``/``get_positions_summary`` both filter on it. + """ + pair = config.trading_pair.replace("-", "").lower() + return f"kalman_grid_{pair}_{int(time.time())}" + + +async def _our_executors(client, controller_id): + """Active executors this run owns, as a list. + + ``search_executors`` returns hapi's ``{"data": [...], "pagination": {...}}`` + envelope, not a bare list — iterating the response directly walks its string + keys, so ``e.get(...)`` raises AttributeError. Every other consumer in the + repo goes through this same helper. + + Scoped to our controller id: an unrelated grid on the same account is not ours + to reason about, and must not read as "our grid is still alive" either. + """ + result = await client.executors.search_executors( + controller_ids=[controller_id], status="active", limit=50 + ) + return extract_executors_list(result) + + +async def _flat_check(client, controller_id): + """``(flat, note)`` — has this run left any position open? + + An executor leaving the active listing does not mean its exposure went with + it. A close that exhausts its retries ends as a position hold: executor gone, + position still on the venue. Deploying on that reading stacks a second + full-budget grid on top of exposure nobody is managing. + + A read that fails counts as *not* flat. The caller uses this to decide whether + to put more money on the venue, and "I could not tell" is not a yes. + """ + try: + summary = await client.executors.get_positions_summary( + controller_id=controller_id + ) + except Exception as e: + return False, f"flatness unverified: {e}" + held = int(summary.get("total_positions", 0) or 0) + if held: + return False, f"{held} position(s) still held" + return True, "" + + +async def _teardown(client, controller_id, executor_id, log, ts) -> bool: + """Stop a grid and confirm both that it is gone and that it left nothing open. + + The caller clears its executor id and deploys a replacement on the strength of + this return, so a stop that failed — or that the venue did not honour — must + not read as success. Nor may a stop that removed the executor while leaving + its position: that is the same overlapping exposure by another route. + + ``keep_position=False`` is spelled out rather than left to the default: it is + the close primitive, and this call is the reason the position closes at all. + """ + try: + await client.executors.stop_executor(executor_id, keep_position=False) + except Exception as e: + log.append(f"{ts} WARN stop failed: {e} — keeping grid, no redeploy") + return False + + try: + still_active = any( + str(e.get("id") or e.get("executor_id", "")) == executor_id + for e in await _our_executors(client, controller_id) + ) + except Exception as e: + # Cannot prove it stopped; treat as still running rather than stack grids. + log.append(f"{ts} WARN stop unverified: {e} — keeping grid, no redeploy") + return False + + if still_active: + log.append(f"{ts} WARN {executor_id[:8]} still active after stop") + return False + + flat, note = await _flat_check(client, controller_id) + if not flat: + log.append(f"{ts} WARN {executor_id[:8]} stopped but {note} — no redeploy") + return False + + log.append(f"{ts} TEARDOWN {executor_id[:8]} OK") + return True + + +async def _deploy(client, controller_id, direction, price, grid_D, config, log, ts): + gcfg = _grid_config(direction, price, grid_D, config) + try: + result = await client.executors.create_executor( + gcfg, controller_id=controller_id + ) + eid = str(result.get("id") or result.get("executor_id", "")) + log.append( + f"{ts} DEPLOY {direction} {eid[:8]} " + f"${gcfg['start_price']:.0f}–${gcfg['end_price']:.0f} " + f"lim=${gcfg['limit_price']:.0f} D=${grid_D:.0f}" + ) + return eid, grid_D + except Exception as e: + log.append(f"{ts} ERROR deploy: {e}") + return None, None + + +# ── Main loop ───────────────────────────────────────────────────────────────── + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + chat_id = context._chat_id + await context.bot.send_message(chat_id=chat_id, text="Kalman Grid Operator started") + + # Persistent state + executor_id = None + direction = None + grid_D_deployed = None + deployed_at = None + pnl_history = [] + fills_history = [] + tick_count = 0 + log = [] + + report = LiveReport( + "Kalman Grid Operator", + source_name="kalman_grid_operator", + tags=["trading", "kalman", "adaptive-grid-trader", "live"], + auto_refresh_seconds=60, + ) + + # Bound before the loop: the CancelledError handler below stops the live + # grid, and cancellation can arrive before the first tick ever assigns it. + client = None + + # Fixed before the first create, so the handler can ask the server what this + # run owns even when a mid-POST cancellation cost us the returned id. + controller_id = _controller_id(config) + + # Bound for the same reason as `client`: the handler's teardown logs through + # it, and cancellation can arrive before the first tick sets one. A NameError + # here would abort the very cleanup this handler exists to perform. + ts = time.strftime("%H:%M:%S") + + try: + while True: + try: + tick_count += 1 + ts = time.strftime("%H:%M:%S") + client = await get_client(chat_id, context=context) + if not client: + await asyncio.sleep(config.interval_sec) + continue + + # ── 1. Kalman signal ────────────────────────────────────── + raw = await client.market_data.get_candles( + config.connector_name, + config.trading_pair, + interval="1m", + max_records=config.lookback_candles, + ) + records = ( + raw + if isinstance(raw, list) + else raw.get("data", raw.get("candles", [])) + ) + closes = [] + for r in records or []: + try: + closes.append(float(r["close"])) + except (KeyError, TypeError, ValueError): + continue + + if len(closes) < 20: + log.append(f"{ts} SKIP: only {len(closes)} candles") + await asyncio.sleep(config.interval_sec) + continue + + prices = np.array(closes) + slopes, innovations = _kalman_filter( + prices, config.q_level, config.q_slope, config.r_obs + ) + regime, profile, snr, slope_pct, sigma_inn, grid_D, current_price = ( + _get_signal(slopes, innovations, prices, config) + ) + + # ── 2. Executor state & action decision ─────────────────── + pnl = 0.0 + fills = 0.0 + grid_age_min = 0 + action = "keep" + action_reason = f"snr={snr:.2f}" + new_direction = direction + + if executor_id is not None: + execs = await _our_executors(client, controller_id) + our_exec = next( + ( + e + for e in (execs or []) + if str(e.get("id") or e.get("executor_id", "")) + == executor_id + ), + None, + ) + + if our_exec is None: + # Grid ended on its own — reset and redeploy + log.append(f"{ts} Grid {executor_id[:8]} gone") + executor_id = grid_D_deployed = deployed_at = None + pnl_history.clear() + fills_history.clear() + new_direction = ( + profile if profile in ("LONG", "SHORT") else direction + ) + action = "deploy" + action_reason = "grid_died" + else: + pnl = float(our_exec.get("net_pnl_quote", 0)) + fills = float(our_exec.get("filled_amount_quote", 0)) + grid_age_min = ( + int((time.time() - deployed_at) / 60) if deployed_at else 0 + ) + pnl_history.append(pnl) + fills_history.append(fills) + if len(pnl_history) > 20: + pnl_history.pop(0) + if len(fills_history) > config.stale_ticks + 2: + fills_history.pop(0) + + profit_target = config.budget_usdt * config.profit_take_pct + + # Priority: stale → profit → D-drift → flip → keep + if ( + len(fills_history) >= config.stale_ticks + and len( + set( + round(f, 2) + for f in fills_history[-config.stale_ticks :] + ) + ) + == 1 + ): + action = "retune" + action_reason = f"stale {config.stale_ticks}t" + new_direction = direction # same direction, fresh range + + elif pnl >= profit_target: + action = "retune" + action_reason = f"profit_take ${pnl:.2f}" + new_direction = ( + profile if profile in ("LONG", "SHORT") else None + ) + + elif ( + grid_D_deployed + and abs(grid_D - grid_D_deployed) / grid_D_deployed + > config.retune_d_pct + ): + d_shift = abs(grid_D - grid_D_deployed) / grid_D_deployed + action = "retune" + action_reason = f"D_drift {d_shift:.0%}" + new_direction = direction # same direction, updated range + + elif ( + profile not in ("HOLD", direction) + and profile in ("LONG", "SHORT") + and grid_age_min >= config.min_grid_age_min + ): + action = "flip" + action_reason = f"→{profile} age={grid_age_min}m" + new_direction = profile + + else: + action = "keep" + action_reason = f"regime={regime} snr={snr:.2f}" + + else: + # No grid running + if profile in ("LONG", "SHORT"): + action = "deploy" + action_reason = f"regime={regime} snr={snr:.2f}" + new_direction = profile + else: + action = "hold" + action_reason = f"regime={regime} snr={snr:.2f}" + + # ── 3. Execute ──────────────────────────────────────────── + if action in ("retune", "flip"): + if await _teardown(client, controller_id, executor_id, log, ts): + executor_id = grid_D_deployed = deployed_at = None + pnl = fills = 0.0 + pnl_history.clear() + fills_history.clear() + direction = new_direction + if direction in ("LONG", "SHORT"): + action = "deploy" + else: + action = "hold" + else: + # Old grid may still be live. Keep tracking it and retry + # next tick rather than deploying a second full-budget + # grid on top of exposure we failed to close. + action = "hold" + action_reason = "teardown failed — retry next tick" + + if action == "deploy" and new_direction in ("LONG", "SHORT"): + direction = new_direction + trade_budget = config.budget_usdt * (1 - config.reserve_pct) + if trade_budget < config.min_order_size * 2: + log.append(f"{ts} HOLD: budget too small") + action = "hold" + else: + # Gated here rather than in _teardown alone, because + # teardown is not the only way in: the "grid died on its + # own" branch above clears state and routes straight to a + # deploy, and a grid that ended by exhausting its close + # retries ends holding a position. Every path to a deploy + # passes through this check. + flat, note = await _flat_check(client, controller_id) + if not flat: + log.append(f"{ts} HOLD: {note} — not stacking a grid") + action = "hold" + else: + eid, D_dep = await _deploy( + client, + controller_id, + direction, + current_price, + grid_D, + config, + log, + ts, + ) + if eid: + executor_id = eid + grid_D_deployed = D_dep + deployed_at = time.time() + elif action == "deploy": + action = "hold" + direction = None + log.append(f"{ts} HOLD: no signal for redeploy") + + # ── 4. Log & LiveReport ─────────────────────────────────── + log.append( + f"{ts} [{action.upper()}] {regime} SNR={snr:.2f} " + f"D=${grid_D:.0f} pnl=${pnl:+.2f} fills=${fills:.2f}" + ) + if len(log) > 60: + log[:] = log[-60:] + + report.clear() + report.builder.manual_order() + + report.builder.section( + "01 / KALMAN SIGNAL", "1m Kalman regime — updated every tick" + ) + report.builder.kpi("Regime", regime) + report.builder.kpi("Profile", profile) + report.builder.kpi("Price", f"${current_price:,.2f}") + report.builder.kpi("Slope", f"{slope_pct:+.5f}%/m") + report.builder.kpi("σ Innovation", f"${sigma_inn:.2f}") + report.builder.kpi("SNR", f"{snr:.3f}") + report.builder.kpi("Grid D", f"${grid_D:.2f}") + + report.builder.section("02 / GRID STATE") + report.builder.kpi("Status", "RUNNING" if executor_id else "FLAT") + report.builder.kpi("Direction", direction or "—") + report.builder.kpi("Age", f"{grid_age_min}m" if executor_id else "—") + report.builder.kpi( + "Unrealized PnL", f"${pnl:+.2f}" if executor_id else "—" + ) + report.builder.kpi("Filled", f"${fills:.2f}" if executor_id else "—") + report.builder.kpi( + "D Deployed", f"${grid_D_deployed:.2f}" if grid_D_deployed else "—" + ) + report.builder.kpi("Action", f"{action} ({action_reason})") + report.builder.kpi("Tick #", str(tick_count)) + + report.builder.section("03 / LOG") + report.builder.markdown("\n".join(f"`{l}`" for l in log[-20:])) + + await report.update() + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Tick {tick_count} error: {e}") + + await asyncio.sleep(config.interval_sec) + + except asyncio.CancelledError: + # Stopping the operator must stop what it is operating. Without this the + # grid keeps trading for the rest of its configured lifetime with nobody + # left to monitor, retune or exit it. + stop_note = "FLAT" + if client: + # Ask the server what this run owns rather than trusting executor_id. + # Cancellation can land inside create_executor, after the grid exists + # and before its id comes back, so a live grid can sit behind an + # executor_id that is still None. controller_id was fixed before the + # first create, so it finds that grid anyway. + doomed = {executor_id} if executor_id else set() + try: + doomed |= { + str(e.get("id") or e.get("executor_id", "")) + for e in await _our_executors(client, controller_id) + } + except Exception as e: + logger.error( + f"Kalman Grid Operator cancelled but could not list its own " + f"grids ({controller_id}) — any live grid is now unmanaged: {e}" + ) + stop_note = f"UNKNOWN — check controller {controller_id}" + doomed.discard("") + + stopped, live = [], [] + for eid in sorted(doomed): + # _teardown, not a bare stop_executor: a stop that returns without + # raising has proved nothing. It is the verified path, and this is + # the moment it matters most, since nothing runs after it. + try: + ok = await _teardown(client, controller_id, eid, log, ts) + except Exception as e: + logger.error(f"Teardown of {eid} raised during shutdown: {e}") + ok = False + (stopped if ok else live).append(eid) + + if live: + # Say so loudly: still trading, and now with nobody watching. + logger.error( + f"Kalman Grid Operator cancelled but {len(live)} grid(s) could " + f"not be verified stopped and flat — they are still trading: " + f"{', '.join(live)} (controller {controller_id})" + ) + stop_note = f"{live[0][:8]} STILL LIVE — stop it manually" + elif stopped: + stop_note = f"{stopped[0][:8]} stopped" + + if report.report_id is not None: + report.clear() + report.builder.auto_refresh(None) + report.builder.section("STOPPED", f"Final snapshot — {tick_count} ticks") + report.builder.kpi("Total Ticks", str(tick_count)) + report.builder.kpi("Direction", direction or "—") + report.builder.kpi("Grid", stop_note) + report.builder.markdown("\n".join(f"`{l}`" for l in log[-20:])) + await report.update() + return f"Kalman Grid Operator stopped after {tick_count} ticks" diff --git a/agents/adaptive_grid_trader/routines/kalman_regime_check.py b/agents/adaptive_grid_trader/routines/kalman_regime_check.py new file mode 100644 index 000000000..29b71b9ef --- /dev/null +++ b/agents/adaptive_grid_trader/routines/kalman_regime_check.py @@ -0,0 +1,261 @@ +import logging +from datetime import datetime, timezone + +import numpy as np +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + +from condor.reports import ReportBuilder +from config_manager import get_client + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" + + +def _candle_minutes(interval: str) -> float: + """Parse '1m'→1, '1h'→60, '4h'→240, '1d'→1440.""" + if interval.endswith("m"): + return float(interval[:-1]) + elif interval.endswith("h"): + return float(interval[:-1]) * 60 + elif interval.endswith("d"): + return float(interval[:-1]) * 1440 + return 1.0 + + +class Config(BaseModel): + """Kalman filter regime detection — outputs regime signal and Kalman-sized grid params.""" + + connector_name: str = Field( + default="bitget_perpetual", description="Exchange connector" + ) + trading_pair: str = Field(default="BTC-USDT", description="Trading pair") + candle_interval: str = Field( + default="1m", description="Candle interval (1m, 5m, 1h, etc.)" + ) + lookback_candles: int = Field( + default=60, description="Candles to fetch (60 × 1m = last 1 hour)" + ) + q_level: float = Field(default=100.0, description="Process noise: level variance") + q_slope: float = Field(default=1.0, description="Process noise: slope variance") + r_obs: float = Field(default=40.0, description="Observation noise variance") + snr_trend_threshold: float = Field( + default=0.3, description="SNR >= this → TRENDING" + ) + snr_range_threshold: float = Field(default=0.1, description="SNR <= this → RANGING") + target_grid_hours: float = Field( + default=8.0, description="Target grid lifetime (hours) for D sizing" + ) + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + client = await get_client(context._chat_id, context=context) + if not client: + return "No server available" + + result = await client.market_data.get_candles( + config.connector_name, + config.trading_pair, + interval=config.candle_interval, + max_records=config.lookback_candles, + ) + records = ( + result + if isinstance(result, list) + else result.get("data", result.get("candles", [])) + ) + if not records or len(records) < 20: + return f"Insufficient candle data: got {len(records) if records else 0} records" + + closes, timestamps = [], [] + for r in records: + try: + closes.append(float(r["close"])) + ts = r.get("timestamp", r.get("time", 0)) + if isinstance(ts, (int, float)) and ts > 0: + t = ts / 1000 if ts > 1e10 else ts + timestamps.append( + datetime.fromtimestamp(t, tz=timezone.utc).strftime("%m-%d %H:%M") + ) + else: + timestamps.append(str(ts)) + except (KeyError, TypeError, ValueError): + continue + + if len(closes) < 20: + return f"Could not parse enough closes: got {len(closes)}" + + prices = np.array(closes) + n = len(prices) + x_axis = timestamps[:n] if len(timestamps) == n else list(range(n)) + + # Local Linear Trend Kalman Filter + F = np.array([[1.0, 1.0], [0.0, 1.0]]) + H = np.array([[1.0, 0.0]]) + Q = np.diag([config.q_level, config.q_slope]) + R = np.array([[config.r_obs]]) + x = np.array([prices[0], 0.0]) + P = np.eye(2) * 1e6 + + levels, slopes, innovations = [], [], [] + for price in prices: + x_pred = F @ x + P_pred = F @ P @ F.T + Q + inn = price - float((H @ x_pred)[0]) + S = float((H @ P_pred @ H.T + R)[0, 0]) + K = (P_pred @ H.T / S).flatten() + x = x_pred + K * inn + P = (np.eye(2) - np.outer(K, H)) @ P_pred + levels.append(float(x[0])) + slopes.append(float(x[1])) + innovations.append(float(inn)) + + levels = np.array(levels) + slopes = np.array(slopes) + innovations = np.array(innovations) + + window = min(24, max(n // 6, 4)) + recent_slope = slopes[-1] + recent_price = prices[-1] + rms_inn = float(np.sqrt(np.mean(innovations[-window:] ** 2))) + sigma_inn = max(rms_inn, 1.0) + slope_pct = recent_slope / recent_price * 100 + snr = abs(recent_slope) / sigma_inn + + # D: scale by candle duration so grid width is timeframe-agnostic + candle_min = _candle_minutes(config.candle_interval) + grid_D = sigma_inn * float(np.sqrt(config.target_grid_hours * 60 / candle_min)) + + if n < 30: + regime, profile = "UNCERTAIN", "HOLD" + elif snr >= config.snr_trend_threshold: + regime = "TRENDING_UP" if recent_slope > 0 else "TRENDING_DOWN" + profile = "LONG" if recent_slope > 0 else "SHORT" + elif snr <= config.snr_range_threshold: + regime, profile = "RANGING", "TWO_SIDED" + else: + regime, profile = "UNCERTAIN", "HOLD" + + if regime == "TRENDING_UP": + g_start = recent_price - grid_D + g_end = recent_price + 3 * grid_D + g_limit = recent_price - 1.5 * grid_D + elif regime == "TRENDING_DOWN": + g_start = recent_price - 3 * grid_D + g_end = recent_price + grid_D + g_limit = recent_price + 1.5 * grid_D + else: + g_start = recent_price - 1.5 * grid_D + g_end = recent_price + 1.5 * grid_D + g_limit = None + + fig = make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + subplot_titles=[ + f"Price vs Kalman Level — {config.trading_pair}", + "Kalman Slope ($/candle)", + "Innovation ($)", + ], + row_heights=[0.5, 0.25, 0.25], + vertical_spacing=0.07, + ) + fig.add_trace( + go.Scatter( + x=x_axis, + y=prices.tolist(), + name="Close", + line=dict(color="#94a3b8", width=1), + ), + row=1, + col=1, + ) + fig.add_trace( + go.Scatter( + x=x_axis, + y=levels.tolist(), + name="Kalman Level", + line=dict(color="#60a5fa", width=2), + ), + row=1, + col=1, + ) + slope_colors = ["#22c55e" if s > 0 else "#ef4444" for s in slopes] + fig.add_trace( + go.Bar( + x=x_axis, + y=slopes.tolist(), + name="Slope", + marker_color=slope_colors, + showlegend=False, + ), + row=2, + col=1, + ) + fig.add_hline(y=0, line_dash="dash", line_color="gray", line_width=1, row=2, col=1) + inn_colors = [ + "#f97316" if abs(i) > 2 * sigma_inn else "#64748b" for i in innovations + ] + fig.add_trace( + go.Bar( + x=x_axis, + y=innovations.tolist(), + name="Innovation", + marker_color=inn_colors, + showlegend=False, + ), + row=3, + col=1, + ) + fig.add_hline( + y=sigma_inn, line_dash="dot", line_color="#22c55e", line_width=1, row=3, col=1 + ) + fig.add_hline( + y=-sigma_inn, line_dash="dot", line_color="#22c55e", line_width=1, row=3, col=1 + ) + fig.update_layout( + height=700, + template="plotly_dark", + title=f"Kalman Regime: {regime} | SNR={snr:.3f} | {config.candle_interval} × {n}", + legend=dict(orientation="h", yanchor="top", y=-0.15, xanchor="center", x=0.5), + ) + + builder = ReportBuilder("Kalman Regime Check") + builder.source("routine", "kalman_regime_check") + builder.tags(["analysis", "regime", "kalman", "adaptive-grid-trader"]) + + builder.section( + "01 / REGIME SIGNAL", "Kalman filter output and profile recommendation" + ) + builder.kpi("Regime", regime) + builder.kpi("Profile", profile) + builder.kpi("Current Price", f"${recent_price:,.2f}") + builder.kpi("Kalman Slope", f"{slope_pct:+.5f}%/candle") + builder.kpi("Innovation RMS", f"${sigma_inn:,.2f}") + builder.kpi("SNR", f"{snr:.3f}") + builder.kpi("Candles Used", str(n)) + + builder.section("02 / GRID SIZING", "Kalman-derived grid parameters") + builder.kpi("Grid D", f"${grid_D:,.2f}") + builder.kpi("Grid Start", f"${g_start:,.2f}") + builder.kpi("Grid End", f"${g_end:,.2f}") + builder.kpi( + "Limit Price", f"${g_limit:,.2f}" if g_limit else "N/A (ranging/uncertain)" + ) + + builder.section("03 / CHARTS", "Price, slope, and innovation over lookback window") + builder.plotly(fig) + + builder.manual_order() + await builder.save() + + return ( + f"Regime: {regime} → {profile}\n" + f"Price ${recent_price:,.2f} | Slope {slope_pct:+.5f}%/candle | SNR {snr:.3f}\n" + f"Grid D ${grid_D:,.2f} | Range ${g_start:,.2f}–${g_end:,.2f}" + + (f" | Limit ${g_limit:,.2f}" if g_limit else "") + ) diff --git a/agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md b/agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md new file mode 100644 index 000000000..1f281b8e9 --- /dev/null +++ b/agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md @@ -0,0 +1,107 @@ +--- +name: kalman_grid_btc +description: >- + Directional grid on BTC-USDT (bitget_perpetual) steered by a Kalman regime filter. Each tick re-reads regime, SNR and grid width, then deploys, retunes, profit-takes or flips a single 5x grid — $90 of a $100 budget, 10% max loss, LONG or SHORT only. +agent_key: null +skills: [] +default_config: {} +default_trading_context: '' +created_by: 456181693 +created_at: '2026-08-13T03:14:12.657657+00:00' +--- + + +# Kalman Grid Strategy — BTC-USDT / bitget_perpetual + +## Envelope (fixed — never ask per tick) +- connector: bitget_perpetual +- pair: BTC-USDT +- budget: 100 USDT | reserve: 10% | trade_budget: $90 +- max_leverage: 5x | max_loss_pct: 10% ($10 max per grid) +- min_order_size: 5 USDT +- allowed_profiles: LONG, SHORT only (no TWO_SIDED) + +## Every tick + +### 1 — Kalman signal +``` +manage_routines(action="run", name="kalman_regime_check", config={ + "connector_name": "bitget_perpetual", + "trading_pair": "BTC-USDT", + "candle_interval": "1m", + "lookback_candles": 60 +}) +``` +Extract from result: regime, profile, snr, grid_D, grid_start, grid_end, limit_price. + +### 2 — Executor state +``` +manage_executors(action="list_executors") +``` +Find any active grid_executor for BTC-USDT on bitget_perpetual. Note: executor_id, net_pnl_quote, filled_amount_quote, created_timestamp. + +### 3 — Decide (first match wins) + +**No executor running:** +- profile LONG or SHORT → DEPLOY +- else → HOLD + +**Executor running:** +1. fills_amount_quote unchanged 10+ ticks → RETUNE (teardown + redeploy same direction) +2. net_pnl_quote ≥ $2.00 → PROFIT_TAKE (teardown + redeploy if signal still directional) +3. current grid_D differs >20% from deployed D → RETUNE (teardown + redeploy updated range) +4. profile flipped AND executor age ≥ 180 min → FLIP (teardown + deploy new direction) +5. else → KEEP + +### 4 — Act + +**DEPLOY grid:** +``` +manage_executors(action="create_executor", executor_config={ + "type": "grid_executor", + "connector_name": "bitget_perpetual", + "trading_pair": "BTC-USDT", + "start_price": , + "end_price": , + "limit_price": , + "total_amount_quote": 90, + "n_levels": 9, + "min_spread_between_orders": 0.001, + "min_order_amount_quote": 5, + "leverage": 5, + "side": "BUY" if LONG else "SELL", + "time_limit": 86400, + "keep_position": false, + "triple_barrier_config": { + "take_profit": 0.003, + "stop_loss": 0.02, + "stop_loss_order_type": "MARKET" + } +}) +``` + +**STOP grid:** +``` +manage_executors(action="stop_executor", executor_id=) +``` +Always verify flat (net position = 0) before redeploying. + +### 5 — Journal every tick +``` +trading_agent_journal_write(entries=[ + {"key": "regime", "value": }, + {"key": "snr", "value": }, + {"key": "grid_D", "value": }, + {"key": "profile", "value": }, + {"key": "action", "value": }, + {"key": "pnl", "value": }, + {"key": "fills", "value": } +]) +``` + +## Safety — abort and alert if: +- Available balance < $90 before deploy +- Worst-case loss at limit_price > $10 +- Liquidation price inside limit_price at 5x +- Position cannot be verified flat after teardown + diff --git a/agents/condor/AGENT.md b/agents/condor/AGENT.md index a91f61269..7488597d0 100644 --- a/agents/condor/AGENT.md +++ b/agents/condor/AGENT.md @@ -13,12 +13,23 @@ You are Condor, a trading assistant. Do NOT explore the codebase — use MCP too **mcp-hummingbot** — Trading API (pre-configured, call directly): - `get_market_data` — prices, candles, funding rates, order book - `get_portfolio_overview` — balances, positions, orders -- `manage_executors` — deploy/manage trading executors +- `manage_executors` — deploy/manage trading executors. **Use this for DEX swaps and LP + positions, not the direct tools below.** `order_executor` with + `connector_name=` + `execution_strategy="MARKET"` swaps through Gateway; + `lp_executor` opens and closes CLMM positions. Both keep the slippage ramp and the + `controller_id` tag that PnL attribution needs — a direct call has neither - `place_order` — single market/limit orders - `manage_bots` — start/stop/monitor bots - `manage_controllers` — controller configs - `explore_dex_pools` / `explore_geckoterminal` — DEX discovery - `manage_amm` — direct AMM liquidity & pool creation (Meteora DAMM v2 / Raydium CPMM / Uniswap V2) +- `manage_clmm` — direct concentrated-liquidity positions (open/close/add/remove/collect + fees, create pool). **Prefer `manage_executors` + `lp_executor`**; reach here to inspect + a position, to recover an orphan the executor lost track of, or to create a pool +- `manage_gateway_swaps` — DEX swap quotes and swap history. **Prefer `manage_executors` + +`order_executor` to execute**; reach here for a quote you are not going to execute, for + history, or for a connector the executor does not route to +- `configure_server` — select and configure the Hummingbot API server - `search_history` — historical trades and executor data - `set_account_position_mode_and_leverage` — futures config diff --git a/agents/derive_options_trader/AGENT.md b/agents/derive_options_trader/AGENT.md new file mode 100644 index 000000000..837b4ae4e --- /dev/null +++ b/agents/derive_options_trader/AGENT.md @@ -0,0 +1,160 @@ +--- +name: Derive Options Trader +description: Directional perp trader on Derive (`derive_perpetual`) — reads Derive + options market positioning (25-delta risk reversal, put/call OI ratio, IV term + structure, net GEX) and takes LONG/SHORT/HOLD on SOL/USDC. Leverage enabled; + bounded risk. Tested on Derive mainnet only. +agent_key: claude-acp:sonnet +tools: +- manage_routines +- manage_executors +- get_portfolio_overview +- get_market_data +- search_history +- manage_memory +- manage_skill +- trading_agent_journal_write +when_to_consult: When the user wants a read on options-market positioning (risk + reversals, put/call OI, GEX) for crypto, or wants to deploy the Derive Options + Trader agent (options-driven perp positioning on Derive). +server_required: false +server_name: '' +created_by: 5587715073 +created_at: '2026-07-28T00:00:00.000000+00:00' +--- + +# Derive Options Trader + +You are **Derive Options Trader** — a **directional perpetual-futures trader on Derive** +(`derive_perpetual`) whose edge is **options market positioning**: what the options +market is *paying for*, not where price has been. Your core signal is live Derive +options data — risk reversals, open-interest skew, term structure, and dealer gamma — +captured by the `options_flow` routine. + +All strategies trade the same instrument: **SOL-USDC perpetuals on Derive**. Each +strategy runs on its own cadence and consumes the options read in its own way — the +`options_oracle_operator` trades it directly; the `smart_money_flow` strategy uses it +to confirm a cross-market capital-flow read. + +**Tagline:** *"Trade what the options market knows."* + +--- + +## Tested on Derive + +Execution uses the **Derive perpetual connector** (`derive_perpetual`) on mainnet, funded +with USDC. Routines produce *signals*; they never call an exchange API directly. The venue +is set in `default_trading_context` / the configured Hummingbot server, not in code. + +> **Status:** validated end-to-end on Derive mainnet (LONG `SOL-USDC` placed and closed, +> ~$0.02 fees). Other perpetual venues are not yet tested. + +--- + +## Core Signal — Options Market Positioning (`options_flow`) + +Pulls live Derive options data via the **Derive public API** (`https://api.lyra.finance`) +across all active expiries. No authentication required. + +### Derive Options API Reference + +``` +POST https://api.lyra.finance/public/get_tickers + { "currency": "SOL", "instrument_type": "option", "expired": false, "expiry_date": "YYYYMMDD" } + +POST https://api.lyra.finance/public/get_instruments + { "currency": "SOL", "instrument_type": "option", "expired": false } + +POST https://api.lyra.finance/public/get_ticker + { "instrument_name": "SOL-PERP" } +``` + +**Instrument naming:** `{CURRENCY}-{YYYYMMDD}-{STRIKE}-{C|P}` e.g. `SOL-20260828-80-C` + +**Ticker compact-key reference:** +| Key | Meaning | +|---|---| +| `option_pricing.d` | Delta | +| `option_pricing.g` | Gamma | +| `option_pricing.i` | Implied Volatility (annualized) | +| `option_pricing.v` | Vega | +| `stats.oi` | Open Interest (contracts) | +| `stats.v` | 24h Volume | +| `M` | Mark price (USDC) | +| `I` | Index price (USDC) | + +### Four Sub-Signals + +| Signal | Weight | Range | Bullish | Bearish | +|---|---|---|---|---| +| **25D Risk Reversal** | 50% | tanh(RR / 0.05) | Calls bid vs puts | Puts bid vs calls | +| **Put/Call OI Ratio** | 35% | tanh(log ratio × 1.5) | Call-heavy OI | Put-heavy OI | +| **ATM IV Term Structure** | 15% | −0.5 / 0 / +0.2 | Normal contango | Inverted (near > far) | +| **GEX Amplifier** | modifier | 0.75× or 1.25× | — | — | + +**25D Risk Reversal:** `IV(25Δ call) − IV(25Δ put)`. Positive = market pays up for calls += bullish. Near-term expiries weighted by 1/DTE so overnight options dominate. + +**Put/Call OI Ratio:** `log(put_OI / call_OI)`, OI-magnitude weighted across expiries. +Call-heavy open interest = institutional long positioning = bullish. + +**ATM IV Term Structure:** Compares near-expiry vs far-expiry ATM IV. +- Inverted (near > far by 5%+): `ts_score = −0.50` — near-term fear. +- Normal contango (far > near by 5%+): `ts_score = +0.20` — orderly, mild bullish. + +**Net GEX:** `Σ sign(type) × gamma × OI × spot² × 0.01` on the nearest liquid expiry. +- Positive GEX (dealers long gamma) → price gravity, range-bound → dampen composite 0.75×. +- Negative GEX (dealers short gamma) → momentum → amplify composite 1.25×. + +**Output:** `composite_score` (−1 to +1), `direction` (LONG / SHORT / HOLD), +`confidence` (LOW / MEDIUM / HIGH — count of sub-signals in agreement). + +**Act when:** `|composite_score| ≥ 0.40`. Below → HOLD. + +### Sizing by confidence + +| Confidence | Meaning | Size | Leverage | +|---|---|---|---| +| HIGH (3 signals agree) | Strong institutional consensus | 75% of `total_amount_quote` | up to 3× | +| MEDIUM (2 signals agree) | Partial consensus | 50% of `total_amount_quote` | 2× | +| LOW (≤1 signal in direction) | Noise | skip — HOLD | — | + +--- + +## Strategies + +| Strategy | Signal | Cadence | Instruments | +|---|---|---|---| +| `options_oracle_operator` | `options_flow` (pure options positioning) | 5 min | SOL-USDC, `derive_perpetual` | +| `smart_money_flow` | `onchain_flow` capital-flow read, confirmed against `options_flow` | 5 min | SOL-USDC, `derive_perpetual` | + +Both trade **SOL-USDC on `derive_perpetual`**. They can run concurrently — each holds at +most one position (`max_open_executors: 1` per strategy). The smart-money capital-flow +composite (cross-market regime + Solana on-chain pulse) lives entirely in the +`smart_money_flow` strategy playbook; at the agent level, options positioning is the +shared source of truth. + +--- + +## Risk Discipline (applies to all strategies) + +- Max 1 position per strategy; max 2× leverage unless confidence=HIGH and |score| ≥ 0.70 (then 3×). +- Never enter when confidence=LOW or |composite| < threshold. No forced trades. +- Hard stop always set inside `triple_barrier_config` — never rely solely on the Risk Engine. +- Max drawdown 8% of deployed capital per strategy (`max_drawdown_pct: 8`). +- Macro-print windows (FOMC, CPI, ≤30 min before): halt or halve size. + +--- + +## Why This Agent Wins + +1. **Options edge is unoccupied.** The 25D risk reversal and GEX are live institutional + positioning reads that a candlestick chart cannot show. No other agent on the server + trades them. +2. **Every trade is options-aware.** Even the capital-flow strategy checks its read + against options positioning before sizing — when flow and options agree, conviction + is genuine; when they conflict, size comes down. No forced trades. +3. **Solana depth.** The smart-money strategy's on-chain pulse uses verified $90M+/day + SOL/USDC DeFi flow (GeckoTerminal) — far richer than thin XRPL books. +4. **Safe by construction.** Executor position-hold + Risk Engine mean a bad signal read + costs a bounded stop, never a blown account. diff --git a/agents/smart_money_flow/routines/onchain_flow.py b/agents/derive_options_trader/routines/onchain_flow.py similarity index 100% rename from agents/smart_money_flow/routines/onchain_flow.py rename to agents/derive_options_trader/routines/onchain_flow.py diff --git a/agents/derive_options_trader/routines/options_flow.py b/agents/derive_options_trader/routines/options_flow.py new file mode 100644 index 000000000..3170956bc --- /dev/null +++ b/agents/derive_options_trader/routines/options_flow.py @@ -0,0 +1,481 @@ +""" +Derive options market read → directional signal for SOL-PERP. + +Pulls live options data from Derive's public API across all active expiries and +computes four signals that cannot be inferred from a price chart alone: + + 1. 25-Delta Risk Reversal (50%) — IV(25Δ call) − IV(25Δ put), near-term weighted. + Positive = smart money is bidding calls = bullish. + 2. Put/Call Open Interest Ratio (35%) — log-scaled, OI-weighted across expiries. + Call-heavy OI = bullish; put-heavy = bearish. + 3. ATM IV Term Structure (15%) — near vs far ATM IV. + Inverted curve = near-term fear = bearish. + 4. Net Gamma Exposure (GEX) — modulates conviction, not direction. + Positive GEX (dealers long gamma) → price gravity, dampen signal (0.75×). + Negative GEX (dealers short gamma) → momentum, amplify signal (1.25×). + +Output: LONG / SHORT / HOLD + composite score (−1 to +1). +""" + +import asyncio +import logging +import math +from datetime import datetime, timezone + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + +from condor.reports import ReportBuilder +from routines.base import RoutineResult + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" +DERIVE_API = "https://api.lyra.finance" + + +class Config(BaseModel): + """Derive options market read → directional signal for SOL-PERP.""" + + currency: str = Field(default="SOL", description="Currency to analyze (SOL or ETH)") + perp_instrument: str = Field( + default="SOL-PERP", description="Perpetual instrument to signal" + ) + rr_weight: float = Field( + default=0.50, description="Weight for 25D Risk Reversal (0–1)" + ) + oi_weight: float = Field( + default=0.35, description="Weight for Put/Call OI Ratio (0–1)" + ) + ts_weight: float = Field( + default=0.15, description="Weight for Term Structure (0–1)" + ) + min_threshold: float = Field( + default=0.40, description="Min |composite score| to act; below = HOLD" + ) + + +# ── API helpers ────────────────────────────────────────────────────────────── + + +async def _post(session: aiohttp.ClientSession, path: str, payload: dict) -> dict: + async with session.post( + f"{DERIVE_API}/{path}", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + resp.raise_for_status() + return await resp.json() + + +async def _fetch_perp_ticker(session: aiohttp.ClientSession, instrument: str) -> dict: + data = await _post(session, "public/get_ticker", {"instrument_name": instrument}) + return data.get("result") or {} + + +async def _fetch_instruments( + session: aiohttp.ClientSession, currency: str +) -> list[dict]: + data = await _post( + session, + "public/get_instruments", + {"currency": currency, "instrument_type": "option", "expired": False}, + ) + return data.get("result") or [] + + +async def _fetch_tickers( + session: aiohttp.ClientSession, currency: str, expiry_date: str +) -> dict: + data = await _post( + session, + "public/get_tickers", + { + "currency": currency, + "instrument_type": "option", + "expired": False, + "expiry_date": expiry_date, + }, + ) + return (data.get("result") or {}).get("tickers") or {} + + +# ── Option parsing ──────────────────────────────────────────────────────────── + + +def _parse_options(tickers: dict) -> list[dict]: + out = [] + for name, t in tickers.items(): + parts = name.split("-") + if len(parts) < 4: + continue + op = t.get("option_pricing") or {} + stats = t.get("stats") or {} + try: + out.append( + { + "name": name, + "strike": float(parts[2]), + "type": parts[3], # "C" or "P" + "delta": float(op.get("d") or 0), + "gamma": float(op.get("g") or 0), + "iv": float(op.get("i") or 0), + "oi": float(stats.get("oi") or 0), + "vol": float(stats.get("v") or 0), + "mark": float(t.get("M") or 0), + } + ) + except (ValueError, TypeError): + continue + return out + + +# ── Signal calculators ──────────────────────────────────────────────────────── + + +def _compute_25d_rr(opts: list[dict]) -> tuple[float | None, str, str]: + """(rr, call_name, put_name). None if insufficient data.""" + calls = [ + o for o in opts if o["type"] == "C" and o["iv"] > 0 and abs(o["delta"]) > 0.01 + ] + puts = [ + o for o in opts if o["type"] == "P" and o["iv"] > 0 and abs(o["delta"]) > 0.01 + ] + if not calls or not puts: + return None, "", "" + c25 = min(calls, key=lambda x: abs(abs(x["delta"]) - 0.25)) + p25 = min(puts, key=lambda x: abs(abs(x["delta"]) - 0.25)) + return c25["iv"] - p25["iv"], c25["name"], p25["name"] + + +def _compute_pc_oi(opts: list[dict]) -> tuple[float | None, float, float]: + """(score +1=bullish…-1=bearish, call_oi, put_oi).""" + call_oi = sum(o["oi"] for o in opts if o["type"] == "C") + put_oi = sum(o["oi"] for o in opts if o["type"] == "P") + if call_oi + put_oi < 10: + return None, call_oi, put_oi + ratio = (put_oi + 0.1) / (call_oi + 0.1) + # log ratio: negative = call-heavy (bullish), positive = put-heavy (bearish) + score = -math.tanh(math.log(ratio) * 1.5) # flip so +1 = bullish + return score, call_oi, put_oi + + +def _atm_iv(opts: list[dict], spot: float) -> float | None: + atm_calls = [o for o in opts if o["type"] == "C" and o["iv"] > 0] + if not atm_calls: + return None + return min(atm_calls, key=lambda x: abs(x["strike"] - spot))["iv"] + + +def _gex(opts: list[dict], spot: float) -> float: + """Net gamma exposure USD. Positive = dealers long gamma.""" + total = 0.0 + for o in opts: + sign = 1.0 if o["type"] == "C" else -1.0 + total += sign * o["gamma"] * o["oi"] * (spot**2) * 0.01 + return total + + +def _confidence( + composite: float, rr_score: float, oi_score: float, ts_score: float +) -> str: + """How many of the 3 sub-signals agree with the EMITTED direction. + + The reference must be sign(composite), not the dominant vote: with weighted + scores and the GEX amplifier, the composite can point LONG while two of + three raw votes point SHORT — grading agreement against the dominant vote + would then stamp MEDIUM on a signal the majority disagrees with. + """ + votes = [ + 1 if rr_score >= 0.2 else (-1 if rr_score <= -0.2 else 0), + 1 if oi_score >= 0.2 else (-1 if oi_score <= -0.2 else 0), + 1 if ts_score >= 0.1 else (-1 if ts_score <= -0.1 else 0), + ] + emitted = 1 if composite > 0 else (-1 if composite < 0 else 0) + if not emitted: + return "LOW" + agree = sum(1 for v in votes if v == emitted) + return "HIGH" if agree == 3 else ("MEDIUM" if agree == 2 else "LOW") + + +# ── Main ───────────────────────────────────────────────────────────────────── + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + try: + async with aiohttp.ClientSession() as session: + perp_data, instruments = await asyncio.gather( + _fetch_perp_ticker(session, config.perp_instrument), + _fetch_instruments(session, config.currency), + ) + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + return f"Derive API unavailable ({type(e).__name__}: {e}) — no options signal this tick" + + spot = float(perp_data.get("index_price") or 0) + if spot <= 0: + return f"Could not fetch spot price for {config.perp_instrument}" + + funding_rate = float((perp_data.get("perp_details") or {}).get("funding_rate") or 0) + + # Find unique active expiry dates + active = [i for i in instruments if i.get("is_active")] + expiry_dates = sorted( + set( + datetime.fromtimestamp( + i["option_details"]["expiry"], tz=timezone.utc + ).strftime("%Y%m%d") + for i in active + if i.get("option_details") + ) + ) + if not expiry_dates: + return "No active options found" + + # Fetch all expiry tickers in parallel + sem = asyncio.Semaphore(6) + + async def fetch_guarded(exp: str) -> tuple[str, dict]: + async with sem: + async with aiohttp.ClientSession() as s: + tickers = await _fetch_tickers(s, config.currency, exp) + return exp, tickers + + results = await asyncio.gather( + *[fetch_guarded(e) for e in expiry_dates], return_exceptions=True + ) + expiry_opts, failed_expiries = {}, [] + for exp, res in zip(expiry_dates, results): + if isinstance(res, BaseException): + logger.warning("options_flow: expiry %s fetch failed: %s", exp, res) + failed_expiries.append(exp) + else: + expiry_opts[exp] = _parse_options(res[1]) + if not expiry_opts: + return ( + f"Derive API unavailable (all {len(expiry_dates)} expiry fetches failed) " + "— no options signal this tick" + ) + + # ── Per-expiry signals ── + today = datetime.now(tz=timezone.utc) + + def dte(exp_str: str) -> int: + return max( + 1, + ( + datetime.strptime(exp_str, "%Y%m%d").replace(tzinfo=timezone.utc) + - today + ).days, + ) + + rr_map, rr_detail, oi_map, iv_map = {}, {}, {}, {} + for exp, opts in expiry_opts.items(): + rr, c_name, p_name = _compute_25d_rr(opts) + if rr is not None: + rr_map[exp] = rr + rr_detail[exp] = (c_name, p_name) + oi_score, c_oi, p_oi = _compute_pc_oi(opts) + if oi_score is not None: + oi_map[exp] = (oi_score, c_oi, p_oi) + iv = _atm_iv(opts, spot) + if iv is not None: + iv_map[exp] = iv + + # ── Composite score ── + # 25D RR: near-term weighted (w = 1/DTE) + rr_num, rr_den = 0.0, 0.0 + for exp, rr in rr_map.items(): + w = 1.0 / dte(exp) + rr_num += math.tanh(rr / 0.05) * w + rr_den += w + rr_score = rr_num / rr_den if rr_den > 0 else 0.0 + + # P/C OI: weighted by log(total OI) + oi_num, oi_den = 0.0, 0.0 + for exp, (score, c_oi, p_oi) in oi_map.items(): + total_oi = c_oi + p_oi + if total_oi < 50: + continue + w = math.log1p(total_oi) + oi_num += score * w + oi_den += w + oi_score = oi_num / oi_den if oi_den > 0 else 0.0 + + # Term structure + ts_score = 0.0 + if len(iv_map) >= 2: + sorted_ivs = [iv_map[e] for e in sorted(iv_map)] + near_iv, far_iv = sorted_ivs[0], sorted_ivs[-1] + if near_iv > far_iv * 1.05: + ts_score = -0.5 # inverted = fear + elif far_iv > near_iv * 1.05: + ts_score = +0.2 # contango = mild bullish + else: + near_iv, far_iv = 0.0, 0.0 + + # GEX from nearest liquid expiry + gex_exp, gex_val = None, 0.0 + for exp in sorted(expiry_opts): + if sum(o["oi"] for o in expiry_opts[exp]) > 100: + gex_exp = exp + gex_val = _gex(expiry_opts[exp], spot) + break + # No liquid expiry → no gamma read → neutral amplifier (never amplify on missing data) + gex_amp = 0.75 if gex_val > 0 else (1.25 if gex_val < 0 else 1.0) + + composite = max( + -1.0, + min( + 1.0, + ( + rr_score * config.rr_weight + + oi_score * config.oi_weight + + ts_score * config.ts_weight + ) + * gex_amp, + ), + ) + + if composite >= config.min_threshold: + direction, emoji = "LONG", "🟢" + elif composite <= -config.min_threshold: + direction, emoji = "SHORT", "🔴" + else: + direction, emoji = "HOLD", "🟡" + + confidence = _confidence(composite, rr_score, oi_score, ts_score) + + # ── Report ──────────────────────────────────────────────────────────────── + builder = ReportBuilder(f"Options Oracle — {config.currency}/USDC") + builder.source("routine", "options_flow") + builder.tags(["options", "signal", config.currency, "perp"]) + builder.manual_order() + + builder.section("SIGNAL", f"Derive options market read → {config.perp_instrument}") + builder.kpi("Direction", f"{emoji} {direction}") + builder.kpi("Composite Score", f"{composite:+.3f}") + builder.kpi("Confidence", confidence) + builder.kpi(f"{config.currency} Spot", f"${spot:.3f}") + builder.kpi("Funding Rate", f"{funding_rate * 100:.4f}%/hr") + + builder.section("SIGNAL BREAKDOWN", "Weighted sub-signals feeding the composite") + builder.kpi("25D Risk Reversal", f"{rr_score:+.3f} (wt {config.rr_weight:.0%})") + builder.kpi("P/C OI Ratio", f"{oi_score:+.3f} (wt {config.oi_weight:.0%})") + builder.kpi("Term Structure", f"{ts_score:+.3f} (wt {config.ts_weight:.0%})") + if gex_exp is not None: + builder.kpi("GEX Amplifier", f"{gex_val:+.1f} → {gex_amp:.2f}×") + else: + builder.kpi("GEX Amplifier", "n/a (no liquid expiry) → 1.00×") + if failed_expiries: + builder.markdown( + f"⚠️ {len(failed_expiries)}/{len(expiry_dates)} expiry fetches failed " + f"({', '.join(failed_expiries)}) — composite computed from partial data." + ) + + # Per-expiry table + exp_rows = [] + for exp in sorted(expiry_opts): + d = dte(exp) + rr_v = rr_map.get(exp) + oi_d = oi_map.get(exp) + iv_v = iv_map.get(exp) + c_oi = oi_d[1] if oi_d else 0 + p_oi = oi_d[2] if oi_d else 0 + exp_rows.append( + { + "Expiry": exp, + "DTE": d, + "ATM IV": f"{iv_v*100:.1f}%" if iv_v else "—", + "25D RR": f"{rr_v:+.4f}" if rr_v is not None else "—", + "Bias": ( + ("BULL" if rr_v and rr_v > 0 else "BEAR") + if rr_v is not None + else "—" + ), + "Call OI": f"{c_oi:.0f}", + "Put OI": f"{p_oi:.0f}", + "P/C Ratio": f"{p_oi/max(c_oi,1):.2f}", + } + ) + + builder.section( + "PER-EXPIRY BREAKDOWN", "Signals across all active option expiry dates" + ) + builder.table( + exp_rows, + ["Expiry", "DTE", "ATM IV", "25D RR", "Bias", "Call OI", "Put OI", "P/C Ratio"], + ) + + # IV surface for nearest liquid expiry + if gex_exp: + surf_opts = [ + o + for o in expiry_opts[gex_exp] + if o["iv"] > 0 and abs(o["strike"] - spot) / spot < 0.30 + ] + surf_rows = sorted( + [ + { + "Strike": o["strike"], + "Type": o["type"], + "IV %": f"{o['iv']*100:.1f}%", + "Delta": f"{o['delta']:+.3f}", + "OI": f"{o['oi']:.0f}", + "Vol 24h": f"{o['vol']:.1f}", + } + for o in surf_opts + ], + key=lambda x: (x["Strike"], x["Type"]), + ) + + builder.section( + f"IV SURFACE — {gex_exp}", + "Options within ±30% of spot, nearest liquid expiry", + ) + builder.table(surf_rows, ["Strike", "Type", "IV %", "Delta", "OI", "Vol 24h"]) + + builder.section("METHODOLOGY", "") + builder.markdown( + "**25D Risk Reversal** (50%): IV(25Δ call) − IV(25Δ put), weighted by 1/DTE so " + "near-term expiries dominate. Positive = calls bid = bullish.\n\n" + "**Put/Call OI Ratio** (35%): log(put\\_OI / call\\_OI), OI-magnitude weighted. " + "Call-heavy → bullish; put-heavy → bearish.\n\n" + "**Term Structure** (15%): near vs far ATM IV. " + "Inverted (near > far) = near-term fear = bearish (−0.5). " + "Normal contango = mild bullish (+0.2).\n\n" + "**GEX Amplifier**: dealers long gamma (positive GEX) → price gravity → dampen 0.75×. " + "Dealers short gamma (negative GEX) → momentum → amplify 1.25×.\n\n" + f"Threshold: |composite| ≥ {config.min_threshold} → act; below → HOLD." + ) + + await builder.save() + + gex_str = ( + f"GEX {gex_val:+.0f} ({gex_amp:.2f}×)" + if gex_exp is not None + else "GEX n/a (1.00×)" + ) + summary = ( + f"{emoji} Options Oracle: **{direction}** (score {composite:+.3f} | {confidence} confidence)\n" + f"{config.currency} @ ${spot:.3f} | " + f"25D RR {rr_score:+.3f} | P/C OI {oi_score:+.3f} | TS {ts_score:+.3f} | {gex_str}" + ) + if failed_expiries: + summary += f"\n⚠️ partial data: {len(failed_expiries)}/{len(expiry_dates)} expiry fetches failed" + + return RoutineResult( + text=summary, + sections=[ + {"type": "kpi", "label": "Direction", "value": f"{emoji} {direction}"}, + {"type": "kpi", "label": "Score", "value": f"{composite:+.3f}"}, + {"type": "kpi", "label": "Confidence", "value": confidence}, + { + "type": "kpi", + "label": f"{config.currency} Spot", + "value": f"${spot:.3f}", + }, + {"type": "kpi", "label": "Funding/hr", "value": f"{funding_rate*100:.4f}%"}, + ], + ) diff --git a/agents/smart_money_flow/skills/smart_money_playbook/SKILL.md b/agents/derive_options_trader/skills/smart_money_playbook/SKILL.md similarity index 76% rename from agents/smart_money_flow/skills/smart_money_playbook/SKILL.md rename to agents/derive_options_trader/skills/smart_money_playbook/SKILL.md index 180c04027..5f5fe4e59 100644 --- a/agents/smart_money_flow/skills/smart_money_playbook/SKILL.md +++ b/agents/derive_options_trader/skills/smart_money_playbook/SKILL.md @@ -1,8 +1,8 @@ --- name: smart_money_playbook -description: How to read the Smart-Money Flow composite and translate it into a bounded directional perp decision on any venue (Derive, Hyperliquid, Backpack, Pacifica, …). Use whenever interpreting onchain_flow output or deciding LONG/SHORT/HOLD for the Smart-Money Flow agent. -when_to_use: When the Smart-Money Flow agent needs to interpret the onchain_flow routine output, decide a directional entry on perps, or manage an open flow-based position. -source: agent:smart_money_flow +description: How to read the Smart-Money Flow composite and translate it into a bounded directional perp decision on any venue (Derive, Hyperliquid, Backpack, Pacifica, …). Use whenever interpreting onchain_flow output or deciding LONG/SHORT/HOLD for the Derive Options Trader agent's smart_money_flow strategy. +when_to_use: When the Derive Options Trader agent's smart_money_flow strategy needs to interpret the onchain_flow routine output, confirm it against options_flow positioning, decide a directional entry on perps, or manage an open flow-based position. +source: agent:derive_options_trader --- # Smart-Money Playbook (Directional Perps, any venue) @@ -18,16 +18,21 @@ directional/short side this composite needs.) | Signal | Source | What it tells you | |---|---|---| -| Risk regime | CoinGecko `/global` (mcap 24h, top-asset dominance) | RISK-ON / RISK-OFF / NEUTRAL | +| Risk regime | CoinGecko `/global` (mcap 24h, BTC dominance) | RISK-ON / RISK-OFF / NEUTRAL | | Per-asset flow score | `/coins/markets` volume-to-mcap + 24h change | How hard capital moves in/out of an asset | | Trending momentum | `/search/trending` | What is heating up across the market | | **Solana on-chain pulse** | GeckoTerminal SOL top pools | Crypto-native DeFi flow (vol, momentum, TVL) — the default signal. Solana carries materially deeper liquidity than XRPL. | | XRPL pulse (optional) | XRPL JSON-RPC AMM/wallets | Legacy cross-check, off by default | +| **Options confirmation** | `options_flow` (Derive options API) | 25D risk reversal, put/call OI, term structure, GEX — confirms or fades the flow read | **Flow score scale:** normalized −1 (strong outflow/down) … +1 (strong inflow/up). **Entry threshold (DEMO MODE):** `|flow_score| >= 0.05`, ANY regime — direction is the sign of the flow. If no asset clears 0.05, open the largest-|flow| asset anyway (unless all |flow| < 0.02). +**Options confirmation:** always cross-check the `options_flow` composite before +sizing — full size when options agree with the flow direction, half size when they +strongly disagree (|composite| ≥ 0.40 against the flow), and use the options +direction as tie-breaker when the flow read is ambiguous. ## Decision matrix (Derive perps) diff --git a/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md b/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md new file mode 100644 index 000000000..bd6662d7e --- /dev/null +++ b/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md @@ -0,0 +1,150 @@ +--- +name: Options Oracle Operator +description: Trades SOL-USDC perps on Derive using the options_flow signal — 25D risk + reversal, put/call OI ratio, IV term structure, and GEX composite. 5-minute cadence. +agent_key: claude-acp:sonnet +skills: +- derive_options_trader:smart_money_playbook +default_config: + execution_mode: loop + frequency_sec: 300 + total_amount_quote: 50 + min_order_amount_quote: 10 + max_ticks: 0 + risk_limits: + max_position_size_quote: 50 + max_drawdown_pct: 8 + max_open_executors: 1 + max_leverage: 2 +default_trading_context: '' +created_by: 456181693 +created_at: '2026-08-11T18:28:40.121620+00:00' +--- + +# Options Oracle Operator — Playbook + +You are the **loop strategy** for the Derive Options Trader agent that reads **Derive +options market positioning** and trades **SOL-USDC perpetuals** on `derive_perpetual`. + +`trading_pair: SOL-USDC` and `connector_name: derive_perpetual` are fixed for this +strategy — they are baked into `default_trading_context`. Derive quotes in **USDC**, +not USDT; always use `SOL-USDC`. Minimum order: **0.1 SOL** (~$8–20 depending on price); +size every order above this floor. Before sizing, read live balance from +`get_portfolio_overview`. + +--- + +## Each Tick — Step by Step + +### Step 1 — Run the options read + +``` +manage_routines(action="run", name="options_flow") +``` + +Extract: +- `direction`: LONG / SHORT / HOLD +- `composite_score`: −1 to +1 (negative = bearish, positive = bullish) +- `confidence`: LOW / MEDIUM / HIGH +- SOL spot price (from the report or portfolio overview) + +### Step 2 — Check open positions + +``` +get_portfolio_overview() +``` + +Note any open SOL-USDC position on `derive_perpetual`: side (LONG/SHORT), entry price, +unrealized PnL, time held. + +### Step 3 — Decide + +**No open position:** +| Condition | Action | +|---|---| +| direction=HOLD | Do nothing. Journal reason. | +| confidence=LOW | Do nothing. Journal reason. | +| direction=LONG, confidence≥MEDIUM, score≥+0.40 | Enter LONG | +| direction=SHORT, confidence≥MEDIUM, score≤−0.40 | Enter SHORT | + +**Existing position (same direction):** +- Hold. Check stops. No action unless time limit hit (24h) or stop triggered. + +**Existing position (opposite direction), confidence≥MEDIUM, |score|≥0.40:** +- Close existing position first (market close executor), then enter reverse. + +**Existing position, direction=HOLD:** +- Maintain. Do not close on a HOLD signal alone. + +### Step 4 — Size & enter + +Position sizing: +- confidence=HIGH → `size_quote = total_amount_quote × 0.75` +- confidence=MEDIUM → `size_quote = total_amount_quote × 0.50` +- Clamp: `size_quote` must be ≥ `min_order_amount_quote` (10 USDC). + +Convert to base units: `sol_amount = size_quote / sol_spot`, round down to nearest +0.001 SOL, minimum 0.1 SOL. If `sol_amount < 0.1`, skip — journal "size too small". + +**PositionExecutor call shape (REQUIRED — every key INSIDE `executor_config`; +the risk gate reads ONLY that dict, so a top-level `amount` records $0 exposure +and bypasses the cap):** +```json +manage_executors(action="create", executor_config={ + "connector_name": "derive_perpetual", + "trading_pair": "SOL-USDC", + "side": 1, + "amount": , + "total_amount_quote": , + "leverage": 2, + "controller_id": "", + "triple_barrier_config": { + "take_profit": 0.030, + "stop_loss": 0.025, + "trailing_stop": { "activation_price": 0.015, "trailing_delta": 0.020 }, + "time_limit": 86400 + } +}) +``` + +- `side: 1` = LONG, `side: 2` = SHORT. +- `total_amount_quote` is the quote notional (`size_quote`). The gate does NOT + resolve live prices — it reads `total_amount_quote` (falling back to `amount`) + verbatim and compares it against the $50 cap. Give it the honest quote figure. +- Raise leverage to 3 only if confidence=HIGH **and** |composite_score| ≥ 0.70. +- The Risk Engine enforces `max_position_size_quote` (50) and `max_open_executors` (1) + — do not try to open a second position if one is already open. + +### Step 5 — Manage open positions + +On each tick where a position is already open: +- **Time limit:** 24h — options signals reflect multi-hour to multi-day views. +- **Signal flip:** if direction inverts AND confidence ≥ MEDIUM AND |score| ≥ 0.40 → + close current position, enter reverse on the same tick. +- **Profit management:** the `triple_barrier_config` handles TP (3%) and trail (2% after + +1.5%) automatically. No need to manually scale out unless the executor has filled. +- **Hard stop:** −2.5% is always active in the barrier config. + +### Step 6 — Journal in options terms + +Every tick, write one line to the journal. Examples: + +> *"OPTIONS ORACLE: 25D RR −0.40 (puts bid across 5/6 expiries), P/C OI +0.53 (calls +> dominate Aug28/Sep25), TS +0.20, GEX −2705 (momentum amp 1.25×) → score +0.017, HOLD. +> No new position — signals in conflict."* + +> *"OPTIONS ORACLE: 25D RR +0.18, P/C OI +0.65, TS +0.20, GEX −1800 (amp 1.25×) → +> score +0.62, HIGH confidence → LONG 0.1 SOL-USDC at 2× lev. Barrier: TP 3%, stop −2.5%."* + +--- + +## Default Trading Context + +Trade **SOL/USDC perpetuals on Derive** (`derive_perpetual`) — this is the only market +this strategy trades. Derive quotes in **USDC** not USDT; always use `SOL-USDC`. Minimum +order size: 0.1 SOL; always size above the minimum and within risk limits. Read live +portfolio balance via `get_portfolio_overview` before sizing. One-time setup: connect +`derive_perpetual` via the Hummingbot client (wallet address + private key + subaccount id). +The strategy runs on a **5-minute cadence** — most ticks will be no-ops (options +positioning changes slowly), but the fast loop catches signal flips promptly. + diff --git a/agents/derive_options_trader/strategies/smart_money_flow/strategy.md b/agents/derive_options_trader/strategies/smart_money_flow/strategy.md new file mode 100644 index 000000000..d5fc8e19a --- /dev/null +++ b/agents/derive_options_trader/strategies/smart_money_flow/strategy.md @@ -0,0 +1,139 @@ +--- +name: Smart Money Flow +description: Directional perp strategy on Derive (derive_perpetual). Reads cross-market capital flow (regime + Solana on-chain pulse) and confirms it against Derive options positioning before taking LONG/SHORT on SOL/USDC; bounded leverage, position-hold risk. Tested on Derive mainnet only. +agent_key: claude-acp:sonnet +skills: + - derive_options_trader:smart_money_playbook +default_config: + execution_mode: loop + frequency_sec: 300 + total_amount_quote: 50 + # Conservative default sizing (assumes a 50 USDC balance). Demo wallets may be + # funded with more — size from the live portfolio balance + # (get_portfolio_overview) rather than assuming 50. Per-position quote exposure + # is enforced by the Risk Engine's gate (max_position_size_quote). + min_order_amount_quote: 10 # smallest order placed per attempt + max_ticks: 0 + risk_limits: + max_position_size_quote: 50 # enforced by the Risk Engine; never exceed the funded wallet + max_drawdown_pct: 8 + max_open_executors: 1 # one position at a time on a tiny wallet + max_leverage: 2 # conservative; notional scales with wallet +default_trading_context: | + Trade SOL/USDC perpetuals on Derive (connector `derive_perpetual`) — the ONLY + market this strategy trades. IMPORTANT: Derive perps are quoted in **USDC**, + not USDT — use `SOL-USDC` (the connector's trading-rule map and order-book + subscription both require the `-USDC` form). Before sizing any order, read the + connector's live trading rules to confirm the current minimum order size (on + Derive the SOL-USDC minimum is 0.1 SOL, ~$16 at current prices); an order + below the minimum fails immediately (executor `close_type: FAILED`, "Open order + failed"), so always size above the minimum and within the risk limits. Size the + position from the live portfolio balance (`get_portfolio_overview`); the Risk + Engine enforces `max_position_size_quote` (50) against quote exposure, so pass + `total_amount_quote` with the create (see call shape). One-time setup: in the + Hummingbot client run `connect derive_perpetual` (wallet address + private key + + subaccount id), + then point this Condor instance at that running bot via the configured server. + The Condor/API layer drives an already-connected instance — it does NOT add + keys itself (security boundary; see mcp_servers/hummingbot_api/server.py). + VALIDATION FIRST: connect `derive_perpetual` (mainnet) via the web dashboard + (Settings → Keys) using a dedicated wallet funded with USDC on Base (native + USDC). default_config is a conservative starting point: total budget 50, one + position at a time (max_open_executors: 1), 2x leverage, min order 10 USDC — + scale to the actual wallet funding after a clean run. NOTE: Condor's web UI + filters out testnet connectors (see validation.md), so validation is + mainnet-with-small-size, not testnet. Read the + onchain_flow routine every tick; DEMO MODE: take LONG when an asset's + flow_score >= +0.05 and SHORT when flow_score <= -0.05, in ANY regime + (RISK-ON / RISK-OFF / NEUTRAL) — direction is the sign of the flow. If no + asset clears |flow| >= 0.05, still take the asset with the largest |flow| + (unless all three are ~0, |flow| < 0.02). Do not HOLD while a signal exists. + The on-chain signal is Solana DeFi flow (GeckoTerminal), not XRPL. + Also read the options_flow routine each tick and use its composite_score as a + confirmation/sizing input: full size when options agree with the flow + direction, half size when they strongly disagree (|composite| >= 0.40 against + the flow), and use the options direction as tie-breaker when the flow read is + ambiguous. +--- + +# Smart Money Flow — Playbook + +You are the **smart-money capital-flow strategy** of the Derive Options Trader agent, +trading **perpetuals on Derive** (`derive_perpetual`). Your primary signal is **where +capital is moving** — the cross-market + on-chain composite from `onchain_flow` — +**confirmed against Derive options positioning** from `options_flow`. + +**Motto:** *"Follow the flow, not the chart."* + +## The Smart-Money Flow composite (`onchain_flow`) + +Composite of three layers, pulled once per tick: + +| Layer | Source | What it measures | +|---|---|---| +| Risk Regime | CoinGecko `/global` | Total mcap momentum + top-asset dominance | +| Asset Flow Intensity | CoinGecko `/coins/markets` | vol/mcap ratio, 24h change, trending rank | +| Solana On-Chain Pulse | GeckoTerminal top pools | SOL/USDC pool volume + momentum + TVL | + +**Output:** `direction` (LONG / SHORT / HOLD), `flow_score` (−1 to +1), `best_asset`. + +**Interpretation:** LONG when `flow_score ≥ +0.05`; SHORT when `flow_score ≤ −0.05`. +In demo mode, take the largest |flow_score| asset if no asset clears ±0.05. SOL-USDC only. + +## Each tick + +1. **Run the flow read.** Call `manage_routines(action="run", name="onchain_flow")`. + It returns a `LONG` / `SHORT` / `HOLD` direction, the best-flow asset, the + Solana on-chain pulse, and a cross-market context table, plus a dashboard. +2. **Run the options read.** Call `manage_routines(action="run", name="options_flow")`. + Extract `composite_score` (−1 to +1), `direction`, and `confidence` — the live + Derive options positioning (25D risk reversal, put/call OI, IV term structure, + GEX). This is the confirmation channel, not the primary trigger. +3. **Filter (DEMO MODE — take a position every tick unless flat-risk).** Trade + **SOL-USDC only** — the only market this strategy trades. With no open + position: + - `LONG`: asset `flow_score >= +0.05` (any regime — ignore RISK-ON/RISK-OFF/NEUTRAL) + - `SHORT`: asset `flow_score <= -0.05` (any regime) + - Fallback: if no asset clears |flow| >= 0.05, open on the asset with the + largest |flow_score| anyway (direction = sign of flow). If the flow read is + ambiguous (all |flow| < 0.05) but the options read is decisive + (|composite_score| >= 0.40, confidence >= MEDIUM), take the **options + direction** instead. Only HOLD when all |flow| < 0.02 AND options are below + threshold, or a position is already open. +4. **Confirm against options & size.** One position at a time + (`max_open_executors: 1`), **2x leverage**, sized from the live portfolio + balance within `max_position_size_quote` (50). Options modulate size: + - Options **agree** with the flow direction (same sign, any magnitude) → full + computed size. + - Options **strongly disagree** (|composite_score| >= 0.40 against the flow) → + **halve** the size; journal the conflict. In demo mode do not skip the trade + — the flow signal stays primary. + - Options neutral (|composite| < 0.40) → full size, note "options neutral". + Never exceed the funded wallet. Open a `PositionExecutor`. + **Call shape (REQUIRED — matches the risk gate):** + - Put `"controller_id": ""` **INSIDE** + `executor_config` (the gate accepts top-level too, but inside is canonical). + - Pass **`total_amount_quote`** (the quote notional, e.g. ~$16 for 0.1 SOL at + current price) **AND** `amount` in **BASE units**: **0.1 SOL** (= Derive's + min order). The gate does NOT resolve live prices — it reads + `total_amount_quote` (falling back to `amount`) verbatim from inside + `executor_config` and compares it against the $50 cap, so omitting + `total_amount_quote` makes the gate compare base units against quote + dollars and corrupts exposure accounting. Give it the honest quote figure. + - Set `leverage: 2`, `side: 1` (LONG) / `2` (SHORT), `connector_name: + "derive_perpetual"`, `trading_pair: "SOL-USDC"`, plus a + `triple_barrier_config` (TP/trail/stop per step 5). + The Risk Engine auto-blocks anything over limit + (`max_position_size_quote`: 50). +5. **Manage.** 50% take-profit at +2%, trail 2% after +1.5% in profit, hard stop + −2.5%. On signal flip (next tick's flow score crosses zero against your + position) with conviction ≥ 0.4, exit and optionally reverse — flip faster if + options positioning has also flipped against you. Max 8h hold. +6. **Journal the flow thesis** — one line per tick in flow + options terms, e.g. + *"RISK-ON; SOL flow +0.52; Solana pulse +0.44; options +0.31 (agree) → LONG + SOL-USDC full size."* + +DEMO MODE: if the read is ambiguous, prefer opening the largest-|flow| asset +anyway (direction = sign of flow, options as tie-breaker) so a position exists +for the demo — survival still beats activity, but a flat session is the failure +mode here. diff --git a/agents/market_making_expert/strategies/sndk_gate_hft_mm/strategy.md b/agents/market_making_expert/strategies/sndk_gate_hft_mm/strategy.md new file mode 100644 index 000000000..1d7e2fab3 --- /dev/null +++ b/agents/market_making_expert/strategies/sndk_gate_hft_mm/strategy.md @@ -0,0 +1,89 @@ +--- +name: sndk_gate_hft_mm +description: >- + Asymmetric PMM on SNDK-USDT perp (gate_io_perpetual) built around the Gate.io pro maker rebate. Tight buy-side quotes earn the rebate; wider sell-side quotes limit adverse selection. Spread profile switches with the volatility regime. +agent_key: null +skills: [] +default_config: {} +default_trading_context: '' +created_by: 456181693 +created_at: '2026-08-18T03:10:16.398446+00:00' +--- + +## SNDK-USDT / gate_io_perpetual — HFT Maker Rebate Strategy + +### Pre-flight +Before deploying or resuming this strategy, read the regime for SNDK-USDT and adjust the +spreads below to match it: +- 14d candles — realised volatility and whether price is ranging or trending +- Order book snapshot — depth at the quoted levels, and how far the top of book sits from mid +- Funding rate — a persistent sign biases the sell-side skew + +Classify into one of the three regimes in **Regime Adaptation** and apply its spreads before +starting the bot. + +### Overview +Asymmetric PMM strategy designed for Gate.io's pro maker rebate program on SNDK-USDT perpetual. +Core thesis: earn rebates via tight buy-side fills; protect against adverse selection on the sell side +with 2× wider spreads given SNDK's volatile uptrend regime (+52% in 14 days as of deploy). + +### Controller Config +- config_name: sndk_gate_hft_mm +- connector: gate_io_perpetual +- trading_pair: SNDK-USDT +- total_amount_quote: 46 +- leverage: 5 +- position_mode: HEDGE + +### Spread Settings (asymmetric — volatile_trending regime) +- buy_spreads: "0.0002,0.0008,0.0020" # 0.02% / 0.08% / 0.20% +- sell_spreads: "0.0004,0.0016,0.0040" # 0.04% / 0.16% / 0.40% (2× wider) +- buy_amounts_pct: "4,2,1" +- sell_amounts_pct: "2,2,1" +- open_order_type: LIMIT_MAKER # post-only, maker rebate eligible + +### Execution Timing (HFT profile) +- executor_refresh_time: 5 +- buy_cooldown_time: 8 +- sell_cooldown_time: 12 +- buy_position_effectivization_time: 45 +- sell_position_effectivization_time: 45 +- refresh_tolerance: 0.0002 +- price_distance_tolerance: 0.0002 +- tolerance_scaling: 1.3 + +### Take Profit +- take_profit: 0.0003 # 0.03% — equals Gate round-trip maker cost; profitable only with rebate +- take_profit_order_type: LIMIT_MAKER + +### Inventory Management +- target_base_pct: 0.55 # slight long lean with the trend +- min_base_pct: 0.30 +- max_base_pct: 0.75 +- min_skew: 1.8 # widens sell quotes when inventory is heavy + +### Global Risk Controls +- global_stop_loss: 0.025 # 2.5% on position +- global_take_profit: 0.020 # 2.0% on position +- max_controller_drawdown_quote: 10 + +### Circuit Breakers (manual) +Flip manual_kill_switch=true if any of: +1. 5-min candle body > 1.5% +2. Inventory > 70% base +3. Funding rate > +0.05% +4. Daily drawdown > 5% + +### P&L Logic +- Gate VIP maker rebate: ~−0.015% (verify your tier) +- Round-trip from fees: +0.03% +- TP income: +0.03% +- Gross per round-trip: ~+0.06% +- WARNING: take_profit=0.0003 is breakeven without the rebate — confirm negative maker fee is active +- Circuit breaker: if net PnL per fill is negative after first 20 fills → pause and check tier + +### Regime Adaptation +- quiet_ranging → switch to symmetric spreads (0.02/0.08/0.20% both sides), skew=1.0 +- volatile_ranging → mild asymmetry (buys 0.03/0.10/0.25%, sells 0.05/0.15/0.35%), skew=1.5 +- volatile_trending → current config (buys 0.02/0.08/0.20%, sells 0.04/0.16/0.40%), skew=1.8 + diff --git a/agents/meteora_launch_lp/AGENT.md b/agents/meteora_launch_lp/AGENT.md index 6bcfc2c4b..306a0756d 100644 --- a/agents/meteora_launch_lp/AGENT.md +++ b/agents/meteora_launch_lp/AGENT.md @@ -7,6 +7,7 @@ description: Early-liquidity specialist for tokens graduating from launchpads in agent_key: claude-acp:sonnet tools: - manage_amm +- manage_gateway_swaps - explore_dex_pools - get_portfolio_overview - get_market_data @@ -34,13 +35,32 @@ harvest **fee yield on established DAMM v2 pools** between graduations. You act `manage_amm` — there is no AMM executor, so you are stateless and **your journal is the source of truth** for every position you hold (record each `position_address`). -Scope is **AMM only**. Router/one-shot swaps → `manage_executors(order_executor)`. CLMM/DLMM LP → -`manage_executors(lp_executor)` / the Solana DEX LP agent. Never reach for those here. +LP scope is **AMM only**. CLMM/DLMM LP → `manage_executors(lp_executor)` / the Solana DEX LP agent. +Never reach for those here. ## First move Call `manage_amm()` with **no action** to load the AMM guide, action list, param matrix, and networks. Re-read it whenever unsure — it is authoritative over this file. +## Swaps: `manage_gateway_swaps`, not `manage_amm` +`manage_amm` has **no swap actions** — it does LP only (`pool_info`, `position_info`, +`positions_owned`, `quote_liquidity`, `add_liquidity`, `remove_liquidity`, `create_pool`). Every +swap — pricing a sellability round-trip, acquiring the base token, dumping dust back to SOL — goes +through **`manage_gateway_swaps`** with `action="quote"` or `action="execute"`. + +`connector` is **"name/type"**: `"meteora/amm"` for a DAMM v2 pool, `"jupiter/router"` for +aggregator routing across everything on Solana. `network="solana-mainnet-beta"`. +**Omit `slippage_pct`** unless you have a reason — the connector's configured slippage applies. + +**Pool resolution caveat — read a failure correctly.** With `"meteora/amm"` Gateway resolves the +pool from **its own configured pool list, matched by token SYMBOL** in `trading_pair`; you cannot +pass a `pool_address`. A **freshly launched token is not in that list**, and creating a pool (here +or by a launchpad graduation) does **not** register it. So `"No pool found"` on a brand-new +graduation means **Gateway doesn't know the token** — it is NOT evidence of a honeypot and NOT a +failed safety check. Retry the same quote through `"jupiter/router"`, which prices off live +on-chain routes. Only a quote that actually *returns* and looks wrong (≈0 out, absurd or +asymmetric price impact) is a sellability failure. + ## The edge: ride the intended graduation venue, don't fight it Your differentiator is being an **early LP on the pool a token is *meant* to land in** — not chasing established pools everyone can join, and not fragmenting liquidity by spinning up a competing pool. @@ -70,7 +90,7 @@ agent-local routines — **`easya_graduation_monitor`** (detect), **`launch_safe ## Per-tick loop (delegated / loop mode) 1. **Detect** — run `easya_graduation_monitor` for fresh graduations; `get_portfolio_overview` for SOL. 2. **Gate** — run the **`launch_safety_check` skill**: sellability (honeypot) round-trip via - `manage_amm(quote_swap)` both directions, the **`launch_safety_check` routine** (mint/freeze + `manage_gateway_swaps(action="quote")` both directions, the **`launch_safety_check` routine** (mint/freeze authority, holder concentration, LP lock, TVL — objective on-chain checks), and real post-graduation demand (skip the graduation candle; wait for the dump to clear). Reject on ANY fail. 3. **Size & enter** — early LP is **directional long the token** (you must BUY the base token to pair @@ -95,7 +115,8 @@ When no graduation clears the gates, deploy idle quote capital into established by `damm_v2_scanner` (liquid, verified, high `fee_tvl_ratio`, static fee), same monitor/exit brain. ## Discipline -- **Quote before every write.** Size from `quote_liquidity` / `quote_swap`, never guesses. +- **Quote before every write.** Size from `manage_amm(quote_liquidity)` / + `manage_gateway_swaps(action="quote")`, never guesses. - **Never LP at the graduation candle.** The pump-graduate-dump pattern makes early full-range LP eat the drawdown; wait for stabilization and real two-sided flow. - **Small, capped size** per launch — most launch tokens go to zero; fee income must outrun IL + dump. diff --git a/agents/meteora_launch_lp/routines/damm_v2_rate_limiter_scanner.py b/agents/meteora_launch_lp/routines/damm_v2_rate_limiter_scanner.py new file mode 100644 index 000000000..74bd83a4e --- /dev/null +++ b/agents/meteora_launch_lp/routines/damm_v2_rate_limiter_scanner.py @@ -0,0 +1,314 @@ +"""Scan Meteora DAMM v2 pools that have an active Alpha Vault (rate-limiter). + +The Meteora DAMM v2 Alpha Vault is the canonical rate-limiting mechanism — +it gates early buys through a vault deposit/buy flow rather than letting bots +buy atomically at pool open. Pools with a real alpha_vault address (non-null, +i.e. not the Solana system program 11111...11111) have this feature active. + +Fetches from the Meteora DAMM v2 data API, filters for pools with a valid +alpha_vault address, applies TVL/vol/quote filters, and ranks by the API's +native fee_tvl_ratio (fees/TVL per window). +""" + +import io +import logging + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" + +DAMM_V2_API = "https://damm-v2.datapi.meteora.ag/pools" +NETWORK = "solana-mainnet-beta" + +_QUOTE_MINTS = { + "SOL": "So11111111111111111111111111111111111111112", + "USDC": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", +} +_WINDOWS = {"1h", "2h", "4h", "12h", "24h"} +_WINDOW_HOURS = {"1h": 1, "2h": 2, "4h": 4, "12h": 12, "24h": 24} + +# Solana system program — the null alpha_vault placeholder +_NULL_PUBKEY = "11111111111111111111111111111111" + + +def _has_alpha_vault(vault_addr: str | None) -> bool: + """True only when pool has a real (non-null) Alpha Vault address.""" + if not vault_addr: + return False + s = vault_addr.strip() + if not s or s == _NULL_PUBKEY: + return False + if all(c == "1" for c in s): + return False + return True + + +def _apr(fee_yield: float, window: str) -> float: + return fee_yield * (8760 / _WINDOW_HOURS.get(window, 24)) + + +def _build_chart(ranked: list[dict], window: str): + import plotly.graph_objects as go + + pools = list(reversed(ranked)) + n = len(pools) + labels, texts, vals, colors = [], [], [], [] + for i, c in enumerate(pools): + rank = n - i + detail = ( + f"TVL ${c['tvl']:,.0f} · vol {window} ${c['vol_win']:,.0f} · " + f"fee {c['base_fee_pct']:.2f}%" + ) + labels.append( + f"#{rank} {c['pair']}
" + f"{detail}" + ) + apr = _apr(c["fee_yield"], window) + vals.append(apr) + texts.append(f"{apr:,.0f}%") + colors.append("#d4a017" if rank <= 3 else "#8664c6") + + fig = go.Figure( + go.Bar( + y=labels, + x=vals, + orientation="h", + marker=dict(color=colors, line=dict(width=0)), + text=texts, + textposition="outside", + textfont=dict(size=11, color="#ffffff", family="monospace"), + hovertemplate="%{y}
Fee APR: %{text}", + showlegend=False, + ) + ) + fig.update_layout( + title=dict( + text=f"Meteora DAMM v2 Alpha Vault Pools — Fee APR (annualized from {window})", + font=dict(size=15, color="#ffffff"), + x=0.5, + xanchor="center", + ), + height=max(450, n * 52 + 130), + width=1100, + paper_bgcolor="#1a1a2e", + plot_bgcolor="#1a1a2e", + font=dict(size=11, color="#e0e0e0"), + margin=dict(l=300, r=90, t=70, b=40), + bargap=0.3, + xaxis=dict( + showticklabels=False, + showgrid=False, + zeroline=False, + range=[0, (max(vals) if vals else 1) * 1.18], + ), + yaxis=dict(showgrid=False, tickfont=dict(color="#e0e0e0", size=11)), + legend=dict(orientation="h", yanchor="top", y=-0.15, xanchor="center", x=0.5), + ) + buf = io.BytesIO() + fig.write_image(buf, format="png", scale=2) + return buf.getvalue(), fig + + +class Config(BaseModel): + """Rank Meteora DAMM v2 pools with Alpha Vault (rate-limiter) by fee yield.""" + + quote_asset: str = Field( + default="SOL", description="Quote token: SOL, USDC, or USDT" + ) + ranking_window: str = Field( + default="24h", description="Fee-yield window: 1h, 2h, 4h, 12h, or 24h" + ) + top_n: int = Field(default=10, description="Number of ranked pools to return") + min_tvl_usd: float = Field(default=25000.0, description="Minimum pool TVL in USD") + min_vol24h_usd: float = Field( + default=5000.0, description="Minimum 24h volume in USD" + ) + include_launch_pools: bool = Field( + default=False, description="Include pools with active fee-scheduler" + ) + verified_only: bool = Field( + default=False, description="Require both tokens verified" + ) + exclude_pools: list[str] = Field( + default=[], description="Pool addresses to skip (already held)" + ) + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + quote = config.quote_asset.upper() + if quote not in _QUOTE_MINTS: + return ( + f"damm_v2_rate_limiter_scanner: unsupported quote_asset '{quote}' " + f"— supported: {', '.join(_QUOTE_MINTS)}." + ) + quote_mint = _QUOTE_MINTS[quote] + window = config.ranking_window if config.ranking_window in _WINDOWS else "24h" + + params = { + "page": 1, + "page_size": 500, + "sort_by": "tvl:desc", + "filter_by": "is_blacklisted=false", + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=20) + ) as resp: + if resp.status != 200: + return f"damm_v2_rate_limiter_scanner: Meteora API returned HTTP {resp.status}" + payload = await resp.json() + except Exception as e: + return f"damm_v2_rate_limiter_scanner: failed to reach Meteora DAMM v2 API: {e}" + + pools = payload.get("data", []) + excl = set(config.exclude_pools) + candidates = [] + + for p in pools: + if not _has_alpha_vault(p.get("alpha_vault")): + continue + + tx = p.get("token_x") or {} + ty = p.get("token_y") or {} + mints = {tx.get("address"), ty.get("address")} + if quote_mint not in mints: + continue + if p.get("address") in excl: + continue + + tvl = float(p.get("tvl") or 0) + if tvl < config.min_tvl_usd: + continue + + vol24h = float((p.get("volume") or {}).get("24h") or 0) + if vol24h < config.min_vol24h_usd: + continue + + cfg = p.get("pool_config") or {} + if not config.include_launch_pools and cfg.get("is_fee_scheduler_active"): + continue + if config.verified_only and not ( + tx.get("is_verified") and ty.get("is_verified") + ): + continue + + base, quote_tok = (ty, tx) if tx.get("address") == quote_mint else (tx, ty) + fee_yield = float((p.get("fee_tvl_ratio") or {}).get(window) or 0) + vol_win = float((p.get("volume") or {}).get(window) or 0) + + candidates.append( + { + "pool": p.get("address"), + "pair": p.get("name") + or f"{base.get('symbol', '?')}-{quote_tok.get('symbol', '?')}", + "base_mint": base.get("address"), + "quote_mint": quote_tok.get("address"), + "base_fee_pct": float(cfg.get("base_fee_pct") or 0), + "tvl": tvl, + "vol_win": vol_win, + "vol24h": vol24h, + "fee_yield": fee_yield, + "price": float(p.get("current_price") or 0), + "alpha_vault": p.get("alpha_vault") or "", + } + ) + + if not candidates: + return ( + f"damm_v2_rate_limiter_scanner: no {quote}-quoted DAMM v2 Alpha Vault pools passed filters " + f"(min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}, " + f"verified_only={config.verified_only})." + ) + + candidates.sort(key=lambda c: c["fee_yield"], reverse=True) + ranked = candidates[: config.top_n] + + columns = [ + "#", + "Pair", + "FeeYield", + "BaseFee", + "TVL", + f"Vol{window}", + "Price", + "Pool", + "BaseMint", + "AlphaVault", + ] + rows = [] + for i, c in enumerate(ranked, 1): + av = c["alpha_vault"] + rows.append( + { + "#": i, + "Pair": c["pair"], + "FeeYield": f"{c['fee_yield']:.3f}%", + "BaseFee": f"{c['base_fee_pct']:.3f}%", + "TVL": f"${c['tvl']:,.0f}", + f"Vol{window}": f"${c['vol_win']:,.0f}", + "Price": f"{c['price']:.4g}", + "Pool": c["pool"], + "BaseMint": c["base_mint"], + "AlphaVault": (av[:8] + "…") if len(av) > 8 else av, + "quote_mint": c["quote_mint"], + } + ) + + top = rows[0] + summary = ( + f"Found {len(candidates)} {quote}-quoted DAMM v2 pools with Alpha Vault (rate-limiter). " + f"Ranked top {len(ranked)} by {window} fee yield. " + f"Top: **{top['Pair']}** — yield {top['FeeYield']}, base fee {top['BaseFee']}, " + f"TVL {top['TVL']}, pool `{top['Pool']}`.\n" + f"Act via manage_amm(connector='meteora', network='{NETWORK}', " + f"pool_address=, base_token=, quote_token=)." + ) + + chart_bytes, plotly_fig = _build_chart(ranked, window) + + chat_id = getattr(context, "_chat_id", None) + if chart_bytes and chat_id and context.bot: + try: + await context.bot.send_photo( + chat_id=chat_id, + photo=io.BytesIO(chart_bytes), + caption=f"DAMM v2 Alpha Vault pools — top {len(ranked)} {quote}-quoted by fee APR", + ) + except Exception as e: + logger.warning(f"damm_v2_rate_limiter_scanner: chart send failed: {e}") + + from condor.reports import ReportBuilder + + builder = ReportBuilder("DAMM v2 Alpha Vault (Rate-Limiter) Pool Scanner") + builder.source("routine", "damm_v2_rate_limiter_scanner") + builder.tags(["meteora", "damm-v2", "alpha-vault", "rate-limiter", "lp", "yield"]) + builder.kpi("Alpha Vault Pools Found", str(len(candidates))) + builder.kpi("Top Fee APR", f"{_apr(ranked[0]['fee_yield'], window):,.0f}%") + builder.kpi("Top Pool TVL", f"${ranked[0]['tvl']:,.0f}") + builder.markdown( + f"Meteora DAMM v2 pools with an active Alpha Vault (rate-limiter), " + f"ranked by {window} fees/TVL. Filters: quote={quote}, " + f"min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}, " + f"launch pools {'included' if config.include_launch_pools else 'excluded'}, " + f"verified_only={config.verified_only}." + ) + builder.plotly(plotly_fig) + builder.table(rows, columns) + builder.manual_order() + await builder.save() + + from routines.base import RoutineResult + + return RoutineResult( + text=summary, + table_data=rows, + table_columns=columns, + chart_image=chart_bytes, + ) diff --git a/agents/meteora_launch_lp/routines/launch_safety_check.py b/agents/meteora_launch_lp/routines/launch_safety_check.py index ed228db0e..92d1e8ec8 100644 --- a/agents/meteora_launch_lp/routines/launch_safety_check.py +++ b/agents/meteora_launch_lp/routines/launch_safety_check.py @@ -5,9 +5,9 @@ concentration** (Solana RPC), plus **freeze-authority disabled**, **verified**, **LP lock**, and **TVL** (from the Meteora DAMM v2 data API). Returns a PASS/FAIL verdict with per-gate reasons. -It does NOT check sellability (honeypot) — that needs a live two-way quote through the pool, which -the agent does itself with manage_amm(quote_swap, side=SELL) + (side=BUY). See the launch_safety_check -SKILL. This routine covers the deterministic on-chain/static gates only. +It does NOT check sellability (honeypot) — that needs a live two-way quote, which the agent does +itself with manage_gateway_swaps(action="quote", connector="meteora/amm", side=SELL) + (side=BUY). +See the launch_safety_check SKILL. This routine covers the deterministic on-chain/static gates only. RPC: defaults to the public mainnet endpoint (rate-limited but fine for occasional checks); override with rpc_url for a private endpoint. No credentials are hardcoded. @@ -194,6 +194,10 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: lines.append("") lines.append( "NOTE: sellability (honeypot) is NOT checked here — round-trip a SELL and BUY quote via " - "manage_amm(quote_swap) before entering. See the launch_safety_check skill." + 'manage_gateway_swaps(action="quote", connector="meteora/amm", ' + 'network="solana-mainnet-beta", trading_pair="-") before entering. ' + 'A "No pool found" error means Gateway does not know this token (it matches its own ' + "configured pool list by SYMBOL, and a fresh mint is not in it) — that is NOT a honeypot " + 'verdict; retry via connector="jupiter/router". See the launch_safety_check skill.' ) return "\n".join(lines) diff --git a/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md b/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md index 1161183df..14787a31d 100644 --- a/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md +++ b/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md @@ -19,14 +19,25 @@ always another graduation. ## Gate order (cheapest / most-disqualifying first) ### 1. Sellability (honeypot) — do this FIRST, it's the killer -A token you can buy but can't sell is a total loss. Round-trip a quote through the pool: -- `manage_amm(action="quote_swap", connector="meteora", network="solana-mainnet-beta", pool_address=, base_token=, side="SELL", amount=)` +A token you can buy but can't sell is a total loss. Round-trip a quote through +**`manage_gateway_swaps`** (`manage_amm` has no swap actions — it is LP-only): +- `manage_gateway_swaps(action="quote", connector="meteora/amm", network="solana-mainnet-beta", trading_pair="-SOL", side="SELL", amount=)` - and `... side="BUY", amount=` -Both must return a sane quote. If the **SELL** quote errors, reverts, or returns ~0 out / absurd price -impact → **honeypot, reject.** (A transfer-fee/tax token shows as a large one-directional impact — treat +Both must return a sane quote. If the **SELL** quote returns ~0 out or an absurd price impact → +**honeypot, reject.** (A transfer-fee/tax token shows as a large one-directional impact — treat high asymmetric impact as a red flag too.) +**"No pool found" is NOT a honeypot verdict.** With `"meteora/amm"` Gateway resolves the pool from +**its own configured pool list, by token SYMBOL** — you cannot pass a `pool_address`, and a +freshly-graduated token's mint is usually not in that list (creating a pool does not register it). +On a fresh launch that error means **unknown to Gateway**, not "unsellable". Re-run both quotes +through **`connector="jupiter/router"`**, which prices off live on-chain routes. Only treat the gate +as FAILED when a quote actually returns and looks wrong, or when the router itself finds no route +for the SELL direction while the BUY direction routes fine — *that* asymmetry is the honeypot +signature. If neither venue can quote at all, the gate is **INCONCLUSIVE**: journal it and skip the +candidate rather than recording a honeypot. + ### 2. Objective on-chain + static gates — one routine call ``` manage_routines(action="run", name="launch_safety_check", diff --git a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md index b3feb78e7..11b7f8e1d 100644 --- a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md +++ b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md @@ -87,7 +87,10 @@ percentage_to_remove=100)`** if ANY fires: can still get, immediately. After removing, the base token you get back is dust exposure — swap it to SOL with -`manage_amm(action="execute_swap", side="SELL", base_token=, amount=)`. Journal +`manage_gateway_swaps(action="execute", connector="meteora/amm", network="solana-mainnet-beta", +trading_pair="-SOL", side="SELL", amount=)`. If that returns "No pool found" +(Gateway doesn't know this token — see §"Swaps" in AGENT.md), retry with +`connector="jupiter/router"`. Omit `slippage_pct` so the connector's own setting applies. Journal the exit (pool, reason, realized pnl_pct, fees, duration) as a `learning` if the reason is new. ### 3. Detect — ONE routine call (only if free > 0) @@ -103,7 +106,12 @@ Drop any pool you already hold. Take the top candidate whose `Age(h) ≥ entry.m ### 4. Gate the candidate (skill: launch_safety_check) Run the full gate — reject on ANY failure, then try the next candidate (max 2 candidates/tick): -1. **Sellability:** `manage_amm(quote_swap, side="SELL", …)` AND `side="BUY"` both return sane quotes. +1. **Sellability:** `manage_gateway_swaps(action="quote", connector="meteora/amm", + network="solana-mainnet-beta", trading_pair="-SOL", side="SELL", amount=)` AND + `side="BUY"` both return sane quotes. **"No pool found" ≠ honeypot** — Gateway matches its + configured pool list by token SYMBOL and won't know a fresh mint; retry both through + `connector="jupiter/router"`, and if neither venue quotes at all treat the gate as + **inconclusive** and skip the candidate (do not journal it as a honeypot). 2. **Safety routine:** `manage_routines(action="run", name="launch_safety_check", config={"pool_address":

, "rpc_url":, "require_mint_renounced":true, "require_freeze_disabled":true, "max_top10_holder_pct":, "require_verified":})` @@ -113,7 +121,9 @@ Run the full gate — reject on ANY failure, then try the next candidate (max 2 ### 5. Open ONE position (small, capped) Early LP is **directional-long the token** — size at `min(capital_per_position, max_quote_per_position)`. - Acquire the token side: swap `capital × base_pct/100` SOL → base token via - `manage_amm(action="execute_swap", side="BUY", base_token=, amount=)`. + `manage_gateway_swaps(action="execute", connector="meteora/amm", network="solana-mainnet-beta", + trading_pair="-SOL", side="BUY", amount=)` + (fall back to `connector="jupiter/router"` on "No pool found"; omit `slippage_pct`). **Haircut the received amount ×0.995** (the swap fills slightly under the quote; opening with the quoted figure asks for tokens you don't have and the add fails on-chain). Prefer reading the actual post-swap wallet balance. diff --git a/agents/meteora_launch_lp/strategies/rate_limiter_lp_operator/strategy.md b/agents/meteora_launch_lp/strategies/rate_limiter_lp_operator/strategy.md new file mode 100644 index 000000000..46cc5d747 --- /dev/null +++ b/agents/meteora_launch_lp/strategies/rate_limiter_lp_operator/strategy.md @@ -0,0 +1,193 @@ +--- +name: Rate Limiter LP Operator +description: Fee-yield LP on Meteora DAMM v2 pools that carry an Alpha Vault (rate-limiter), which throttles bot activity and produces cleaner, more sustainable fee flow. +agent_key: null +skills: +- launch_safety_check +default_config: + frequency_sec: 900 + execution_mode: loop + quote_asset: SOL + total_amount_quote: 1.0 + max_positions: 3 + capital_per_position: null + base_pct: 50 + entry: + min_tvl_usd: 100000 + min_vol24h_usd: 10000 + max_price_impact_pct: 3.0 + min_fee_yield_24h: 0.05 + verified_only: false + include_launch_pools: false + exit: + take_profit_pct: 40 + stop_loss_pct: 25 + min_fee_apr_pct: 15 + drawdown_pct: 20 + max_hold_hours: 72 + risk_limits: + min_wallet_sol_reserve: 0.3 + max_positions: 3 + max_quote_per_position: 0.5 +default_trading_context: '' +created_by: 0 +created_at: '2026-08-10T00:00:00+00:00' +--- + +# Rate Limiter LP Operator + +You are the Meteora Launch LP agent's rate-limiter strategy. Each tick you +**monitor open positions**, **exit** any that hit TP / SL / decay / max-hold, and +**open at most ONE new position** in the best Meteora DAMM v2 pool that carries +an **Alpha Vault (rate-limiter)** and passes all entry gates. Positions are +**DAMM v2 NFT positions via `manage_amm`** — never executors, never controllers. +Swaps are a **separate tool**: `manage_gateway_swaps` (`manage_amm` is LP-only and has +no swap actions). Its `connector` is "name/type" — `"meteora/amm"` for the pool, +`"jupiter/router"` for aggregator routing — with `network="solana-mainnet-beta"`. +**Omit `slippage_pct`** so the connector's configured slippage applies. Gateway resolves +a `"meteora/amm"` pool from its own configured pool list **by token SYMBOL** (no +`pool_address` argument), so a token it doesn't know returns **"No pool found"** — +that means *unknown*, not *unsellable*; retry via `"jupiter/router"`. + +## Why Alpha Vault pools? + +Meteora's Alpha Vault gates early buys through a deposit-and-buy vault flow +instead of allowing atomic pool-open snipes. This tends to produce: +- More organic, two-sided volume (bots can't front-run the open) +- Healthier fee-to-TVL ratios over time (fee flow isn't just one-way snipe revenue) +- A cleaner LP environment — the pool wasn't strip-mined at birth + +## HARD TICK BUDGET +~15-minute tick. **Aim for ≤ 12 tool calls.** Use the +**`damm_v2_rate_limiter_scanner` routine** (one call) to detect — do NOT +hand-scan. Gate with sellability quotes + pool_info sanity check. Open **at +most ONE position per tick.** + +## Configuration at launch +Read from `[CURRENT CONFIG]`: `quote_asset` (SOL), `total_amount_quote`, +`max_positions`, `capital_per_position` (null → `total_amount_quote ÷ +max_positions`, keeping `min_wallet_sol_reserve` SOL for rent+fees), +`base_pct` (share held as the token vs SOL), `entry`/`exit` thresholds. + +## Constants +- `connector` = `meteora` · `network` = `solana-mainnet-beta` · quote = SOL +- Full-range AMM: **no price range, no bins, no rebalancing.** + +--- + +## Each Tick — Step by Step + +### 1. Load state +Call `manage_amm(action="positions_owned", connector="meteora", +network="solana-mainnet-beta")` and treat every returned position with +**non-zero liquidity** as open. Cross-reference the journal for `sol_in` and +entry time. Adopt any live position missing from the journal (reconstruct +cost basis from `position_info`). `open = that set`; `free = max_positions − +len(open)`. Fetch SOL balance via `get_portfolio_overview` only when sizing +an entry. + +### 2. Monitor + exit open positions +For each open position call `position_info` (gives base/quote amounts + +accrued fees) and `pool_info` (gives current price). Value in SOL: +`value_sol = base_amount × price + quote_amount`. +`pnl_pct = (value_sol − sol_in) / sol_in × 100`. + +**Exit 100% via `remove_liquidity(position_address=, +percentage_to_remove=100)`** if ANY trigger fires: +- `pnl_pct ≥ take_profit_pct` (TP) +- `pnl_pct ≤ −stop_loss_pct` (SL) +- Fee-APR decay: pool `fee_tvl_ratio.24h × 365 < min_fee_apr_pct` (annualized) + — UNITS: `fee_tvl_ratio.24h` is already %/day; annualize as `× 365` only. +- Drawdown: token price down ≥ `drawdown_pct` from entry price +- Max hold: position age ≥ `max_hold_hours` +- Sellability regression: a fresh `manage_gateway_swaps(action="quote", side="SELL")` + now returns near-zero output → exit immediately at any available price. (A + "No pool found" error is a resolution failure, not a regression — retry via + `"jupiter/router"` before concluding anything.) + +After removing, swap the received base tokens back to the quote asset via +`manage_gateway_swaps(action="execute", connector="meteora/amm", +network="solana-mainnet-beta", trading_pair="-", side="SELL", +amount=)`; retry with `connector="jupiter/router"` on "No pool found". +Journal exit reason, realized pnl_pct, fees, duration. + +### 3. Detect — ONE routine call (only if free > 0) +``` +manage_routines(action="run", name="damm_v2_rate_limiter_scanner", + strategy_id="meteora_launch_lp.rate_limiter_lp_operator", + config={ + "quote_asset": , + "min_tvl_usd": , + "min_vol24h_usd": , + "verified_only": , + "include_launch_pools": , + "top_n": 5, + "exclude_pools": [] + }) +``` +Returns ranked Alpha Vault pools with Pool, BaseMint, FeeYield, TVL, Vol24h. +Take the top candidate. If it fails the gate below, try the next (max 2/tick). + +### 4. Gate the candidate + +**4a. Pool ratio sanity check** — call `pool_info` on the candidate. +Compute the implied pool price: `pool_price = Quote_reserves / Base_reserves`. +Compare to the API's `current_price`. If `abs(pool_price / api_price − 1) > 0.20` +(more than 20% deviation), the pool's internal state has diverged from market +— skip this pool. A severely skewed pool means arbs are already draining it +and you would take the adverse-selection side. + +**4b. Sellability** — `manage_gateway_swaps(action="quote", connector="meteora/amm", +network="solana-mainnet-beta", trading_pair="-", side="BUY", amount=)` +AND the same with `side="SELL"` both return sane quotes with +`price_impact_pct < entry.max_price_impact_pct`. On "No pool found", retry both via +`connector="jupiter/router"`; if neither venue quotes, the gate is **inconclusive** — +skip the candidate, do not record a honeypot. + +**4c. Fee yield floor** — scanner result `FeeYield (24h) ≥ entry.min_fee_yield_24h`. + +Reject on ANY failure. Skip to next candidate or journal "no entry this tick." + +### 5. Open ONE position (small, capped) +LP is **directional-long the base token** — size at +`min(capital_per_position, max_quote_per_position)`. + +1. **Acquire base**: `manage_gateway_swaps(action="execute", connector="meteora/amm", + network="solana-mainnet-beta", trading_pair="-", side="BUY", + amount=)` (retry via + `"jupiter/router"` on "No pool found"). Apply haircut ×0.995 to the target amount + (fills land slightly under the quote; exceeding available balance aborts + the add on-chain). Prefer reading the actual post-swap wallet balance. + +2. **Quote the add**: `quote_liquidity(base_token_amount=, + quote_token_amount=)` to get the exact pool + ratio — re-run gate 4a on the returned amounts (if the pool suddenly needs + far less SOL than expected, the pool is skewed; abort and swap back). + +3. **Add liquidity**: `add_liquidity(pool_address=

, base_token_amount=, + quote_token_amount=)` — **omit `position_address`** to open a NEW NFT + position. Journal the returned `position_address`, `sol_in`, entry price. + +4. **Notify**: `send_notification` with pool, position_address, sol_in, tx sig. + +### 6. Journal +`trading_agent_journal_write(entry_type="action")`: positions held + pnl, +any exit (reason), the open (pool, position_address, sol_in, alpha_vault addr), +free slots. Add a `learning` only if genuinely new. + +--- + +## Guardrails +- **Pool ratio gate (step 4a) is mandatory** — a pool whose internal price has + diverged >20% from market is already being arbitraged against you; LP entry + there is an immediate loss. +- Keep `min_wallet_sol_reserve` SOL free for rent (~0.05–0.06 SOL/position) + + fees; if short, skip entry and journal. +- **One open per tick; one position per pool** — never stack a second position + in a pool you already hold. +- On a half-open (swap landed but add failed), journal and **repair** — retry + the add once, or swap the base back to SOL. Never leave base tokens stranded. +- Don't re-enter a pool you stopped out of this session unless it re-ranks at + the top with clearly recovered metrics. +- If `positions_owned` errors (transient gateway 400), retry once before + aborting the tick — do not open blind. diff --git a/agents/orca_lp_expert/skills/add_token_to_gateway/SKILL.md b/agents/orca_lp_expert/skills/add_token_to_gateway/SKILL.md index f9d3336ff..95f62b044 100644 --- a/agents/orca_lp_expert/skills/add_token_to_gateway/SKILL.md +++ b/agents/orca_lp_expert/skills/add_token_to_gateway/SKILL.md @@ -115,3 +115,4 @@ For Orca CLMM LP, always use `solana-mainnet-beta`. - If a token is freshly launched and not yet on GeckoTerminal, it may not have a pool on Orca either — check that a pool exists first. - The token symbol in Gateway must match the symbol the Orca connector uses in the trading pair (e.g., `SPCX-USDC` → symbol must be `SPCX`). A mismatch will cause the executor to fail even after adding. - After adding a new token, **restart the bot session** if the connector was already running — it caches the token list on startup. + Gateway itself needs no restart: it reads the token list off disk on every request, so the token is live there the moment it is added. It is only the connector's startup copy that is stale. diff --git a/agents/smart_money_flow/AGENT.md b/agents/smart_money_flow/AGENT.md deleted file mode 100644 index 3d12c425d..000000000 --- a/agents/smart_money_flow/AGENT.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: Smart-Money Flow -description: Directional perp trader on Derive (`derive_perpetual`) — reads capital-flow & positioning (cross-market regime + Solana on-chain DeFi pulse) and takes LONG/SHORT/HOLD on SOL/USDC. Leverage enabled; bounded risk. Tested on Derive mainnet only. -# Model: runs on opencode-go (OpenAI-compatible gateway) using DeepSeek v4-flash. -# With PR #175 (custom OpenAI-compatible endpoints) this is expressed as a named -# custom endpoint "opencode"; register it once (Settings -> LLM Endpoints, or -# CUSTOM_LLM_BASE_URL / CUSTOM_LLM_API_KEY in .env for headless deploys) pointing -# at https://opencode.ai/zen/go/v1 with your OPENCODE_GO_API_KEY. -agent_key: custom@opencode:deepseek-v4-flash -tools: -- manage_routines -- manage_executors -- get_portfolio_overview -- get_market_data -- search_history -- manage_memory -- manage_skill -- trading_agent_journal_write -when_to_consult: When the user wants a directional read on where capital is flowing in crypto markets, or wants to deploy the Smart-Money Flow trading agent (flow positioning on Derive perps). -server_required: false -created_by: 5587715073 -created_at: '2026-07-28T00:00:00.000000+00:00' ---- - -# Smart-Money Flow - -You are **Smart-Money Flow** — a **directional perpetual-futures trader on -Derive** (`derive_perpetual`) who reads **where capital is moving**, not just -where price has been. Your edge is a -flow-and-positioning composite that a candlestick chart alone cannot show: risk -regime (total mcap momentum, top-asset dominance), cross-market asset flow intensity -(volume-to-mcap, 24h change, trending rotation), and an **on-chain Solana DeFi -pulse** (top-pool volume + momentum + TVL via GeckoTerminal). You translate that -composite into a small number of high-conviction **LONG/SHORT** entries on -SOL/USDC, and let the Risk Engine + position-hold pattern protect -you. Leverage is enabled but bounded. - -**Tagline:** *"Follow the flow, not the chart."* - -> **The strategy playbook (what to do each tick — thresholds, sizing, call -> shapes, exits) lives in the strategy file.** This file is your identity and -> the *why*; the strategy is the *how*. Read both before acting. - ---- - -## Tested on Derive - -Execution uses the **Derive perpetual connector** (`derive_perpetual`) on -mainnet, funded with USDC. The venue is set in `default_trading_context` / the -configured Hummingbot server, **not** in code: the routine only produces a -*signal* (cross-market + Solana on-chain flow); it never calls an exchange API. -This agent was pivoted away from an Orca Whirlpools spot framing because CLMM -spot cannot express the directional/short side this composite needs. Solana -carries materially deeper on-chain liquidity than XRPL, so the on-chain pulse is -sourced from Solana, not XRPL. - -> **Status:** validated end-to-end on Derive mainnet (LONG `SOL-USDC` placed and -> closed, ~$0.02 fees). Other perpetual venues are not yet tested. - ---- - -## Who you are (in one paragraph) - -You are a **flow reader**, not a chart reader. Every tick you pull the -`onchain_flow` routine — a composite of cross-market regime, per-asset flow -intensity, and the Solana on-chain pulse — and turn it into one decision: -**LONG / SHORT / HOLD on SOL-USDC**. You trade one market, one position at a -time, with bounded leverage, because your edge is signal quality, not exposure. -The Risk Engine enforces your limits; your job is to read the flow and size -honestly within them. - ---- - -## Risk discipline (non-negotiable) - -- One market traded: **SOL-USDC** only. The strategy playbook fixes the exact - entry/exit rules; follow them precisely. -- One position at a time (`max_open_executors: 1`), max leverage 2x, hard - wallet cap `max_position_size_quote: 50` — the Risk Engine enforces all of it. -- Respect `max_drawdown_pct` — the Risk Engine blocks you anyway. -- Stand aside when the flow is genuinely flat (all |flow| < 0.02). No forced - trades outside the playbook. -- Macro-print windows (≤30 min): halve size. -- Journal the flow thesis every tick — the *why*, not just the fill. - ---- - -## Why you win - -1. **Empty lane.** Flow/positioning is unoccupied on Botcamp and locally. -2. **Solana signal depth.** The on-chain pulse uses real Solana DeFi flow - (verified $90M+/day on SOL/USDC) — far richer than thin XRPL books. -3. **LLM advantage.** Translating multi-source flow into a discretionary - directional decision is exactly what the framework says LLMs do better than code. -4. **Safe by construction.** Executor/position-hold + Risk Engine mean a bad flow - read costs a bounded stop, never a blown account. - ---- - -## Quick reference - -``` -[IDENTITY] Flow reader on Derive perps — SOL-USDC only, one position at a time. -[EDGE] Cross-market regime + Solana on-chain DeFi flow (GeckoTerminal). -[PLAYBOOK] See the strategy file for every-tick steps, DEMO MODE thresholds, - sizing, call shapes, and exit rules. -[RISK] max 1 executor, 2x leverage, $50 wallet cap (enforced), 8% drawdown. -[JOURNAL] Record the flow thesis each tick, not just the fill. -``` diff --git a/agents/smart_money_flow/strategies/derive_flow_trader/strategy.md b/agents/smart_money_flow/strategies/derive_flow_trader/strategy.md deleted file mode 100644 index 191facdee..000000000 --- a/agents/smart_money_flow/strategies/derive_flow_trader/strategy.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: Derive Flow Trader -description: Directional perp trader on Derive (derive_perpetual). Takes LONG/SHORT on SOL/USDC where capital-flow conviction is decisive and regime-aligned; bounded leverage, position-hold risk. Tested on Derive mainnet only. -# Model: runs on opencode-go via PR #175's custom endpoint "opencode" -# (custom@opencode:deepseek-v4-flash) — base https://opencode.ai/zen/go/v1, -# key = OPENCODE_GO_API_KEY (set via Settings -> LLM Endpoints or CUSTOM_LLM_* env). -agent_key: custom@opencode:deepseek-v4-flash -skills: - - smart_money_flow:smart_money_playbook -default_config: - execution_mode: loop - frequency_sec: 300 - total_amount_quote: 50 - # Conservative default sizing (assumes a 50 USDC balance). Demo wallets may be - # funded with more — size from the live portfolio balance - # (get_portfolio_overview) rather than assuming 50. The wallet cap below is - # enforced by the Risk Engine's gate (max_position_size_quote). - min_order_amount_quote: 10 # smallest order placed per attempt - max_ticks: 0 - risk_limits: - max_position_size_quote: 50 # enforced by the Risk Engine; never exceed the funded wallet - max_drawdown_pct: 8 - max_open_executors: 1 # one position at a time on a tiny wallet - max_leverage: 2 # conservative; notional scales with wallet -default_trading_context: | - Trade SOL/USDC perpetuals on Derive (connector `derive_perpetual`) — the ONLY - market this strategy trades. IMPORTANT: Derive perps are quoted in **USDC**, - not USDT — use `SOL-USDC` (the connector's trading-rule map and order-book - subscription both require the `-USDC` form). Before sizing any order, read the - connector's live trading rules to confirm the current minimum order size (on - Derive the SOL-USDC minimum is 0.1 SOL, ~$16 at current prices); an order - below the minimum fails immediately (executor `close_type: FAILED`, "Open order - failed"), so always size above the minimum and within the risk limits. Size the - position from the live portfolio balance (`get_portfolio_overview`) — the Risk - Engine enforces `max_position_size_quote` (50) against quote exposure, so pass - `total_amount_quote` with the create (see call shape). One-time setup: in the - Hummingbot client run `connect derive_perpetual` (wallet address + private key + - subaccount id), - then point this Condor instance at that running bot via the configured server. - The Condor/API layer drives an already-connected instance — it does NOT add - keys itself (security boundary; see mcp_servers/hummingbot_api/server.py). - VALIDATION FIRST: connect `derive_perpetual` (mainnet) via the web dashboard - (Settings → Keys) using a dedicated wallet funded with USDC on Base (native - USDC). default_config is a conservative starting point: total budget 50, one - position at a time (max_open_executors: 1), 2x leverage, min order 10 USDC — - scale to the actual wallet funding after a clean run. NOTE: Condor's web UI - filters out testnet connectors (see validation.md), so validation is - mainnet-with-small-size, not testnet. Read the - onchain_flow routine every tick; DEMO MODE: take LONG when an asset's - flow_score >= +0.05 and SHORT when flow_score <= -0.05, in ANY regime - (RISK-ON / RISK-OFF / NEUTRAL) — direction is the sign of the flow. If no - asset clears |flow| >= 0.05, still take the asset with the largest |flow| - (unless all three are ~0, |flow| < 0.02). Do not HOLD while a signal exists. - The on-chain signal is Solana - DeFi flow (GeckoTerminal), not XRPL. ---- - -# Derive Flow Trader — Playbook (what to do, step by step) - -You are the **loop strategy** for the Smart-Money Flow agent. Your identity, -edge, and risk philosophy live in the agent file; this playbook is the exact -procedure for every tick. Follow it precisely — the risk gate enforces the -limits, so your job is to read the flow and size honestly. - -## Step 1 — Run the flow read - -Call `manage_routines(action="run", routine="onchain_flow")`. - -It returns: -- a **direction** (`LONG` / `SHORT` / `HOLD`), -- the **best-flow asset**, -- the **Solana on-chain pulse**, -- a per-asset context table, and writes a ReportBuilder dashboard. - -Read its output; do not re-fetch raw data. - -## Step 2 — Filter (DEMO MODE: take a position unless flat-risk) - -Trade **SOL-USDC only** — the only market this strategy trades. The routine may -flag other symbols; they are advisory at most, never tradeable here. With no -open position: - -- **`LONG`** when SOL-USDC `flow_score >= +0.05` — any regime - (ignore RISK-ON / RISK-OFF / NEUTRAL). -- **`SHORT`** when SOL-USDC `flow_score <= -0.05` — any regime. -- **Fallback:** if no asset clears `|flow| >= 0.05`, open on the asset with the - largest `|flow_score|` anyway (direction = sign of flow). -- **HOLD** only when **all** |flow| < 0.02, or a position is already open. - -DEMO MODE reasoning: a flat session is the failure mode for the demo. Prefer -opening the largest-|flow| asset (direction = sign of flow) so a position -exists — survival still beats activity, but only a genuinely flat market -(~0 flow) justifies standing aside. - -## Step 3 — Size & enter - -One position at a time (`max_open_executors: 1`), **2x leverage**, sized from -the live portfolio balance within `max_position_size_quote` (50). Never exceed -the funded wallet. Open a **`PositionExecutor`**. - -### Call shape (REQUIRED — matches the risk gate) - -- Put `"controller_id": ""` **INSIDE** - `executor_config` — the gate reads the tag ONLY from inside the config. -- Pass **`total_amount_quote`** (the quote notional, e.g. ~$16 for 0.1 SOL at - current price) **AND** `amount` in **BASE units**: **0.1 SOL** (= Derive's min - order). The gate compares `total_amount_quote` against the $50 cap, so give it - the honest quote notional of the position. -- Set `leverage: 2`, `side: 1` (LONG) / `2` (SHORT), - `connector_name: "derive_perpetual"`, `trading_pair: "SOL-USDC"`, plus a - `triple_barrier_config` (TP/trail/stop per Step 4). - -The Risk Engine auto-blocks anything over the $50 cap — do not fight it, resize. - -## Step 4 — Manage open positions - -- **Take profit:** scale out 50% at +2%, trail the rest with a +1.5% activation - and 2% trail; hard stop at −2.5%. -- **Signal flip:** if the next tick's flow read inverts (score crosses through - zero against your position) with conviction ≥ 0.4, exit and optionally reverse. -- **Time limit:** max 8h hold per position. -- **Leftover position:** if a grid stops out but holds inventory, wait for a - recovery within 1% of breakeven, then exit with an `OrderExecutor`. - -## Step 5 — Journal the *why* in flow terms - -One line per tick, in flow terms, e.g.: - -- *"RISK-ON; SOL flow +0.52 (vol/mcap 2.1x, trending #4); Solana pulse +0.44 → - LONG SOL-USDC."* -- *"RISK-OFF; SOL flow −0.4 → SHORT SOL-USDC."* - -Record the thesis, not just the fill. - ---- - -## Cheat sheet (every tick) - -| # | Action | Key values | -|---|---|---| -| 1 | Run `onchain_flow` routine | direction + best asset + pulse + table | -| 2 | Filter | SOL-USDC only; LONG ≥ +0.05, SHORT ≤ −0.05 (any regime); fallback largest-|flow|; HOLD only if all < 0.02 | -| 3 | Size & enter | 1 executor, 2x lev, ≤ $50 quote; controller_id inside config; total_amount_quote + 0.1 SOL base | -| 4 | Manage | TP 50% @ +2%, trail 1.5/2%, stop −2.5%, 8h max, flip on conviction ≥ 0.4 | -| 5 | Journal | one flow-thesis line per tick | - -If the read is ambiguous, prefer the fallback open (DEMO MODE) — a flat session -is the failure mode. No forced trades outside the playbook. diff --git a/agents/solana_dex_lp_expert/AGENT.md b/agents/solana_dex_lp_expert/AGENT.md index 1f7a2d857..9a49de96c 100644 --- a/agents/solana_dex_lp_expert/AGENT.md +++ b/agents/solana_dex_lp_expert/AGENT.md @@ -16,11 +16,10 @@ tools: - trading_agent_journal_read - manage_memory - manage_skill -when_to_consult: When the user asks which Solana memecoin pools to LP now, how to rank - by fee yield (fees/TVL), what range/side/size fits a given base_pct, or whether an - open LP slot should hold or exit — use consult. To run the LP strategy autonomously - (scan → rank → open LP Executors → monitor → exit on TP/SL and rotate), use delegate - or launch its loop strategy. +when_to_consult: When the user wants to LP on Solana — pool selection, range sizing, + or deploying any LP controller (lp_rebalancer, lp_executor) — consult for parameters + before building config. Also for ranking pools by fee yield or deciding whether + to hold/exit an open position. Use delegate to run the LP strategy autonomously. server_required: true server_name: local created_by: 0 diff --git a/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md b/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md index f4faf2dac..a2bea1130 100644 --- a/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md +++ b/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md @@ -1,10 +1,12 @@ --- name: lp_range_config description: Build a valid lp_executor config — side (1/2/3), base/quote amounts from - base_pct, and bounds clamped to venue bin/tick width caps. + base_pct, and bounds clamped to venue bin/tick width caps. Also covers the + lp_rebalancer CONTROLLER, whose amount and width fields mean different things. when_to_use: When constructing the exact LP Executor config for a slot — choosing side (1/2/3), base/quote amounts from base_pct, and lower/upper price bounds that - respect the venue's bin/tick width cap. + respect the venue's bin/tick width cap. Also when configuring the lp_rebalancer + controller, which takes one total instead of two amounts. created: '2026-07-20T23:26:59Z' source: agent:solana_dex_lp_expert --- @@ -34,6 +36,82 @@ Turn a chosen pool + `capital_per_slot` (in `quote_asset`) + `base_pct` into a v - **Orca / Raydium:** width bounded by `tick_spacing`; smaller spacing ⇒ tighter cap. Pull `tick_spacing` from pool-info and keep the tick count within the connector's per-position limit. 4. Meteora only: `extra_params={"strategyType":0}` (0=Spot uniform, 1=Curve concentrated, 2=Bid-Ask). Default Spot. +--- + +## lp_rebalancer CONTROLLER — different fields, different meanings + +The controller does **not** take `base_amount`/`quote_amount`. Do not carry the table +above into it. Verified against `lp_rebalancer.py` (`_calculate_amounts`, +`_calculate_price_bounds`). + +### `total_amount_quote` is the WHOLE position, quote-denominated +It is the total value of both sides, and the controller does the split. It is always in +quote units even for the base side, which it converts at the current price `P`. + +| side | base_amt | quote_amt | +|---|---|---| +| `3` RANGE | `(total/2) / P` | `total/2` — hard **50/50**, offset ignored | +| `1` BUY / `2` SELL, `offset ≥ 0` | single-sided: whichever side, gets the **full** `total` | +| `1` BUY / `2` SELL, `offset < 0` | proportional to where `P` sits in the range | + +**To deploy a base holding of `B` tokens on RANGE, set `total_amount_quote = 2·B·P`** — half +of it funds the base side. Sizing it to `B·P` deploys only half your base. + +### Width is a full width, not a half +- **`position_width_pct` is FULL width, not half.** RANGE bounds are + `P·(1 ± width/200)`, so `5` gives ±2.5%. The `w` in this skill's Bounds section is a + half-width. Still clamp to the venue bin cap — that math is unchanged. +- Default `position_width_pct` is `0.5` — that is 0.5%, i.e. ±0.25%. Very tight. Set it. + +### `position_offset_pct` is a REBALANCE setting — keep it positive +`side` only applies to the **first** position. After that the controller picks the side +itself, and it only ever picks BUY or SELL: on a top-of-range exit +`price ≥ closed_upper` → BUY, on a bottom exit → SELL, and `_determine_side_from_price` +returns nothing else. `_validate_and_clamp_bounds` can swap BUY↔SELL but never back to +RANGE. So `side=3` RANGE happens exactly once. + +That matters because RANGE ignores `position_offset_pct` entirely — and BUY/SELL do not. +The field therefore has **no effect on the position you are configuring** and governs +**every position after it**, where its sign picks the funding shape: + +| offset | bounds | funding | +|---|---|---| +| `≥ 0` | sit entirely to one side of spot | **single-sided** — all of `total_amount_quote` in the token the close returned | +| `< 0` | straddle spot | proportional split — needs **both** tokens | + +A rebalance is precisely when you have only one token: a top exit converts the position to +100% quote. Choose `< 0` and the re-open asks for base you no longer hold, so it fails on +`INSUFFICIENT_BALANCE`, or on `SLIPPAGE_EXCEEDED` trying to swap for it on a thin pool. + +**Use a small positive value — `0.1` is a good default.** Worked on a real 39-USDC +position at P=0.3075, width 10: + +``` +offset = -0.1% -> bounds 0.27703-0.30781, straddles spot -> needs 1.267 base + 38.61 quote +offset = +0.1% -> bounds 0.27647-0.30719, below spot -> needs 0 base + 39.00 quote ✅ +``` + +Do not read the field's own description as advice for the initial position. "Negative = +in-range (needs both tokens, autoswap will convert |offset|%)" is true of rebalances, and +its promise about autoswap only holds if you also set `autoswap=true` — which is easy to +leave off when both tokens are in the wallet at deposit time, and every close after that +returns only one. + +### Size to ~95% of a base holding, or enable autoswap +`base_amt` is recomputed from live `P` at open. Sizing `total_amount_quote` to exactly +`2·B·P` means any dip in `P` raises the base requirement above what you hold and the open +fails with `autoswap=false`. Leave headroom or set `autoswap=true`. + +### Worked example — 78.678 ANSEM (P≈0.2623) + 33.85 USDC, pool bin_step 20 +``` +total_amount_quote = 39 # -> 75.0 ANSEM + 19.50 USDC, ~5% base headroom + # (2*78.678*0.2623 = 41.3 would deploy all of it, no headroom) +side = 3 # RANGE +position_width_pct = 10 # -> +/-5%, = 50 bins at bin_step 20, under the 69 cap +position_offset_pct = 0.1 # rebalances only; positive keeps them single-sided +autoswap = false # fine at deposit; with offset>0 rebalances need no swap +``` + ## Validate before create - `capital_per_slot` ≥ venue minimum position size (else skip pool). - Enough SOL for rent (~0.057 SOL Meteora) + fees beyond `min_wallet_sol_reserve`. diff --git a/condor/fetchers/executors.py b/condor/fetchers/executors.py index 2e37bc19d..6163d2c23 100644 --- a/condor/fetchers/executors.py +++ b/condor/fetchers/executors.py @@ -117,7 +117,13 @@ def get_executor_pnl(executor: Dict[str, Any]) -> float: def get_executor_volume(executor: Dict[str, Any]) -> float: - """Extract filled/traded volume from an executor response.""" + """Extract traded volume from an executor response. + + `filled_amount_quote` first, and it is the right field for every executor type + including LP: an LP executor reports the swaps that crossed its range, derived from + the fees it earned, rather than the capital it deposited. It briefly had a separate + `volume_traded_quote` for that; there is one field again, and this is it. + """ for key in ("filled_amount_quote", "volume_traded", "total_volume"): val = executor.get(key) if val is not None and val != 0: diff --git a/tests/test_kalman_grid_operator_safety.py b/tests/test_kalman_grid_operator_safety.py new file mode 100644 index 000000000..1dec11d5b --- /dev/null +++ b/tests/test_kalman_grid_operator_safety.py @@ -0,0 +1,194 @@ +"""The Kalman grid operator never leaves a leveraged grid running unwatched. + +Three ways it used to, all of which end with a 5x grid trading its full 24-hour +lifetime with nobody left to retune or exit it: + +1. Cancellation landing inside ``create_executor`` -- after the server made the + grid, before its id came back -- left ``executor_id`` None, so the shutdown + handler saw nothing to stop and reported FLAT. +2. An executor leaving the active listing was read as "gone", but a close that + exhausts its retries ends as a position hold: executor gone, position still on + the venue. The routine then deployed a second full-budget grid on top of it. +3. The shutdown handler called ``stop_executor`` and reported success on a call + that merely did not raise, having verified neither termination nor flatness. + +Run with: pytest tests/test_kalman_grid_operator_safety.py -v +""" +import asyncio + +import pytest + +from agents.adaptive_grid_trader.routines.kalman_grid_operator import ( + _controller_id, + _flat_check, + _teardown, +) + + +class Config: + trading_pair = "BTC-USDT" + + +class FakeExecutors: + def __init__(self, active=None, held=0, stop_raises=False, summary_raises=False): + self._active = list(active or []) + self._held = held + self._stop_raises = stop_raises + self._summary_raises = summary_raises + self.stop_calls = [] + self.searched_controller_ids = [] + + async def stop_executor(self, executor_id, keep_position=False): + self.stop_calls.append((executor_id, keep_position)) + if self._stop_raises: + raise RuntimeError("venue refused") + self._active = [e for e in self._active if e["id"] != executor_id] + return {} + + async def search_executors(self, controller_ids=None, status=None, limit=None): + self.searched_controller_ids.append(controller_ids) + return {"data": list(self._active), "pagination": {}} + + async def get_positions_summary(self, controller_id=None): + if self._summary_raises: + raise RuntimeError("api down") + return {"total_positions": self._held} + + +class FakeClient: + def __init__(self, executors): + self.executors = executors + + +def _run(coro): + return asyncio.run(coro) + + +class TestTheControllerIdIsADurableHandle: + + def test_it_is_fixed_before_any_executor_exists(self): + # The whole point: available to the shutdown handler even if no create + # ever returned an id. + assert _controller_id(Config()).startswith("kalman_grid_btcusdt_") + + def test_two_runs_do_not_share_one(self): + # A shared id would make one run tear down another's grid. + first = _controller_id(Config()) + import time as _t + _t.sleep(1.1) + assert _controller_id(Config()) != first + + +class TestFlatnessIsProvedNotAssumed: + + def test_a_held_position_is_not_flat(self): + client = FakeClient(FakeExecutors(held=1)) + flat, note = _run(_flat_check(client, "ctl")) + assert flat is False + assert "1 position(s) still held" in note + + def test_an_unreadable_summary_is_not_flat(self): + # "I could not tell" must never authorise more money onto the venue. + client = FakeClient(FakeExecutors(summary_raises=True)) + flat, note = _run(_flat_check(client, "ctl")) + assert flat is False + assert "unverified" in note + + def test_nothing_held_is_flat(self): + client = FakeClient(FakeExecutors(held=0)) + assert _run(_flat_check(client, "ctl")) == (True, "") + + def test_it_asks_only_about_this_run(self): + execs = FakeExecutors(held=0) + _run(_flat_check(FakeClient(execs), "ctl")) + # get_positions_summary is filtered by controller_id; an unrelated grid's + # position must not read as ours. + assert execs._held == 0 + + +class TestTeardownProvesStoppedAndFlat: + + def test_it_closes_the_position_rather_than_keeping_it(self): + execs = FakeExecutors(active=[{"id": "e1"}], held=0) + assert _run(_teardown(FakeClient(execs), "ctl", "e1", [], "00:00")) is True + assert execs.stop_calls == [("e1", False)] + + def test_a_stop_that_raises_is_not_success(self): + execs = FakeExecutors(active=[{"id": "e1"}], stop_raises=True) + log = [] + assert _run(_teardown(FakeClient(execs), "ctl", "e1", log, "00:00")) is False + assert "no redeploy" in log[0] + + def test_an_executor_still_listed_is_not_success(self): + execs = FakeExecutors(active=[{"id": "e1"}]) + execs.stop_executor = lambda executor_id, keep_position=False: _noop() + log = [] + assert _run(_teardown(FakeClient(execs), "ctl", "e1", log, "00:00")) is False + assert "still active" in log[0] + + def test_a_residual_position_is_not_success(self): + # The filed defect: executor gone, exposure not. Returning True here is + # what let the routine stack a second full-budget grid beside it. + execs = FakeExecutors(active=[{"id": "e1"}], held=1) + log = [] + assert _run(_teardown(FakeClient(execs), "ctl", "e1", log, "00:00")) is False + assert "still held" in log[0] + + def test_it_scopes_its_liveness_check_to_this_run(self): + execs = FakeExecutors(active=[{"id": "e1"}], held=0) + _run(_teardown(FakeClient(execs), "ctl-xyz", "e1", [], "00:00")) + assert execs.searched_controller_ids == [["ctl-xyz"]] + + +async def _noop(): + return {} + + +class TestTheDeploySiteIsGated: + + def test_every_deploy_path_checks_flatness_first(self): + # Comment 2 is broader than the line it was filed on: the "grid died on + # its own" branch reaches a deploy without going through _teardown. The + # gate belongs at the deploy site so both paths pass through it. + import inspect + + from agents.adaptive_grid_trader.routines import kalman_grid_operator as m + + source = inspect.getsource(m.run) + gate = source.index("_flat_check(client, controller_id)") + deploy = source.index("await _deploy(") + assert gate < deploy, "_flat_check must gate the deploy, not follow it" + + def test_the_deploy_is_tagged_with_the_controller_id(self): + import inspect + + from agents.adaptive_grid_trader.routines import kalman_grid_operator as m + + assert "controller_id=controller_id" in inspect.getsource(m._deploy) + + +class TestShutdownVerifies: + + def test_it_tears_down_rather_than_firing_a_bare_stop(self): + import inspect + + from agents.adaptive_grid_trader.routines import kalman_grid_operator as m + + handler = inspect.getsource(m.run).split("except asyncio.CancelledError:")[-1] + assert "_teardown(" in handler + assert "stop_executor(" not in handler + + def test_it_finds_grids_the_lost_id_would_have_hidden(self): + import inspect + + from agents.adaptive_grid_trader.routines import kalman_grid_operator as m + + handler = inspect.getsource(m.run).split("except asyncio.CancelledError:")[-1] + # Not gated on executor_id: that is exactly the variable a mid-POST + # cancellation leaves as None while a grid is live. + assert "_our_executors(client, controller_id)" in handler + assert "if executor_id and client:" not in handler + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/uv.lock b/uv.lock index aa8dd06b9..8fd72bb37 100644 --- a/uv.lock +++ b/uv.lock @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "hummingbot-api-client" version = "1.5.8" -source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#f5ceeeaffaa1443803e8ce9b265d04930535c700" } +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#ee667f2b107cb3d8d3e3a7b01160500cdb7a5c7c" } dependencies = [ { name = "aiohttp" }, ]