Skip to content
Merged
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
112 changes: 95 additions & 17 deletions cli/commands/hedge.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ def _build_proposal(

Returns (proposal, snapshot) or raises typer.Exit if no position open.
"""
state = hl.get_account_state()
return _build_proposal_from_state(
state,
coin,
mainnet=mainnet,
hedge_instrument=hedge_instrument,
)


def _build_proposal_from_state(
state: Optional[dict],
coin: str,
*,
mainnet: bool = False,
hedge_instrument: Optional[str] = None,
):
"""Build a hedge proposal from an already-fetched account state."""
from strategies.cfi_hedge import build_cfi_hedge_proposal, get_cfi_profile
from strategies.cfi_funding import (
fetch_cfi_funding_snapshot,
Expand All @@ -130,7 +147,6 @@ def _build_proposal(
typer.echo(f"Error: no deployed CFI v2 profile for coin '{coin}'", err=True)
raise typer.Exit(2)

state = hl.get_account_state()
if not state:
typer.echo("Error: could not fetch HL account state", err=True)
raise typer.Exit(1)
Expand Down Expand Up @@ -162,28 +178,56 @@ def _build_proposal(
return proposal, snapshot


def _build_view_only_proposal(
address: str,
coin: str,
*,
mainnet: bool = False,
hedge_instrument: Optional[str] = None,
):
from cli.hl_adapter import read_only_account_state

state = read_only_account_state(address, testnet=not mainnet)
return _build_proposal_from_state(
state,
coin,
mainnet=mainnet,
hedge_instrument=hedge_instrument,
)


# ─── propose ─────────────────────────────────────────────────────────────────


@hedge_app.command("propose")
def propose_cmd(
coin: str = typer.Argument("BTC", help="Coin to hedge (BTC, ETH)"),
mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"),
address: Optional[str] = typer.Option(
None,
"--address",
"-a",
help="View-only: build proposal for this address without loading a signing key.",
),
):
"""Show a CFI v2 hedge proposal without executing."""
_boot_cli()

from cli.config import TradingConfig
from cli.hedge_display import hedge_proposal_block
from cli.hl_adapter import DirectHLProxy
from cli.view_mode import view_address
from parent.hl_proxy import HLProxy

cfg = TradingConfig()
private_key = cfg.get_private_key()
raw_hl = HLProxy(private_key=private_key, testnet=not mainnet)
hl = DirectHLProxy(raw_hl)

proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet)
view_only_address = view_address(address)
if view_only_address:
proposal, snapshot = _build_view_only_proposal(view_only_address, coin, mainnet=mainnet)
else:
cfg = TradingConfig()
private_key = cfg.get_private_key()
raw_hl = HLProxy(private_key=private_key, testnet=not mainnet)
hl = DirectHLProxy(raw_hl)
proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet)
typer.echo(hedge_proposal_block(proposal, snapshot, mainnet=mainnet))


Expand All @@ -196,6 +240,12 @@ def execute_cmd(
dry_run: bool = typer.Option(False, "--dry-run", help="Preview only; do not sign or submit"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirm"),
mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"),
address: Optional[str] = typer.Option(
None,
"--address",
"-a",
help="View-only dry-run: preview this address without loading a signing key.",
),
):
"""Build the proposal and optionally sign + submit a real yex:{COIN}SWP order.

Expand All @@ -207,14 +257,19 @@ def execute_cmd(
from cli.display import BOLD, GREEN, RESET
from cli.hedge_display import hedge_proposal_block
from cli.hl_adapter import DirectHLProxy
from cli.view_mode import view_address
from parent.hl_proxy import HLProxy

cfg = TradingConfig()
private_key = cfg.get_private_key()
raw_hl = HLProxy(private_key=private_key, testnet=not mainnet)
hl = DirectHLProxy(raw_hl)

proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet)
view_only_address = view_address(address)
if view_only_address and dry_run:
proposal, snapshot = _build_view_only_proposal(view_only_address, coin, mainnet=mainnet)
hl = None
else:
cfg = TradingConfig()
private_key = cfg.get_private_key()
raw_hl = HLProxy(private_key=private_key, testnet=not mainnet)
hl = DirectHLProxy(raw_hl)
proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet)
typer.echo(hedge_proposal_block(proposal, snapshot, mainnet=mainnet))

# Size the order in CFI v2 (BTCSWP) units. SDK rounds to szDecimals.
Expand Down Expand Up @@ -417,12 +472,35 @@ def backtest_cmd(
/ "hedge_calculator.py"
)
if not script_path.exists():
from strategies.cfi_hedge import get_cfi_profile, hourly_to_apy
from strategies.cfi_funding import fetch_cfi_funding_snapshot

profile = get_cfi_profile(coin, mainnet=False)
if profile is None:
typer.echo(f"Error: no CFI profile for coin '{coin}'", err=True)
raise typer.Exit(2)
snapshot = fetch_cfi_funding_snapshot(profile)
hedge_notional = notional / profile.vol_mult_l
fixed_cost = notional * snapshot.k_fixed_hr * 24 * days
typer.echo(
f"Error: hedge_calculator.py not found at {script_path}. "
f"Pass --script to override.",
err=True,
json.dumps(
{
"mode": "builtin_cfi_projection",
"note": "Reference hedge_calculator.py was not packaged; using built-in CFI v2 math.",
"coin": coin.upper(),
"days": days,
"perp_notional_usd": notional,
"hedge_notional_usd": hedge_notional,
"vol_mult_l": profile.vol_mult_l,
"k_fixed_hr": snapshot.k_fixed_hr,
"k_fixed_apy": hourly_to_apy(snapshot.k_fixed_hr),
"estimated_fixed_leg_cost_usd": fixed_cost,
"script_path_missing": str(script_path),
},
indent=2,
)
)
raise typer.Exit(2)
return

cmd = [
sys.executable,
Expand Down
11 changes: 11 additions & 0 deletions cli/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
"x-nunchi-secret-nunchi-web-auth-pair-token": "NUNCHI_WEB_AUTH_PAIR_TOKEN",
"x-nunchi-web-auth-address": "NUNCHI_WEB_AUTH_ADDRESS",
"x-nunchi-secret-nunchi-web-auth-address": "NUNCHI_WEB_AUTH_ADDRESS",
"x-nunchi-account-id": "NUNCHI_ACCOUNT_ID",
"x-nunchi-secret-nunchi-account-id": "NUNCHI_ACCOUNT_ID",
"x-nunchi-trading-permission-tier": "NUNCHI_TRADING_PERMISSION_TIER",
"x-nunchi-secret-nunchi-trading-permission-tier": "NUNCHI_TRADING_PERMISSION_TIER",
"x-nunchi-trading-network": "NUNCHI_TRADING_NETWORK",
Expand Down Expand Up @@ -227,6 +229,11 @@ def _trusted_context_env_overrides(ctx: Any) -> dict[str, str]:
policy = _policy_from_context_env(overrides)
if policy is not None:
overrides["NUNCHI_SESSION_POLICY"] = policy
view_address = _clean_context_value(
overrides.get("NUNCHI_WEB_AUTH_ADDRESS") or overrides.get("NUNCHI_ACCOUNT_ID")
)
if view_address and "HL_VIEW_AS_USER" not in overrides:
overrides["HL_VIEW_AS_USER"] = view_address
return overrides


Expand Down Expand Up @@ -487,11 +494,15 @@ def setup_check(ctx: FastMCPContext = None) -> str:
has_web_auth = bool(env_overrides.get("NUNCHI_WEB_AUTH_PAIR_TOKEN")) and bool(
env_overrides.get("NUNCHI_WEB_AUTH_ADDRESS")
)
has_view_only = bool(env_overrides.get("HL_VIEW_AS_USER"))
permission_tier = str(env_overrides.get("NUNCHI_TRADING_PERMISSION_TIER") or "").strip().lower()
keystores = list_keystores()
if has_env_key:
ok_items.append("HL_PRIVATE_KEY set")
elif has_web_auth:
ok_items.append("web-auth pairing context provided")
elif has_view_only and permission_tier == "read_only":
ok_items.append(f"view-only context provided ({env_overrides.get('HL_VIEW_AS_USER')})")
elif keystores:
ok_items.append(f"Keystore found ({len(keystores)} keys)")
else:
Expand Down
22 changes: 21 additions & 1 deletion scripts/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ def handle_mcp_json_rpc(raw_body: bytes, headers: Any) -> tuple[int, dict[str, A
if method == "tools/call":
name = str(params.get("name", ""))
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
return 200, _json_rpc_result(request_id, {"content": [{"type": "text", "text": call_mcp_tool(name, arguments, headers)}]})
return 200, _json_rpc_result(request_id, _mcp_tool_result(call_mcp_tool(name, arguments, headers)))
return 200, _json_rpc_error(request_id, -32601, f"method not found: {method}")
except Exception as exc:
log.exception("MCP JSON-RPC error")
Expand Down Expand Up @@ -522,6 +522,22 @@ def call_mcp_tool(name: str, arguments: dict[str, Any], headers: Any) -> str:
return _json_error(f"unknown tool: {name}")


def _mcp_tool_result(text: str) -> dict[str, Any]:
result: dict[str, Any] = {"content": [{"type": "text", "text": text}]}
try:
parsed = json.loads(text)
except (TypeError, json.JSONDecodeError):
return result
if isinstance(parsed, dict) and parsed.get("error"):
result["isError"] = True
result["structuredContent"] = {
"ok": False,
"error": parsed.get("error"),
**({"code": parsed.get("code")} if parsed.get("code") else {}),
}
return result


def _context_from_headers(headers: Any) -> Any:
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
request = SimpleNamespace(headers=normalized)
Expand All @@ -542,11 +558,15 @@ def _setup_check_text(env_overrides: dict[str, str]) -> str:

has_env_key = bool(env_overrides.get("HL_PRIVATE_KEY") or os.environ.get("HL_PRIVATE_KEY"))
has_web_auth = bool(env_overrides.get("NUNCHI_WEB_AUTH_PAIR_TOKEN")) and bool(env_overrides.get("NUNCHI_WEB_AUTH_ADDRESS"))
has_view_only = bool(env_overrides.get("HL_VIEW_AS_USER"))
permission_tier = str(env_overrides.get("NUNCHI_TRADING_PERMISSION_TIER") or "").strip().lower()
keystores = list_keystores()
if has_env_key:
ok_items.append("HL_PRIVATE_KEY set")
elif has_web_auth:
ok_items.append("web-auth pairing context provided")
elif has_view_only and permission_tier == "read_only":
ok_items.append(f"view-only context provided ({env_overrides.get('HL_VIEW_AS_USER')})")
elif keystores:
ok_items.append(f"Keystore found ({len(keystores)} keys)")
else:
Expand Down
Loading