diff --git a/README.md b/README.md index 5d87330..812c632 100644 --- a/README.md +++ b/README.md @@ -510,100 +510,67 @@ Every deployed agent also exposes an HTTP REST API and SSE real-time feed for da ## Deploy on Railway -Three deployment options: **headless** (APEX runs strategies directly), or a conversational AI trading assistant on either the **OpenClaw** or **Hermes** agent harness (with optional Telegram). +The subscription product uses Railway as a **shared MCP tools runtime**, not as +a per-user Hermes/OpenClaw/autonomous-agent host. The top-level Railway template +defaults to `RUN_MODE=mcp`; Nunchi operates this runner pool behind +`mcp-gateway`, while users run their own Cursor, Claude, Codex, or local +`agent-cli` clients. -### Option A: Headless APEX (Deterministic) +### Shared MCP Tools Runtime -One-click deploy to run APEX autonomously. No AI model needed — pure deterministic strategy execution. - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli&envs=HL_PRIVATE_KEY,HL_TESTNET,RUN_MODE,APEX_PRESET&HL_TESTNETDefault=true&RUN_MODEDefault=apex&APEX_PRESETDefault=default) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli&envs=HL_TESTNET,RUN_MODE,DATA_DIR&HL_TESTNETDefault=true&RUN_MODEDefault=mcp&DATA_DIRDefault=/data) | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `HL_PRIVATE_KEY` | Yes | — | Your Hyperliquid private key | | `HL_TESTNET` | No | `true` | `true` for testnet, `false` for mainnet | -| `RUN_MODE` | No | `apex` | `apex`, `wolf` (alias), `strategy`, or `mcp` | -| `APEX_PRESET` | No | `default` | `conservative`, `default`, or `aggressive` | +| `RUN_MODE` | No | `mcp` | `mcp` for the hosted tools runtime; `apex`/`strategy` are local or legacy operator modes | +| `DATA_DIR` | No | `/data` | Persistent ledgers, wallet state, and metering data | +| `NUNCHI_METERING_URL` | No | — | Generic web-auth metering upload endpoint when the runner reports usage | +| `NUNCHI_METERING_TOKEN` | No | — | Metering bearer token issued by web-auth/gateway config | **Run modes:** -- **apex** (default) — APEX multi-slot orchestrator with autonomous entry, exit, Guard trailing stops, and REFLECT self-improvement loop -- **strategy** — Single strategy loop (set `STRATEGY=engine_mm`, `avellaneda_mm`, etc.) -- **mcp** — MCP server for AI agent integration (SSE transport) - -### Option B: OpenClaw Agent (Conversational AI) - -One-click deploy of a full OpenClaw agent that uses our CLI as the tool backend. Talk to your trading bot via Telegram — it scans markets, enters trades, manages risk, and learns from its mistakes. - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli&envs=HL_PRIVATE_KEY,AI_PROVIDER,AI_API_KEY,TELEGRAM_BOT_TOKEN,TELEGRAM_USERNAME,HL_TESTNET&HL_TESTNETDefault=true) - -> **Setup:** This image bundles the full agent-cli source, so the build context must be the repo root. After creating the service, in **Settings → Build** leave **Root Directory** at `/` (repo root) and set the **Config-as-code path** to `deploy/openclaw-railway/railway.toml`. (The Dockerfile lives at `deploy/openclaw-railway/Dockerfile` and `COPY`s from the repo root — pointing the service Root Directory at the subdirectory makes the build context too narrow and `pip install` of the CLI fails.) - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `HL_PRIVATE_KEY` | Yes | — | Your Hyperliquid private key | -| `AI_PROVIDER` | Yes | — | `anthropic`, `openai`, `gemini`, or `openrouter` | -| `AI_API_KEY` | Yes | — | API key for the chosen AI provider | -| `TELEGRAM_BOT_TOKEN` | Yes | — | Telegram bot token (from @BotFather) | -| `TELEGRAM_USERNAME` | Yes | — | Your Telegram @username | -| `HL_TESTNET` | No | `true` | `true` for testnet, `false` for mainnet | - -**What you get:** -- OpenClaw gateway with web UI at `/openclaw` -- Telegram integration — chat with your bot to start/stop trading, run scans, check status -- Our 13 MCP trading tools as the agent's primary capabilities -- Persistent state across redeploys via `/data` volume -- Auto-onboard: bot sends "Agent ready" to Telegram on first deploy -- REFLECT self-improvement: the agent analyzes its own trades and adjusts strategy parameters - -**How it works:** -1. Deploy sets up OpenClaw + our `hl mcp serve` as the tool provider -2. Bot auto-configures Telegram and sends you a ready message -3. Tell it "start trading" → it runs APEX with autonomous entry, exit, and risk management -4. Ask "how did we do?" → it runs REFLECT and reports performance metrics -5. The agent reads workspace files (AGENTS.md, SOUL.md) that define its trading behavior - -### Option C: Hermes Agent (Nous Research) - -One-click deploy of a full [Hermes](https://github.com/NousResearch/hermes-agent) agent (Nous Research) that uses our CLI as the MCP tool backend. Same conversational trading experience as OpenClaw, on a different harness — chat via Telegram while the agent scans markets, enters trades, manages risk, and reflects on its performance. An Express front door proxies to the internal Hermes dashboard. - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli&envs=HL_PRIVATE_KEY,AI_PROVIDER,AI_API_KEY,TELEGRAM_BOT_TOKEN,TELEGRAM_USERNAME,HL_TESTNET&HL_TESTNETDefault=true) - -> **Setup:** This image bundles the full agent-cli source, so the build context must be the repo root. After creating the service, in **Settings → Build** leave **Root Directory** at `/` (repo root) and set the **Config-as-code path** to `deploy/hermes-railway/railway.toml`. (The Dockerfile lives at `deploy/hermes-railway/Dockerfile` and `COPY`s from the repo root — pointing the service Root Directory at the subdirectory makes the build context too narrow and `pip install` of the CLI fails.) +- **mcp** (default) — SSE MCP server for the shared Railway tools runtime. +- **apex** / **strategy** — direct autonomous loops for local/self-hosted operators, not the Nunchi subscription product path. -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `HL_PRIVATE_KEY` | Yes | — | Your Hyperliquid private key | -| `AI_PROVIDER` | Yes | — | `anthropic`, `openai`, `openrouter`, `gemini`, `google`, `nous`, `zai`, `kimi`, or `huggingface` | -| `AI_API_KEY` | Yes | — | API key for the chosen AI provider | -| `TELEGRAM_BOT_TOKEN` | No | — | Telegram bot token (from @BotFather); enables Telegram chat + auto-onboard | -| `TELEGRAM_USERNAME` | No | — | Your Telegram @username (used to resolve your chat ID) | -| `HL_TESTNET` | No | `true` | `true` for testnet, `false` for mainnet | -| `HERMES_MODEL` | No | — | Override the default model for the chosen provider | -| `DISCORD_BOT_TOKEN` | No | — | Discord bot token (optional messaging platform) | -| `SLACK_BOT_TOKEN` | No | — | Slack bot token (optional messaging platform) | - -The following are preset by `deploy/hermes-railway/railway.toml` and normally need no change: `PORT` (`8080`), `INTERNAL_GATEWAY_HOST`/`INTERNAL_GATEWAY_PORT` (the internal Hermes dashboard bind), and `HERMES_HOME`/`HERMES_DASHBOARD_HOST`/`HERMES_DASHBOARD_PORT`. +### Legacy Agent Templates -**What you get:** -- Hermes dashboard (web UI) proxied through the Express front door, plus our MCP trading tools registered as the `nunchi_trading` MCP server (the same toolset the OpenClaw deploy uses) -- Optional Telegram integration — chat with your bot to start/stop trading, run scans, check status -- Persistent state across redeploys via `/data` volume (Hermes config, memories, sessions under `HERMES_HOME`) -- Auto-onboard: when Telegram credentials are present, the bot sends a "ready" message on first deploy -- REFLECT self-improvement: the agent analyzes its own trades and adjusts strategy parameters +The OpenClaw and Hermes Railway templates are retained as legacy/reference +self-host templates. They are not part of the new hosted MCP subscription +architecture because they provision user-facing conversational/autonomous +agents. Do not expose them as the paid Nunchi product path. -Both conversational options persist state via Railway volume at `/data` — APEX state, REFLECT reports, Radar history, and agent memory survive redeploys. +For legacy experiments, use `deploy/openclaw-railway/railway.toml` or +`deploy/hermes-railway/railway.toml` as the config-as-code path and keep the +Railway build root at the repo root. --- -## YEX Yield Markets +## YEX Yield Markets (testnet) & BTCSWP (mainnet) -[YEX](https://yex.nunchi.trade) (Nunchi HIP-3) yield perpetuals on Hyperliquid: +[YEX](https://yex.nunchi.trade) (Nunchi HIP-3) yield perpetuals on Hyperliquid testnet: | Instrument | HL Coin | Description | |------------|---------|-------------| | VXX-USDYP | yex:VXX | Volatility index yield perp | | US3M-USDYP | yex:US3M | US 3M Treasury rate yield perp | -| BTCSWP-USDYP | yex:BTCSWP | BTC interest rate swap yield perp — tracks the BTC-denominated swap curve | +| BTCSWP-USDYP | yex:BTCSWP | BTC interest rate swap yield perp (testnet) | + +Paragon BTCSWP on Hyperliquid **mainnet** uses the `para` HIP-3 dex: + +| Instrument | HL Coin | Description | +|------------|---------|-------------| +| BTCSWP-PARA | para:BTCSWP | BTC interest rate swap yield perp (mainnet) | + +Mainnet requires `--mainnet` and `HL_TESTNET=false`. Approve builder fees on mainnet before live trading: + +```bash +export HL_TESTNET=false +hl builder approve --mainnet --yes +hl run engine_mm -i BTCSWP-PARA --mainnet --tick 10 +hl trade buy 0.1 -i SOL-PERP --mainnet --yes +``` + +Testnet examples: ```bash hl run avellaneda_mm -i VXX-USDYP --tick 15 @@ -611,6 +578,8 @@ hl run funding_arb -i US3M-USDYP --tick 30 hl run engine_mm -i BTCSWP-USDYP --tick 10 ``` +Native HL perps (`ETH-PERP`, `SOL-PERP`, etc.) resolve generically on mainnet without a whitelist. + --- ## Architecture diff --git a/cli/commands/guard.py b/cli/commands/guard.py index f6d285d..d4d5fdf 100644 --- a/cli/commands/guard.py +++ b/cli/commands/guard.py @@ -120,7 +120,7 @@ def guard_start( typer.echo(f"Mode: LIVE ({'mainnet' if mainnet else 'testnet'})") from cli.strategy_registry import resolve_instrument - resolved = resolve_instrument(instrument) + resolved = resolve_instrument(instrument, mainnet=mainnet) typer.echo(f"Instrument: {resolved}") typer.echo(f"Direction: {direction} | Entry: {entry_price} | Size: {size} | Leverage: {leverage}x") diff --git a/cli/commands/run.py b/cli/commands/run.py index da2940d..c8b6b90 100644 --- a/cli/commands/run.py +++ b/cli/commands/run.py @@ -17,7 +17,7 @@ def run_cmd( ), instrument: str = typer.Option( "ETH-PERP", "--instrument", "-i", - help="Trading instrument (ETH-PERP, VXX-USDYP, US3M-USDYP)", + help="Trading instrument (any HL perp, e.g. ETH-PERP, SOL-PERP, BTCSWP-PARA)", ), tick_interval: float = typer.Option( 10.0, "--tick", "-t", @@ -81,7 +81,7 @@ def run_cmd( cfg = TradingConfig() cfg.strategy = strategy - cfg.instrument = resolve_instrument(instrument) + cfg.instrument = resolve_instrument(instrument, mainnet=mainnet) cfg.tick_interval = tick_interval cfg.mainnet = mainnet cfg.dry_run = dry_run diff --git a/cli/commands/trade.py b/cli/commands/trade.py index fabc751..fa79de5 100644 --- a/cli/commands/trade.py +++ b/cli/commands/trade.py @@ -12,7 +12,7 @@ def trade_cmd( instrument: str = typer.Argument( "ETH-PERP", - help="Instrument (ETH-PERP, VXX-USDYP, US3M-USDYP)", + help="Instrument (any HL perp, e.g. ETH-PERP, SOL-PERP, BTCSWP-PARA, BTCSWP-USDYP)", ), side: str = typer.Argument( ..., @@ -65,7 +65,7 @@ def trade_cmd( from cli.strategy_registry import resolve_instrument from parent.hl_proxy import HLProxy - instrument = resolve_instrument(instrument) + instrument = resolve_instrument(instrument, mainnet=mainnet) network = "mainnet" if mainnet else "testnet" policy_path = str(policy) if policy else None diff --git a/cli/hl_adapter.py b/cli/hl_adapter.py index 68b678b..897677c 100644 --- a/cli/hl_adapter.py +++ b/cli/hl_adapter.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from common.models import HIP3_DEXS, instrument_to_coin +from common.models import active_hip3_dex_ids, instrument_to_coin from parent.hl_proxy import HLFill, HLProxy, MockHLProxy log = logging.getLogger("hl_adapter") @@ -111,7 +111,11 @@ def _default_builder() -> Optional[dict]: ZERO = Decimal("0") -def _assemble_account_state(info, address: str) -> Dict: +def _mainnet_from_info(info) -> bool: + return "testnet" not in getattr(info, "base_url", "").lower() + + +def _assemble_account_state(info, address: str, *, mainnet: Optional[bool] = None) -> Dict: """Build the unified account-state dict from an HL Info client + address. Pure read path: only calls public Info endpoints (user_state / @@ -146,7 +150,8 @@ def _assemble_account_state(info, address: str) -> Dict: return {} # Merge HIP-3 DEX state (asset positions + account value/margin/withdrawable). - for dex_id in HIP3_DEXS: + net_mainnet = mainnet if mainnet is not None else _mainnet_from_info(info) + for dex_id in active_hip3_dex_ids(mainnet=net_mainnet): try: dex_state = info.post("/info", { "type": "clearinghouseState", "user": address, "dex": dex_id, @@ -247,17 +252,12 @@ def read_only_account_state(address: str, testnet: bool = True) -> Dict: info = _retry_on_429( Info, base_url, skip_ws=True, timeout=10, ) - return _assemble_account_state(info, address) + return _assemble_account_state(info, address, mainnet=not testnet) -def _to_hl_coin(instrument: str) -> str: - """Map instrument name to HL coin for API calls. - - Standard perps: ETH-PERP -> ETH - YEX markets: VXX-USDYP -> yex:VXX - US3M-USDYP -> yex:US3M - """ - return instrument_to_coin(instrument) +def _to_hl_coin(instrument: str, mainnet: Optional[bool] = None) -> str: + """Map instrument name to HL coin for API calls.""" + return instrument_to_coin(instrument, mainnet=mainnet) def _funding_rates_from_markets(data: Any, coin: Optional[str] = None) -> Dict[str, float]: @@ -328,7 +328,7 @@ def get_snapshot(self, instrument: str = "ETH-PERP"): ) try: - hl_coin = instrument_to_coin(instrument) + hl_coin = instrument_to_coin(instrument, mainnet=not self._hl.testnet) if ":" in hl_coin: snap = self._get_hip3_snapshot(instrument, hl_coin) else: @@ -390,7 +390,9 @@ def get_account_state(self) -> Dict: "** NO FUNDS DETECTED **" at preflight even though they hold $1000 USDYP in yex, because the universal clearinghouse query returns $0. """ - return _assemble_account_state(self._info, self._address) + return _assemble_account_state( + self._info, self._address, mainnet=not self._hl.testnet, + ) def _get_price_tick(self, coin: str, price: float) -> float: """Get the price tick size for an asset. @@ -432,7 +434,7 @@ def _get_sz_decimals(self, coin: str) -> int: if name: self._sz_decimals_cache[name] = int(asset.get("szDecimals", 1)) # Include HIP-3 DEX assets - for dex_id in HIP3_DEXS: + for dex_id in active_hip3_dex_ids(mainnet=not self._hl.testnet): try: dex_meta = self._info.meta(dex=dex_id) for asset in dex_meta.get("universe", []): @@ -473,7 +475,7 @@ def place_order( # This is the sole enforcement point — all order paths flow through here. if builder is None: builder = _default_builder() - coin = _to_hl_coin(instrument) + coin = self._to_coin(instrument) is_buy = side.lower() == "buy" # Round size to instrument's szDecimals (e.g. BTC=3, DOGE=0, ETH=4) @@ -613,7 +615,7 @@ def _send_order( def cancel_order(self, instrument: str, oid: str) -> bool: """Cancel an open order by OID.""" - coin = _to_hl_coin(instrument) + coin = self._to_coin(instrument) try: self._exchange.cancel(coin, oid) return True @@ -626,7 +628,7 @@ def get_open_orders(self, instrument: str = "") -> List[Dict]: try: orders = self._info.open_orders(self._address) if instrument: - coin = _to_hl_coin(instrument) + coin = self._to_coin(instrument) orders = [o for o in orders if o.get("coin") == coin] return orders except Exception as e: @@ -769,7 +771,7 @@ def list_hip3_dexes(self) -> list: def _to_coin(self, instrument: str) -> str: """Map instrument to HL coin symbol.""" - return _to_hl_coin(instrument) + return _to_hl_coin(instrument, mainnet=not self._hl.testnet) def _round_size(self, coin: str, size: float) -> float: """Round size to instrument's szDecimals.""" diff --git a/cli/strategy_registry.py b/cli/strategy_registry.py index 61ea3eb..27f45d1 100644 --- a/cli/strategy_registry.py +++ b/cli/strategy_registry.py @@ -106,7 +106,7 @@ }, } -# YEX market definitions — Nunchi HIP-3 yield perpetuals +# YEX market definitions — Nunchi HIP-3 yield perpetuals (testnet) YEX_MARKETS: Dict[str, Dict[str, str]] = { "VXX-USDYP": { "hl_coin": "yex:VXX", @@ -118,7 +118,24 @@ }, "BTCSWP-USDYP": { "hl_coin": "yex:BTCSWP", - "description": "BTC interest rate swap yield perpetual — tracks the BTC-denominated swap curve", + "description": "BTC interest rate swap yield perpetual (YEX testnet)", + }, +} + + +# OSRS swap perp (testnet HIP-3 dex, explicit instrument) +SWAP_MARKETS: Dict[str, Dict[str, str]] = { + "BTCSWP-OSRS": { + "hl_coin": "osrs:BTCSWP", + "description": "BTC interest rate swap perp (OSRS testnet)", + }, +} + +# Paragon mainnet HIP-3 markets +PARA_MARKETS: Dict[str, Dict[str, str]] = { + "BTCSWP-PARA": { + "hl_coin": "para:BTCSWP", + "description": "BTC interest rate swap yield perpetual (Paragon mainnet)", }, } @@ -138,17 +155,22 @@ def resolve_strategy_path(name_or_path: str) -> str: return entry["path"] -def resolve_instrument(name: str) -> str: - """Resolve an instrument name to the HL coin symbol. +def resolve_instrument(name: str, mainnet: bool | None = None) -> str: + """Resolve an instrument name to the canonical form.""" + from common.models import BTCSWP_ASSET, coin_to_instrument, is_mainnet - Handles: - - Standard perps: 'ETH-PERP' -> 'ETH-PERP' (unchanged, HLProxy maps internally) - - YEX markets: 'VXX-USDYP' -> 'VXX-USDYP' (DirectHLProxy maps to yex:VXX) - - Direct HL coins: 'yex:VXX' -> 'VXX-USDYP' (reverse lookup) - """ - # Direct YEX coin reference -> canonical name - for name_key, info in YEX_MARKETS.items(): - if name.lower() == info["hl_coin"].lower(): - return name_key - # Already a known YEX market or standard perp - return name + normalized = name.strip() + lower = normalized.lower() + + for markets in (PARA_MARKETS, SWAP_MARKETS, YEX_MARKETS): + for name_key, info in markets.items(): + if lower == info["hl_coin"].lower(): + return name_key + + if lower.startswith("yex:") or lower.startswith("para:") or lower.startswith("osrs:"): + return coin_to_instrument(normalized, mainnet=mainnet) + + if lower == BTCSWP_ASSET.lower(): + return "BTCSWP-PARA" if is_mainnet(mainnet) else "BTCSWP-USDYP" + + return normalized diff --git a/common/models.py b/common/models.py index ec3e952..8848fe4 100644 --- a/common/models.py +++ b/common/models.py @@ -1,92 +1,161 @@ """Shared Pydantic models for the trading system.""" from __future__ import annotations +import os from typing import Any, Collection, Dict, List, Optional, Set from pydantic import BaseModel, Field -# --------------------------------------------------------------------------- -# Instrument name registry -# --------------------------------------------------------------------------- - DEFAULT_SUFFIX = "-PERP" +BTCSWP_ASSET = "BTCSWP" -# HIP-3 DEX definitions HIP3_DEXS: Dict[str, Dict[str, Any]] = { "yex": { "coin_prefix": "yex:", "instrument_suffix": "-USDYP", - "assets": frozenset({"VXX", "US3M", "BTCSWP"}), + "assets": frozenset({"VXX", "US3M", BTCSWP_ASSET}), + }, + "osrs": { + "coin_prefix": "osrs:", + "instrument_suffix": "-OSRS", + "assets": frozenset({BTCSWP_ASSET}), + }, + "para": { + "coin_prefix": "para:", + "instrument_suffix": "-PARA", + "assets": frozenset({BTCSWP_ASSET}), }, } -# Derived lookups +SINGLE_DEX_ASSETS = frozenset({"VXX", "US3M"}) + SPECIAL_ASSETS: Dict[str, str] = {} HL_COIN_PREFIXES: Dict[str, str] = {} DEX_BY_SUFFIX: Dict[str, str] = {} for _dex_id, _dex in HIP3_DEXS.items(): - for _asset in _dex["assets"]: - SPECIAL_ASSETS[_asset] = _dex["instrument_suffix"] HL_COIN_PREFIXES[_dex["instrument_suffix"]] = _dex["coin_prefix"] DEX_BY_SUFFIX[_dex["instrument_suffix"]] = _dex_id + for _asset in _dex["assets"]: + if _asset in SINGLE_DEX_ASSETS: + SPECIAL_ASSETS[_asset] = _dex["instrument_suffix"] + +INSTRUMENT_SUFFIXES = tuple( + sorted({DEFAULT_SUFFIX} | set(HL_COIN_PREFIXES.keys()), key=len, reverse=True) +) + + +def is_mainnet(mainnet: Optional[bool] = None) -> bool: + if mainnet is not None: + return mainnet + return os.environ.get("HL_TESTNET", "true").lower() == "false" + + +def active_hip3_dex_ids(mainnet: Optional[bool] = None) -> List[str]: + return ["para"] if is_mainnet(mainnet) else ["yex"] -INSTRUMENT_SUFFIXES = tuple({DEFAULT_SUFFIX} | set(SPECIAL_ASSETS.values())) +def normalize_hl_coin(coin: str) -> str: + if ":" not in coin: + return coin + prefix, asset = coin.split(":", 1) + return f"{prefix}:{asset.upper()}" -def asset_to_instrument(asset: str) -> str: - return asset + SPECIAL_ASSETS.get(asset, DEFAULT_SUFFIX) +def hl_coin_to_asset(coin: str) -> str: + if ":" in coin: + return coin.split(":", 1)[1].upper() + return coin.upper() -def instrument_to_coin(instrument: str) -> str: + +def _btcswp_hl_coin(mainnet: Optional[bool] = None) -> str: + dex = "para" if is_mainnet(mainnet) else "yex" + return f"{HIP3_DEXS[dex]['coin_prefix']}{BTCSWP_ASSET}" + + +def asset_to_instrument(asset: str, mainnet: Optional[bool] = None) -> str: + upper = asset.upper() + if upper == BTCSWP_ASSET: + suffix = HIP3_DEXS["para" if is_mainnet(mainnet) else "yex"]["instrument_suffix"] + return upper + suffix + return upper + SPECIAL_ASSETS.get(upper, DEFAULT_SUFFIX) + + +def instrument_to_coin(instrument: str, mainnet: Optional[bool] = None) -> str: upper = instrument.upper() - for suffix, prefix in HL_COIN_PREFIXES.items(): + if ":" in instrument: + return normalize_hl_coin(instrument) + for suffix in INSTRUMENT_SUFFIXES: if upper.endswith(suffix): - return prefix + instrument[:-len(suffix)] - if upper.endswith(DEFAULT_SUFFIX): - return instrument[:-len(DEFAULT_SUFFIX)] + asset = instrument[:-len(suffix)] + if suffix == DEFAULT_SUFFIX: + return asset + if asset.upper() == BTCSWP_ASSET and suffix == "-USDYP": + return _btcswp_hl_coin(mainnet) + return f"{HL_COIN_PREFIXES[suffix]}{asset.upper()}" return instrument def instrument_to_asset(instrument: str) -> str: - """Strip suffix to get bare asset name. VXX-USDYP -> VXX, ETH-PERP -> ETH.""" upper = instrument.upper() for suffix in INSTRUMENT_SUFFIXES: if upper.endswith(suffix): return instrument[:-len(suffix)] + if ":" in instrument: + return hl_coin_to_asset(instrument) return instrument -def coin_to_instrument(coin: str) -> str: +def coin_to_instrument(coin: str, mainnet: Optional[bool] = None) -> str: + normalized = normalize_hl_coin(coin) for suffix, prefix in HL_COIN_PREFIXES.items(): - if coin.startswith(prefix): - return coin[len(prefix):] + suffix - return asset_to_instrument(coin) - - -def asset_to_coin(asset: str) -> str: - suffix = SPECIAL_ASSETS.get(asset, DEFAULT_SUFFIX) + if normalized.startswith(prefix): + asset = normalized[len(prefix):] + if asset.upper() == BTCSWP_ASSET: + return asset_to_instrument(BTCSWP_ASSET, mainnet=mainnet) + return asset + suffix + return asset_to_instrument(normalized, mainnet=mainnet) + + +def asset_to_coin(asset: str, mainnet: Optional[bool] = None) -> str: + upper = asset.upper() + if upper == BTCSWP_ASSET: + return _btcswp_hl_coin(mainnet) + suffix = SPECIAL_ASSETS.get(upper, DEFAULT_SUFFIX) prefix = HL_COIN_PREFIXES.get(suffix, "") - return prefix + asset + return prefix + upper if prefix else upper def asset_matches_allowed(asset: str, allowed: Collection[str]) -> bool: - """Check if a bare asset name matches any entry in an allowed instruments set.""" - if asset in allowed: + upper = asset.upper() + allowed_upper = {a.upper() for a in allowed} + if upper in allowed_upper: return True - return any(asset + suffix in allowed for suffix in INSTRUMENT_SUFFIXES) + return any(upper + suffix in allowed_upper for suffix in INSTRUMENT_SUFFIXES) -def dex_for_instrument(instrument: str) -> Optional[str]: - """Return HIP-3 dex ID for an instrument, or None for native perps.""" +def dex_for_instrument(instrument: str, mainnet: Optional[bool] = None) -> Optional[str]: + upper = instrument.upper() + if upper.endswith("-PARA"): + return "para" + if upper.endswith("-OSRS"): + return "osrs" + if upper.endswith("-USDYP"): + asset = instrument[:-len("-USDYP")] + if asset.upper() == BTCSWP_ASSET: + return "para" if is_mainnet(mainnet) else "yex" + return "yex" + if ":" in instrument: + prefix = instrument.split(":", 1)[0] + if prefix in HIP3_DEXS: + return prefix for suffix, dex_id in DEX_BY_SUFFIX.items(): if instrument.endswith(suffix): return dex_id return None -def get_hip3_dex_ids(instruments: Collection[str]) -> Set[str]: - """Return set of HIP-3 dex IDs needed for a list of instruments.""" - return {d for inst in instruments for d in [dex_for_instrument(inst)] if d} +def get_hip3_dex_ids(instruments: Collection[str], mainnet: Optional[bool] = None) -> Set[str]: + return {d for inst in instruments for d in [dex_for_instrument(inst, mainnet=mainnet)] if d} class MarketSnapshot(BaseModel): @@ -108,22 +177,21 @@ class VerifyResult(BaseModel): class StrategyDecision(BaseModel): - action: str = "noop" # "place_order" or "noop" + action: str = "noop" instrument: str = "ETH-PERP" - side: str = "" # "buy" or "sell" + side: str = "" size: float = 0.0 limit_price: float = 0.0 - order_type: str = "Gtc" # "Gtc" (rest on book), "Ioc" (cross spread), "Alo" (maker-only) + order_type: str = "Gtc" meta: Dict[str, Any] = Field(default_factory=dict) class Decision(BaseModel): - """Individual decision — matches KorAI MVP Listing 1 inner 'decision' object.""" decision_id: str strategy_id: str = "" - action: str = "limit_order" # quote | limit_order | hedge + action: str = "limit_order" instrument: str = "ETH" - side: Optional[str] = None # buy | sell | null + side: Optional[str] = None size: float = 0.0 limit_price: float = 0.0 timestamp_ms: int = 0 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 08f1d5d..8d63b7f 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -1,4 +1,10 @@ -# APEX Operational Runbook +# Agent CLI Railway Runbook + +The default Railway deployment is the shared MCP/tools runtime used behind +`mcp-gateway`. It should run `RUN_MODE=mcp` and expose tools only; it must not +run a per-user Hermes/OpenClaw/autonomous-agent loop for the subscription +product. The APEX sections below apply only when an operator explicitly opts +into self-hosted autonomous modes. ## Starting / Stopping @@ -7,7 +13,7 @@ # Deploy via Railway dashboard or CLI railway up ``` -The entrypoint starts a health server on `$PORT` (default 8080) then launches the configured `RUN_MODE`. +The entrypoint starts a health server on `$PORT` (default 8080) then launches the configured `RUN_MODE`. For the hosted MCP product, leave `RUN_MODE=mcp`. ### Start (Local) ```bash @@ -113,7 +119,7 @@ railway logs | jq '.level, .message' | Variable | Default | Purpose | |----------|---------|---------| -| `RUN_MODE` | `apex` | `apex`, `strategy`, `mcp` | +| `RUN_MODE` | `mcp` | `mcp` for shared hosted tools; `apex`/`strategy` only for self-hosted operators | | `APEX_PRESET` | `default` | `conservative`, `default`, `aggressive` | | `APEX_BUDGET` | auto | Total trading capital | | `APEX_SLOTS` | `3` | Max concurrent positions | @@ -126,11 +132,12 @@ railway logs | jq '.level, .message' ## Railway Deployment Checklist -- [ ] `HL_PRIVATE_KEY` or keystore credentials configured -- [ ] `HL_TESTNET=false` for mainnet -- [ ] `APEX_BUDGET` set to desired capital +- [ ] `RUN_MODE=mcp` for Nunchi-hosted shared tools runtime +- [ ] `HL_TESTNET=true` unless the runner is explicitly approved for mainnet +- [ ] Do not deploy Hermes/OpenClaw as a Nunchi subscription product surface +- [ ] Configure generic metering upload when reporting usage: `NUNCHI_METERING_URL`, `NUNCHI_METERING_TOKEN` - [ ] `API_AUTH_TOKEN` set for control endpoint security - [ ] Persistent volume mounted at `/data` - [ ] Health check endpoint `/health` responds with 200 -- [ ] Run `hl apex reconcile` after first deploy to verify clean state -- [ ] Monitor `/metrics` endpoint for tick latency and error counts +- [ ] Gateway points at this runner with `NUNCHI_MCP_TOOLS_RUNNER_URL` +- [ ] For self-hosted APEX/strategy modes only: run `hl apex reconcile` after first deploy and monitor `/metrics` diff --git a/modules/strategy_guard.py b/modules/strategy_guard.py index 9fe22a0..b27fd79 100644 --- a/modules/strategy_guard.py +++ b/modules/strategy_guard.py @@ -14,7 +14,7 @@ import time from typing import Any, Dict, List, Optional, Set -from common.models import MarketSnapshot, StrategyDecision, asset_to_instrument, instrument_to_asset +from common.models import MarketSnapshot, StrategyDecision, asset_to_instrument, coin_to_instrument, hl_coin_to_asset, instrument_to_asset from sdk.strategy_sdk.base import BaseStrategy, StrategyContext log = logging.getLogger("strategy_guard") @@ -241,7 +241,9 @@ def _build_snapshots( if not name: continue - if only_assets is not None and name not in only_assets: + asset = hl_coin_to_asset(name) + + if only_assets is not None and asset not in only_assets: continue try: @@ -259,8 +261,8 @@ def _build_snapshots( funding = float(ctx.get("funding", 0) or 0) oi = float(ctx.get("openInterest", 0) or 0) - snapshots[name] = MarketSnapshot( - instrument=asset_to_instrument(name), + snapshots[asset] = MarketSnapshot( + instrument=coin_to_instrument(name) if ":" in name else asset_to_instrument(name), mid_price=mid, bid=bid, ask=ask, diff --git a/parent/hl_proxy.py b/parent/hl_proxy.py index 8495321..f927e12 100644 --- a/parent/hl_proxy.py +++ b/parent/hl_proxy.py @@ -13,7 +13,7 @@ from decimal import Decimal from typing import Any, Dict, List, Optional -from common.models import HIP3_DEXS, MarketSnapshot, instrument_to_coin +from common.models import HIP3_DEXS, MarketSnapshot, active_hip3_dex_ids, instrument_to_coin log = logging.getLogger("hl_proxy") @@ -235,14 +235,43 @@ def get_all_mids(self) -> Dict[str, str]: "ARB": "1.2", "OP": "2.5", "AVAX": "35.0", "MATIC": "0.8", "LINK": "15.0", "UNI": "7.0", "AAVE": "100.0", "CRV": "0.5", "MKR": "1500.0", "SNX": "3.0", "COMP": "50.0", + "yex:VXX": "25.0", "yex:US3M": "4.5", "yex:BTCSWP": "0.05", + "osrs:BTCSWP": "57869.0", + "para:BTCSWP": "0.05", } def get_dex_markets(self, dex: str) -> list: - """Return empty HIP-3 DEX markets for mock.""" + """Return mock HIP-3 DEX markets.""" + if dex == "yex": + universe = [ + {"name": "yex:VXX", "szDecimals": 1}, + {"name": "yex:US3M", "szDecimals": 0}, + {"name": "yex:BTCSWP", "szDecimals": 1}, + ] + ctxs = [ + {"midPx": "25.0", "markPx": "25.0", "dayNtlVlm": "100000", "funding": "0.0001", "openInterest": "50000"}, + {"midPx": "4.5", "markPx": "4.5", "dayNtlVlm": "80000", "funding": "0.0001", "openInterest": "40000"}, + {"midPx": "0.05", "markPx": "0.05", "dayNtlVlm": "60000", "funding": "0.0002", "openInterest": "30000"}, + ] + return [{"universe": universe}, ctxs] + if dex == "osrs": + universe = [{"name": "osrs:BTCSWP", "szDecimals": 3}] + ctxs = [{"midPx": "57869.0", "markPx": "57869.0", "dayNtlVlm": "60000", "funding": "0.0002", "openInterest": "30000"}] + return [{"universe": universe}, ctxs] + if dex == "para": + universe = [{"name": "para:BTCSWP", "szDecimals": 1}] + ctxs = [{"midPx": "0.05", "markPx": "0.05", "dayNtlVlm": "60000", "funding": "0.0002", "openInterest": "30000"}] + return [{"universe": universe}, ctxs] return [{"universe": []}, []] def get_dex_mids(self, dex: str) -> Dict[str, str]: - """Return empty HIP-3 DEX mids for mock.""" + """Return mock HIP-3 DEX mids.""" + if dex == "yex": + return {"yex:VXX": "25.0", "yex:US3M": "4.5", "yex:BTCSWP": "0.05"} + if dex == "osrs": + return {"osrs:BTCSWP": "57869.0"} + if dex == "para": + return {"para:BTCSWP": "0.05"} return {} def get_fills(self, since_ms: int = 0) -> List[HLFill]: @@ -293,7 +322,7 @@ def _ensure_client(self): _patch_spot_meta_indexing() base_url = constants.TESTNET_API_URL if self.testnet else constants.MAINNET_API_URL - perp_dexs = [""] + list(HIP3_DEXS.keys()) + perp_dexs = [""] + active_hip3_dex_ids(mainnet=not self.testnet) # Info() constructor calls perp_dexs() which hits HL API — # wrap with retry so startup 429s don't crash the agent. self._info = _retry_on_429( @@ -324,7 +353,7 @@ def _ensure_client(self): log.info("HL client initialized: %s (testnet=%s)", self._address, self.testnet) # Enable HIP-3 DEX abstraction for agent trading - if HIP3_DEXS: + if active_hip3_dex_ids(mainnet=not self.testnet): try: self._exchange.agent_enable_dex_abstraction() log.info("HIP-3 DEX abstraction enabled") diff --git a/tests/test_hl_adapter.py b/tests/test_hl_adapter.py index 81b35ca..a719612 100644 --- a/tests/test_hl_adapter.py +++ b/tests/test_hl_adapter.py @@ -27,6 +27,7 @@ def _mock_hl_proxy(): hl._info = MagicMock() hl._exchange = MagicMock() hl._address = "0xTEST" + hl.testnet = True hl._ensure_client = MagicMock() hl.get_snapshot = MagicMock(return_value=MarketSnapshot( instrument="ETH-PERP", @@ -55,6 +56,20 @@ def test_standard_perp(self): def test_lowercase_perp(self): assert _to_hl_coin("sol-perp") == "sol" + def test_yex_usdyp(self): + assert _to_hl_coin("VXX-USDYP") == "yex:VXX" + assert _to_hl_coin("BTCSWP-USDYP") == "yex:BTCSWP" + + def test_para_btcswp(self): + assert _to_hl_coin("BTCSWP-PARA", mainnet=True) == "para:BTCSWP" + assert _to_hl_coin("para:BTCSWP") == "para:BTCSWP" + + + + def test_para_btcswp(self): + assert _to_hl_coin("BTCSWP-PARA", mainnet=True) == "para:BTCSWP" + assert _to_hl_coin("para:BTCSWP") == "para:BTCSWP" + class TestRoundPrice: def test_btc_price(self): diff --git a/tests/test_instrument_registry.py b/tests/test_instrument_registry.py new file mode 100644 index 0000000..4722388 --- /dev/null +++ b/tests/test_instrument_registry.py @@ -0,0 +1,58 @@ +"""Tests for common.models instrument registry.""" +from common.models import ( + active_hip3_dex_ids, + asset_to_coin, + asset_to_instrument, + coin_to_instrument, + dex_for_instrument, + get_hip3_dex_ids, + instrument_to_asset, + instrument_to_coin, + is_mainnet, + normalize_hl_coin, +) + + +def test_instrument_to_coin_yex(): + assert instrument_to_coin("VXX-USDYP") == "yex:VXX" + assert instrument_to_coin("BTCSWP-USDYP", mainnet=False) == "yex:BTCSWP" + + +def test_instrument_to_coin_para_mainnet(): + assert instrument_to_coin("BTCSWP-PARA", mainnet=True) == "para:BTCSWP" + assert instrument_to_coin("BTCSWP-USDYP", mainnet=True) == "para:BTCSWP" + assert normalize_hl_coin("para:btcswp") == "para:BTCSWP" + + +def test_asset_to_instrument_network(): + assert asset_to_instrument("BTCSWP", mainnet=False) == "BTCSWP-USDYP" + assert asset_to_instrument("BTCSWP", mainnet=True) == "BTCSWP-PARA" + + +def test_active_hip3_dex_ids(): + assert active_hip3_dex_ids(mainnet=False) == ["yex"] + assert active_hip3_dex_ids(mainnet=True) == ["para"] + + +def test_dex_for_instrument_btcswp(): + assert dex_for_instrument("BTCSWP-USDYP", mainnet=True) == "para" + assert dex_for_instrument("BTCSWP-USDYP", mainnet=False) == "yex" + + +def test_asset_to_coin(): + assert asset_to_coin("BTCSWP", mainnet=False) == "yex:BTCSWP" + assert asset_to_coin("BTCSWP", mainnet=True) == "para:BTCSWP" + + +def test_coin_roundtrip(): + for inst, mainnet in [("BTCSWP-USDYP", False), ("BTCSWP-PARA", True)]: + assert coin_to_instrument(instrument_to_coin(inst, mainnet=mainnet), mainnet=mainnet) == inst + + +def test_is_mainnet_from_env(monkeypatch): + monkeypatch.setenv("HL_TESTNET", "false") + assert is_mainnet() is True + + +def test_instrument_to_asset(): + assert instrument_to_asset("BTCSWP-PARA") == "BTCSWP" diff --git a/tests/test_strategy_registry.py b/tests/test_strategy_registry.py index a72afd8..f534946 100644 --- a/tests/test_strategy_registry.py +++ b/tests/test_strategy_registry.py @@ -53,5 +53,18 @@ def test_yex_coin_reverse_lookup(self): assert resolve_instrument("yex:VXX") == "VXX-USDYP" assert resolve_instrument("yex:US3M") == "US3M-USDYP" + def test_yex_btcswp_reverse_lookup(self): + assert resolve_instrument("yex:BTCSWP") == "BTCSWP-USDYP" + + + + def test_para_btcswp_reverse_lookup(self): + assert resolve_instrument("para:BTCSWP", mainnet=True) == "BTCSWP-PARA" + assert resolve_instrument("BTCSWP-PARA", mainnet=True) == "BTCSWP-PARA" + + def test_bare_btcswp_network_default(self): + assert resolve_instrument("BTCSWP", mainnet=False) == "BTCSWP-USDYP" + assert resolve_instrument("BTCSWP", mainnet=True) == "BTCSWP-PARA" + def test_unknown_instrument_passthrough(self): assert resolve_instrument("UNKNOWN-PERP") == "UNKNOWN-PERP"