From 26a439feed1994a8bbd15b9a4dff601129fcac24 Mon Sep 17 00:00:00 2001 From: Cedric AUDRIT Date: Fri, 12 Jun 2026 21:55:32 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(paper):=20PAPER=20mode=20=E2=80=94=20s?= =?UTF-8?q?imulated=20fills=20against=20live=20market=20prices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New mode between OFF and LIVE: the full pipeline (scan, TA, AI research, risk gates, DSL exits) runs unchanged against live data, but every authenticated exchange call routes to a simulated book (client/paper_engine.py): - fills at the live L2 touch + paper_slippage_bps, taker fees at paper_fee_bps, position netting with realized PnL - reduce-only clamps to the live size (never flips), pre-trade margin check - virtual SL/TP triggers evaluated against live mids on every account read - state persisted atomically to .paper-state.json across restarts - fetch_account_state returns the virtual book in HL's exact clearinghouseState shape, so gates/DSL/dashboard need no changes - no HYPERLIQUID_* env vars required (resolve_user_address -> "paper") Loop startup now logs the real configured mode (was hardcoded "LIVE"). Covered by tests/test_paper.py (14 offline tests). Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + README.md | 18 +- hermes_trader/client/exchange.py | 13 + hermes_trader/client/hl_client.py | 20 +- hermes_trader/client/paper_engine.py | 418 +++++++++++++++++++++++++++ scripts/hermes-mcp-server.py | 6 +- scripts/trading_loop.py | 8 +- tests/test_paper.py | 206 +++++++++++++ 8 files changed, 686 insertions(+), 4 deletions(-) create mode 100644 hermes_trader/client/paper_engine.py create mode 100644 tests/test_paper.py diff --git a/.gitignore b/.gitignore index bfa78d6d..e9940024 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ DISTRIBUTE.md .agent-config.json .agent-memory.json .dsl-state.json +.paper-state.json .positions-snapshot.json .monitor-journal.md diff --git a/README.md b/README.md index ac9267cf..87f0409e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,19 @@ python3 scripts/status.py Dashboard served at `http://localhost:8000` (port from `HERMES_PORT`). +### Paper trading (no keys, no funds) + +Set `"mode": "PAPER"` in `.agent-config.json` and start the loop normally. The +full pipeline runs against **live market data** — scans, AI research, risk +gates, DSL exits — but every order is filled by a simulated book +(`hermes_trader/client/paper_engine.py`): fills at the live L2 touch plus +`paper_slippage_bps`, taker fees at `paper_fee_bps`, virtual SL/TP triggers +evaluated against live mids, state persisted to `.paper-state.json` across +restarts. No `HYPERLIQUID_*` env vars required. Limitations: orders always +fill in full (no partial fills / book-depth exhaustion) and triggers fill at +their trigger price — real markets gap. Graduate to `LIVE` only after the +paper book has survived long enough to trust the configuration. + --- ## The problem it solves @@ -211,7 +224,10 @@ both resolve (`max_trade_notional_usd` ≡ `maxTradeNotionalUsd`). | Key | What it does | Default | |-----|--------------|---------| -| `mode` | `OFF` = analyse only, no orders · `LIVE` = place real orders | `OFF` | +| `mode` | `OFF` = analyse only, no orders · `PAPER` = simulated fills against live prices, no keys needed · `LIVE` = place real orders | `OFF` | +| `paper_starting_equity` | PAPER mode: virtual starting balance (USD) | `10000` | +| `paper_fee_bps` | PAPER mode: taker fee charged per side, in bps | `4.5` | +| `paper_slippage_bps` | PAPER mode: slippage applied past the live touch on fills | `2` | | `equity_fraction_per_trade` | Fraction of perp equity committed as margin per trade — see [Trade Sizing](#trade-sizing) | `0.01` | | `leverage` | Leverage **ceiling** — each trade uses `min(this, the coin's own max)`. Coin maxes differ (BOME 3×, BTC 40×). Set high (e.g. 40) to ride each coin's max. Also multiplies position notional. | `5` | | `min_ai_confidence` | Minimum AI confidence for a LONG/SHORT to execute | `0.8` | diff --git a/hermes_trader/client/exchange.py b/hermes_trader/client/exchange.py index a2f6440b..5a707449 100644 --- a/hermes_trader/client/exchange.py +++ b/hermes_trader/client/exchange.py @@ -37,6 +37,7 @@ fetch_hl_candles, resolve_user_address, ) +from hermes_trader.client import paper_engine logger = logging.getLogger(__name__) @@ -342,6 +343,8 @@ def set_leverage(coin: str, leverage: int) -> Dict[str, Any]: No-op when no private key is set. """ + if paper_engine.paper_mode_active(): + return paper_engine.set_leverage(coin, leverage) if not PRIVATE_KEY_HEX: return {"ok": False, "error": "no private key"} @@ -483,6 +486,9 @@ def place_hl_order( OVERSHOOT and flip the position to the opposite side without this flag. With reduce_only, HL fills only up to the live position size and rejects the rest → clean flatten, never a flip.""" + if paper_engine.paper_mode_active(): + return paper_engine.place_order(is_buy, size, mid_price, coin, + reduce_only=reduce_only) if not PRIVATE_KEY_HEX: return {"ok": False, "error": "HYPERLIQUID_PRIVATE_KEY not set"} if mid_price <= 0: @@ -548,6 +554,9 @@ def place_hl_trigger_order( Triggers a market order in the position-closing direction once the trigger price is crossed. """ + if paper_engine.paper_mode_active(): + return paper_engine.place_trigger_order(is_long_position, size, + trigger_px, kind, coin) if not PRIVATE_KEY_HEX: return {"ok": False, "error": "HYPERLIQUID_PRIVATE_KEY not set"} if size <= 0 or trigger_px <= 0: @@ -602,6 +611,8 @@ def cancel_open_orders_for_coin(coin: str) -> int: stale triggers accumulate and a later reduce-only order on the same coin is rejected ('reduce only order would increase position'). Returns the count cancelled. Never raises.""" + if paper_engine.paper_mode_active(): + return paper_engine.cancel_open_orders_for_coin(coin) try: user = resolve_user_address() if not user: @@ -622,6 +633,8 @@ def cancel_open_orders_for_coin(coin: str) -> int: def cancel_orders(oid: int, coin: Optional[str] = None, asset_idx: Optional[int] = None) -> Dict[str, Any]: """Cancel an order by order ID.""" + if paper_engine.paper_mode_active(): + return paper_engine.cancel_order(oid) if not PRIVATE_KEY_HEX: return {"ok": False, "error": "PRIVATE_KEY not set"} diff --git a/hermes_trader/client/hl_client.py b/hermes_trader/client/hl_client.py index c5836f23..41002066 100644 --- a/hermes_trader/client/hl_client.py +++ b/hermes_trader/client/hl_client.py @@ -151,7 +151,15 @@ def get_info() -> "Info | None": def resolve_user_address() -> str: - """Master address if set, else wallet address, else empty string.""" + """Master address if set, else wallet address, else empty string. + + In PAPER mode returns the sentinel "paper" so `if not user` guards + (heartbeat, executor, close path) pass without any wallet env vars — + every authenticated read/write is intercepted by the paper engine anyway. + """ + from hermes_trader.client.paper_engine import paper_mode_active + if paper_mode_active(): + return "paper" return os.environ.get("HYPERLIQUID_MASTER_ADDRESS") or os.environ.get("HYPERLIQUID_WALLET_ADDRESS", "") @@ -229,7 +237,13 @@ def fetch_account_state(user: str, include_hip3: bool = False) -> Dict[str, Any] `available` stays main-dex only because HIP-3 free margin only backs trades on its own dex; the executor sizes against this for main trades. + + In PAPER mode, returns the simulated book (same shape) marked to live mids. """ + from hermes_trader.client.paper_engine import paper_mode_active, account_state + if paper_mode_active(): + return account_state(include_hip3=include_hip3) + perp = _http_post("/info", {"type": "clearinghouseState", "user": user}) spot = _http_post("/info", {"type": "spotClearinghouseState", "user": user}) @@ -338,7 +352,11 @@ def fetch_aggregate_contributions_since(user: str, start_ms: int) -> float: spot↔HIP-3); treats intra-pool transfers (main↔xyz, xyz↔vntl) as neutral. Returns 0.0 on lookup failure to avoid distorting PnL on transient outages. + Always 0.0 in PAPER mode — the simulated book has no deposits/transfers. """ + from hermes_trader.client.paper_engine import paper_mode_active + if paper_mode_active(): + return 0.0 if not user or start_ms <= 0: return 0.0 try: diff --git a/hermes_trader/client/paper_engine.py b/hermes_trader/client/paper_engine.py new file mode 100644 index 00000000..f754c67b --- /dev/null +++ b/hermes_trader/client/paper_engine.py @@ -0,0 +1,418 @@ +"""Paper trading engine — simulated fills against LIVE market prices. + +Activated by `"mode": "PAPER"` in .agent-config.json. The whole pipeline +(scan → TA → AI research → risk gates → DSL exits) runs exactly as in LIVE, +but every authenticated exchange call is intercepted: + +- `place_hl_order` → filled instantly against the live book touch + (best bid/ask via l2Book, fallback mid) plus + configurable slippage, minus taker fees +- `place_hl_trigger_order` → stored as a virtual resting trigger, evaluated + against live mids on every account-state read +- `set_leverage` / cancels → applied to the virtual book +- `fetch_account_state` → returns the virtual portfolio in the exact + shape of HL's clearinghouseState aggregation + +State is persisted atomically to .paper-state.json (HERMES_PAPER_STATE_FILE) +so the book survives daemon restarts, mirroring .dsl-state.json. + +Config knobs (all in .agent-config.json): +- paper_starting_equity (default 10_000 USD) +- paper_fee_bps (default 4.5 — HL taker, charged per side) +- paper_slippage_bps (default 2 — applied past the touch on fills, + and adversely on trigger fills) + +Known approximations, by design: +- IOC orders always fill in full (no partial fills, no "could not + immediately match"); paper can't reproduce book-depth exhaustion. +- Trigger orders fill AT the trigger price ± slippage; real markets gap. +- The pre-trade margin check uses realized cash, not marked equity. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_LOCK = threading.RLock() +_state: Optional[Dict[str, Any]] = None + +_MAX_FILLS_KEPT = 300 + + +def paper_mode_active() -> bool: + """True when .agent-config.json says `"mode": "PAPER"` (case-insensitive).""" + from hermes_trader.agents.config_store import read_agent_config + return str(read_agent_config().get("mode", "OFF")).upper() == "PAPER" + + +def _cfg() -> Dict[str, Any]: + from hermes_trader.agents.config_store import read_agent_config + return read_agent_config() + + +def _state_path() -> str: + return os.environ.get( + "HERMES_PAPER_STATE_FILE", os.path.join(_ROOT, ".paper-state.json")) + + +def _fresh_state() -> Dict[str, Any]: + start = float(_cfg().get("paper_starting_equity", + os.environ.get("HERMES_PAPER_EQUITY", 10_000)) or 10_000) + return { + "cash": start, + "starting_equity": start, + "positions": {}, # coin -> {szi, entry_px, leverage} + "triggers": [], # [{oid, coin, is_buy, trigger_px, kind, size}] + "leverage": {}, # coin -> int (set_leverage results) + "next_oid": 1, + "realized_pnl": 0.0, + "fees_paid": 0.0, + "fills": [], + "created_at": time.time(), + } + + +def _load() -> Dict[str, Any]: + global _state + with _LOCK: + if _state is not None: + return _state + try: + with open(_state_path(), "r") as f: + _state = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + _state = _fresh_state() + logger.info(f"[paper] fresh book: ${_state['cash']:.2f} starting equity") + return _state + + +def _save() -> None: + with _LOCK: + if _state is None: + return + tmp = _state_path() + ".tmp" + with open(tmp, "w") as f: + json.dump(_state, f, indent=2) + os.replace(tmp, _state_path()) + + +def reset_book() -> Dict[str, Any]: + """Wipe the paper book back to starting equity (operator/testing helper).""" + global _state + with _LOCK: + _state = _fresh_state() + _save() + return dict(_state) + + +# ── Live market data (read-only, best-effort) ───────────────────────────────── + +def _live_mid(coin: str, fallback: float = 0.0) -> float: + """Current live mid for one coin; falls back to the caller's price.""" + from hermes_trader.client.hl_client import _http_post + try: + if ":" in coin: + dex = coin.split(":", 1)[0] + mids = _http_post("/info", {"type": "allMids", "dex": dex}) or {} + else: + mids = _http_post("/info", {"type": "allMids"}) or {} + v = mids.get(coin) + if v is not None: + return float(v) + except Exception as e: + logger.warning(f"[paper] allMids failed for {coin}: {e}") + return fallback + + +def _touch_price(coin: str, is_buy: bool, mid: float) -> float: + """Best ask (buy) / best bid (sell) from a live l2Book; fallback mid.""" + from hermes_trader.client.hl_client import _http_post + try: + book = _http_post("/info", {"type": "l2Book", "coin": coin}) or {} + levels = book.get("levels", []) + bids, asks = levels[0], levels[1] + if is_buy and asks: + return float(asks[0]["px"]) + if not is_buy and bids: + return float(bids[0]["px"]) + except Exception: + pass # thin/namespaced books flake; mid fallback is fine for paper + return mid + + +def _fill_px(coin: str, is_buy: bool, mid: float) -> float: + slip = float(_cfg().get("paper_slippage_bps", 2)) / 10_000.0 + px = _touch_price(coin, is_buy, mid) + return px * (1 + slip) if is_buy else px * (1 - slip) + + +# ── Book mutations ───────────────────────────────────────────────────────────── + +def _record_fill(st: Dict[str, Any], **fill: Any) -> None: + fill["ts"] = time.time() + st["fills"].append(fill) + if len(st["fills"]) > _MAX_FILLS_KEPT: + st["fills"] = st["fills"][-_MAX_FILLS_KEPT:] + + +def _apply_fill(st: Dict[str, Any], coin: str, delta: float, px: float, + kind: str) -> float: + """Apply a signed size delta at px to the book. Returns realized PnL. + + Handles netting: the portion opposing an existing position realizes PnL + against its entry; any remainder opens (or extends) at the fill price + with a size-weighted average entry. + """ + fee_bps = float(_cfg().get("paper_fee_bps", 4.5)) / 10_000.0 + pos = st["positions"].get(coin) + szi = float(pos["szi"]) if pos else 0.0 + entry = float(pos["entry_px"]) if pos else 0.0 + lev = int(pos["leverage"]) if pos else int(st["leverage"].get(coin, 5)) + + realized = 0.0 + if szi != 0.0 and (szi > 0) != (delta > 0): + closed = min(abs(szi), abs(delta)) + realized = (px - entry) * closed * (1 if szi > 0 else -1) + st["cash"] += realized + st["realized_pnl"] += realized + + new_szi = szi + delta + if abs(new_szi) < 1e-12: + st["positions"].pop(coin, None) + st["triggers"] = [t for t in st["triggers"] if t["coin"] != coin] + elif szi == 0.0 or (szi > 0) == (new_szi > 0) and abs(new_szi) > abs(szi): + # opened or extended: size-weighted entry over the added portion + added = abs(new_szi) - max(0.0, abs(szi)) + base = abs(szi) if (szi != 0 and (szi > 0) == (new_szi > 0)) else 0.0 + avg_entry = (entry * base + px * added) / (base + added) if base else px + st["positions"][coin] = {"szi": new_szi, "entry_px": avg_entry, "leverage": lev} + else: + # reduced (same side, smaller) or flipped through zero + flipped = (szi > 0) != (new_szi > 0) + st["positions"][coin] = { + "szi": new_szi, + "entry_px": px if flipped else entry, + "leverage": lev, + } + + fee = abs(delta) * px * fee_bps + st["cash"] -= fee + st["fees_paid"] += fee + _record_fill(st, coin=coin, side="buy" if delta > 0 else "sell", + px=px, sz=abs(delta), fee=fee, realized=realized, kind=kind) + return realized + + +def _margin_used(st: Dict[str, Any]) -> float: + return sum(abs(p["szi"]) * p["entry_px"] / max(1, int(p.get("leverage", 1))) + for p in st["positions"].values()) + + +# ── Exchange-API mirrors (called from exchange.py when paper mode is on) ────── + +def place_order(is_buy: bool, size: float, mid_price: float, coin: str, + reduce_only: bool = False) -> Dict[str, Any]: + """Paper mirror of exchange.place_hl_order — instant full fill.""" + if size <= 0: + return {"ok": False, "error": "invalid size"} + mid = _live_mid(coin, fallback=mid_price) + if mid <= 0: + return {"ok": False, "error": f"invalid price for {coin}"} + px = _fill_px(coin, is_buy, mid) + + with _LOCK: + st = _load() + pos = st["positions"].get(coin) + szi = float(pos["szi"]) if pos else 0.0 + delta = size if is_buy else -size + + if reduce_only: + if szi == 0.0 or (szi > 0) == (delta > 0): + return {"ok": False, + "error": "reduce only order would increase position"} + # HL fills only up to the live position size — clean flatten. + delta = max(-abs(szi), min(abs(szi), delta)) + + if not reduce_only: + opening = abs(szi + delta) - abs(szi) + if opening > 0: + lev = max(1, int(st["leverage"].get(coin, 5))) + projected = _margin_used(st) + (opening * px) / lev + if projected > st["cash"]: + return {"ok": False, + "error": "Insufficient margin to place order (paper)"} + + _apply_fill(st, coin, delta, px, kind="ioc") + oid = st["next_oid"] + st["next_oid"] += 1 + _save() + + logger.info(f"[paper] FILL {coin} {'BUY' if is_buy else 'SELL'} " + f"{abs(delta):.6f} @ {px:.6g} (reduce_only={reduce_only})") + return {"ok": True, "order_id": str(oid), "avg_px": px, + "total_sz": abs(delta), "paper": True} + + +def place_trigger_order(is_long_position: bool, size: float, trigger_px: float, + kind: str, coin: str) -> Dict[str, Any]: + """Paper mirror of exchange.place_hl_trigger_order — virtual resting order.""" + if size <= 0 or trigger_px <= 0: + return {"ok": False, "error": "invalid size/price"} + with _LOCK: + st = _load() + oid = st["next_oid"] + st["next_oid"] += 1 + st["triggers"].append({ + "oid": oid, "coin": coin, "is_buy": not is_long_position, + "trigger_px": float(trigger_px), "kind": kind, "size": float(size), + }) + _save() + return {"ok": True, "order_id": str(oid), "paper": True} + + +def set_leverage(coin: str, leverage: int) -> Dict[str, Any]: + with _LOCK: + st = _load() + st["leverage"][coin] = int(leverage) + _save() + return {"ok": True, "paper": True, "is_cross": True} + + +def cancel_order(oid: int) -> Dict[str, Any]: + with _LOCK: + st = _load() + before = len(st["triggers"]) + st["triggers"] = [t for t in st["triggers"] if int(t["oid"]) != int(oid)] + if len(st["triggers"]) != before: + _save() + return {"ok": True, "paper": True} + return {"ok": False, "error": f"unknown paper order {oid}"} + + +def cancel_open_orders_for_coin(coin: str) -> int: + with _LOCK: + st = _load() + before = len(st["triggers"]) + st["triggers"] = [t for t in st["triggers"] if t["coin"] != coin] + n = before - len(st["triggers"]) + if n: + _save() + logger.info(f"[paper] cancelled {n} virtual trigger(s) for {coin}") + return n + + +# ── Trigger evaluation + account state ───────────────────────────────────────── + +def _check_triggers(st: Dict[str, Any], mids: Dict[str, float]) -> None: + """Fire virtual SL/TP triggers crossed by the live mid (reduce-only).""" + slip = float(_cfg().get("paper_slippage_bps", 2)) / 10_000.0 + fired: List[Dict[str, Any]] = [] + for t in list(st["triggers"]): + pos = st["positions"].get(t["coin"]) + if not pos: + st["triggers"].remove(t) + continue + mid = mids.get(t["coin"]) + if not mid or mid <= 0: + continue + long_pos = float(pos["szi"]) > 0 + trig = float(t["trigger_px"]) + if long_pos: + hit = mid <= trig if t["kind"] == "sl" else mid >= trig + else: + hit = mid >= trig if t["kind"] == "sl" else mid <= trig + if hit: + fired.append(t) + + for t in fired: + pos = st["positions"].get(t["coin"]) + if not pos: + continue + szi = float(pos["szi"]) + close_sz = min(float(t["size"]), abs(szi)) + delta = close_sz if t["is_buy"] else -close_sz + # adverse slippage on the stop-out, none granted on the take-profit side + px = float(t["trigger_px"]) + if t["kind"] == "sl": + px = px * (1 + slip) if t["is_buy"] else px * (1 - slip) + realized = _apply_fill(st, t["coin"], delta, px, kind=f"trigger_{t['kind']}") + if t in st["triggers"]: + st["triggers"].remove(t) + logger.info(f"[paper] TRIGGER {t['kind'].upper()} fired {t['coin']} " + f"@ {px:.6g} (realized {realized:+.2f})") + + +def account_state(include_hip3: bool = False) -> Dict[str, Any]: + """Paper mirror of hl_client.fetch_account_state — same shape, virtual book.""" + from hermes_trader.client.hl_client import fetch_all_mids + with _LOCK: + st = _load() + need_hip3 = include_hip3 or any(":" in c for c in st["positions"]) + try: + mids = {k: float(v) for k, v in fetch_all_mids(include_hip3=need_hip3).items()} + except Exception as e: + logger.warning(f"[paper] mids fetch failed, marking at entry: {e}") + mids = {} + + with _LOCK: + st = _load() + _check_triggers(st, mids) + _save() + + upnl = 0.0 + total_ntl = 0.0 + asset_positions = [] + for coin, p in st["positions"].items(): + szi = float(p["szi"]) + entry = float(p["entry_px"]) + mid = mids.get(coin, entry) + pos_upnl = (mid - entry) * szi + upnl += pos_upnl + total_ntl += abs(szi) * mid + asset_positions.append({ + "type": "oneWay", + "position": { + "coin": coin, + "szi": f"{szi:.10g}", + "entryPx": f"{entry:.10g}", + "leverage": {"type": "cross", "value": int(p.get("leverage", 5))}, + "unrealizedPnl": f"{pos_upnl:.6f}", + "positionValue": f"{abs(szi) * mid:.6f}", + }, + }) + + equity = st["cash"] + upnl + available = max(0.0, equity - _margin_used(st)) + + # Every dex reports "queried" so DSL reconciliation can both drop + # closed paper positions and never falsely preserve stale trackers. + queried: set = {""} + if need_hip3: + try: + from hermes_trader.client.universe import list_hip3_dexes + queried.update(list_hip3_dexes()) + except Exception: + queried.update(c.split(":", 1)[0] for c in st["positions"] if ":" in c) + + return { + "equity": equity, + "available": available, + "available_aggregated": available, + "spot_usdc": 0.0, + "total_usdc": equity, + "total_ntl": total_ntl, + "spot_balances": [], + "asset_positions": asset_positions, + "dex_equity": {d: equity for d in queried}, + "dex_available": {d: available for d in queried}, + "queried_dexes": queried, + "paper": True, + } diff --git a/scripts/hermes-mcp-server.py b/scripts/hermes-mcp-server.py index 1479d9d1..40c837c8 100644 --- a/scripts/hermes-mcp-server.py +++ b/scripts/hermes-mcp-server.py @@ -172,7 +172,11 @@ def handler(params: Dict[str, Any]) -> str: "inputSchema": { "type": "object", "properties": { - "mode": {"type": "string", "enum": ["OFF", "LIVE"]}, + "mode": {"type": "string", "enum": ["OFF", "PAPER", "LIVE"], + "description": "OFF = analyse only; PAPER = simulated fills against live prices; LIVE = real orders."}, + "paper_starting_equity": {"type": "number", "description": "PAPER mode: virtual starting balance (USD)."}, + "paper_fee_bps": {"type": "number", "description": "PAPER mode: taker fee per side, bps."}, + "paper_slippage_bps": {"type": "number", "description": "PAPER mode: slippage past the live touch on fills, bps."}, # ── Sizing / leverage ──────────────────────────────────── "leverage": {"type": "number", "description": "Leverage ceiling per trade (min with coin max)."}, "equity_fraction_per_trade": {"type": "number", "description": "Fraction of equity committed as margin per trade."}, diff --git a/scripts/trading_loop.py b/scripts/trading_loop.py index 7e722489..43c109ed 100644 --- a/scripts/trading_loop.py +++ b/scripts/trading_loop.py @@ -95,7 +95,13 @@ def _watchdog() -> None: logger.info(f"[watchdog] armed pre-startup: re-exec if no progress for {_watchdog_timeout_s}s") logger.info("=== HERMES TRADER - Starting Continuous Trading Loop ===") -logger.info(f"Mode: LIVE env={_args.env} daemon={_args.daemon}") +try: + from hermes_trader.agents.config_store import read_agent_config as _rac + _mode = str(_rac().get("mode", "OFF")).upper() +except Exception: + _mode = "UNKNOWN" +logger.info(f"Mode: {_mode} env={_args.env} daemon={_args.daemon}" + + (" [PAPER — simulated fills, live prices]" if _mode == "PAPER" else "")) config = get_config() # HIP-3 toggle: read once at startup so the prefetched universe includes diff --git a/tests/test_paper.py b/tests/test_paper.py new file mode 100644 index 00000000..86269e79 --- /dev/null +++ b/tests/test_paper.py @@ -0,0 +1,206 @@ +"""Offline tests for the paper trading engine (mode: PAPER). + +All market-data reads are monkeypatched — no network, no credentials. +""" +import json + +import pytest + +from hermes_trader.client import paper_engine + + +MIDS = {"BTC": 100_000.0, "ETH": 3_000.0, "xyz:NVDA": 180.0} + + +@pytest.fixture(autouse=True) +def _paper_sandbox(tmp_path, monkeypatch): + """Isolated paper book per test: temp state file, PAPER config, fixed mids.""" + monkeypatch.setenv("HERMES_PAPER_STATE_FILE", str(tmp_path / "paper.json")) + monkeypatch.setattr(paper_engine, "_state", None) + monkeypatch.setattr(paper_engine, "_cfg", lambda: { + "mode": "PAPER", + "paper_starting_equity": 10_000, + "paper_fee_bps": 4.5, + "paper_slippage_bps": 0, # deterministic fills at the mid for math tests + }) + monkeypatch.setattr(paper_engine, "_live_mid", + lambda coin, fallback=0.0: MIDS.get(coin, fallback)) + monkeypatch.setattr(paper_engine, "_touch_price", + lambda coin, is_buy, mid: mid) + # account_state lazy-imports fetch_all_mids from hl_client + import hermes_trader.client.hl_client as hl + monkeypatch.setattr(hl, "fetch_all_mids", + lambda include_hip3=False: {k: str(v) for k, v in MIDS.items()}) + yield + paper_engine._state = None + + +def _fee(notional): + return notional * 4.5 / 10_000.0 + + +# ── fills & book math ─────────────────────────────────────────────────── + +def test_open_long_fills_and_charges_fee(): + res = paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + assert res["ok"] and res["paper"] + assert res["avg_px"] == 100_000.0 + assert res["total_sz"] == 0.1 + st = paper_engine._load() + assert st["positions"]["BTC"]["szi"] == pytest.approx(0.1) + assert st["cash"] == pytest.approx(10_000 - _fee(10_000)) + + +def test_account_state_matches_hl_shape_and_marks_to_mid(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + MIDS["BTC"] = 110_000.0 + try: + state = paper_engine.account_state() + finally: + MIDS["BTC"] = 100_000.0 + pos = state["asset_positions"][0]["position"] + assert pos["coin"] == "BTC" + assert float(pos["szi"]) == pytest.approx(0.1) + assert float(pos["entryPx"]) == pytest.approx(100_000.0) + assert isinstance(pos["leverage"], dict) and "value" in pos["leverage"] + # equity = cash + unrealized = (10k - fee) + 0.1 * 10k + assert state["equity"] == pytest.approx(10_000 - _fee(10_000) + 1_000) + assert "" in state["queried_dexes"] + assert state["dex_equity"][""] == pytest.approx(state["equity"]) + + +def test_close_realizes_pnl(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + MIDS["BTC"] = 105_000.0 + try: + res = paper_engine.place_order(False, 0.1, 105_000.0, "BTC", + reduce_only=True) + finally: + MIDS["BTC"] = 100_000.0 + assert res["ok"] + st = paper_engine._load() + assert "BTC" not in st["positions"] + assert st["realized_pnl"] == pytest.approx(500.0) # 0.1 × 5 000 + + +def test_short_close_sign_is_correct(): + paper_engine.place_order(False, 1.0, 3_000.0, "ETH") # short 1 ETH @ 3000 + MIDS["ETH"] = 2_700.0 + try: + paper_engine.place_order(True, 1.0, 2_700.0, "ETH", reduce_only=True) + finally: + MIDS["ETH"] = 3_000.0 + st = paper_engine._load() + assert st["realized_pnl"] == pytest.approx(300.0) # short profits on the drop + + +def test_reduce_only_rejects_wrong_direction_and_clamps_size(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + same_dir = paper_engine.place_order(True, 0.1, 100_000.0, "BTC", + reduce_only=True) + assert not same_dir["ok"] and "increase" in same_dir["error"] + oversized = paper_engine.place_order(False, 5.0, 100_000.0, "BTC", + reduce_only=True) + assert oversized["ok"] + assert oversized["total_sz"] == pytest.approx(0.1) # clamped, never flips + assert "BTC" not in paper_engine._load()["positions"] + + +def test_margin_check_blocks_oversized_entry(): + paper_engine.set_leverage("BTC", 5) + # 1 BTC @ 100k / 5x = 20k margin > 10k cash → reject + res = paper_engine.place_order(True, 1.0, 100_000.0, "BTC") + assert not res["ok"] and "margin" in res["error"].lower() + assert paper_engine._load()["positions"] == {} + + +def test_extend_position_averages_entry(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + MIDS["BTC"] = 110_000.0 + try: + paper_engine.place_order(True, 0.1, 110_000.0, "BTC") + finally: + MIDS["BTC"] = 100_000.0 + pos = paper_engine._load()["positions"]["BTC"] + assert pos["szi"] == pytest.approx(0.2) + assert pos["entry_px"] == pytest.approx(105_000.0) + + +# ── virtual triggers ──────────────────────────────────────────────────── + +def test_stop_loss_trigger_fires_on_long(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + paper_engine.place_trigger_order(True, 0.1, 97_500.0, "sl", "BTC") + MIDS["BTC"] = 97_000.0 # below the stop + try: + state = paper_engine.account_state() + finally: + MIDS["BTC"] = 100_000.0 + assert state["asset_positions"] == [] # position closed by the stop + st = paper_engine._load() + assert st["triggers"] == [] + assert st["realized_pnl"] == pytest.approx(-250.0) # 0.1 × −2 500 + assert any(f["kind"] == "trigger_sl" for f in st["fills"]) + + +def test_take_profit_does_not_fire_early_and_fires_on_cross(): + paper_engine.place_order(False, 1.0, 3_000.0, "ETH") # short + paper_engine.place_trigger_order(False, 1.0, 2_850.0, "tp", "ETH") + state = paper_engine.account_state() # mid 3 000 + assert len(state["asset_positions"]) == 1 # not yet + MIDS["ETH"] = 2_800.0 + try: + state = paper_engine.account_state() + finally: + MIDS["ETH"] = 3_000.0 + assert state["asset_positions"] == [] + assert paper_engine._load()["realized_pnl"] == pytest.approx(150.0) + + +def test_cancel_open_orders_for_coin_drops_triggers(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + paper_engine.place_trigger_order(True, 0.1, 97_500.0, "sl", "BTC") + paper_engine.place_trigger_order(True, 0.1, 104_000.0, "tp", "BTC") + assert paper_engine.cancel_open_orders_for_coin("BTC") == 2 + assert paper_engine._load()["triggers"] == [] + + +# ── persistence & reset ───────────────────────────────────────────────── + +def test_state_survives_reload(tmp_path): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + cash_before = paper_engine._load()["cash"] + paper_engine._state = None # simulate daemon restart + st = paper_engine._load() + assert st["positions"]["BTC"]["szi"] == pytest.approx(0.1) + assert st["cash"] == pytest.approx(cash_before) + + +def test_reset_book_restores_starting_equity(): + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + st = paper_engine.reset_book() + assert st["positions"] == {} and st["cash"] == pytest.approx(10_000) + + +# ── integration: exchange-layer interception ──────────────────────────── + +def test_exchange_layer_routes_to_paper(monkeypatch): + from hermes_trader.client import exchange + monkeypatch.setattr(paper_engine, "paper_mode_active", lambda: True) + res = exchange.place_hl_order(True, 0.05, 100_000.0, coin="BTC") + assert res["ok"] and res.get("paper") + assert exchange.set_leverage("BTC", 3)["paper"] + trig = exchange.place_hl_trigger_order(True, 0.05, 95_000.0, "sl", coin="BTC") + assert trig["ok"] and trig.get("paper") + assert exchange.cancel_open_orders_for_coin("BTC") == 1 + + +def test_fetch_account_state_routes_to_paper(monkeypatch): + import hermes_trader.client.hl_client as hl + monkeypatch.setattr(paper_engine, "paper_mode_active", lambda: True) + paper_engine.place_order(True, 0.1, 100_000.0, "BTC") + state = hl.fetch_account_state("whatever", include_hip3=True) + assert state.get("paper") is True + assert len(state["asset_positions"]) == 1 + assert hl.resolve_user_address() == "paper" + assert hl.fetch_aggregate_contributions_since("paper", 1) == 0.0 From 25955154539abb1de026ffacc8d19811c7fe9d46 Mon Sep 17 00:00:00 2001 From: Cedric AUDRIT Date: Fri, 12 Jun 2026 21:55:53 +0200 Subject: [PATCH 2/3] fix(exits): partial-fill close, restart timeout reset, backup-SL resilience - close_position_market: an IOC reduce-only close can PARTIALLY fill on a thin book; the code deregistered the DSL tracker on ok=True without checking totalSz, orphaning the residual position with no stop/floor/ timeout. Now reconciles totalSz vs requested, retries the residual up to 3x (fresh size + price each pass), keeps the tracker and returns ok=False + partial details if any size remains (next DSL tick re-fires). Realized PnL uses the size-weighted fill price across all fills. - rehydrate_from_exchange: synthesized trackers stamped entry_time=now, re-arming hard_timeout_minutes from scratch on every restart. Now looks up the real open time from the account's fill history (most recent open-from-flat fill), falling back to now when unavailable. - backup SL/TP triggers: placement is retried once (typical failure is a transient meta-cache/429 flake) and a persistent SL failure now emits an error event to the session feed instead of only a log line. - gate inputs: open-position notional now uses HL's positionValue (live mark) instead of size x the stale analysis entry price. - place_hl_trigger_order callers pass is_long_position= explicitly (the positional is_buy only worked because entry direction == position side). - maybe_execute no longer requires HYPERLIQUID_PRIVATE_KEY in PAPER mode. Covered by tests/test_close_and_rehydrate_fixes.py (6 regression tests). Co-Authored-By: Claude Fable 5 --- hermes_trader/agents/dsl_exit.py | 35 +++++- hermes_trader/agents/executor.py | 96 ++++++++++++++-- tests/test_close_and_rehydrate_fixes.py | 141 ++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 tests/test_close_and_rehydrate_fixes.py diff --git a/hermes_trader/agents/dsl_exit.py b/hermes_trader/agents/dsl_exit.py index 0988e528..327b39a2 100644 --- a/hermes_trader/agents/dsl_exit.py +++ b/hermes_trader/agents/dsl_exit.py @@ -459,6 +459,32 @@ def _policy_from_config() -> ExitPolicy: return ExitPolicy() +def _position_open_time_s(coin: str, side: str) -> float: + """Best-effort epoch (s) when the live position was opened, from the + account's fill history: the most recent fill that opened it FROM FLAT. + + Synthesized trackers used to stamp entry_time=now, which RESET the + hard-timeout clock — a 2h50m-old position got a fresh 3h lease on every + daemon restart. Falls back to now when history is unavailable (paper + mode, API flake, or a position older than the fill-history window). + """ + try: + from hermes_trader.client.hl_client import _http_post, resolve_user_address + user = resolve_user_address() + if not user or user == "paper": + return time.time() + fills = _http_post("/info", {"type": "userFills", "user": user}) or [] + want_dir = "Open Long" if side == "long" else "Open Short" + for f in fills: # newest first + if f.get("coin") != coin: + continue + if f.get("dir") == want_dir and float(f.get("startPosition", 0) or 0) == 0: + return float(f["time"]) / 1000.0 + except Exception as e: + logger.warning(f"[dsl] open-time lookup failed for {coin}: {e}") + return time.time() + + def rehydrate_from_exchange(asset_positions: Iterable[Dict[str, Any]], policy: Optional[ExitPolicy] = None, default_leverage: int = 1, @@ -501,10 +527,15 @@ def rehydrate_from_exchange(asset_positions: Iterable[Dict[str, Any]], # the default silently widened live stops ("policy drift"). Pull # config when the caller didn't pass an explicit policy. synth_policy = policy if policy is not None else _policy_from_config() - _active_positions[key] = DSLTracker(coin, side, entry, time.time(), synth_policy, + # Use the REAL open time from fill history (fallback: now) so a + # restart doesn't re-arm hard_timeout_minutes from scratch. + opened_at = _position_open_time_s(coin, side) + _active_positions[key] = DSLTracker(coin, side, entry, opened_at, synth_policy, leverage=lev) added += 1 - logger.info(f"[dsl] Synthesized tracker for existing {key} @ {entry} ({lev}x)") + age_min = max(0.0, (time.time() - opened_at) / 60.0) + logger.info(f"[dsl] Synthesized tracker for existing {key} @ {entry} " + f"({lev}x, opened {age_min:.0f}m ago)") def _key_in_queried_scope(k: str) -> bool: """True iff the dex behind this tracker key was queried this cycle. diff --git a/hermes_trader/agents/executor.py b/hermes_trader/agents/executor.py index cd58e48a..e7564aff 100644 --- a/hermes_trader/agents/executor.py +++ b/hermes_trader/agents/executor.py @@ -305,11 +305,15 @@ def _read_state() -> tuple[dict, float, float]: memory.track_daily_pnl(agg_equity) daily_pnl = memory.get_daily_pnl() + # Notional from HL's own positionValue (|szi| × live mark). The old + # reconstruction multiplied szi by the ANALYSIS entry price — stale by + # however long the position had been open — skewing the exposure gates. positions = [ { "coin": p["position"]["coin"], "side": "long" if float(p["position"]["szi"]) > 0 else "short", - "size_usd": abs(float(p["position"]["szi"])) * (analysis.get("entry_px") or 0), + "size_usd": float(p["position"].get("positionValue") or 0) + or abs(float(p["position"]["szi"])) * (analysis.get("entry_px") or 0), } for p in state["asset_positions"] ] @@ -436,7 +440,8 @@ def _read_state() -> tuple[dict, float, float]: "gate_results": gate_output["results"], } - if not os.environ.get("HYPERLIQUID_PRIVATE_KEY"): + # PAPER mode trades against the simulated book — no signing key involved. + if mode != "PAPER" and not os.environ.get("HYPERLIQUID_PRIVATE_KEY"): return { "executed": False, "mode": mode, "analysis_id": analysis["id"], @@ -522,11 +527,28 @@ def _read_state() -> tuple[dict, float, float]: sl_atr_mult = float(config.get("sl_atr_mult", _DEFAULT_SL_ATR_MULT)) if atr > 0 and size_in_coin > 0: sl_px = mid_price - atr * sl_atr_mult if is_buy else mid_price + atr * sl_atr_mult - sl_res = place_hl_trigger_order(is_buy, size_in_coin, sl_px, "sl", coin) + # is_buy == "entry was a buy" == "position is long" — pass it under its + # positional meaning explicitly so a refactor of the entry-direction + # variable can't silently invert the trigger side. + sl_res = place_hl_trigger_order(is_long_position=is_buy, size=size_in_coin, + trigger_px=sl_px, kind="sl", coin=coin) + if not sl_res.get("ok"): + # One retry — the common failure is a transient meta-cache/429 flake. + sl_res = place_hl_trigger_order(is_long_position=is_buy, size=size_in_coin, + trigger_px=sl_px, kind="sl", coin=coin) if sl_res.get("ok"): logger.info(f"[executor] Placed backup SL at {sl_px} ({sl_atr_mult}x ATR)") else: - logger.error(f"[executor] Backup SL FAILED for {coin}: {sl_res.get('error')}") + # The position now runs with the 60s DSL loop as its ONLY stop. + # Surface that loudly in the session feed, not just the log file. + logger.error(f"[executor] Backup SL FAILED for {coin} after retry: {sl_res.get('error')}") + try: + from hermes_trader.session_log import append as _log_event + _log_event({"event": "error", "scope": "backup_sl", + "coin": coin, "side": trade_side, + "error": str(sl_res.get("error"))}) + except Exception: + pass # Take-profit scale-out — the OFFENSIVE complement to the backup SL. Banks a # fraction of the position SERVER-SIDE at the TP target so a winner is @@ -538,7 +560,11 @@ def _read_state() -> tuple[dict, float, float]: if atr > 0 and size_in_coin > 0 and 0 < tp_scale_fraction <= 1.0: tp_px_trig = mid_price + atr * TP_ATR_MULT if is_buy else mid_price - atr * TP_ATR_MULT tp_size = size_in_coin * tp_scale_fraction - tp_res = place_hl_trigger_order(is_buy, tp_size, tp_px_trig, "tp", coin) + tp_res = place_hl_trigger_order(is_long_position=is_buy, size=tp_size, + trigger_px=tp_px_trig, kind="tp", coin=coin) + if not tp_res.get("ok"): + tp_res = place_hl_trigger_order(is_long_position=is_buy, size=tp_size, + trigger_px=tp_px_trig, kind="tp", coin=coin) if tp_res.get("ok"): logger.info(f"[executor] Placed TP scale-out {tp_scale_fraction:.0%} " f"at {tp_px_trig} ({TP_ATR_MULT}x ATR)") @@ -681,17 +707,71 @@ def close_position_market(coin: str) -> Dict[str, Any]: # reduce_only: a close must only FLATTEN. Without it, the $10-min size floor in # place_hl_order overshoots a sub-$10 position and flips it to the opposite side # (the BIRD short<->long churn loop). reduce_only makes HL ignore the excess. - res = place_hl_order(is_buy=not is_long, size=abs(szi), mid_price=mid_price, coin=coin, - reduce_only=True) + # + # An IOC reduce-only can PARTIALLY fill on a thin book — HL reports ok with + # totalSz < requested. Deregistering on that would orphan the residual + # position with no stop/floor/timeout, so: retry the residual a couple of + # times, and if any size still remains, KEEP the DSL tracker and report a + # failed close — the next DSL tick re-fires it against the live size. + requested = abs(szi) + remaining = requested + fills: List[tuple] = [] # (px, sz) per attempt, for the weighted fill price + res: Dict[str, Any] = {} + for attempt in range(3): + res = place_hl_order(is_buy=not is_long, size=remaining, mid_price=mid_price, + coin=coin, reduce_only=True) + if not res.get("ok"): + break + filled = res.get("total_sz") + if filled is None: + # No fill size in the response — nothing to reconcile against; + # treat as fully filled (pre-totalSz behavior). + fills.append((res.get("avg_px") or mid_price, remaining)) + remaining = 0.0 + break + filled = float(filled) + fills.append((res.get("avg_px") or mid_price, filled)) + remaining = max(0.0, remaining - filled) + # Flat within size-tick rounding noise, or dust below $1 notional. + if remaining <= requested * 1e-3 or remaining * mid_price < 1.0: + remaining = 0.0 + break + logger.warning( + f"[executor] PARTIAL close on {coin}: {filled} of {requested} filled " + f"(attempt {attempt + 1}/3) — retrying residual {remaining}") + time.sleep(0.5) + mid_price = get_hl_price(coin) or mid_price + + total_filled = sum(sz for _, sz in fills) + avg_fill_px = (sum(px * sz for px, sz in fills) / total_filled) if total_filled else None out: Dict[str, Any] = {**res, "coin": coin, "side": side, "entry_px": entry_px, "leverage": leverage} + if res.get("ok") and remaining > 0: + # Residual position still live after retries: keep the tracker so the + # stop/timeout keeps covering it. ok=False so callers treat the close + # as not-done and the DSL loop retries next tick. + logger.error( + f"[executor] close on {coin} left a residual of {remaining} " + f"after 3 attempts — tracker KEPT, will retry next tick") + try: + from hermes_trader.session_log import append as _log_event + _log_event({"event": "error", "scope": "partial_close", + "coin": coin, "filled_sz": total_filled, + "remaining_sz": remaining}) + except Exception: + pass + out.update({"ok": False, "partial": True, "filled_sz": total_filled, + "remaining_sz": remaining, + "error": f"partial_close_residual ({remaining} {coin} unfilled)"}) + return out + if res.get("ok"): deregister_position(coin, side) # Cancel the now-stranded reduce-only SL/TP trigger bracket so stale # orders don't pile up and reject a future reduce-only order on this coin. cancel_open_orders_for_coin(coin) - fill_px = res.get("avg_px") + fill_px = avg_fill_px or res.get("avg_px") if fill_px and entry_px > 0: # Spot move from the perspective of the position: long earns when # mark rises, short earns when mark falls. diff --git a/tests/test_close_and_rehydrate_fixes.py b/tests/test_close_and_rehydrate_fixes.py new file mode 100644 index 00000000..26371002 --- /dev/null +++ b/tests/test_close_and_rehydrate_fixes.py @@ -0,0 +1,141 @@ +"""Regression tests for the partial-fill close fix and the DSL restart-clock fix. + +- close_position_market must NOT deregister the DSL tracker when an IOC + reduce-only close only partially fills (the residual would run stopless). +- rehydrate_from_exchange must stamp synthesized trackers with the position's + REAL open time from fill history, not time.time() (which re-armed the + hard-timeout clock on every restart). +""" +import time + +import pytest + +from hermes_trader.agents import dsl_exit, executor + + +@pytest.fixture(autouse=True) +def _sandbox(tmp_path, monkeypatch): + monkeypatch.setattr(dsl_exit, "DSL_STATE_FILE", str(tmp_path / "dsl.json")) + dsl_exit._active_positions.clear() + monkeypatch.setattr(executor.time, "sleep", lambda s: None) + yield + dsl_exit._active_positions.clear() + + +def _one_btc_long_state(szi=0.5, entry=100_000.0): + return { + "asset_positions": [{ + "position": { + "coin": "BTC", "szi": str(szi), "entryPx": str(entry), + "leverage": {"type": "cross", "value": 5}, + "positionValue": str(abs(szi) * entry), + }, + }], + } + + +def _wire_close(monkeypatch, order_results): + """Stub everything close_position_market touches except the DSL registry.""" + calls = [] + + def fake_order(is_buy, size, mid_price, coin="BTC", reduce_only=False): + calls.append({"size": size, "reduce_only": reduce_only}) + return order_results[min(len(calls) - 1, len(order_results) - 1)] + + monkeypatch.setattr(executor, "resolve_user_address", lambda: "0xtest") + monkeypatch.setattr(executor, "fetch_account_state", + lambda user, include_hip3=False: _one_btc_long_state()) + monkeypatch.setattr(executor, "get_hl_price", lambda coin="BTC": 100_000.0) + monkeypatch.setattr(executor, "place_hl_order", fake_order) + monkeypatch.setattr(executor, "cancel_open_orders_for_coin", lambda coin: 0) + return calls + + +def test_partial_close_keeps_tracker_and_reports_failure(monkeypatch): + dsl_exit.register_position("BTC", "long", 100_000.0) + # Every attempt fills only 0.1 of the requested size → residual remains. + calls = _wire_close(monkeypatch, [ + {"ok": True, "avg_px": 100_000.0, "total_sz": 0.1}, + ]) + res = executor.close_position_market("BTC") + assert res["ok"] is False + assert res["partial"] is True + assert res["remaining_sz"] == pytest.approx(0.2) + assert res["filled_sz"] == pytest.approx(0.3) + assert len(calls) == 3 # initial + 2 residual retries + # Each retry asks only for what's left, reduce-only. + assert [round(c["size"], 6) for c in calls] == [0.5, 0.4, 0.3] + assert all(c["reduce_only"] for c in calls) + # THE fix: the tracker survives so the residual still has a stop/timeout. + assert "BTC_long" in dsl_exit._active_positions + + +def test_partial_then_complete_close_deregisters(monkeypatch): + dsl_exit.register_position("BTC", "long", 100_000.0) + _wire_close(monkeypatch, [ + {"ok": True, "avg_px": 100_000.0, "total_sz": 0.3}, # partial + {"ok": True, "avg_px": 99_900.0, "total_sz": 0.2}, # residual fills + ]) + res = executor.close_position_market("BTC") + assert res["ok"] is True and "partial" not in res + # Weighted fill price across both fills: (100000×0.3 + 99900×0.2) / 0.5 + assert res["fill_px"] == pytest.approx(99_960.0) + assert "BTC_long" not in dsl_exit._active_positions + + +def test_full_fill_close_unchanged(monkeypatch): + dsl_exit.register_position("BTC", "long", 100_000.0) + calls = _wire_close(monkeypatch, [ + {"ok": True, "avg_px": 100_050.0, "total_sz": 0.5}, + ]) + res = executor.close_position_market("BTC") + assert res["ok"] is True and len(calls) == 1 + assert res["fill_px"] == pytest.approx(100_050.0) + assert "BTC_long" not in dsl_exit._active_positions + + +def test_dust_residual_treated_as_flat(monkeypatch): + dsl_exit.register_position("BTC", "long", 100_000.0) + # Fills all but 1e-9 BTC (≈ $0.0001) — rounding dust, not a real residual. + _wire_close(monkeypatch, [ + {"ok": True, "avg_px": 100_000.0, "total_sz": 0.5 - 1e-9}, + ]) + res = executor.close_position_market("BTC") + assert res["ok"] is True + assert "BTC_long" not in dsl_exit._active_positions + + +# ── restart clock ──────────────────────────────────────────────────────── + +def test_synthesized_tracker_uses_fill_history_open_time(monkeypatch): + import hermes_trader.client.hl_client as hl + opened_ms = (time.time() - 2 * 3600) * 1000 # opened 2h ago + monkeypatch.setattr(hl, "resolve_user_address", lambda: "0xtest") + monkeypatch.setattr(hl, "_http_post", lambda path, payload, timeout=5: [ + # newest first: a later pyramiding fill, then the open-from-flat + {"coin": "BTC", "dir": "Open Long", "startPosition": "0.2", + "time": opened_ms + 3_600_000}, + {"coin": "ETH", "dir": "Open Long", "startPosition": "0", + "time": opened_ms + 1_000}, + {"coin": "BTC", "dir": "Open Long", "startPosition": "0", + "time": opened_ms}, + ]) + dsl_exit.rehydrate_from_exchange( + _one_btc_long_state()["asset_positions"], queried_dexes={""}) + t = dsl_exit._active_positions["BTC_long"] + assert t.entry_time == pytest.approx(opened_ms / 1000.0) + # The 2h-old position has only ~1h left on a 3h hard timeout — not 3h. + age_min = (time.time() - t.entry_time) / 60 + assert 115 < age_min < 125 + + +def test_open_time_falls_back_to_now_on_api_failure(monkeypatch): + import hermes_trader.client.hl_client as hl + monkeypatch.setattr(hl, "resolve_user_address", lambda: "0xtest") + monkeypatch.setattr(hl, "_http_post", + lambda path, payload, timeout=5: None) + before = time.time() + dsl_exit.rehydrate_from_exchange( + _one_btc_long_state()["asset_positions"], queried_dexes={""}) + t = dsl_exit._active_positions["BTC_long"] + assert before <= t.entry_time <= time.time() From a56d6e9c933bbf25cf182622ae32804b85f9b193 Mon Sep 17 00:00:00 2001 From: Cedric AUDRIT Date: Fri, 12 Jun 2026 21:55:53 +0200 Subject: [PATCH 3/3] fix(security): bind the API server to localhost by default The FastAPI server (dashboard + operator endpoints that place/close real trades behind a single static token) listened on 0.0.0.0 unconditionally. Default is now 127.0.0.1, overridable via HERMES_HOST. Containerized deploys keep their behavior: HERMES_HOST=0.0.0.0 is set in the Dockerfile, fly.toml and the k8s configmap, where the platform provides the network isolation. Dockerfile also pins HERMES_PAPER_STATE_FILE onto the /data volume alongside the other state files. Co-Authored-By: Claude Fable 5 --- Dockerfile | 4 +++- fly.toml | 1 + hermes_trader/server.py | 8 ++++++-- k8s/configmap.yaml | 1 + 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7dc5f806..262398e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,9 @@ RUN mkdir -p /data ENV SESSION_LOG_PATH=/data/session-log.jsonl \ HERMES_DSL_STATE_FILE=/data/.dsl-state.json \ HERMES_AGENT_CONFIG_FILE=/data/.agent-config.json \ - HERMES_AGENT_MEMORY_FILE=/data/.agent-memory.json + HERMES_AGENT_MEMORY_FILE=/data/.agent-memory.json \ + HERMES_PAPER_STATE_FILE=/data/.paper-state.json \ + HERMES_HOST=0.0.0.0 EXPOSE 8000 diff --git a/fly.toml b/fly.toml index 57ff3cea..4d9f6d6f 100644 --- a/fly.toml +++ b/fly.toml @@ -46,4 +46,5 @@ primary_region = "iad" # change to a region near you [env] HERMES_PORT = "8000" + HERMES_HOST = "0.0.0.0" HERMES_SCAN_INTERVAL = "60" diff --git a/hermes_trader/server.py b/hermes_trader/server.py index 3949fa3e..9a367e0f 100644 --- a/hermes_trader/server.py +++ b/hermes_trader/server.py @@ -659,5 +659,9 @@ async def metrics(): # (notably PRIVATE_KEY_HEX in client/exchange.py) capture real values. import uvicorn port = int(os.environ.get("HERMES_PORT", 8000)) - logger.info(f"Starting Hermes server on port {port}") - uvicorn.run("hermes_trader.server:app", host="0.0.0.0", port=port, reload=False) + # Localhost by default: the operator endpoints place/close REAL trades + # behind a single static token, so never expose them network-wide + # implicitly. Containerized deploys (Docker/Fly/k8s) set HERMES_HOST=0.0.0.0. + host = os.environ.get("HERMES_HOST", "127.0.0.1") + logger.info(f"Starting Hermes server on {host}:{port}") + uvicorn.run("hermes_trader.server:app", host=host, port=port, reload=False) diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index 0f5cfe65..b4dc9137 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -9,4 +9,5 @@ metadata: # containers already agree on where shared state lives. data: HERMES_PORT: "8000" + HERMES_HOST: "0.0.0.0" HERMES_SCAN_INTERVAL: "60"