Trading Bot — Signal-Driven Architecture
Build a trading bot that consumes live signals from SignalNGN via sn signals , executes trades via ledger trades add , and enforces risk management locally.
Architecture
sn signals --json → Bot Process → ledger trades add (trade recording) ↑ ↓ ↓ NATS stream Risk Management Loop ledger positions (state queries) (periodic price check)
The bot is a single long-running process with two concurrent concerns:
-
Signal loop — reads JSON lines from sn signals --json stdout, decides whether to act, executes trades
-
Risk loop — periodically checks open positions against SL/TP/trailing stop/max hold time, closes positions that hit limits
Prerequisites: sn and ledger CLIs installed and configured. See the sn and ledger skills for setup. No other dependencies needed — sn provides signals AND live prices, ledger provides trade recording AND position queries.
Core Loop
Spawn sn signals --json as a subprocess. Read stdout line by line. Each line is a complete JSON signal.
while true: proc = spawn("sn signals --json") for line in proc.stdout: signal = parse_json(line) handle_signal(signal) maybe_run_risk_check() # every N seconds between signals # proc exited — wait, then restart sleep(10) refresh_config()
Key points:
-
sn signals maintains the NATS connection internally and handles auth
-
Signals have a 2-minute TTL in NATS headers — stale signals are never delivered
-
If the subprocess exits (network blip, server restart), restart it with backoff
-
On restart, refresh the trading config — it may have changed server-side
Signal Payload
Each JSON line from sn signals --json :
{ "exchange": "coinbase", "product": "BTC-USD", "granularity": "FIVE_MINUTES", "strategy": "ml_xgboost", "action": "BUY", "confidence": 0.82, "price": 98500.50, "stop_loss": 97200.00, "take_profit": 100800.00, "risk_reasoning": "ATR-based stop 1.3% below entry", "reason": "Strong bullish signal across 38 features", "position_pct": 0.25, "market": "futures", "leverage": 2, "indicators": { "rsi": 58.3, "macd_hist": 0.0042, "sma50": 96800, "sma200": 91200 }, "timestamp": 1740218400 }
Field Use
action
BUY (open long), SELL (close long), SHORT (open short), COVER (close short)
confidence
0–1. Filter by threshold (e.g. only act on ≥ 0.72)
price
Candle close at signal time. Use as entry price
stop_loss / take_profit
Strategy-suggested levels. Validate before using (see Risk Management)
position_pct
Kelly-derived sizing (0 = no recommendation, use fixed sizing)
risk_reasoning
Human-readable explanation of SL/TP rationale
market
"spot" or "futures"
Trading Config
Fetch the server-side trading config to know which products and strategies are active.
sn trading list --enabled --json
Returns an array of config objects. Build a lookup map keyed by product_id :
{ "BTC-USD": { "exchange": "coinbase", "granularity": "FIVE_MINUTES", "long_leverage": 2, "short_leverage": 2, "strategies_long": ["ml_xgboost"], "strategies_short": ["ml_xgboost"], "strategies_spot": [] } }
Product + Strategy Filtering
Critical: The signal stream contains signals for ALL tenants on the platform. The engine publishes signals from the union of all users' trading configs. Your bot must filter signals to only act on products and strategies in your own trading config. Without this filter, the bot will trade random products from other users' configs.
Two-layer filter (both required):
Product filter: Reject any signal whose product is not a key in your trading config map. This is the first check — if the product isn't in your config, drop the signal immediately.
Strategy filter: For products that pass the product filter, check that the signal's strategy matches one of the allowed strategies for that product.
Layer 1: Product must be in our config
cfg = trading_config.get(signal.product) if cfg is None: → reject (product not in our config)
Layer 2: Strategy must be allowed for this product
allowed = cfg.strategies_long + cfg.strategies_short + cfg.strategies_spot if not prefix_match(signal.strategy, allowed): → reject (strategy not allowed)
The engine appends direction suffixes to strategy names when publishing signals:
Config name Signal strategy field
ml_xgboost
ml_xgboost (long) or ml_xgboost+trend
ml_xgboost
ml_xgboost_short or ml_xgboost_short+bearish
user:bb_squeeze
user:bb_squeeze or user:bb_squeeze+trend
Use prefix matching: a signal's strategy matches a config entry if it equals the entry OR starts with entry+ or entry_ . This is critical — exact matching will miss most signals.
for allowed in config_strategies: if signal.strategy == allowed or signal.strategy.startswith(allowed + "+") or signal.strategy.startswith(allowed + "_"): → match
Trade Execution
Opening a Position
-
Check for existing position: ledger positions <account> --json — skip if already have an open position for this symbol + side
-
Calculate size: Use position_pct (Kelly) if > 0, otherwise fixed percentage of portfolio
-
Calculate quantity: size_usd / price
-
Validate SL/TP: See Risk Management section
-
Record via ledger: ledger trades add <account> --symbol ... --side ... --quantity ... --price ...
-
Save position state: Persist SL/TP/trailing stop data locally for the risk loop
Check existing position
ledger positions paper --json | jq '.[] | select(.symbol == "BTC-USD" and .side == "long" and .status == "open")'
Record entry trade
ledger trades add paper
--symbol BTC-USD --side buy --quantity 0.015 --price 98500.50
--fee 0.30 --market-type futures --leverage 2 --margin 750.00
--strategy ml_xgboost --confidence 0.82
--entry-reason "Strong bullish signal across 38 features"
--stop-loss 97200 --take-profit 100800
-
side : "buy" for long entry or short exit, "sell" for short entry or long exit
-
fee : Estimate — 0.02% for futures, 0.1% for spot
-
leverage and margin : Only for futures. margin = size_usd / leverage
-
Trade ID is auto-generated. Pass --trade-id to set a specific one (idempotent)
Closing a Position
-
Get position details: ledger portfolio <account> --json → find the position by symbol + side
-
Use the position's avg_entry_price and quantity for P&L calculation
-
Record the closing trade with the opposite side (sell to close long, buy to close short)
-
Include --exit-reason (e.g. "🛑 STOP LOSS", "🎯 TARGET HIT", "🔒 TRAILING STOP")
-
Clean up local position state
ledger trades add paper
--symbol BTC-USD --side sell --quantity 0.015 --price 97180.00
--fee 0.30 --market-type futures
--exit-reason "🛑 STOP LOSS"
Position Sizing
if signal.position_pct > 0: base_size = portfolio_value × position_pct # Kelly-derived else: base_size = portfolio_value × fixed_pct # e.g. 15%
size = clamp(base_size, min=150, max=2000) # USD bounds quantity = size / price
-
Kelly sizing (position_pct ): The signal provides a fraction of capital to risk, derived from the strategy's historical win rate and payoff ratio
-
Fixed sizing: Fallback when no Kelly recommendation. 10–20% of portfolio per position is typical
-
Bounds: Enforce min/max to avoid micro-positions or overexposure
Risk Management
See references/risk-management.md for full implementation details.
Summary of the risk management stack:
Layer Trigger Default
Stop Loss Price hits SL level -3% post-leverage
Take Profit Price hits TP level +6% post-leverage
Trailing Stop Activates at +2% P&L, trails 1.5% behind peak —
Max Hold Position held > N hours 72 hours
SL/TP Validation
Always validate the signal's SL/TP before using them:
sl_valid = stop_loss > 0 AND |stop_loss - price| / price > 0.001 # >0.1% from entry tp_valid = take_profit > 0 AND |take_profit - price| / price > 0.001
if not sl_valid → use default SL (entry ± 3%/leverage) if not tp_valid → use default TP (entry ± 6%/leverage)
This catches degenerate cases where SL/TP equal the entry price.
Risk Loop
Run every 5 minutes (or between signal processing). For each open position:
-
Fetch current price
-
Calculate P&L percentage (accounting for leverage and direction)
-
Update trailing stop if P&L exceeds activation threshold
-
Check all exit conditions (SL → trailing → TP → max hold)
-
Close position if any condition triggers
Cooldowns
Prevent duplicate trades from rapid-fire signals on the same product:
key = f"{product}:{strategy}:{action}" if last_signal[key] was < 5 minutes ago → skip
5 minutes is a sensible default for 5-minute candle strategies.
Daemon Management
For production, run the bot as a managed process that auto-restarts on failure.
macOS (launchd):
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.example.trading-bot</string> <key>ProgramArguments</key> <array> <string>/usr/bin/python3</string> <string>/path/to/trading_bot.py</string> </array> <key>EnvironmentVariables</key> <dict> <key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:~/go/bin</string> </dict> <key>KeepAlive</key> <true/> <key>StandardOutPath</key> <string>/path/to/bot.log</string> <key>StandardErrorPath</key> <string>/path/to/bot.log</string> <key>WorkingDirectory</key> <string>/path/to/workspace</string> </dict> </plist>
Control:
launchctl load ~/Library/LaunchAgents/com.example.trading-bot.plist # start launchctl unload ~/Library/LaunchAgents/com.example.trading-bot.plist # stop launchctl stop com.example.trading-bot # restart (KeepAlive relaunches)
Linux (systemd): Standard user service unit with Restart=always .
Notifications
Send trade notifications to Telegram (or any messaging channel) on:
-
Position opened — include entry price, size, SL/TP, strategy, indicators
-
Position closed — include exit price, P&L (% and USD), reason
-
Risk management closes — include which condition triggered (🛑 SL, 🎯 TP, 🔒 trailing, ⏰ max hold)
Use OpenClaw's message send for notifications, or direct Telegram Bot API.
Portfolio Value Tracking
Track the live portfolio value as:
portfolio_value = starting_capital + total_realized_pnl + total_unrealized_pnl
-
starting_capital : Initial deposit (e.g. $10,000)
-
total_realized_pnl : From ledger portfolio <account> → total_realized_pnl field (sum of all closed position P&L)
-
total_unrealized_pnl : Sum of (current_price - entry) / entry × leverage for each open position, multiplied by position cost basis
Display this on dashboards instead of a static starting balance. Color green when above starting capital, red when below.
State Files
The bot maintains local state files alongside the script:
File Purpose
.position_state.json
SL/TP/trailing stop levels, peak P&L, open time per position
.exit_reasons.json
Why each position was closed (for dashboard display)
These are the bot's local runtime state — the ledger is the source of truth for actual positions and trades. If state files are lost, the bot reconstructs defaults from ledger positions on next risk check.
Backtests
Use sn backtest to run and review historical strategy performance before going live.
Running a Backtest
sn backtest run
--exchange binance --product BTC-USD
--strategy ml_xgboost --granularity FIVE_MINUTES
--mode spot
--start 2025-01-01 --end 2025-12-31
Options: --mode spot|futures-long|futures-short , --leverage N , --trend-filter , --no-wait .
Strategy Params
Use --params key=value (repeatable) to override strategy parameters. The result always records the full effective params used — including defaults for anything not specified — so results are self-describing and comparable.
sn backtest run
--exchange binance --product BTC-USD
--strategy ml_xgboost --granularity FIVE_MINUTES
--params confidence=0.80
--params exit_confidence=0.40
--params rr_ratio=2.5
--params atr_stop_mult=1.5
Available params per strategy:
Strategy Params
ml_xgboost
confidence , exit_confidence , rr_ratio , atr_stop_mult
alpha_beast
rsi_buy_max , rsi_sell_min , vol_multiplier , atr_stop_mult , rr_ratio
zscore_mean_reversion
entry , exit , max_pos , dampening
rsi_mean_reversion
oversold , overbought
bollinger_rsi
rsi_oversold , rsi_overbought
macd_momentum
threshold
volume_momentum
multiplier
combined_rsi_macd
oversold , overbought
Listing Results
sn backtest list # newest 20 results (default) sn backtest list --limit 50 # show 50 results sn backtest list --limit 0 # show all results sn backtest list --sort winrate # sort by win rate descending sn backtest list --sort date # sort by date descending (default) sn backtest list --sort winrate --limit 0 # all results, best win rate first sn backtest list --product BTC-USD --limit 0 # filter by product, show all
--limit (default 20 ): number of results to return. 0 means all.
--sort (default date ): date = newest first; winrate = highest win rate first.
Filters: --exchange , --product , --strategy .
Inspecting a Single Result
sn backtest get 113
Shows full metrics: total return, win rate, max drawdown, Sharpe ratio, profit factor, avg win/loss, max consecutive losses.
Interpreting Results
High win rate ≠ profitable. A strategy can show 77% win rate yet negative returns if winning trades are small and losing trades are large. Always check profit_factor (> 1.0 required) and avg_win / avg_loss ratio alongside win rate.
Key metrics to evaluate:
Metric What it measures Good threshold
total_return
Overall P&L % for the period Positive
win_rate
% of trades that closed in profit Context-dependent
profit_factor
Gross profit / gross loss
1.2
max_drawdown
Largest peak-to-trough loss < 20%
sharpe_ratio
Risk-adjusted return
1.0
avg_win / avg_loss
Win size vs loss size
1.0
Checklist for a New Bot
-
Install sn and ledger CLIs, run sn auth login
-
Set up trading config: sn trading set <exchange> <product> --granularity ... --long ... --short ... --enable
-
Reload engine: sn trading reload
-
Implement signal loop (spawn sn signals --json , read lines)
-
Implement strategy filter (prefix match against trading config)
-
Implement trade execution (position check → size → ledger trades add )
-
Implement SL/TP validation (reject degenerate values, fall back to defaults)
-
Implement risk management loop (periodic price check → SL/TP/trailing/max hold)
-
Implement cooldowns (prevent duplicate signals)
-
Implement subprocess auto-restart with backoff
-
Set up as a managed daemon (launchd/systemd)
-
Add notifications (Telegram or other channel)
-
Test with paper account before going live