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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ DISTRIBUTE.md
.agent-config.json
.agent-memory.json
.dsl-state.json
.paper-state.json
.positions-snapshot.json
.monitor-journal.md

Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
35 changes: 33 additions & 2 deletions hermes_trader/agents/dsl_exit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
96 changes: 88 additions & 8 deletions hermes_trader/agents/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
]
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand All @@ -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)")
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions hermes_trader/client/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
fetch_hl_candles,
resolve_user_address,
)
from hermes_trader.client import paper_engine

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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"}

Expand Down
20 changes: 19 additions & 1 deletion hermes_trader/client/hl_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")


Expand Down Expand Up @@ -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})

Expand Down Expand Up @@ -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:
Expand Down
Loading