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
83 changes: 68 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,53 @@ MCP tools: `funding_hedge_propose`, `funding_hedge_backtest`

---

## Hosted MCP Entitlements

Fully local/BYO MCP mode stays ungated by default. Hosted Nunchi MCP/tools or
Nunchi inference enforcement activates only when one of these is configured:

- `NUNCHI_MCP_ENTITLEMENT_JSON`: inline web-auth `GET /api/entitlements/mcp`
response.
- `NUNCHI_MCP_ENTITLEMENT_FILE`: path to the entitlement JSON.
- `NUNCHI_CONNECTION_MODE=hosted-mcp-tools` or
`NUNCHI_CONNECTION_MODE=hosted-mcp-tools-inference`: fetch entitlement from
web-auth with the stored pair token.
- `NUNCHI_MCP_REQUIRE_ENTITLEMENT=true`: fail closed if no entitlement can be
fetched.

When active, agent-cli enforces web-auth `allowedTools`, free/paid/safety tool
buckets, local free-call accounting, and model policy (`openrouter/auto` and
Fusion stay blocked unless the entitlement allows them). Safety-gated tools
still require explicit `confirm=true`.

Register local agent identity with web-auth:

```bash
hl pair register --agent-id local-mm-1 --agent-name "Local MM 1" --connection-mode clone-local
```

## Builder-Code Validation

All direct Hyperliquid order broadcasts flow through `DirectHLProxy.place_order`.
That adapter fails closed before calling `exchange.order` unless valid Nunchi
builder-fee metadata is present. `hl trade --dry-run` prints the builder-code
status and does not submit. Live trade ledgers include builder-code metadata so
Mode 3 economics can be measured without implying Nunchi hosts the agent.

## Pricing Dry Runs

Use dry-run mode when OpenRouter keys or funded wallets are unavailable:

```bash
python scripts/pricing_experiment_suite.py --dry-run-only
```

The suite writes `experiment_manifest.json` with blocked live measurements
instead of faking results. Fill-level validation still requires funded maker and
taker wallet env vars.

---

## Strategies

14 built-in strategies across four categories. Every strategy extends `BaseStrategy` with a single `on_tick()` method — no shared state, no hidden coupling between strategies.
Expand Down Expand Up @@ -511,29 +558,34 @@ Every deployed agent also exposes an HTTP REST API and SSE real-time feed for da

## Deploy on Railway

Two deployment options: **headless** (APEX runs strategies directly) or **OpenClaw agent** (conversational AI trading assistant with 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)
- **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.

### Option B: OpenClaw Agent (Conversational AI)
### Legacy OpenClaw Agent Template

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/openclaw-railway` is retained as a legacy/reference self-host template.
It is not part of the new hosted MCP subscription architecture because it
provisions a user-facing conversational/autonomous agent. Do not expose it as
the paid Nunchi product path.

[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli/tree/main/deploy/openclaw-railway&envs=HL_PRIVATE_KEY,AI_PROVIDER,AI_API_KEY,TELEGRAM_BOT_TOKEN,TELEGRAM_USERNAME,HL_TESTNET&HL_TESTNETDefault=true)

Expand All @@ -546,7 +598,7 @@ One-click deploy of a full OpenClaw agent that uses our CLI as the tool backend.
| `TELEGRAM_USERNAME` | Yes | — | Your Telegram @username |
| `HL_TESTNET` | No | `true` | `true` for testnet, `false` for mainnet |

**What you get:**
**Legacy behavior:**
- 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
Expand All @@ -561,7 +613,8 @@ One-click deploy of a full OpenClaw agent that uses our CLI as the tool backend.
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

Both options persist state via Railway volume at `/data` — APEX state, REFLECT reports, Radar history, and agent memory survive redeploys.
Both templates persist state via Railway volume at `/data`, but only the
top-level `RUN_MODE=mcp` deployment reflects the shared hosted tools runtime.

---

Expand Down
25 changes: 24 additions & 1 deletion cli/builder_fee.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
from dataclasses import dataclass
from typing import Any, Dict, Optional

NUNCHI_BUILDER_ADDRESS = "0x0D1DB1C800184A203915757BbbC0ee3A8E12FfB0"


@dataclass
class BuilderFeeConfig:
"""Builder fee settings. Loaded from env vars or YAML config."""

builder_address: str = "0x0D1DB1C800184A203915757BbbC0ee3A8E12FfB0" # Nunchi fee wallet
builder_address: str = NUNCHI_BUILDER_ADDRESS
fee_rate_tenths_bps: int = 100 # 10 bps (0.1%)

@property
Expand All @@ -39,6 +41,27 @@ def to_builder_info(self) -> Optional[Dict[str, Any]]:
return None
return {"b": self.builder_address, "f": self.fee_rate_tenths_bps}

def metadata(self) -> Dict[str, Any]:
"""Return ledger-safe builder-code metadata."""
return {
"builder_code_required": True,
"builder_address": self.builder_address or None,
"builder_fee_tenths_bps": self.fee_rate_tenths_bps,
"builder_fee_bps": self.fee_bps,
"builder_fee_enabled": self.enabled,
}

def validate_for_broadcast(self) -> None:
"""Fail closed before a live order can leave agent-cli."""
if not self.enabled:
raise RuntimeError(
"builder-code validation failed: BUILDER_ADDRESS and BUILDER_FEE_TENTHS_BPS must configure a positive Nunchi builder fee"
)
if not isinstance(self.builder_address, str) or not self.builder_address.startswith("0x") or len(self.builder_address) != 42:
raise RuntimeError("builder-code validation failed: builder address must be a 20-byte hex address")
if self.fee_rate_tenths_bps <= 0:
raise RuntimeError("builder-code validation failed: builder fee must be positive")

@classmethod
def from_env(cls) -> "BuilderFeeConfig":
"""Load from env vars, falling back to hardcoded defaults."""
Expand Down
65 changes: 65 additions & 0 deletions cli/commands/pair.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def pair_connect(
no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."),
timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for browser approval."),
app_name: str = typer.Option("HL Agent CLI", "--app-name", help="Display name shown on the authorize page."),
agent_id: str = typer.Option("", "--agent-id", help="Stable local agent id to include in pairing metadata."),
agent_name: str = typer.Option("", "--agent-name", help="Human-readable local agent name."),
connection_mode: str = typer.Option(
"clone-local",
"--connection-mode",
help="clone-local, hosted-mcp-tools, or hosted-mcp-tools-inference.",
),
) -> None:
_ensure_path()
from cli.web_auth import PairingTimedOutError, get_stored_pairing, start_pairing
Expand Down Expand Up @@ -69,6 +76,9 @@ def _on_polling() -> None:
try:
result = start_pairing(
app_name=app_name,
agent_id=agent_id or None,
agent_name=agent_name or app_name,
connection_mode=connection_mode,
no_browser=no_browser,
on_url=_on_url,
on_polling=_on_polling,
Expand Down Expand Up @@ -125,6 +135,9 @@ def pair_status() -> None:
typer.echo(f" paired: {_humanize_age(pairing.paired_at_ms)}")
if pairing.account_id:
typer.echo(f" account: {pairing.account_id}")
if pairing.agent_id:
typer.echo(f" agent: {pairing.agent_name or pairing.agent_id} ({pairing.agent_id})")
typer.echo(f" runtime: {pairing.runtime_location or 'local'} / {pairing.connection_mode or 'clone-local'}")
if pairing.master_address:
typer.echo(f" master: {pairing.master_address}")
typer.echo(f" addresses ({len(pairing.addresses)}):")
Expand Down Expand Up @@ -152,6 +165,10 @@ def pair_list() -> None:
"ok": True,
"label": pairing.label,
"accountId": pairing.account_id,
"agentId": pairing.agent_id,
"agentName": pairing.agent_name,
"runtimeLocation": pairing.runtime_location,
"connectionMode": pairing.connection_mode,
"masterAddress": pairing.master_address,
"selectedAddress": pairing.selected_or_master_address,
"wallets": [
Expand Down Expand Up @@ -190,6 +207,11 @@ def pair_open(
account_id: str = typer.Option("", "--account-id", help="Optional account id for agent-wallet binding view."),
agent_id: str = typer.Option("", "--agent-id", help="Optional agent id for agent-wallet binding view."),
agent_name: str = typer.Option("", "--agent-name", help="Optional display name for the agent-wallet binding view."),
connection_mode: str = typer.Option(
"clone-local",
"--connection-mode",
help="clone-local, hosted-mcp-tools, or hosted-mcp-tools-inference.",
),
include_pair_token: bool = typer.Option(
False,
"--include-pair-token",
Expand All @@ -204,6 +226,8 @@ def pair_open(
account_id=account_id or None,
agent_id=agent_id or None,
agent_name=agent_name or None,
runtime_location="local",
connection_mode=connection_mode,
include_pair_token=include_pair_token,
)
typer.echo(f"web-auth: {url}")
Expand All @@ -215,6 +239,7 @@ def pair_bind_role(
account_id: str = typer.Option("", "--account-id", help="web-auth account id for the binding. Defaults to the paired account."),
agent_id: str = typer.Option("", "--agent-id", help="Override agent id. Defaults to agent-cli-cost-e2e-<role>."),
agent_name: str = typer.Option("", "--agent-name", help="Override display name in web-auth."),
connection_mode: str = typer.Option("clone-local", "--connection-mode", help="Connection mode to tag in web-auth."),
timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for the web-auth selection."),
no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."),
) -> None:
Expand Down Expand Up @@ -247,6 +272,8 @@ def pair_bind_role(
account_id=resolved_account_id,
agent_id=resolved_agent_id,
agent_name=resolved_agent_name,
runtime_location="local",
connection_mode=connection_mode,
include_pair_token=True,
)
except PairingMissingError as exc:
Expand Down Expand Up @@ -283,6 +310,44 @@ def _on_polling() -> None:
typer.echo(f" agentId: {resolved_agent_id}")


@pair_app.command("register", help="Register or update this local agent identity in web-auth")
def pair_register(
agent_id: str = typer.Option(..., "--agent-id", help="Stable local agent id."),
agent_name: str = typer.Option("", "--agent-name", help="Human-readable agent name."),
account_id: str = typer.Option("", "--account-id", help="web-auth account id. Defaults to paired account."),
connection_mode: str = typer.Option(
"clone-local",
"--connection-mode",
help="clone-local, hosted-mcp-tools, or hosted-mcp-tools-inference.",
),
json_output: bool = typer.Option(False, "--json", help="Print raw JSON response."),
) -> None:
_ensure_path()
from cli.web_auth import PairingInvalidError, PairingMissingError, register_agent

try:
result = register_agent(
account_id=account_id or None,
agent_id=agent_id,
agent_name=agent_name or agent_id,
connection_mode=connection_mode,
)
except (PairingMissingError, PairingInvalidError) as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1)
except Exception as exc:
typer.echo(f"Agent register failed: {exc}", err=True)
raise typer.Exit(1)

if json_output:
typer.echo(json.dumps(result, indent=2))
return
agent = result.get("agent") or {}
typer.echo(f"Registered agent: {agent.get('agentName') or agent_name or agent_id}")
typer.echo(f" agentId: {agent.get('agentId') or agent.get('agent_id') or agent_id}")
typer.echo(f" runtime: {agent.get('runtimeLocation') or 'local'} / {agent.get('connectionMode') or connection_mode}")


@pair_app.command("roles", help="Show maker/taker wallet-role selections stored for this pairing")
def pair_roles() -> None:
_ensure_path()
Expand Down
17 changes: 17 additions & 0 deletions cli/commands/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,25 @@ def trade_cmd(
f"Placing {side.upper()} {size} {instrument} @ {price} ({tif}) on {network} "
f"(notional=${notional_usd:.2f}, max=${notional_cap:.2f})"
)
builder_cfg = cfg.get_builder_config()
try:
builder_cfg.validate_for_broadcast()
builder_validation = "ok"
except RuntimeError as exc:
builder_validation = str(exc)
builder_info = builder_cfg.to_builder_info()
typer.echo(
"Builder-code: "
f"{builder_validation}; address={builder_cfg.builder_address or '-'}; "
f"fee_tenths_bps={builder_cfg.fee_rate_tenths_bps}"
)

if dry_run:
typer.echo("Dry run: order not submitted.")
return
if builder_validation != "ok":
typer.echo(builder_validation, err=True)
raise typer.Exit(1)

if hl is None:
private_key = cfg.get_private_key()
Expand All @@ -148,6 +163,7 @@ def trade_cmd(
size=size,
price=price,
tif=tif,
builder=builder_info,
)

if fill:
Expand Down Expand Up @@ -180,6 +196,7 @@ def trade_cmd(
"strategy": "manual_trade",
"route": "cli.trade",
"network": network,
**builder_cfg.metadata(),
})
else:
typer.echo("No fill (order may have been rejected or not matched)")
18 changes: 17 additions & 1 deletion cli/hl_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,24 @@ class APICircuitBreakerOpen(Exception):
def _default_builder() -> Optional[dict]:
"""Return the default Nunchi builder fee. Always active unless overridden."""
from cli.builder_fee import BuilderFeeConfig
return BuilderFeeConfig().to_builder_info()
cfg = BuilderFeeConfig()
cfg.validate_for_broadcast()
return cfg.to_builder_info()
ZERO = Decimal("0")


def _validate_builder_info(builder: Optional[dict]) -> dict:
if not isinstance(builder, dict):
raise RuntimeError("builder-code validation failed: missing builder fee metadata")
address = builder.get("b")
fee = builder.get("f")
if not isinstance(address, str) or not address.startswith("0x") or len(address) != 42:
raise RuntimeError("builder-code validation failed: invalid builder address")
if not isinstance(fee, int) or fee <= 0:
raise RuntimeError("builder-code validation failed: builder fee must be a positive integer")
return builder


def _to_hl_coin(instrument: str) -> str:
"""Map instrument name to HL coin for API calls.

Expand Down Expand Up @@ -297,6 +311,7 @@ def place_order(
# This is the sole enforcement point — all order paths flow through here.
if builder is None:
builder = _default_builder()
builder = _validate_builder_info(builder)
coin = _to_hl_coin(instrument)
is_buy = side.lower() == "buy"

Expand Down Expand Up @@ -474,6 +489,7 @@ def place_trigger_order(self, instrument: str, side: str, size: float, trigger_p
"""
if builder is None:
builder = _default_builder()
builder = _validate_builder_info(builder)
coin = self._to_coin(instrument)
is_buy = side.lower() == "buy"
sz = self._round_size(coin, size)
Expand Down
Loading