Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 43 additions & 74 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,107 +510,76 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion cli/commands/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cli/commands/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
...,
Expand Down Expand Up @@ -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

Expand Down
40 changes: 21 additions & 19 deletions cli/hl_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 /
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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", []):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
52 changes: 37 additions & 15 deletions cli/strategy_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)",
},
}

Expand All @@ -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
Loading