Skip to content

feat: Hyperliquid perp-bot with TUI dashboard#1

Merged
morfize merged 8 commits intomainfrom
dev
Mar 20, 2026
Merged

feat: Hyperliquid perp-bot with TUI dashboard#1
morfize merged 8 commits intomainfrom
dev

Conversation

@morfize
Copy link
Copy Markdown
Owner

@morfize morfize commented Mar 19, 2026

Summary

  • Complete Hyperliquid mean-reversion perpetual futures trading bot (design doc sections 1-10)
  • Unix socket IPC layer for daemon-TUI communication with thread-safe state sharing
  • Textual TUI dashboard with 6 live-updating panels (header, position, signals, risk, trades, log)
  • Key-bound daemon control: pause (p), resume (r), emergency close with symbol picker (e)
  • 167 tests passing, zero lint errors

Architecture

TUI Client  <-- Unix socket (JSON) -->  Daemon (trade loop)
            <-- SQLite WAL (read-only)

IPC split: Socket carries volatile state (mid prices, signals, WS health). SQLite WAL provides concurrent read access for persistent data (trades, candles, PnL).

New modules

Module Files Description
IPC src/perp_bot/ipc/ (5 files) Protocol constants, DaemonState dataclass, threaded Unix socket server, client
TUI src/perp_bot/tui/ (9 files) Textual App, CSS layout, 6 widget panels (header, position, signals, risk, trades, log)
Tests tests/test_ipc.py, tests/test_tui.py 22 new tests for IPC round-trips and widget rendering

Modified files

  • main.py -- DaemonState + IPC server wired into trade loop, pause logic, signal exposure, tui/status CLI commands
  • src/perp_bot/infra/logging.py -- Added log_file param with RotatingFileHandler for TUI log tailing
  • pyproject.toml -- Added textual>=1.0.0 dependency

Test plan

  • uv run pytest tests/ -v -- all 167 tests pass
  • uv run ruff check src/ tests/ -- zero errors
  • uv run python main.py trade starts daemon with IPC socket
  • uv run python main.py status returns JSON state from running daemon
  • uv run python main.py tui renders dashboard, updates live
  • Quit TUI -- daemon continues running
  • Kill TUI with kill -9 -- daemon unaffected

Generated with Claude Code


Note

High Risk
High risk because this introduces live trading execution (signed Exchange API orders, server-side stop-losses, leverage setting) plus automated reconciliation and alerting, where bugs can directly place/close positions or lose funds. Adds many new subsystems (SQLite persistence, WS reconnect logic, IPC/TUI control surface) that affect runtime behaviour and operational safety.

Overview
Implements an end-to-end Hyperliquid mean-reversion perp trading bot with config-driven data ingestion (REST + WebSocket), SQLite persistence, indicator-based signal generation, and risk-gated trading in paper or live mode.

Adds live execution via LiveExecutor (limit-first with IOC fallback, leverage setup, server-side stop-loss placement, slippage monitoring) plus startup position reconciliation and alerting (Discord/Telegram) and periodic health heartbeats.

Introduces a full backtesting suite (fee/slippage/funding models, walk-forward, sensitivity sweeps, and paper-vs-backtest comparison) and a Unix-socket IPC + Textual TUI to monitor and control the daemon (pause/resume/emergency close, status command, live panels including log tailing).

Written by Cursor Bugbot for commit 9bb138a. This will update automatically on new commits. Configure here.

…ng system

Hyperliquid mean-reversion perpetual futures trading bot with:
- Signal engine (Z-score, Bollinger, RSI, ADX) with prediction market regime modifiers
- Paper and live execution with limit-first/taker-fallback strategy
- Risk management (stop-loss, daily limits, cooldowns, position sizing)
- Backtesting with realistic cost model and walk-forward analysis
- Unix socket IPC for daemon-TUI communication
- Textual TUI dashboard with live monitoring and key-bound commands
- 167 tests passing, zero lint errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 19, 2026 04:58
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a full-featured Hyperliquid perpetual futures mean-reversion bot, including an IPC layer and a Textual-based TUI dashboard for live monitoring/control, plus supporting modules for signals, risk, execution, backtesting, and reporting.

Changes:

  • Introduces Unix-socket IPC for daemon state + command dispatch and a Textual TUI dashboard (pause/resume/emergency close).
  • Implements core trading components: indicators + signal engine, risk manager, Hyperliquid WS client, and a live execution layer.
  • Adds a backtesting framework (fees/slippage/funding, walk-forward, sensitivity) and extensive test coverage.

Reviewed changes

Copilot reviewed 63 out of 75 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/init.py Marks tests package.
tests/test_alerts.py Tests Discord/Telegram alert sending (mocked).
tests/test_backtest_cost_model.py Tests fee/slippage/funding cost models.
tests/test_backtest_engine.py Integration tests for backtest engine/executor/risk adapter.
tests/test_backtest_metrics.py Tests backtest metric calculations.
tests/test_indicators.py Tests self-implemented technical indicators.
tests/test_ipc.py Tests IPC protocol/state/server/client.
tests/test_live_executor.py Tests LiveExecutor behavior with mocked SDK.
tests/test_prediction.py Tests prediction-market scoring + regime integration.
tests/test_risk.py Tests RiskManager behavior and cooldown persistence.
tests/test_signals.py Tests SignalEngine behavior.
tests/test_tui.py Tests widget formatting/helpers and basic state updates.
tests/test_ws_client.py Tests WsClient subscriptions/caching/health/reconnect.
src/perp_bot/backtest/init.py Backtest module exports.
src/perp_bot/backtest/config.py Re-exports BacktestConfig.
src/perp_bot/backtest/cost_model.py Fee/slippage/funding models for backtests.
src/perp_bot/backtest/engine.py Core backtest engine simulation loop.
src/perp_bot/backtest/executor.py In-memory backtest executor.
src/perp_bot/backtest/metrics.py Backtest performance metrics.
src/perp_bot/backtest/results.py Backtest result dataclasses + CSV export.
src/perp_bot/backtest/risk_adapter.py Deterministic backtest risk manager.
src/perp_bot/backtest/sensitivity.py Parameter sensitivity sweep.
src/perp_bot/backtest/walk_forward.py Walk-forward analysis runner.
src/perp_bot/config.py Centralized config schema + YAML/.env loader.
src/perp_bot/data/init.py Data module marker.
src/perp_bot/data/client.py Hyperliquid REST client wrapper.
src/perp_bot/data/db.py SQLite schema + persistence helpers (candles/trades/predictions/state).
src/perp_bot/data/ingest.py Data ingestion orchestration (candles/funding/predictions).
src/perp_bot/data/prediction_client.py Polymarket + Kalshi fetchers.
src/perp_bot/data/ws_client.py Hyperliquid WebSocket client wrapper with caches/subscriptions.
src/perp_bot/execution/init.py Execution module marker.
src/perp_bot/execution/executor.py Executor interface + PaperExecutor.
src/perp_bot/execution/live_executor.py Live Hyperliquid execution (limit-first + SL + fallback + slippage stats).
src/perp_bot/infra/init.py Infra module marker.
src/perp_bot/infra/alerts.py Discord/Telegram alert dispatch.
src/perp_bot/infra/health.py Periodic heartbeat/health checker.
src/perp_bot/infra/logging.py JSON logging + optional rotating file handler.
src/perp_bot/ipc/init.py IPC module marker.
src/perp_bot/ipc/client.py Unix socket daemon client.
src/perp_bot/ipc/protocol.py IPC constants + socket path helper.
src/perp_bot/ipc/server.py Threaded Unix socket server for state/commands.
src/perp_bot/ipc/state.py Thread-safe daemon state container.
src/perp_bot/reporting/init.py Reporting module marker.
src/perp_bot/reporting/compare.py Paper-vs-backtest comparison report.
src/perp_bot/reporting/weekly.py Weekly performance report.
src/perp_bot/risk/init.py Risk module marker.
src/perp_bot/risk/manager.py Live risk manager (daily loss, cooldown, sizing, stop-loss).
src/perp_bot/signals/engine.py Signal evaluation logic + regime adjustments.
src/perp_bot/signals/indicators.py Self-implemented indicators (zscore/BB/RSI/Hurst/ADX).
src/perp_bot/signals/prediction.py Prediction-market scoring + regime classification.
src/perp_bot/tui/init.py TUI module marker.
src/perp_bot/tui/app.py Textual app wiring (poll daemon/DB/log, actions).
src/perp_bot/tui/app.tcss TUI grid layout and widget styling.
src/perp_bot/tui/widgets/init.py Widget module marker.
src/perp_bot/tui/widgets/header.py Header widget formatting + uptime.
src/perp_bot/tui/widgets/log.py Log tail widget (JSON parsing).
src/perp_bot/tui/widgets/position.py Position widget with uPnL calculation.
src/perp_bot/tui/widgets/risk.py Risk status widget.
src/perp_bot/tui/widgets/signals.py Signals widget with indicator bars.
src/perp_bot/tui/widgets/trades.py Trades DataTable widget.
pyproject.toml Project metadata + dependencies (adds Textual).
config.yaml Default bot configuration incl. prediction/backtest blocks.
deploy/deploy.sh GCP deployment helper script.
deploy/perp-bot.service systemd unit for daemon deployment.
hyperliquid-mean-reversion-bot-design.md Strategy/design document (JP).
CLAUDE.md Repository guidance and command references.
.env.example Example env vars for secrets/alerts.
.gitignore Ignores venv, secrets, DBs, caches.
.python-version Pins local Python version for tooling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_ipc.py
Comment on lines +66 to +71
def test_update_ignores_private_fields(self):
state = DaemonState()
state.update(_lock="hacked")
import threading
assert isinstance(state._lock, threading.Lock)

Comment on lines +24 to +60
def poll_log(self) -> None:
"""Read new lines from the log file since last poll."""
if not self._log_path.exists():
return

try:
with open(self._log_path) as f:
f.seek(self._last_pos)
new_data = f.read()
self._last_pos = f.tell()
except OSError:
return

if not new_data:
return

import json
for line in new_data.strip().split("\n"):
if not line:
continue
try:
entry = json.loads(line)
ts = entry.get("ts", "")
# Extract HH:MM:SS from ISO timestamp
time_part = ts.split("T")[1][:8] if "T" in ts else ts[:8]
level = entry.get("level", "INFO")
msg = entry.get("msg", line)

level_colors = {
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "bold red",
}
color = level_colors.get(level, "white")
self.write(f"[dim]{time_part}[/] [{color}]{msg}[/]")
except (json.JSONDecodeError, KeyError):
self.write(line)
Comment on lines +140 to +158
def _handle_emergency_close(self, request: dict) -> dict:
symbol = request.get("symbol")
if not symbol:
return {"ok": False, "error": "symbol_required"}

if not self._executor or not self._db:
return {"ok": False, "error": "executor_not_available"}

open_trades = self._db.get_open_trades(symbol)
if not open_trades:
return {"ok": True, "message": f"no_open_trades_for_{symbol}"}

closed = 0
for trade in open_trades:
try:
self._executor.close_position(
trade["id"], symbol, trade["entry_price"], 0.0,
"emergency_close_ipc",
)
Comment on lines +113 to +130
pnls = [_get(t, "pnl", 0) for t in trades]
wins = sum(1 for p in pnls if p > 0)
net = sum(pnls)

hold_hours = []
for t in trades:
entry = _get(t, "entry_time", 0) or _get(t, "entry_time_ms", 0)
exit_ = _get(t, "exit_time", 0) or _get(t, "exit_time_ms", 0)
if entry and exit_:
hold_hours.append((exit_ - entry) / 3_600_000)

return {
"trades": len(trades),
"win_rate_pct": wins / len(trades) * 100,
"net_pnl": net,
"avg_pnl": net / len(trades),
"avg_hold_h": sum(hold_hours) / len(hold_hours) if hold_hours else 0,
}
Comment thread src/perp_bot/tui/app.py
Comment on lines +141 to +169
def _query_open_trades(self) -> list[dict]:
try:
conn = self._get_ro_connection()
cur = conn.execute(
"SELECT * FROM trades WHERE exit_time IS NULL",
)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
return rows
except Exception:
return []

def _query_recent_trades(self) -> list[dict]:
try:
conn = self._get_ro_connection()
now_ms = int(time.time() * 1000)
week_ms = 7 * 24 * 3600 * 1000
cur = conn.execute(
"SELECT * FROM trades WHERE exit_time IS NOT NULL"
" AND exit_time >= ? ORDER BY exit_time DESC LIMIT 10",
(now_ms - week_ms,),
)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
return list(reversed(rows))
except Exception:
return []
Comment thread .python-version
@@ -0,0 +1 @@
3.14
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread main.py Outdated
ingestor.update_candles(symbol)

# Load candles into DataFrame
candles = db.get_candles(symbol, tf, limit=min_candles + 50)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trading loop fetches oldest candles instead of newest

High Severity

db.get_candles(symbol, tf, limit=min_candles + 50) with no start_time generates a query with ORDER BY open_time ASC LIMIT ?, which returns the oldest candles in the database, not the most recent ones. After a 90-day backfill populates thousands of rows, the signal engine computes indicators on ancient historical data instead of current market conditions. All trading decisions (entries, exits, z-scores, RSI, ADX) are based on stale data from months ago.

Additional Locations (1)
Fix in Cursor Fix in Web

pnl = (entry_price - current_price) / entry_price * position_size

max_loss = self.trading.capital_usd * self.risk.max_loss_per_trade_pct
return pnl <= -max_loss
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop-loss uses computed size, not actual trade size

Medium Severity

check_stop_loss calls self.compute_position_size() to get a hypothetical position size rather than using the actual trade's size_usd. Positions opened under HIGH_RISK regime have half the normal size, but the stop-loss check always uses the full NORMAL-regime size, causing PnL to be overestimated by 2× and triggering premature stop-losses. The backtest's BacktestRiskManager.check_stop_loss correctly accepts size_usd as a parameter.

Additional Locations (1)
Fix in Cursor Fix in Web

"DOVISH_SHIFT": "yellow", "HAWKISH_SHIFT": "yellow",
"CRISIS": "red",
}
rc = regime_colors.get(regime, "white")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regime color keys don't match lowercase enum values

Low Severity

The regime_colors dict uses uppercase keys ("NORMAL", "HIGH_RISK", "CRISIS"), but _tick returns prediction_regime.value which produces lowercase strings ("normal", "high_risk", "crisis"). After the first tick, the lookup always falls through to the "white" default, so the regime indicator never displays its intended color coding.

Additional Locations (1)
Fix in Cursor Fix in Web

@morfize
Copy link
Copy Markdown
Owner Author

morfize commented Mar 19, 2026

Code review

Found 2 issues:

  1. RiskManager.check_stop_loss uses compute_position_size() (regime-dependent, defaults to NORMAL) instead of the actual trade's size_usd. If the prediction regime changes after entry (e.g., to CRISIS), compute_position_size() returns 0, making PnL always 0 and the stop-loss never fires. The backtest version (BacktestRiskManager.check_stop_loss) correctly accepts size_usd as a parameter. (bug: signature mismatch between live and backtest risk managers)

def check_stop_loss(self, entry_price: float, current_price: float, side: str) -> bool:
"""Check if the capital-based stop-loss threshold is breached.
Returns True if the position should be stopped out.
"""
position_size = self.compute_position_size()
if side == "long":
pnl = (current_price - entry_price) / entry_price * position_size
else:
pnl = (entry_price - current_price) / entry_price * position_size
max_loss = self.trading.capital_usd * self.risk.max_loss_per_trade_pct
return pnl <= -max_loss

  1. _check_losing_weeks is documented as checking for "3 consecutive losing weeks" but the implementation counts any losing weeks in the range and checks losing_count >= num_weeks without enforcing consecutiveness. Additionally, if any week has zero trades, return True exits early and bypasses the entire check -- meaning a sparse trading period will always pass the safety guard. (bug: early return on empty week + non-consecutive counting)

perp-bot/main.py

Lines 240 to 261 in 9bb138a

def _check_losing_weeks(db: Database, num_weeks: int = 3) -> bool:
"""Check if the last N weeks were all net-negative. Returns True if safe to start."""
now_ms = int(time.time() * 1000)
week_ms = 7 * 24 * 3600 * 1000
losing_count = 0
for i in range(num_weeks):
end = now_ms - i * week_ms
start = end - week_ms
trades = db.get_closed_trades_in_range(start, end)
if not trades:
return True # Not enough history — safe to start
weekly_pnl = sum(t.get("pnl", 0) or 0 for t in trades)
if weekly_pnl < 0:
losing_count += 1
if losing_count >= num_weeks:
logger.error(
"HALTED: %d consecutive losing weeks detected", num_weeks,
)
return False
return True

Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

morfize and others added 5 commits March 19, 2026 02:39
…dent recomputation

check_stop_loss was calling compute_position_size() which uses the current
prediction regime, not the trade's actual notional. If the regime shifted
after entry (e.g. NORMAL→CRISIS), the method returned 0 and PnL was
always 0 — the stop loss would never fire. Now accepts size_usd as a
parameter so the caller passes the real trade size.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The screen command fetched the oldest 500 candles (ASC + LIMIT) rather
than the most recent. Adding descending=True queries ORDER BY DESC then
reverses the result to maintain chronological order, ensuring Hurst
exponent calculations use current market data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
compare_paper_vs_backtest included live trades in the paper bucket,
inflating or deflating paper metrics. Now filters on is_paper == 1
so the comparison accurately reflects simulated-only performance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ports

Three improvements to main.py:

1. Losing-weeks guard no longer calls sys.exit(1) when live positions
   are open. Instead sets entries_halted=True and continues managing
   existing positions, preventing abandoned positions on the exchange.

2. _tick now checks the return value of executor.open_position(). If
   it returns None (entry failed), the OPEN alert is skipped rather
   than broadcasting a misleading success message.

3. Backtest CSV export generates per-symbol filenames (trades-ETH.csv,
   trades-BTC.csv) when backtesting multiple symbols, preventing
   later symbols from overwriting earlier results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all CLI logic from root main.py into src/perp_bot/cli.py and
register a console_scripts entry point (`perpbot`) in pyproject.toml.
main.py becomes a thin compatibility shim. The --force flag is now
passed as a function parameter instead of scanning sys.argv.

Also adds: README with install/usage docs, MIT license, changelog,
GitHub Actions CI/release workflows, pyproject.toml metadata
(classifiers, URLs, sdist excludes), config.py CWD-relative path
resolution, and tests for the new CLI parameter passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cursor
Copy link
Copy Markdown

cursor Bot commented Mar 19, 2026

You have used all of your free Bugbot PR reviews.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

@morfize morfize closed this Mar 20, 2026
@morfize morfize merged commit 99d00f6 into main Mar 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants