Go CI should not depend on a Python runtime, so tests for subprocess-based live helpers should extract pure parsers/decision helpers rather than invoking Python.
Resources
9Install
npx skillscat add richkuo/go-trader Install via the SkillsCat registry.
Agent Setup Guide — go-trader
Repository: https://github.com/richkuo/go-trader.git
Concise skill entry point for agents setting up, configuring, operating, or extending go-trader. For broader context and PR conventions, see AGENTS.md.
Quick flow for a new server: tell OpenClaw install https://github.com/richkuo/go-trader and init.
Core Rules
- Run git from repo root.
- Use
/opt/homebrew/bin/go(macOS) or/usr/local/go/bin/go(Linux) ifgois not on PATH. - Use
uv run --no-sync pythonfor dev/backtest/manual CLI; Go subprocess calls (scheduler) use.venv/bin/python3directly — deterministic relative path afteruv sync, no PATH config needed. - Production: bundled systemd units use
ProtectSystem=strict; noPATH/UV_CACHE_DIRenv injection needed for the scheduler since it calls.venv/bin/python3directly (#752/#753 reverted #748). - Install Python deps with
uv sync. - Scheduler config:
scheduler/config.json(start fromscheduler/config.example.jsonwhen generating manually). - State is SQLite only: default
scheduler/state.db. - Never store secrets in config files — put Discord/exchange credentials in systemd environment variables.
- Prefer
./go-trader initfor humans,./go-trader init --json ... --output scheduler/config.jsonfor agents/scripts. - TradingView export: ask which strategy IDs (or all) before running.
- CRITICAL: ALWAYS use
scripts/update.shto update go-trader. NEVER manually run git pull + go build.update.shis the single source of truth forgit pull --ff-only+uv sync+go buildatomically. Manual steps cause asymmetric deploys (#642).
Prerequisites
python3 --version
uv --version 2>/dev/null || echo "NOT_INSTALLED"
go version 2>/dev/null || /usr/local/go/bin/go version 2>/dev/null || /opt/homebrew/bin/go version 2>/dev/null || echo "NOT_INSTALLED"
git --versionRequirements: Python 3.12+, uv, Go 1.26.2, Git.
curl -LsSf https://astral.sh/uv/install.sh | sh
# Linux
curl -sL https://go.dev/dl/go1.26.2.linux-amd64.tar.gz | tar -C /usr/local -xzf -
# macOS
brew install go@1.26Install
git clone https://github.com/richkuo/go-trader.git
cd go-trader
uv syncIf the repo already exists, ask whether to reconfigure, update, or fresh install before changing it.
Build:
VER=$(git describe --tags --always --dirty 2>/dev/null || echo dev)
/opt/homebrew/bin/go -C scheduler build -ldflags "-X main.Version=$VER" -o ../go-trader .
./go-trader --helpThe Version ldflag appears in Discord summary titles; without it the binary reports dev.
Rebuilding the binary alone is unsafe after #642. The Go binary and Python check scripts share an argv contract (
--strategy-refs,--probe-only, etc.); a build withoutgit pull+uv syncfrom the same SHA can produce an asymmetric deploy. Usebash scripts/update.shfor any update past the initial install — it does pull + sync + build atomically.
Configure
Human flow:
./go-trader initScripted flow:
./go-trader init --json '{"assets":["BTC","ETH"],"enableSpot":true,"spotStrategies":["momentum","rsi"],"spotCapital":1000,"spotDrawdown":60}' --output scheduler/config.jsonThe wizard covers assets, strategy groups, paper/live mode, per-strategy capital, live risk settings, Discord channels, auto-update mode. Prompts before overwriting.
Manual config rules:
- Strategy entries need
id,type,script,args,capital,max_drawdown_pct,interval_seconds. open_strategyand each entry inclose_strategiesare objects of shape{"name": "<id>", "params": {...}}(#640/#642). Per-evaluator params (e.g.tiered_tp_atr'stiers) live on the matching close ref, not on the strategy. Pre-v13 configs with a flatparamsmap and string-typedopen_strategy/close_strategiesare migrated automatically on next start (synchronous, no DM); flat keys split per close-strategy ownership and everything else stays on the open ref.discord.channels/telegram.channelskeys:spot,options,hyperliquid,topstep,robinhood,okx,luno, plus optional paper keys (e.g.,okx-paper).summary_frequency: same key scheme. Values:hourly,daily,every,per_check,always, or Go durations (30m,2h). Wall-clock cadence persisted in SQLite (app_state.last_summary_post); survives restart/SIGHUP.- Trades always force an immediate summary post regardless of cadence.
discord.owner_idfromDISCORD_OWNER_ID; enables DM upgrade/migration prompts.
Live-mode risk defaults prompted by init:
- Per-strategy spot drawdown: 5%
- Per-strategy options drawdown: 10%
- Portfolio kill-switch drawdown: 25%
- Portfolio warn threshold: 60% of kill-switch (warnings repeat every cycle while in band)
Secrets
Set in systemd overrides or exported env vars before installation:
| Variable | Description |
|---|---|
DISCORD_BOT_TOKEN |
Discord bot token |
DISCORD_OWNER_ID |
Discord user ID for DM upgrades/migrations |
STATUS_AUTH_TOKEN |
Optional bearer token for /status |
BINANCE_API_KEY, BINANCE_API_SECRET |
Binance live |
HYPERLIQUID_SECRET_KEY, HYPERLIQUID_ACCOUNT_ADDRESS |
Hyperliquid live |
TOPSTEP_API_KEY, TOPSTEP_API_SECRET, TOPSTEP_ACCOUNT_ID |
TopStep live |
ROBINHOOD_USERNAME, ROBINHOOD_PASSWORD, ROBINHOOD_TOTP_SECRET |
Robinhood live |
OKX_API_KEY, OKX_API_SECRET, OKX_PASSPHRASE, OKX_SANDBOX |
OKX live/demo |
LUNO_API_KEY_ID, LUNO_API_KEY_SECRET |
Luno live |
GO_TRADER_ALLOW_MISSING_STATE |
1 only for genuine first-run live deployments |
Run And Install Service
Smoke test:
./go-trader --config scheduler/config.json --onceInstall systemd:
mkdir -p logs
export DISCORD_BOT_TOKEN="{token}"
sudo bash scripts/install-service.shThe installer copies the unit, runs daemon-reload, enables, starts, and pre-creates logs/ so ProtectSystem=strict doesn't block first-run logging.
Templated multi-instance: sudo bash scripts/install-service.sh systemd/go-trader@.service paper-testing. Without starting: NO_START=1 sudo bash scripts/install-service.sh.
sudo systemctl start|stop|restart|status go-trader
journalctl -u go-trader -n 100 --no-pagerAuto-Update
auto_update: off | daily | heartbeat. When an update is found, the bot notifies active Discord channels. With DISCORD_OWNER_ID set, it DMs the owner; replying yes within 30 minutes runs scripts/update.sh (atomic git pull + uv sync + go build), saves state, and restarts.
Manual update:
# Systemd deploy (default)
cd /path/to/go-trader && bash scripts/update.sh --restart
# Linux bare-process deploy (no systemd)
cd /path/to/go-trader && bash scripts/update.sh --restart --restart-mode signal
# Sync from a source clone without clobbering secrets/state/venv/binary (#791)
bash scripts/update.sh --rsync-from /path/to/source-clone --restart
# Batch-update all go-trader-* siblings at once (requires --restart)
bash scripts/update.sh --all --restart [--update-all-root <parent-dir>]scripts/update.sh is the single source of truth for git pull --ff-only + uv sync + go build (all three steps gated under set -euo pipefail). External deploy automation (Ansible, image bake, etc.) should call this script rather than reproducing the steps inline — that's how asymmetric deploys land.
--rsync-from <src> (#791): replaces git pull --ff-only with an rsync from a source clone into the deployment directory. Preserves .git/, scheduler/config.json, state.db and WAL sidecars, .venv/, and the live binary; safe to use when the deployment directory has local changes or was not cloned from origin. Before the systemd restart, warns on stderr when any required EnvironmentFile= declared in the unit is missing (optional entries prefixed with - are skipped silently).
Signal mode (--restart-mode signal / RESTART_MODE=signal): SIGTERMs the PID in GO_TRADER_PIDFILE (default ./go-trader.pid), respawns via GO_TRADER_RUN_SH (default ./run.sh), then polls /health + PID freshness — same verify/rollback flow as systemd mode. Generate a starter run.sh with bash scripts/create-run-sh.sh. Signal-mode env vars: GO_TRADER_RUN_SH, GO_TRADER_PIDFILE, GO_TRADER_SIGNAL_LOG. Systemd→signal fallback (#786): when --restart-mode systemd encounters a missing unit (systemctl exit 5), update.sh automatically retries via signal mode if go-trader.pid and an executable run.sh are present — no operator action needed for mixed-mode deployments.
Batch mode (--all): scans GO_TRADER_UPDATE_ALL_ROOT (default: parent of this repo) for go-trader-*/ directories and runs the full update flow in each sequentially. Each child inherits GO_TRADER_SERVICE — set per-worktree env if systemd unit names differ across instances.
Verify: journalctl -u go-trader -f | grep -i "\[update\]" (systemd) or tail -f ./go-trader-signal.log (signal mode).
Post-Update Agent Protocol
When invoked after an update (manual git pull, auto-update restart, "I just updated" / "what changed"), walk the operator through anything new commits affect on their existing config, strategies, and open positions — and prompt before applying any opt-in. The binary's runConfigMigrationDM only handles fields registered in configFieldRegistry (≤ v3); newer config-version bumps and opt-ins land silently unless an agent surfaces them.
Trigger
Run when ANY of:
- Operator says "I updated", "I just pulled", "what's new", or asks about migration.
git log -1 --format=%cIis newer than the running binary's version (./go-trader --versionorcurl -s localhost:8099/health→version).git statusclean andgit rev-list --count <running-version>..HEAD> 0.
Steps
- Identify the diff.
git log --oneline <running-version>..HEAD -- scheduler/ shared_scripts/ shared_strategies/ platforms/. If running version unknown, ask the operator (or fall back to last 30 commits). - Classify each commit:
- Auto-migration —
CurrentConfigVersionbumped;MigrateConfigrewrites JSON on next start. Summarize, no prompt. - Runtime default change — behavior shifts on existing strategies without a config edit. Prompt: confirm, or set explicit opt-out.
- New opt-in field — feature dormant until field added. Prompt per affected strategy.
- Open-position constraint — needs flat positions to apply. List affected; warn and skip until flat.
- Internal/no-op — refactors, tests, docs. Mention briefly.
- Auto-migration —
- Read current state. Load
scheduler/config.jsonand queryscheduler/state.db:SELECT strategy_id, symbol, quantity, side FROM positions WHERE quantity > 0; SELECT strategy_id, symbol, contracts, action FROM option_positions WHERE contracts > 0; - Prompt per item. Default to no change if declined. For runtime defaults, also offer to write the explicit opt-out value.
- Apply via SIGHUP-safe edits when supported (see "Reconfiguration"); else require full restart.
- Verify. Tail logs for
[reload]; on rejection, show reason and offer restart.
Required prompt template
Change:
<short description>(PR #)
Affects:<strategy IDs>(and any open positions:<symbol qty side>)
Default if you do nothing:<what happens silently>
Options: 1) accept the new default, 2) opt out by setting<field> = <value>, 3) opt in to the new feature with<field> = <value>(requires flat? Y/N).
Your choice?
Never apply runtime-default changes silently when the operator hasn't been shown the affected strategies. "Auto" means automatic JSON rewrite, not automatic behavior change.
Reference: known categories
When in doubt, treat as runtime default and prompt. Regenerate from git log --oneline -50 when stale.
Auto-migration
config_versionbump, deprecated field removal, silent field copy (e.g. v10sizing_leverage←leverage)- v11 no-op bump (#546)
disable_implicit_closeremoved in #508 —true+ noclose_strategiesnow uses implicit open-strategy close- v12 → v13 co-located strategy refs (#640/#642) —
open_strategy: "name"rewritten to{"name":..., "params":{...}};close_strategies: ["a","b"]rewritten to[{"name":"a","params":{...}}, ...]; flatparamsmap split between the open ref and each close ref by ownership (tiered_tp_atr.tiers,tp_at_pct.pct, etc. routed to the matching close ref).args[0]falls back as the open name;type=manualdefaults to"hold". Migration runs synchronously insideLoadConfigand rewrites the JSON file. Pre-v13 backtests via--configare rejected with a hint to start the live binary once for migration. - v13 → v14 direction enum (#658) —
allow_shorts: falserewritten todirection: "long",allow_shorts: truerewritten todirection: "both"; legacy key dropped. Default for new strategies is"long". Use"short"to run any bidirectional strategy as a dedicated bear-only instrument (e.g.ichimoku_cloud+direction: "short"+allowed_regimes: ["trending_down"]) without writing a new strategy module. Migration is silent — no operator prompt needed.
Runtime default
- HL stop-loss auto-derive from
max_drawdown_pct(#493); margin mode defaultisolated(#486) - Peer normalization of omitted stop/trailing fields (#494/#507; superseded by #601 — peers now place per-strategy sized stops)
- Shared-coin CB drain clears pending without on-chain close when peers share the coin (#515) — operator must flatten manually
- ATR(14) auto-injected + MISSING ENTRY ATR notifier for
tiered_tp_atr(#525) - Paper trailing now books synthetic closes — previously silently ignored (#532)
- Top-level
default_stop_loss_atr_multdefault 1.0 for all-five-omitted HL perps/manual (#562/#601/#605) — applies to shared-coin peers since #601 sizes protection per strategy. Per-strategystop_loss_atr_mult: 0opts out one strategy; top-leveldefault_stop_loss_atr_mult: 0opts out fleet-wide - EntryATR backfill (#568) — pre-stamping or UI-opened positions get ATR stamped on next cycle, silently arming any
tiered_tp_atr/trailing_stop_atr_mult/stop_loss_atr_multpreviously inert - HL shared-coin reconcile (#565) —
reconcileHyperliquidAccountPositionscloses virtual peers when (a) on-chain qty ≈ 0 (full flat) or (b) sole SL owner's residual matches non-owner peers' qty (owner's trigger fired). Ambiguous gaps still gap-only - Detector 3 — TP partial fill (#617) — same-side residual + exactly one strategy with a cleared on-chain TP tier matching the drift → reconciler books external partial close on that strategy and shrinks virtual qty so next protection-sync re-arms SL/TP from true residual; multiple TP candidates → leave gap visible
type=manualreconcile (#576) — manual strategies inisHLLiveReconcilable; UI/SL/TP closes clear scheduler state automatically (no more ghosts)type=manualskipsCheckRisk(#574) — exempt from CB DD math (capital=0, funded ad-hoc)- HL real exchange fee (#585–#590/#603) — scheduler-placed and reconciler-booked trades query
userFillsfor real fee instead of modeled 0.035% estimate. #603 hardens response shape, warns to stderr on malformedclosed_pnl. Pre-existing rows haveexchange_fee=0— rungo-trader backfill hl-fees --all --applyto correct (#591/#602 widened lookup window) - HL peer cash on external close (#584) — non-SL-owner peers closed by Detector 1 get mark-based realized PnL credited to
strategies.cash(was $0) - HL coin-size fill fallback narrowed (#600) — when OID lookup misses,
lookupHyperliquidFillByCoinSizenow anchors on the newest matching record's OID group instead of summing every same-size match in the 24h window — unrelated same-size closes no longer conflate fees/PnL #Tcounts positions opened, not round-trips (#608) —LifetimeTradeStats.PositionsOpened(sourced fromis_close=0) replacesRoundTrips; opens contribute immediately. W/L still round-trip aggregated- HL on-chain reduce-only TP suppresses in-process tiered close evaluators (#604) — for HL live perps strategies that place per-strategy on-chain reduce-only TPs,
tiered_tp_atr/tiered_tp_atr_liveare auto-stripped fromclose_strategiesto prevent races with the on-chain limit fill. Paper perps are never suppressed — paper has no on-chain TPs and relies on the in-process close evaluator for tier exits (#781). SL re-arming queriesuserFillsto detect filled-externally - Tiered-TP final-tier dust fix (#592/#593) — sole-peer final-tier closes use
market_close(sz=None)to flatten on-chain residual; shared-coin peers still use sized close to avoid zeroing peer exposure type=manualcoin-sharing with HL perps (#619/#620) — blanket validation ban (#599) replaced with owner guards;shouldCloseFullPositionprevents flattening peer exposure on full-close; all TP OIDs cancelled viaextraCancelOIDs; peers must shareleverageandmargin_mode- HL SL size capped at on-chain qty (#621/#622) —
hlSLEffectiveQtycaps stop-loss placement atmin(virtualQty, onChainQty)to prevent rejected oversized orders after a manual TP; reconciler SL-close uses actualFilledQtyfromuserFillsfor PnL/cash (was: stale virtual qty) - Trade SL/TP stamp on arm + protection-sync (#624/#625/#631) —
trades.stop_loss_trigger_px/entry_atrnow backfill the momentStopLossTriggerPxarms (paper + live) and again afterapplyHyperliquidProtectionSync(TradeHistory + SQLite), so trade alerts show the SL price even when the SL is placed by the protection sync rather than the execute path - HL TP tier residual eliminated (#629) — non-final tiers pre-floored to lot precision; final tier absorbs the remainder via integer-lot subtraction (
floor_size(size) - sum(non-final floors)) so per-tier truncation can't strand an uncovered residual. Virtual qty normalized viaadapter.round_sizefirst to absorb Go float drift; sub-lot result skips the tier block with[INFO]log - Manual-open SL+TP placed inline (#633) — was: SL armed immediately, TP[n] reduce-only orders deferred to the next scheduler cycle (and both skipped entirely if
--atromitted). Now:placeManualProtectionInlineruns--sync-protectionimmediately after the fill, returning TP OIDs that round-trip viapending_manual_actions.tp_oids_json.--atris optional — fallback0.1*fillPrice/leveragearms SL@1×ATR + TPs when omitted; operator notified via owner DM if fallback can't be computed (no leverage / no fill price) - Manual-open queue-failure cleanup (#635) — when
InsertPendingManualActionfails after a successful on-chain fill (disk full, DB locked),attemptManualOpenCleanupflattens the position via reduce-only market close sized tofillQtyand cancels SL + TP OIDs in the same call; sized so peer manual/perps positions on the same coin are preserved; skipped under--record-only. Cleanup failures notify loudly — operator must flatten manually if both queue insert and cleanup fail - Discord SL line shows ATR multiplier (#638) + ATR before SL ordering (#639) — open trade DMs and per-position summary extras now read
ATR | SL [×Nmult] | TP[i] | leverage; sole-owner fixed-ATR stops display the multiplier next to the SL price so operators can confirm the configuredstop_loss_atr_multfrom the alert without checking config - Portfolio peak rebaselined after strategy prune (#650/#653) — when strategies are removed from config and the service restarts,
rebaselinePortfolioPeakAfterPruneresetsPortfolioRisk.PeakValueto the sum of surviving strategies'RiskState.PeakValue(falling back to configuredCapitalfor cold starts), floored atcomputeInitialPortfolioPeak. Was: stale pre-prune peak survived restart and the first risk-check cycle latched the kill switch immediately on a shrunken book. No action needed — fires automatically on any prune - Update.sh hardening (#647/#648) —
scripts/update.shis now the single source of truth forgit pull --ff-only+uv sync+go build(underset -euo pipefail); both the operator-DM snippet andapplyUpgrade(auto-update DM path) call it. Fails fast with a friendly error ifuvis missing on PATH;applyUpgradetimeout bumped 180s→300s for cold dep bumps; output tail-trimmed to 1500 chars for DM. External deploy automation (Ansible, image bake) should call this script too - Owner DM on HL TP/SL fill (#661/#663) — was: reconciler-detected TP/SL fills only surfaced via the next summary post (up to 5 min later). Now: all three reconciler detectors emit an owner DM the same cycle. Default on; disable with top-level
notify_tp_sl_fills: false. Filled TP tiers also marked✓in Discord/Telegram position summaries (#662/#664) — no toggle - Sole-owner TP partial-fill attribution (#670/#672) — was: non-shared HL perps strategies silently absorbed TP partial fills (no Trade row, no realized PnL credited, no
#T/W-L update, no owner DM); final-tier flatten was booked at SL trigger price when the auto-cancel hadn't propagated. Now:tryBookSoleOwnerTPFillmirrors shared-coin Detector 3 — books partial drift / final-tier flatten at the actual VWAP fill price (or configured TP price as fallback) and emits a TP{n} owner DM. Full-close attribution requires ALLTPOIDs[i]==0so a residual closed by SL/operator/CB after a prior partial TP defers to the legacy SL branch instead of mis-attributing to the already-booked tier. After recovery booking,stampSoleOwnerRecoveryTierConsumedclears that tier's OID and armsTPArmedTierssohlAttemptCloseFromTPFillscannot re-book the same TP OID on a later vanish (#758/#760). Fires automatically — no operator action. - TP-fired close attributed to TP fills, not SL trigger (#673/#675) — was:
syncHyperliquidAccountPositionssaw a state position vanish on-chain withStopLossOID > 0and booked the close at the SL trigger price, producing fictitious losses on dual-TP flattens (HL auto-cancels the resting SL once on-chain qty hits zero). Now:hlAttemptCloseFromTPFillsqueriesuserFillsfor the SL OID first; if SL has no fills but TP OIDs do, books each filled tier as a partial close at VWAP. Falls through to the legacy SL path on confirmed SL fills (preserves the #621 partial-qty adjustment). Fires automatically. - manual-open ATR auto-fetch (#689/#690) — was:
manual-open --atr <X>was effectively required because omitting it triggered the leverage-aware fallback0.1*fillPrice/leverage(≈10% margin at 1× ATR) on every open. Now: when--atris omitted the binary callscheck_hyperliquid.py --fetch-atrto fetch ATR(14) from HL OHLCV at the strategy's symbol+timeframe — same baseline strategy opens get viaensure_atr_indicator. 50%-of-fillPrice plausibility guard mirrorsstampEntryATRIfOpened.computeFallbackATRis preserved as a last-resort when the fetch fails (network error, insufficient candles), behind a single combined notifier message. The startup probe also exercises--fetch-atragainstcheck_hyperliquid.py, so a stale Python missingrun_fetch_atrfails the probe instead of silently degrading. Fires automatically — no operator action. - manual-open operator-friendly defaults (#691/#692) — was:
manual-openrequired explicit--sideand one of--size/--notional/--margin;type=manualstrategies with no stop fields inherited the fleet-widedefault_stop_loss_atr_mult(typically 1.0× ATR). Now:--sidedefaults tolong; when no sizing flag is passed (and not--record-only),--margin 50is auto-applied so./go-trader manual-open <id>is a valid smoke command;type=manualstrategies with all five HL stop fields omitted defaultstop_loss_atr_multto 1.5× ATR (defaultManualStopLossATRMult) — non-manual HL perps keep the tighter fleet default. Explicitstop_loss_atr_mult/other stop fields still win; fleetdefault_stop_loss_atr_mult: 0opts manual out too. Init wizardmanualLeveragedefault bumped from 10 to 20 (matches--jsonfallback ingenerateConfig). - Unknown per-strategy config keys now rejected (#704/#707) — was: typos like
take_profit_pctor pre-v13params/open/close_strategyat the strategy root silently no-op'd, so a missed migration could run undetected against the configured defaults. Now:LoadConfigwalks the strategies array a second time post-migration and rejects any key absent fromStrategyConfig's json tags, with targeted hints (take_profit_*→ "TP lives under close_strategies";stop_loss_*→ list of five valid mutually-exclusive owners;close_strategy/params/open→ "pre-v13 shape — use co-located refs"). One-line[config] <id>: type=X open=Y close=[..] sl=... tp=...summary is logged after load. Operator action: if startup now fails with[config] unknown key, fix or remove the listed key — agents can run./go-trader inspect <id>to see the post-migration effective shape side-by-side with the raw JSON before editing. - Trade-alert DM
Source:line on close legs (#707/#719) — was: every close-leg trade alert read identically regardless of trigger, so operators had to grepTrade.Detailsto tell an exchange SL fill from a paper trailing close. Now: close legs appendSource: <exchange SL|exchange TP{n}|close-strategy exit|external (peer/manual UI)|circuit breaker|paper SL|paper trailing SL|trailing SL>derived from the Details prefix.recordPerpsStopLossClosewrites the prefix viastopLossCloseDetailsPrefix(reason)so paper-mode and immediate trailing-SL closes don't masquerade as exchange SL fires. Open legs skip the line. No operator action; surface in next trade DM. - TP-fill never-armed gate (#719) — was:
findHighestClearedTiertreated any tier withpos.TPOIDs[i]==0as "fired", so a tier whose first protection-sync placement failed (transient HL reject — OID stayed 0) would be advanced over the watermark on the next close-evaluator partial close, triggering thesl_afterSL bump for a TP that never filled. Now:Position.TPArmedTiers []bool(tp_armed_tiers_json) records every tier observed with a positive OID at least once; cleared detection requiresarmed[i]==true. Legacy rows backfill conservatively (armed[i] = oid[i] > 0). Fires automatically. No operator action. - HL TP/SL fill alert includes exchange OID (#706) — owner DMs from reconciler-detected fills now read
<FillType> filled (oid=<id>) — <strategyID>so operators can map a Discord alert directly to a HyperliquiduserFillsentry. No toggle. - Shared-coin reconciler SL attribution hardened (#754/#756/#757) — was: Detectors 1 and 2 booked
hl_sync_stop_losswhenever the SL OID was set anduserFillsreturned any fill for that coin/size, which could mis-attribute a TP fill as a stop-loss on strategies with coinciding sizes. Now: all four SL-attribution call sites (sole-owner vanish, Detector 1, Detector 2,hlAttemptCloseFromTPFills) requirehlReconcileSLFillConfirmed— exact OID match + positiveFilledQtyfromuserFills; unconfirmed SL routes tohl_sync_external(mark-based PnL). Fires automatically — no operator action. TP fill owner DMs fromhlAttemptCloseFromTPFills(the sole-owner vanish TP-path) were also missing; now emitted parity with shared-coin detectors (#754/#755). - HL
--sync-protectionreconcile fill hints (#759/#761) — Go forwards JSON snapshots from the same-cycle reconciler prefetch via--reconcile-fill-hints-json; Python skips a duplicateuserFillsfetch only when the hint confirmsfilled: true(false/malformed/unrelated OID still runs the indexer). JSON marshal failures log to stderr. Fires automatically — no operator action. - HL all-TP-tiers dust reconcile (#777/#778) — was: when every TP tier filled but a tiny same-direction residual remained on-chain (exchange rounding), the reconciler silently resynced virtual qty to on-chain qty with no Trade row, creating a phantom quantity gap and a gap in realized PnL bookkeeping. Now: reconciler detects
all tiers armed + all OIDs zero + same-side dustand callshlAttemptCloseFromArmedTPClears, which books each tier as a partial close atuserFillsVWAP; OIDs fall back to the open-trade snapshot when protection-sync has already zeroedpos.TPOIDs. Qty-drift auto-resync is suppressed in this state to avoid clobbering the fill lookup. Fires automatically — no operator action. - Paper HL perps now run
tiered_tp_atr*close evaluators (#781/#782) — was:hyperliquidPlacesOnChainTPsdid not check live-vs-paper, so paper perps runningtiered_tp_atrortiered_tp_atr_livehad those evaluators stripped byfilterCloseStrategiesForHLOnChainProtectioneach cycle, silently disabling the in-process TP exits. Now: the gate requires--mode=live; paper mode is never suppressed. Fires automatically — no config change needed. - Default-window label resolved correctly (#797/#799) — was:
regime.enabled=truewith no explicitregime.windowsemitted a"default"key butRegimePayload.Labelremapped empty/default selectors toprimaryRegimeWindowKey(which returns""when no windows are configured), soregime_directional_policysilently fell back to the base config direction/invert_signal (ignoring the policy table) andallowed_regimesgate failed open (all regimes admitted). Now:Labelfalls back to"default"whenregimeMultiWindowEnabledis false, matching the check-script emission. No config change required. Strategies usingregime_directional_policyorallowed_regimeswithout explicitregime.windowsnow behave correctly — verify entries were/are gated as expected after update. - HL perps direction orphan auto-close (#822/#823) — during hl-sync reconcile, if a sole-owner live HL perps position's side conflicts with the strategy's current effective direction (resolved from
regime_directional_policywhen configured, else basedirection), the reconciler queues a reduce-only market close. Booked asregime_direction_flip. Scope is broader thanregime_directional_policy: also fires for static-direction strategies (e.g.direction=longwith a seeded short).direction="both"never triggers. Detection lags by one scheduler cycle (reconcile reads prior cycle's regime state). No config change required — fires automatically for any sole-owner HL live perps strategy where position side contradicts effective direction. Operators should review open positions after enablingregime_directional_policyor changingdirectionto ensure no unexpected auto-close fires. storage.pylazy DB init (#824/#825) —shared_tools/storage.pypreviously calledinit_db()at import time, opening a SQLite WAL file at import. Under systemdProtectSystem=strictthe deploy directory is read-only, so thesimulate_strategy.py --probe-onlystartup probe (which importsbacktester → storage) failed with "unable to open database file" and exited 78, keeping the service down. Fixed: schema initialization is now lazy —get_connection()ensures the schema once per path on first real use; import is side-effect free. No operator action required — runtime behavior is unchanged where the filesystem is writable; only probes and read-only contexts benefit.
Opt-in field
trailing_stop_pct(#502);trailing_stop_atr_mult(#507 — initial trigger deferred one cycle)- Open/close composition (#483);
stop_loss_margin_pct(#490);margin_per_trade_usd(#520) tiered_tp_atr_live(#527 —atr_sourceparam, falls back to entry ATR on warm-up)- Regime detection
regime.enabled+allowed_regimes(#541/#546/#558 —Trade.Regimecolumn added on first start) type: "manual"strategy +manual-open/manual-closeCLI (#569) — operator-driven HL perps with auto-defaults SL@1.5×ATR (#691/#692; was 1×ATR) +tiered_tp_atr_live(TP1@2× / TP2@3×); can now share a coin with HL perps or anothertype=manual(#619/#620 — blanket ban lifted; owner guards +shouldCloseFullPosition+extraCancelOIDsprevent cross-strategy mutation; peers must agree onleverageandmargin_mode). SL + TP[n] orders now placed inline onmanual-open(#633) so the position is never naked between fill and the next scheduler cycle.--atris optional and now auto-fetched (#689/#690): when omitted, the binary callscheck_hyperliquid.py --fetch-atrto compute ATR(14) from the strategy's symbol+timeframe (same baseline strategy opens see viaensure_atr_indicator); falls back to0.1*fillPrice/leverageonly if the fetch fails (network error, insufficient candles).--sidedefaults tolong(#691/#692); when no sizing flag is passed (and not--record-only), defaults to--margin 50discord.trade_alert_channels/telegram.trade_alert_channels(#572/#573) — optional map to route trade-fill alerts to a separate channel; omit to keep current behavior (summaries + alerts on samechannelsentry)- Top-level
manual_defaultsblock (#696/#697) — optional overrides for the four hardcodedtype=manual/manual-opendefaults:margin_usd(50),stop_loss_atr_mult(1.5),side("long", lowercase required),tp_tiers([{2×,0.5},{3×,1.0}]). Resolution order at every site is CLI/strategy-param →manual_defaults→ hardcoded constant, so omitting the block preserves existing behavior exactly. Hot-reloadable via SIGHUP.manual_defaults.stop_loss_atr_mult: 0is a manual-only opt-out that doesn't affect non-manual HL perps; the fleet-widedefault_stop_loss_atr_mult: 0still wins over both.tp_tiers: []is rejected at validation — omit the key to inherit the default. No config-version bump. - Regime-aware ATR multipliers across stop/TP surfaces (#733/#735) — four HL-perps-only call sites resolve ATR multiplier from the active trend regime:
stop_loss_atr_regimeandtrailing_stop_atr_regime(strategy-level), andtiered_tp_atr_regime/tiered_tp_atr_live_regime(close-strategy refs). Shape:{"trend_regime": {"trending_up": {"atr": …}, "trending_down": {"atr": …}, "ranging": {"atr": …}}}(close-ref tiers addclose_fraction), or{"use_defaults": true}. Mutually exclusive with the five scalar SL fields. Regime frozen at open viapos.Regime;_live_regimere-resolves each tick. Requiresregime.enabled=true. SIGHUP blocks scalar↔regime flips AND shape changes while open. Backtester parity added in #737/#747 —Backtester(stop_loss_atr_regime=..., trailing_stop_atr_regime=...)and regime TP close refs now work;--config <path> --strategy <id>loads regime fields verbatim. No default behavior change; opt in by switching scalar →_regimesibling. - Post-TP stop-loss adjustment
sl_after(#708/#710/#712) — strategy-level default and/or per-tier rule on atiered_tp_atr*close ref that cancel+replaces the on-chain SL when a TP tier fills. Scalar modes:"breakeven"(SL → AvgCost),{atr_mult: N}(SL → AvgCost ± N·EntryATR, signed; 0=breakeven, negative=behind entry),{trail_from_here: {atr_mult: M}}(perps only — converts to trailing at M·EntryATR). Regime-aware shapes (#736/#742):{kind:"atr_offset","trend_regime":{...}}and{kind:"trail_from_here","trail_from_here":{"trend_regime":{...}}}; resolves frompos.Regimeat fire time; defers when label missing. Scalar keys rejected when regime block present. Backtester rejects regime-awaresl_after("HL-live-only" error — parity deferred). Per-tier overrides shadow strategy-level default. Scope: HL perps +type=manual(manual rejectstrail_from_here). Idempotent; highest cleared tier wins. Requires fixed SL. SIGHUP rejects scalar↔regime or shape changes while open. Backtester parity for scalar modes (#712). Add viaclose_strategies[i].params.sl_afterand/ortiers[j].sl_after. Example:{"name":"tiered_tp_atr","params":{ "tiers":[{"atr_multiple":1,"close_fraction":0.5,"sl_after":"breakeven"}, {"atr_multiple":2,"close_fraction":1.0,"sl_after":{"trail_from_here":{"atr_mult":1.0}}}]}} - N-tier HL TP via
params.tiers(#615/issue #612) — list of{atr_multiple, close_fraction}(cumulative); default[{1×,0.5},{2×,1.0}]; final tier coerced to 1.0 so on-chain TPs sum to full position; non-numeric values rejected per tier.Position.TPOIDs/positions.tp_oids_jsonSQLite column (legacytp1_oid/tp2_oidretained for rollback to pre-#615 — only first two tiers survive a downgrade)
Internal / no ops impact
Discord column truncation/aliases (#514); registry split into open+close (#511)
Trade DM extras enriched (#665/#668) — open-trade DMs now show
| OID: <id>on live fills (paper omits), reorder extras toATR | SL | TP[i] | leverage, and append(<n>x)ATR-multiplier suffix on SL + each TP using%gso fractional tiers (1.25×, 2.5×) render exactly as configured. SharedtradeAlertExtrashelper means Discord and Telegram can never drift on these lines.SL ATR mult + TP tiers persisted on trades (#669/#671) — every Trade row now snapshots the SL arming method (
stop_loss_atr_multREAL, NULL when armed via pct/margin) and the full TP tier list (tp_tiers_jsonTEXT) at fill time, mirrored on Position. Closes the analytics gap where back-computing muls or reading current-config tiers couldn't reconstruct what older trades were placed against after a config edit. Schema migration is idempotent; pre-#671 rows have NULL/empty for these columns.Open-trade snapshot refactor (#674/#677) — open trades now record
entry_atr/stop_loss_oid/stop_loss_trigger_px/tp_oids_json/stop_loss_atr_mult/tp_tiers_jsonin a single INSERT viarecordPositionOpen(deferred-open execute variants stamp protection between fill andRecordTrade). Newtrades.stop_loss_oid(INT) /tp_oids_json(TEXT) columns; migration is idempotent.stampOpenTradeFromPositionremains as the fallback path for late-armed protection (paper SL transition, post-applyHyperliquidProtectionSync).close_fractionhonored — existingclose_strategiesconfigs partial-close as specified (#521)Discord SL/TP[1..n]/ATR position lines (#528/#529/#561); partial-close DMs as
TRADE CLOSED(#530/#531). TP labels and prices in position extras + Discord/Telegram trade DMs now read from configuredtiersinstead of hardcoded 1×/2× — operators with customtiered_tp_atr*tiers (e.g. 2×/3× or 3+ tiers) see the actual rendered TPs match their on-chain orders (#660)Backtester close registry with
--close-strategy/--close-params(#535)HL adapter
cancel_trigger_order→cancel_order_by_oidwith backward-compat alias (#604)shared_tools/hl_user_fills.pyconsolidates fee-lookup helpers shared bycheck_hyperliquid.pyandclose_hyperliquid_position.py(#603/#598)Backtester API aligned with co-located refs (#641/#643) —
Backtester(open_strategy={"name":..., "params":...}, close_strategies=[{"name":..., "params":...}])mirrors the liveStrategyConfigshape.run_backtest.py --close-strategynow accepts both bare names and JSON refs and is repeatable;--close-paramsis removed — fold params into the JSON ref. New--config <path> --strategy <id>flow imports a single strategy from a v13+ live config and uses its open + close refs verbatim (single-mode only; compare/multi/optimize rejected upfront).Startup compatibility probe (#645/#646) — the binary invokes each unique configured check script with
--probe-onlyafter notifier init and before the trading loop. On any non-zero exit it logs the rejecting script + stderr, DMs the owner if Discord is configured, and exits with code 78 (ExitProbeFailure/EX_CONFIG). #821: bothgo-trader.serviceandsystemd/go-trader@.servicesetRestartPreventExitStatus=78so a probe failure keeps the service down rather than restarting everyRestartSecand spamming Discord. Missing-script failures ("can't open file") now produce a distinct error message pointing to a deploy-tree gap rather than an argv mismatch. Operator action: if startup fails aftergit pull/ auto-update, re-pull and rebuild; runsudo systemctl daemon-reloadonce if you updated the service files to pick upRestartPreventExitStatus=78.Graceful shutdown on SIGTERM (#681) — was:
systemctl restart go-traderregularly hung ~90s indeactivating (stop-sigterm)until systemd's default SIGKILL, because in-flight Python subprocesses ran out their full 30sscriptTimeoutper slot. Now: two-phase drain — read-only subprocesses (check_*.py/ fetch helpers) are cancelled immediately on SIGTERM; side-effecting subprocesses (--execute/close_*.py/--sync-protection/ trigger updates) are waited on up to 15s before SIGKILL backstop, so on-chain orders aren't killed mid-call (which would leave on-chain state with no local Trade row). State save / notifier flush / DB close run after the drain. Unit file setsTimeoutStopSec=20. Operator action: aftergit pull, runsudo systemctl daemon-reloadto pick up the newTimeoutStopSec(the binary change works without it, but the unit-file change does not). Verify:journalctl -u go-trader -n 50 | grep '\[shutdown\]'should showdraining→State saved→Completeafter the next restart./healthreturns 503 with{"status":"draining"}during the drain — k8s/ELB-friendly.HL
closedPnlfield renamed toClosedPnLGross(#698/#699) — forward-looking guardrail:userFills.closedPnlis gross of trading fees (the HL UI shows net). No production code currently mis-uses this —bookPerpsPartialCloseWithFillFeecomputes realized PnL locally fromAvgCost/FillPx/Qtyminus the real fee, andbackfill hl-feesrecomputes from stored pre-fee PnL. The rename + regression test (portfolio_closedpnl_gross_test.go) ensures any future refactor that wires the gross value intoTrade.RealizedPnLfails loudly. No operator action; no behavior change.Misplaced subcommand now errors (#700/#701) — was:
./go-trader --config foo manual-open <id> ...(global flags before the subcommand) silently fell through toflag.Parse(), consuming--config fooand dropping the rest, which booted a second scheduler/Discord daemon instead of runningmanual-open. Now:validateDaemonInvocationrejects any positional args remaining afterflag.Parse()with exit code 2; if the leftover token matches a known subcommand (init,manual-open,manual-close,backfill,export,probe,version) the error names it and reminds the operator that subcommands must precede global flags. Correct order is./go-trader <subcommand> [subcommand flags]or./go-trader [global flags]for the daemon — never both with the subcommand last. No behavior change for valid invocations.Update.sh refuses missing config (#702/#703) — was: running
bash scripts/update.shfrom a bare source clone (wherescheduler/config.jsonis gitignored and absent) wouldgit pull+uv sync+ build first, then fail in the probe phase with a confusingread config: open scheduler/config.json: no such file or directory. The mutated tree sometimes drove operators to rsync source over a deployment, clobbering live state. Now: an early[update] phase: preflightcheck verifiesscheduler/config.jsonexists in the repo root (aftercd "$repo_root"so subdirectory invocations report the repo root) and exits 1 with explicit guidance before any mutation. Operator action: runupdate.shfrom a deployment directory; for bare clones, copyscheduler/config.example.json→scheduler/config.jsonfirst../go-trader inspectsubcommand (#704/#707) — new CLI:./go-trader inspect <strategy-id> [--all] [--json]prints the post-migration, post-default effective shape of a strategy: resolved open + close refs with their params, which SL field wonEffectiveStopLossPct(with explicit-vs-default provenance read from raw JSON), tier list resolved from the configured TP close ref, direction provenance. Whenregime_directional_policyis set, showsbase_direction+ per-regime direction/invert table; otherwise legacydirection:label (#784). Loads optional state DB when present for per-open-positioneffective_directionlines (symbol-sorted). Use to diagnose why a strategy isn't behaving like the JSON suggests without grepping code. Agent-friendly: prefer this over re-readingconfig_migration.go.manual-open accepts interspersed flags + resolves --margin/--notional via mark fetch (#711/#713) — was:
./go-trader manual-open hl-manual-btc --side long --margin 50silently failed because stdlibflag.Parsestops at the first positional, dropping all flags after the strategy ID. Separately,--margin/--notionalresolved to0coin qty in the non-record-only path because no mark price was fetched before sizing. Now: the wrapper reorders args before parsing (bothsubcmd <id> --flagandsubcmd --flag <id>work), and the binary fetches the current HL mid beforeresolveManualSizeso notional/margin sizing yields a non-zero order. Dry-run sizing failures prefix the line with[sizing failed]to avoid misreading a0.000000 ETHplan as real. Fires automatically — no operator action.Post-TP SL replace capped at on-chain qty (#714/#717) — was: when an
sl_afterrule fired after a TP partial cleared,runPostTPStopLossAdjustmentissued the cancel+replace at the virtual qty, so HL rejected the order with "size too big" once on-chain qty had shrunk. Now: the SL replace threadshlOnChainAbsQtyand applieshlSLEffectiveQtybefore the subprocess call (matching every other SL placement site). Fires automatically.Framework-injected
regimekwarg now reaches wrapper-shaped strategies (#720/#721) — was: every registered open strategy uses thedef *_strategy(df, **params): return *_core(df, **params)wrapper shape, butstrip_unsupported_position_contextshort-circuited onVAR_KEYWORD, so framework-injectedregime(and other position-context kwargs) sailed through**paramsinto thin cores that crash withTypeError: *_core() got an unexpected keyword argument 'regime'. Now: the VAR_KEYWORD short-circuit is dropped —regimeandPOSITION_CONTEXT_PARAM_KEYSare forwarded only when the wrapper names them explicitly. Affects all 10+ wrapper-shaped strategies on every check script that injects regime (HL/OKX/RH/TopStep/spot). Fires automatically.Regime-aware ATR backtester parity (#737/#747) —
Backtesternow acceptsstop_loss_atr_regime/trailing_stop_atr_regimedicts and regime TP close refs (tiered_tp_atr_regime/tiered_tp_atr_live_regime);--config <path> --strategy <id>loads regime fields from the live config. Regression suite inbacktest/tests/test_regime_backtester_737.py. No operator action.Regime label on Discord summary / leaderboard price lines (#741/#746) — when
regime.enabled, current market regime appended to inline price lines in channel summaries and leaderboard header (one label per base asset). Fires automatically. No operator action.sl_afterregime-aware shapes (#736/#742) —atr_offsetandtrail_from_heresl_after rules can now accept atrend_regimeblock (same wrapper as the other #733 surfaces) in place of scalaratr_mult. SIGHUP blocks shape change while open. Backtester rejects regime-awaresl_afterat init with "HL-live-only" error — parity is a follow-up. Operators opt in by replacing scalaratr_multwith{kind:"atr_offset","trend_regime":{...}}or{kind:"trail_from_here","trail_from_here":{"trend_regime":{...}}}. No default behavior change.TP tier re-placement gate (#749/#751) —
pos.TPArmedTiersforwarded tocheck_hyperliquid.pyas--tp-armed-tiers-json; Python now skips re-placing a tier whose OID==0 because it already fired rather than was never placed. Previously a TP1 fill would cause re-placement of TP1 at the cumulative fraction of the reduced position size. Fires automatically. No operator action.Inspect provenance for regime TP prices (#738/#750) —
go-trader inspect <id>now shows per-regime tier prices and provenance (use_defaultsvs. explicit) forstop_loss_atr_regime/trailing_stop_atr_regime/tiered_tp_atr_regime. Discord position summaries use regime-stamped TP prices for open positions. No config change required.Update.sh atomic swap + rollback (#683) — was:
scripts/update.shoverwrote./go-traderdirectly during build, so a killed/failedgo buildcould leave the live binary corrupted; restart was fire-and-forget with no verification. Now: builds togo-trader.new, probes against just-synced Python, atomic-swaps with.prevretention, and on--restartpollssystemctl is-active+/healthuntilversionmatches ANDMainPIDdiffers from the pre-restart PID. On timeout/mismatch the script resets git tree to the pre-pull SHA, re-syncs uv if HEAD advanced, and restores the.prevbinary automatically.HEALTH_TIMEOUTdefault is 60s (was 15s) to accommodate multi-script startup probes. Operator action: none — the auto-update DM flow and manualbash scripts/update.sh --restartboth pick up the hardening transparently. If a rollback fires, the journal shows[update] rollback: ...with the failing phase; investigate before the next update attempt.Embedded strategy dashboard at
/dashboard(#734) — the status server now also serves an HTML+JS dashboard athttp://localhost:<status_port>/dashboardwith per-strategy candle charts and trade markers, plus JSON endpoints/api/strategiesand/api/strategies/<id>/(candles|trades|status)for tooling. Candles fetched viashared_scripts/fetch_candles.py, cached 30s. The dashboard readsstatus_tokenif configured — the page prompts once and persists to browser local storage. Internally,StatusServergainedstrategiesMu(separate from the global statemu) so SIGHUPUpdateStrategiesno longer deadlocks against the reload lock; lock ordering when both held:mu → strategiesMu. Operator action: none for default deploys. If you've exposed the status port beyond localhost, gate/dashboardbehind an authenticated reverse proxy or VPN rather than relying onstatus_tokenalone (the page is a thin client; the JSON endpoints are the real surface).Dashboard equity sparklines (#813) — strategy sidebar cards now show a mini equity curve. New endpoint
/api/strategies/<id>/equityreturns{points:[{t,v}]}(up to 500 closed positions, independent ofsharpeLookbackLimit). No operator action.Dashboard sortable all-strategies table (#814) — new table view listing all strategies with PnL%, win rate, Sharpe. New endpoint
/api/strategies/overviewreturns{strategies:[UIStrategyOverview]}withpnl_pct,win_rate,sharpe,regime,direction. No operator action.Dashboard color-coded PnL and dark mode (#807/#804) — status grid PnL/drawdown values color-coded; dark mode toggle with theme persisted in browser local storage. No operator action.
Dashboard regime badge + mobile sidebar (#809/#810) — regime label pill in topbar; collapsible sidebar drawer on mobile. No operator action.
Dashboard trade history panel (#808) — scrollable trade history panel below chart.
/api/strategies/<id>/tradesresponse now includes atradeskey (same markers array, oldest-first copy) in addition tomarkers. No operator action.Dashboard strategy tuner (#811) — new parameter editor with live signal preview. GET
/api/strategies/<id>/config→ editable fields + current params; POST/api/strategies/<id>/config→ writes patched config to disk (atomic, validated viaLoadConfigForProbe); POST/api/strategies/<id>/simulate→ runssimulate_strategy.pyvia stdin and returns{live_markers, simulated_markers}. Requiresstatus_tokento be configured (POST endpoints return 403 otherwise). Requiresconfig_version >= 13(auto-migrated on any previous start). Bothstrategy_tuner_schema.pyandsimulate_strategy.pyare probed at startup — a stale Python will fail the probe.optionsstrategies show "not supported" in the preview. After applying via the UI, either SIGHUP (for params/risk fields) or restart (for indicator/script fields). No operator action needed beyond ensuringstatus_tokenis set if you want to use the Apply button.Discord summary spills full positions list to a dedicated message on split (#728/#729) — was: when
FormatCategorySummaryhad to split across multiple Discord messages, positions were packed into msg 1 until the ~2000-char limit, then truncated with… and N more, dropping operators mid-list. Now: when the summary doesn't fit, msg 1 carries header + leaderboard top chunk +Positions: N open+ trades; msg 2 carries the full positions list with no truncation; leaderboard continuation chunks splice in between. Trades section also peels into its own message when msg 1 + leaderboard + trades would breach 2000 chars. Single-fits case unchanged. Operator action: none.Update.sh honors custom systemd unit name (#727) —
scripts/update.shpreviously hardcodedgo-trader.servicein the restart/verify path. Now the unit name is parsed from${UNIT:-go-trader.service}(or auto-derived for templatedgo-trader@.servicedeploys via theINSTANCEenv var). Operators running multi-instance deploys (/opt/go-trader-<name>/withgo-trader@<name>.service) get correct verification after--restartinstead of the script polling the wrong unit. Operator action: none for single-instance deploys; for templated deploys, setINSTANCEorUNITenv when invokingupdate.sh.Update.sh Go resolution + ExecStart vs swap warning (#764/#765) — was:
gohad to be onPATH, so Linux tarball installs (/usr/local/go/bin/go) often failed preflight. Now: aftercommand -v go, the script tries/opt/homebrew/bin/gothen/usr/local/go/bin/go(seescripts/update.sh --help). With--restart, beforesystemctl restartit warns whensystemctl show ExecStart's binary realpath does not match the repo-root./go-traderthis run just swapped — the service may still be pointing at another path. Operator action: installgoon PATH or ensure a fallback path exists; if you see the ExecStart warning, fix the unit file (ExecStart=) or deployment layout so the daemon runs the same binaryupdate.shbuilds, then restart again.Backtester regime gate look-ahead fix + closed-bar contract (#730/#731) — backtester
ensure_regime_columnspreviously wrote bar N's regime to row N, but entries fill at bar N+1 (post-signal-shift); the entry gate at row N+1 was therefore reading a regime label that wouldn't be knowable until after the decision. Nowensure_regime_columnsshifts the regime column by 1 post-injection so the entry gate reads bar N-1's regime, matching the live timing (regime computed at decision time, order fills next bar). New top-of-file docstring documents the look-ahead contract; new regression suitebacktest/tests/test_backtester_lookahead.pypins signal-fills-at-K+1, intra-bar-jump capture at next open, prior-bar regime usage (positive + negative), forward-peek inflation (caller responsibility), and a mechanical shift(1) canary. Mid-series NaN regimes also block entries afterfillna— matches live "no regime data, no entry". No operator action; backtest results for strategies withallowed_regimeswill shift slightly vs. pre-fix.Open-strategy look-ahead bias fixes — amd_ifvg / liquidity_sweeps / chart_patterns (#732/#740) — three caller strategies were peeking forward at data not yet observable:
amd_ifvgselected entry IFVGs by distance to the day's final close;liquidity_sweepsread swing classification before the centered confirmation window completed;chart_patternsstarted breakout search atswing_bar+1instead ofswing_bar+lookback+1. All three now respect the closed-bar contract. Backtest deltas (BTC/USDT, single mode, default params):amd_ifvg15m Return -79.02% → -57.04%, Sharpe -0.81 → -0.36, PF 0.607 → 0.880 — the day-final-close peek was a confounder, not edge.liquidity_sweeps1h slightly worse (-52.40 → -55.14).chart_pattern1h slightly worse (-1.94 → -4.25). Regression tests added for all three. No operator action; live behavior unaffected (live signal generation never had access to forward bars), but backtest comparisons against pre-fix runs will differ.Revert uv subprocess wrapper (#752/#753) — PR #748's
uv runsubprocess path broke servers whereuvis not on systemd's restricted PATH (defaultcurl | shinstalls go to~/.local/bin, which systemd's defaultPATHomits). Reverted to.venv/bin/python3inexecutor.goandversion_probe.go;scheduler/python_cmd.go(newPythonCommand/GO_TRADER_UV) deleted; service units no longer injectPATH/UV_CACHE_DIR;scripts/install-service.shno longer pre-creates the uv cache dir.runPythonWithTimeoutandpythonScriptTimeoutErrorretained (still used bybackfill_hl_fees.go). No operator action; behavior identical to pre-#748.Discord category-summary TP tiers show ATR multiples (#763) — open-position lines in hourly/per-channel summaries append a
%g-formatted(Nx)suffix on each TP tier (same convention as trade-alert extras), so summary TP lines match trade DMs. No operator action.Update.sh signal-mode restart + batch update (#766/#767) —
scripts/update.shnow supports two restart modes. Default--restart-mode systemdunchanged. New--restart-mode signal(orRESTART_MODE=signal) for Linux bare-process deploys: SIGTERMs the PID fromGO_TRADER_PIDFILE(default./go-trader.pid), respawns viaGO_TRADER_RUN_SH(default./run.sh), then polls/health+ PID freshness with same verify/rollback flow as systemd mode. Generate a starterrun.shviabash scripts/create-run-sh.sh. New--allflag (requires--restart) batch-updates allgo-trader-*/directories underGO_TRADER_UPDATE_ALL_ROOT(default: parent of repo). New env:GO_TRADER_RUN_SH,GO_TRADER_PIDFILE,GO_TRADER_SIGNAL_LOG,GO_TRADER_UPDATE_ALL_ROOT. Operator action: none for existing systemd deploys. Signal-mode operators: createrun.shwithscripts/create-run-sh.sh, setRESTART_MODE=signalor pass--restart-mode signal.HL /info burst mitigation (#768/#769) — HL adapter now caches
spot_meta+metato/tmp/hl_meta.json(60-min TTL) shared across all go-trader instances on the host; symbol-miss forces a fresh fetch. Go forwardsallMidssnapshot via--mark-priceto skip a duplicateget_spot_pricecall, andclearinghouseStateleverage/margin-mode via--account-leverage/--account-margin-modeto skip a per-cycleget_position_leveragecall. 429 fromlookup_fill_fee_by_oidreturns{}immediately instead of retrying; modeled-fee fallback preserves bookkeeping.executeProbeArgvadded to probecheck_hyperliquid.pyin execute mode at startup (asymmetric deploys fail fast). Fires automatically — no operator action.Two-leg pairs backtester (#771) — new
backtest/backtest_pairs.py(PairsBacktester): standalone simulator for beta-hedged long/short pairs driven by rolling z-score of log spread. Research/analysis tool only — no live execution path. No operator action.invert_signalfor HL perps/manual (#774/#776) — newStrategyConfig.InvertSignal boolfield (invert_signal): flips BUY↔SELL on non-zero signals fromrunHyperliquidCheckbefore execution. Lets inverse-trend variants reuse the same open/close strategy refs without forking the Python module. HOLD (0) never flipped. Composes withdirection: invert runs in the Go layer before direction interprets the resulting sign, sodirection="short"+invert_signal=trueis valid and distinct from plaindirection="short"(opens short on raw-BUY vs. raw-SELL respectively). Rejected only on non-HL-perps/manual strategies. SIGHUP-blocked while positions are open. Default off; opt in by settinginvert_signal: true.Regime-aware directional policy
regime_directional_policy(#779/#780) — newStrategyConfig.RegimeDirectionalPolicyfield: per-regime override fordirection+invert_signalso a single HL perps strategy automatically switches long/short/inverse mode as the market regime changes, without operator SIGHUP or hot-edits. Shape:"regime_directional_policy": { "trend_regime": { "trending_up": { "direction": "long", "invert_signal": false }, "trending_down": { "direction": "short", "invert_signal": true }, "ranging": { "direction": "long", "invert_signal": false } } }All three canonical labels (
trending_up,trending_down,ranging) required — no undefined runtime fallback. Resolver semantics: when flat, resolves from current cycle's regime (fresh entry decision); when a position is open, resolves frompos.Regimestamped at open ("hold until natural exit" — the position runs under the policy it opened with until natural SL/TP/close-evaluator exit; new entries in the opposing direction never fire becausePerpsOrderSkipReasongates on the resolvedDirection).base_direction/base_invert_signalremain the static fallback when the block is absent or regime detection disabled. Requiresregime.enabled=trueat top level. HL perps only. SIGHUP: shape add/remove/mutate blocked while a position is open; change-when-flat applies on next cycle./statussurfacesbase_direction,base_invert_signal,effective_direction,effective_invert_signal,regime_directional_policy(bool flag),effective_policy_regimeper strategy. Backtester rejects viarun_backtest.py(use staticdirection/invert_signalfor backtesting). Default off; opt in by adding the block.Perps direction validation honors regime policy (#783/#784) — was: startup
ValidatePerpsDirectionConfigcompared open position side to basedirectiononly, soregime_directional_policystrategies could false-alarm (e.g. short opened undertrending_downwhile basedirectionislong). Now: validation uses stampedpos.RegimeviaEffectiveDirectionForPosition(same hold-on-transition as live); unstamped legacy positions skip the warning when any policy regime allows the side.inspectdirection section aligned (#784). Fires automatically — no operator action unless you previously ignored a[WARN] perps state-vs-config gapthat was a false positive.Multi-window regime detection (#792/#793) — new
regime.windowsmap: run independent ADX classifiers per named horizon (value = ADX period in bars); empty = legacy single-lookback unchanged. Per-strategyregime_gate_window/regime_atr_window/regime_directional_windowselectors route each consumer to a different horizon.RegimePayloadfrom check scripts is now string (legacy) or JSON dict keyed by window name.positions.regime_windows_jsonSQLite column added (migration idempotent). OHLCV limit scales to cover the largest window.regime.windowsrequires restart; per-strategyregime_*_windowSIGHUP when flat, blocked while open. No default behavior change — existing configs with emptywindowsare unaffected. Opt in by addingregime.windowsand per-strategy selectors.Default-window label resolved correctly (#797/#799) — bug fix:
regime.enabled=truewith no explicitregime.windowscaused the "default" window key to resolve to an empty label, silently disablingregime_directional_policy(base config used instead of policy entry) and failing openallowed_regimesgate (entries admitted in disallowed regimes). Fixed inRegimePayload.Label. No config change required — existing configs are unaffected and now behave correctly. Strategies usingregime_directional_policyorallowed_regimeswithout explicitregime.windowsshould verify their entries are now properly gated.update.sh
--rsync-from+ EnvironmentFile warning (#791) — new--rsync-from <src>flag syncs code from a source clone without clobbering.git/, config, state DB, venv, or live binary; useful for deployment pipelines that stage builds separately. Before systemd restart, warns on stderr when a requiredEnvironmentFile=path declared in the unit file is missing. No behavior change to trading; no operator action for existing systemd deploys (missing-envfile warning is advisory, restart proceeds).update.sh systemd→signal fallback (#786) — when systemd mode cannot find the unit (systemctl exit 5), update.sh automatically retries via signal mode when
go-trader.pidand an executablerun.share present. No operator action for existing setups.Probe skips live credential checks (#788) —
LoadConfigForProbeno longer requiresHYPERLIQUID_SECRET_KEYand related env vars during the pre-swap probe step;LoadConfigat daemon startup still validates them. No behavior change to live trading; probe now succeeds on build machines without exchange credentials.
Open-position constraint
margin_mode, exchangeleverage, kill-switch identity changes- HL
trailing_stop_atr_mult/stop_loss_atr_multnil↔positive toggle blocked while open invert_signaltoggle blocked while openregime_directional_policyadd/remove/shape change blocked while open (flatten first or restart after close)
Status
Default port 8099. Override with --status-port <port> or status_port in config. If busy, server tries next 5 ports; check logs for [server] Status endpoint at http://localhost:<port>/status.
curl -s localhost:8099/status | python3 -m json.tool
curl -s localhost:8099/health
curl -s localhost:8099/history
open http://localhost:8099/dashboard # embedded strategy charts + trade markers (#734)Dashboard JSON endpoints: /api/strategies, /api/strategies/overview, /api/strategies/<id>/(candles|trades|status|equity|config|simulate). Candles/equity cached 30s. config (GET) and simulate/config (POST) require status_token + same-origin header. If status_token is configured, the dashboard page prompts for it and stores it in browser local storage. Don't expose the status port publicly — gate behind reverse proxy or VPN.
Remote access via Tailscale Serve (#744): The status HTTP server listens on loopback only (localhost:<port> — same as http://127.0.0.1:<port>). Do not rebind go-trader to 0.0.0.0 for remote dashboard use; keep each instance on loopback and front it with Tailscale Serve (or another authenticated proxy on the machine). Example for two instances: tailscale serve --bg --https=8443 http://127.0.0.1:8099 and tailscale serve --bg --https=8444 http://127.0.0.1:8100 → browse https://<node>.tailnet.ts.net:8443/dashboard and :8444/dashboard. Common multi-instance port map (tune to each status_port in config): live 8099, paper-testing 8100, paper-hl-btc 8101, paper-hl-eth 8102, paper-hl-bnb 8103, paper-hl-sol 8104. OpenClaw (or any other agent stack) may expose its own dashboard on different ports/routes — that UI is not go-trader’s /dashboard.
If Discord enabled, wait for the first cycle and verify messages in configured channels. Report success with mode, # strategies, status URL, log command.
TradingView Export
Export SQLite trades to a TradingView portfolio transaction-import CSV:
./go-trader export tradingview --strategy hl-btc-momentum --output tradingview-hl-btc-momentum.csv
./go-trader export tradingview --strategy hl-btc-momentum --strategy okx-eth-breakout --output tradingview-selected.csv
./go-trader export tradingview --all --output tradingview-all.csvCSV header: Symbol,Side,Qty,Status,Fill Price,Commission,Closing Time. Built-in mappings cover known OKX and BinanceUS crypto pairs. Add tradingview_export.symbol_overrides for unmapped:
"tradingview_export": { "symbol_overrides": { "hl:BTC": "BYBIT:BTCUSDT" } }/go-trader Command
When the user says /go-trader, "check bot status", "show strategy health", or "how are the bots doing":
curl -s localhost:8099/status | python3 -c "
import json, sys
d = json.load(sys.stdin)
prices = d.get('prices', {})
strats = d.get('strategies', {})
print(f'=== GO-TRADER (Cycle {d[\"cycle_count\"]}) ===')
for sym, p in sorted(prices.items()):
print(f' {sym}: \${p:,.2f}')
total_val = sum(s['portfolio_value'] for s in strats.values())
total_cap = sum(s['initial_capital'] for s in strats.values())
total_pnl = total_val - total_cap
pct = (total_pnl/total_cap)*100 if total_cap else 0
print(f'\nPortfolio: \${total_cap:,.0f} -> \${total_val:,.0f} ({total_pnl:+,.0f} / {pct:+.1f}%)')
print(f'Strategies: {len(strats)}')
cb_active = [(id,s) for id,s in strats.items() if s['risk_state'].get('circuit_breaker_until','').startswith('20')]
print(f'Circuit breakers active: {len(cb_active)}')
ranked = sorted(strats.items(), key=lambda x: x[1]['pnl_pct'], reverse=True)
print('\nTop 5:')
for id, s in ranked[:5]:
print(f' {id}: {s[\"pnl_pct\"]:+.1f}% (\${s[\"pnl\"]:+,.0f}) | {s[\"trade_count\"]} trades')
print('\nBottom 5:')
for id, s in ranked[-5:]:
print(f' {id}: {s[\"pnl_pct\"]:+.1f}% (\${s[\"pnl\"]:+,.0f}) | {s[\"trade_count\"]} trades')
dead = [id for id,s in strats.items() if s['trade_count'] == 0]
if dead:
print(f'\nDead (0 trades): {len(dead)} - {dead}')
if cb_active:
print('\nCircuit breaker details:')
for id, s in cb_active:
rs = s['risk_state']
print(f' {id}: dd={rs[\"current_drawdown_pct\"]:.1f}% / max={rs[\"max_drawdown_pct\"]:.0f}% | until {rs[\"circuit_breaker_until\"][:19]}')
"Present output in readable prose. Highlight CBs, dead strategies, large PnL changes, missing status data.
/menu Command
When the user says /menu, "show menu", "what can I configure", "what's available", or "help me get started":
=== GO-TRADER MENU ===
1. TRADING PLATFORMS
Binance US spot; Deribit options; IBKR/CME options; Hyperliquid perps;
TopStep futures; Robinhood crypto/options; OKX spot/perps/options; Luno;
custom platforms via the integration checklist.
2. AVAILABLE STRATEGIES
Spot: sma_crossover, ema_crossover, momentum, rsi, bollinger_bands, macd,
mean_reversion, volume_weighted, triple_ema, rsi_macd_combo, pairs_spread
Futures/perps: momentum, mean_reversion, rsi, macd, breakout,
session_breakout, triple_ema_bidir, delta_neutral_funding
Options: vol_mean_reversion, momentum_options, protective_puts,
covered_calls, wheel, butterfly
3. ADJUSTABLE SETTINGS
Global: interval_seconds, db_file, auto_update, status_port,
max_drawdown_pct, portfolio_risk.warn_threshold_pct,
notional_cap_usd, risk_free_rate, correlation.*, summary_frequency,
regime.enabled, regime.period, regime.adx_threshold
Per-strategy: capital, max_drawdown_pct, interval_seconds, htf_filter,
params, leverage, sizing_leverage, margin_per_trade_usd, stop_loss_pct,
stop_loss_margin_pct, trailing_stop_pct, trailing_stop_atr_mult,
trailing_stop_min_move_pct, margin_mode, direction, open_strategy,
close_strategies, allowed_regimes, theta_harvest.*
Discord/Telegram: enabled, channels, trade_alert_channels, dm_channels, owner_id
Environment: Discord token, status token, exchange credentials
4. COMMANDS
/menu
/go-trader
./go-trader init
./go-trader init --json '{...}' --output scheduler/config.json
./go-trader manual-open <strategy-id> [--side long|short] [--size N | --notional N | --margin N]
./go-trader manual-close <strategy-id> [--qty N]
./go-trader backfill hl-fees [--strategy <id>|--all] [--apply] [--reset-cash]
./go-trader inspect <strategy-id> [--all] [--json]
sudo systemctl start|stop|restart|status go-trader
journalctl -u go-trader -n 50 --no-pager
curl -s localhost:8099/status | python3 -m json.tool
5. BACKTESTING
uv run --no-sync python backtest/run_backtest.py --strategy <n> --symbol BTC/USDT --timeframe 1h --mode single|compare|multi|optimize
uv run --no-sync python backtest/backtest_options.py --underlying BTC --since 90 --capital 10000
uv run --no-sync python backtest/backtest_theta.py --underlying BTC --since 90 --capital 10000Manual Trading (HL perps)
Use type: "manual" on Hyperliquid for hand-driven entries/exits with scheduler-tracked P/L, close evaluators (default SL@1.5×ATR + tiered_tp_atr_live TP1@2× / TP2@3×), and Discord trade DMs (#569).
Config skeleton (no script / args / interval_seconds — LoadConfig fills them):
{"id":"hl-manual-btc","type":"manual","platform":"hyperliquid","symbol":"BTC","capital":1000,"leverage":3,"max_drawdown_pct":10}Multiple type=manual strategies and HL perps strategies may share a coin (#619/#620). Owner guards prevent cross-strategy mutation; full-close uses shouldCloseFullPosition to avoid flattening a peer's position; all TP OIDs are cancelled on full close. Peers must share leverage and margin_mode; at most one trailing-stop owner per coin.
CLI:
# Open — pick at most one of --size, --notional, --margin
./go-trader manual-open hl-manual-btc # defaults: --side long --margin 50
./go-trader manual-open hl-manual-btc --side long --size 0.01
./go-trader manual-open hl-manual-btc --side long --notional 500
./go-trader manual-open hl-manual-btc --side short --margin 100 # margin × leverage = notional
# Optional: pass live ATR for accurate SL/TP distances; omit to auto-fetch ATR(14)
./go-trader manual-open hl-manual-btc --side long --size 0.01 --atr 850
# Close — full or partial
./go-trader manual-close hl-manual-btc # full close
./go-trader manual-close hl-manual-btc --qty 0.005
# Record-only (operator placed on HL UI; scheduler tracks)
./go-trader manual-open hl-manual-btc --side long --size 0.01 --record-only --fill-price 67800
./go-trader manual-close hl-manual-btc --qty 0.005 --record-only --fill-price 68250Notes:
--record-onlyskips the live HL order; pair with--fill-price. SL is not auto-armed in record-only mode — place the trigger on the UI manually.- SL and TP[n] reduce-only orders are placed inline on open (#633).
--atris optional: when omitted, the binary auto-fetches ATR(14) from the strategy's symbol+timeframe viacheck_hyperliquid.py --fetch-atr(#690), matching whatensure_atr_indicatorwould compute on a baseline strategy open. If the fetch fails (network error, insufficient candles), it falls back to0.1*fillPrice/leverage(≈10% margin risked at 1× ATR SL) and emits one combined notifier message. Pass--atrexplicitly when you have a live indicator value and want to skip the network round-trip. --sidedefaults tolong(#691/#692). When no sizing flag is passed (and not--record-only),--margin 50is auto-applied somanual-open <strategy-id>works as a smoke-test command. Operators who want a different size must still pass--size/--notional/--marginexplicitly.- Default SL multiplier for
type=manualis 1.5× ATR (#691/#692), distinct from the fleet-widedefault_stop_loss_atr_mult(typically 1.0×) used by non-manual HL perps. Explicitstop_loss_atr_mult/stop_loss_pct/stop_loss_margin_pct/trailing_stop_pct/trailing_stop_atr_multon the strategy still wins; fleetdefault_stop_loss_atr_mult: 0opts manual out too. - All four defaults (margin, SL multiplier, side, TP tiers) are overridable via the optional top-level
manual_defaultsblock (#696/#697) — see Adjustable Settings.manual_defaults.stop_loss_atr_mult: 0is a manual-only opt-out that doesn't affect non-manual HL perps; the block is hot-reloadable via SIGHUP. - Open blocked when portfolio kill switch active or strategy has pending CB close.
- Fills queued in
pending_manual_actions, applied at top of next scheduler cycle (need--onceif daemon idle). If the queue insert fails after a successful on-chain fill, the position is auto-flattened and SL/TP cancelled (#635); cleanup failures notify loudly — flatten manually. - A 99% partial close is not silently collapsed into a full close — the queue carries explicit
is_full_closeintent from--qty. - External closes (UI, SL, TP) detected by reconciler and cleared automatically (#576) — no ghosts.
type=manualexempt from CB drawdown checks (#574).
Backfill HL Fees
HL exchange_fee was $0 for trades placed before #587. Backfill:
# Dry run
./go-trader backfill hl-fees --all
./go-trader backfill hl-fees --strategy hl-btc-momentum
# Apply (stop daemon first)
sudo systemctl stop go-trader
./go-trader backfill hl-fees --all --apply
sudo systemctl start go-traderNotes:
--applyrefuses when anothergo-traderprocess is alive.- Close-leg
realized_pnladjusted by(modeled_fee − real_fee). strategies.cashreplayed frominitial_capitalusing corrected fee/PnL stream.- Cash replay divergence > $1 (likely SIGHUP capital top-up) is WARNING and blocks
--applyunless--reset-cashpassed. - Paper-mode HL strategies skipped (no real OIDs). Manual strategies included.
- Skip reasons (
missing_oid,no_fill_match,already_real_fee) reported per row.
Backtesting
Use uv run --no-sync python for all backtests.
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h --mode single
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h --mode compare
uv run --no-sync python backtest/run_backtest.py --strategy momentum --timeframe 1h --mode multi
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h --mode optimize
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h --since 90
# Close-strategy registry (#535/#641) — repeatable; max close_fraction wins.
# --close-strategy accepts both bare names and JSON refs ({"name","params"}).
# --close-params is removed — fold params into the JSON ref.
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h \
--close-strategy tp_at_pct \
--close-strategy '{"name":"tiered_tp_atr","params":{"tiers":[{"atr_multiple":1,"close_fraction":0.5},{"atr_multiple":2,"close_fraction":1.0}]}}'
# Backtest a live strategy verbatim (single mode only) — pulls the strategy's
# open + close refs from the live config (#643). Pre-v13 configs are rejected.
uv run --no-sync python backtest/run_backtest.py --config scheduler/config.json --strategy hl-btc-momentum \
--symbol BTC/USDT --timeframe 1h --mode single
# Regime gate (#549) — blocks entries outside allowed regimes; closes always execute
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h \
--regime-enabled --regime-period 14 --regime-adx-threshold 20 --allowed-regimes trending_up trending_down
uv run --no-sync python backtest/backtest_options.py --underlying BTC --since 90 --capital 10000
uv run --no-sync python backtest/backtest_theta.py --underlying BTC --since 90 --capital 10000Reconfiguration
After edits to scheduler/config.json:
sudo systemctl kill -s HUP go-trader # hot reload (no state loss)
sudo systemctl restart go-trader # full restartHot reload (SIGHUP) re-applies a safe subset: capital, drawdown, intervals, params, stop-loss (incl. %/ATR-mult trailing), sizing leverage, theta-harvest, portfolio risk knobs, summary cadence, correlation thresholds, allowed_regimes per-strategy, auto-update mode, Discord/Telegram channels and tokens; per-strategy regime_*_window selectors when flat. Refuses if strategy roster, script/args/type/platform, HTF filter, kill-switch identity, or DB path changed; refuses per-strategy exchange leverage / HL margin_mode while positions open; refuses regime_*_window while open. Global regime block (enabled/period/adx_threshold/windows) requires full restart (mirrors correlation). Re-runs HL peer-on-same-coin check (margin_mode/exchange leverage agreement; at most one trailing-stop owner). On rejection, fall back to restart. Status server reflects new port immediately.
Common changes:
- Regenerate config:
./go-trader init - Scripted:
./go-trader init --json '{...}' --output scheduler/config.json - Channels: edit
discord.channels/telegram.channels; update OpenClaw allowlist if needed; usetrade_alert_channelsto send fills to a different channel than summaries - Token:
sudo systemctl edit go-trader, add env override, restart - Add/remove strategies: edit
strategiesarray; removed strategies pruned from state - Risk: edit strategy
max_drawdown_pct, portfoliomax_drawdown_pct,portfolio_risk.warn_threshold_pct - Theta harvesting: add
theta_harvestblock to options strategy entries - Paper → live: change
--mode=paperto--mode=live, add--executewhere required, configure exchange credentials
Changing capital does not reset cash/positions. Full reset: remove scheduler/state.db (or that strategy's rows) and restart.
Adjustable Settings
Global config:
| Setting | Key | Default |
|---|---|---|
| Check interval | interval_seconds |
300 |
| State DB | db_file |
scheduler/state.db |
| Auto-update | auto_update |
off |
| Status port | status_port |
8099 |
| Risk-free rate | risk_free_rate |
0.04 |
| Portfolio kill switch | max_drawdown_pct |
25 |
| Portfolio warn threshold | portfolio_risk.warn_threshold_pct |
60 |
| Correlation tracking | correlation.* |
disabled |
| Summary cadence | summary_frequency |
legacy defaults |
| Regime detection | regime.enabled, regime.period, regime.adx_threshold, regime.windows |
disabled; period=14, threshold=20; windows empty = legacy single horizon (#792) |
| Notify on HL TP/SL fill | notify_tp_sl_fills |
enabled (nil/missing); set false to disable owner DMs from reconciler-detected fills |
type=manual defaults |
manual_defaults.{margin_usd,stop_loss_atr_mult,side,tp_tiers} |
Optional top-level overrides for the four hardcoded manual-open defaults ($50 margin, 1.5× ATR SL, long, [{2×,0.5},{3×,1.0}]). Resolution order: CLI/strategy-param → manual_defaults → hardcoded constant. Block is additive (no config-version bump). Hot-reloadable via SIGHUP; tp_tiers: [] is rejected at validation — omit the key to inherit the default (#696/#697). |
Per-strategy:
| Setting | Key | Notes |
|---|---|---|
| Capital | capital |
Starting capital reference |
| Max drawdown | max_drawdown_pct |
Strategy CB |
| Interval | interval_seconds |
0 uses global; auto-accelerates in DD warn band |
| HTF filter | htf_filter |
Skips counter-trend signals |
| Open strategy params | open_strategy.params |
Per-open overrides; no longer a flat top-level params map (#640). Migrated from legacy on first start |
| Close strategy params | close_strategies[i].params |
Per-close evaluator overrides (e.g. tiered_tp_atr.tiers); each ref carries its own params so they don't leak into the open strategy |
| Direction | direction |
Perps gate: "long" (default), "short" (#656 — open shorts only), or "both" (bidirectional). Replaces legacy allow_shorts; v14 migration converts false→"long", true→"both". SIGHUP-aware when flat. |
| Invert signal | invert_signal |
HL perps/manual only. true flips BUY↔SELL on every non-zero signal; HOLD (0) never flipped. Allows inverse-trend use of any open strategy without a new Python module. Composes with direction="short" (opens short on raw-BUY, distinct from plain short-direction which opens on raw-SELL). SIGHUP-blocked while open. Default false. |
| Regime directional policy | regime_directional_policy |
HL perps only. Per-regime direction+invert_signal override that auto-switches long/short/inverse mode as market regime changes. Requires regime.enabled=true; all three canonical regime labels required. When flat, resolves from current regime; while a position is open, resolves from pos.Regime at open (hold-on-transition). Startup state-vs-config validation and inspect use the same effective-direction rules (#783). SIGHUP blocks shape changes while open. base_direction/effective_direction visible in /status. Backtester rejects (HL-live-only). |
| Stop loss (price %) | stop_loss_pct |
HL perps. Sole-owner auto-derives from max_drawdown_pct (cap 50) when omitted; same-coin peers need one explicit positive owner. 0 opts out. |
| Stop loss (margin %) | stop_loss_margin_pct |
HL perps — leverage-aware; mutually exclusive with the other four owners. 0 opts out. |
| Fixed ATR stop | stop_loss_atr_mult |
HL perps — trigger avg_cost ± mult × entry_atr, armed once after open. Top-level default_stop_loss_atr_mult defaults to 1.0 and applies to every HL perps with all five stop fields omitted (incl. shared-coin peers since #601) (#562/#601/#605); per-strategy 0 or top-level 0 restores max_drawdown_pct fallback. |
| Trailing stop (%) | trailing_stop_pct |
HL perps — distance from high-water mark; mutually exclusive when positive. Live + paper (#532). Capped at 50%; 0 disables. |
| Trailing stop (ATR×mult) | trailing_stop_atr_mult |
HL perps — mult × entry_atr / avg_cost frozen at open; mutually exclusive when positive. Live + paper (#532). Arms cycle after open once ATR exists. |
| Trailing debounce | trailing_stop_min_move_pct |
Min trigger move before cancel/replace. Default 0.5%. |
| Exchange leverage | leverage |
Perps — exchange margin/risk leverage and HL update_leverage (#497). 1× default. |
| Sizing leverage | sizing_leverage |
Perps — notional multiplier (cash * sizing_leverage); defaults to leverage (#497). |
| Margin per trade | margin_per_trade_usd |
Perps (opt-in) — notional = min(margin_per_trade_usd, cash) × leverage. Overrides sizing_leverage. SIGHUP-aware (#520). |
| Margin mode | margin_mode |
HL perps, isolated (default) or cross. Applied from flat. |
| Open strategy | open_strategy |
Override entry strategy name (else args[0]) |
| Close strategies | close_strategies |
Ordered list; max close_fraction wins |
| Regime gate | allowed_regimes |
Labels allowing entries (trending_up, trending_down, ranging); empty = allow all; needs regime.enabled=true; not on type=options |
| Multi-window selectors | regime_gate_window, regime_atr_window, regime_directional_window |
Require non-empty regime.windows. Route entry gate, regime-aware ATR/TP, and directional policy to different ADX horizons. Empty/default → legacy regime.period. Stamped labels persist in pos.RegimeWindows (#792). SIGHUP when flat; blocked while open. |
| Theta harvest | theta_harvest.* |
Options early-exit |
| HL on-chain TP tiers | close_strategies[i].params.tiers (where ref is tiered_tp_atr or tiered_tp_atr_live) |
HL perps only — list of {atr_multiple, close_fraction} (cumulative). Default [{1×,0.5},{2×,1.0}]; final tier coerced to 1.0 so on-chain TPs sum to full position; non-numeric rejected per tier. Live mode: configuring tiers auto-suppresses the in-process tiered_tp_atr* close evaluator to prevent on-chain limit-fill races (#604/#615). Paper mode: evaluator is never suppressed — paper has no on-chain TPs (#781). Pre-v13 configs with flat params.tiers are routed to the matching close ref by closeStrategyOwnedKeys on migration (#640). |
| Post-TP SL adjustment | close_strategies[i].params.sl_after (strategy-level) and/or tiers[j].sl_after (per-tier) — scalar modes: "breakeven", {atr_mult: N} (signed), {trail_from_here: {atr_mult: M}} (perps only). Regime-aware shapes: {kind:"atr_offset","trend_regime":{...}} / {kind:"trail_from_here","trail_from_here":{"trend_regime":{...}}} |
HL perps + manual. Requires fixed SL. SIGHUP blocks scalar↔regime or shape changes while open. Backtester parity for scalar modes (#712); regime-aware sl_after HL-live-only (backtester rejects at init, #736/#742). |
| Regime-aware ATR stop/trailing | stop_loss_atr_regime, trailing_stop_atr_regime |
HL perps. Resolves ATR multiplier per pos.Regime label. Shape: {"trend_regime": {"trending_up": {"atr": N}, "trending_down": {"atr": N}, "ranging": {"atr": N}}} or {"use_defaults": true}. Mutually exclusive with scalar SL fields. Requires regime.enabled=true. Backtester parity since #737/#747 — Backtester(stop_loss_atr_regime=...). SIGHUP blocks flips while open (#733/#735). |
| Regime-aware tiered TP | close_strategies[i].params with ref tiered_tp_atr_regime or tiered_tp_atr_live_regime |
HL perps on-chain TPs with per-regime tiers. _live_regime re-resolves each tick. Backtester parity since #737/#747. |
Discord/Telegram:
enabledchannels: platform/type map for summaries + trade alerts (fallback)trade_alert_channels: optional override for trade fills only; same key scheme; SIGHUP-reloadable (#572)dm_channels: per-platform DM-style trade alertsowner_id: preferDISCORD_OWNER_IDenv
Correlation:
correlation.enabled,correlation.max_concentration_pct(60),correlation.max_same_direction_pct(75)- Warnings → all active channels + owner DM; snapshot in
/status.
Regime detection (global opt-in):
regime.enabled— must betruefor any per-strategyallowed_regimesto fireregime.period— ADX lookback (Wilder), default 14regime.adx_threshold— below =ranging, default 20.0- Valid labels:
trending_up,trending_down,ranging.AllowedRegimesSIGHUP-compatible; globalregimeblock (incl.windows) needs full restart. Per-strategyregime_*_windowselectors SIGHUP when flat; blocked while open. Not on type=options.
Strategy Reference
Source of truth:
uv run --no-sync python shared_strategies/open/spot/strategies.py --list-json
uv run --no-sync python shared_strategies/open/futures/strategies.py --list-json
uv run --no-sync python shared_strategies/options/strategies.py --list-jsonPlatform conventions:
| Platform | ID prefix | Type/script |
|---|---|---|
| BinanceUS spot | none | spot, shared_scripts/check_strategy.py |
| Hyperliquid perps | hl- |
perps, shared_scripts/check_hyperliquid.py |
| Hyperliquid manual | hl- |
manual (#569), no script/interval; manual-open/manual-close; auto-defaults SL@1.5×ATR + tiered_tp_atr_live (TP1@2× / TP2@3×); can share coin with HL perps peers (#619/#620) |
| TopStep futures | ts- |
futures, shared_scripts/check_topstep.py |
| Robinhood | rh- |
spot via check_robinhood.py, options via check_options.py --platform=robinhood |
| OKX | okx- |
check_okx.py (spot/perps), check_options.py --platform=okx for options |
| Deribit options | deribit- |
check_options.py --platform=deribit |
| IBKR options | ibkr- |
check_options.py --platform=ibkr |
| Luno | luno- |
Luno adapter/scripts |
Common entries:
{"id":"momentum-btc","type":"spot","script":"shared_scripts/check_strategy.py","args":["momentum","BTC/USDT","1h"],"capital":1000,"max_drawdown_pct":60,"interval_seconds":300}
{"id":"deribit-vol-btc","type":"options","script":"shared_scripts/check_options.py","args":["vol_mean_reversion","BTC","--platform=deribit"],"capital":1000,"max_drawdown_pct":40,"interval_seconds":1200}
{"id":"ibkr-vol-btc","type":"options","script":"shared_scripts/check_options.py","args":["vol_mean_reversion","BTC","--platform=ibkr"],"capital":1000,"max_drawdown_pct":40,"interval_seconds":1200}
{"id":"ts-momentum-es","type":"futures","platform":"topstep","script":"shared_scripts/check_topstep.py","args":["momentum","ES","1h","--mode=paper"],"capital":1000,"max_drawdown_pct":5,"interval_seconds":3600}
{"id":"rh-sma-btc","type":"spot","platform":"robinhood","script":"shared_scripts/check_robinhood.py","args":["sma_crossover","BTC","1h","--mode=paper"],"capital":500,"max_drawdown_pct":5,"interval_seconds":3600}
{"id":"rh-ccall-spy","type":"options","platform":"robinhood","script":"shared_scripts/check_options.py","args":["covered_calls","SPY","--platform=robinhood"],"capital":5000,"max_drawdown_pct":10,"interval_seconds":14400,"theta_harvest":{"enabled":true,"profit_target_pct":60,"stop_loss_pct":200,"min_dte_close":3}}
{"id":"okx-sma-btc","type":"spot","platform":"okx","script":"shared_scripts/check_okx.py","args":["sma_crossover","BTC","1h","--mode=paper","--inst-type=spot"],"capital":1000,"max_drawdown_pct":5,"interval_seconds":3600}
{"id":"okx-sma-btc-perp","type":"perps","platform":"okx","script":"shared_scripts/check_okx.py","args":["sma_crossover","BTC","1h","--mode=paper","--inst-type=swap"],"capital":1000,"max_drawdown_pct":5,"interval_seconds":3600}
{"id":"okx-mom-btc","type":"options","platform":"okx","script":"shared_scripts/check_options.py","args":["momentum_options","BTC","--platform=okx"],"capital":5000,"max_drawdown_pct":10,"interval_seconds":14400,"theta_harvest":{"enabled":true,"profit_target_pct":60,"stop_loss_pct":200,"min_dte_close":3}}Short-name conventions:
- Options:
vol_mean_reversion → vol,momentum_options → momentum,protective_puts → puts,covered_calls → calls,wheel → wheel,butterfly → butterfly - TopStep:
ts-{strategy}-{symbol} - Robinhood:
rh-{strategy_short}-{asset_or_symbol} - OKX:
okx-{strategy_short}-{asset}for spot/options,okx-{strategy_short}-{asset}-perpfor perps triple_ema_bidiris futures/perps only and needs"direction": "both"(formerly"allow_shorts": true; v14 migrates automatically). Use"direction": "short"to run any bidirectional strategy as a dedicated bear-only instrument (#656).- Short-focused strategies (futures/perps only):
bear_pullback_st(rally-into-EMA20/50 in EMA50<EMA200 + ADX>20 regime, RSI 55–65 rebound, #655),vwap_rejection_st(intraday VWAP/EMA20/EMA50 rejection inside bearish HTF + RSI≤50 confirmation, #657). Both emitsignal=-1only and are pre-registered as bidirectional sodirection: "short"or"both"is required. Pair withallowed_regimes: ["trending_down"]for clean entry gating. donchian_breakout,chart_pattern,liquidity_sweepsalready emittedsignal=-1for bearish setups but were generated long-only byinit.go. Since #654 they default todirection: "both"so existing perps configs need a regenerate or a manualdirectionflip to capture the short side.session_breakoutis futures/perps only; short namesbo- Multiple HL perps strategies on the same coin share an on-chain position; peers must agree on
margin_modeand exchangeleverage(sizing_leveragemay differ). Since #601 each peer places its own per-strategy sized reduce-only protection, so multiple peers can own fixed ATR / margin / trailing stops simultaneously.LoadConfigdefaults all-five-omitted peers todefault_stop_loss_atr_mult(#562/#601/#605); set per-strategystop_loss_atr_mult: 0(one) or top-leveldefault_stop_loss_atr_mult: 0(fleet-wide) to opt out. Per-strategy CB (#515): drain skips on-chain close when peers share the coin — exchange leg stays open until another path flattens. Sub-account isolation is the only path for full per-strategy independence.
Add Or Change Strategies
Open: shared_strategies/open/registry.py. Close: shared_strategies/close/registry.py.
New spot/futures strategy:
- Add implementation +
@register(...)inshared_strategies/open/registry.py. - Set
platforms=(...)correctly; use variants for platform-specific defaults. - Append name to
PLATFORM_ORDER. - Add short name + default entries in
scheduler/init.go. - Add a param grid to
DEFAULT_PARAM_RANGESinbacktest/optimizer.py. - Run registry + optimizer tests.
For close evaluators, add an evaluate(position, market, params) impl under shared_strategies/close/ and register in close/registry.py.
Do not edit shared_strategies/open/{spot,futures}/strategies.py to add strategies — they are thin shims.
Before refactoring registry/shims:
uv run --no-sync python shared_strategies/open/spot/strategies.py --list-json > /tmp/spot.json
uv run --no-sync python shared_strategies/open/futures/strategies.py --list-json > /tmp/futures.jsonDiff afterwards unless intentionally changing discovery.
Custom Platform Integration
Gather: platform name + ID prefix; products (spot/perps/futures/options); API docs URL or ccxt; credential env var names; fees; assets/strategies; paper/live requirements.
Implementation:
platforms/<name>/__init__.pyplatforms/<name>/adapter.py— exactly one class ending inExchangeAdapter- Implement public adapter methods only (no private attribute access from check scripts)
shared_scripts/check_<name>.pyonly if existing entry scripts don't fit- ID prefix inference in
scheduler/config.go - Fee dispatch in
scheduler/fees.go - Executor wiring only if a new live execution path is needed
- Config examples
- Init wizard /
generateConfigif user-selectable - Tests / pure helper tests for Go logic
Adapter references: spot — binanceus; perps — hyperliquid; futures — topstep; options — deribit.
uv run --no-sync python -m py_compile platforms/<name>/adapter.py
uv run --no-sync python -m py_compile shared_scripts/check_<name>.py
/opt/homebrew/bin/go -C scheduler build .
./go-trader --config scheduler/config.json --onceOperator-Required Circuit Breakers
Some venues lack a safe automated close path:
| Platform | Type | Pending key |
|---|---|---|
| OKX | spot | okx_spot |
| Robinhood | options | robinhood_options |
Triggered → scheduler enqueues operator_required: true and emits a CRITICAL warning every cycle until intervention.
Detect:
curl -s localhost:8099/status | uv run --no-sync python -c "
import json, sys
d = json.load(sys.stdin)
for sid, s in d['strategies'].items():
pc = s['risk_state'].get('pending_circuit_closes') or {}
for platform, p in pc.items():
if p.get('operator_required'):
legs = ', '.join(f\"{x['symbol']} size={x['size']}\" for x in p['symbols'])
print(f'{sid} [{platform}]: {legs}')
"Response:
- Open the venue UI.
- Flatten the listed positions.
- Confirm via
/status. - Let the scheduler clear pending on the next CB reset, or reset the portfolio kill switch via owner DM if trading must resume sooner.
Not the same as the portfolio kill switch (portfolio-level, runs automated close paths where available). Operator-required is per-strategy and affects only the strategy that breached drawdown.
Kill-switch auto-reset: once all platforms confirmed flat (OnChainConfirmedFlat=true), the next cycle clears virtual state and resumes trading. The bot posts Virtual state cleared. Kill switch auto-reset; trading will resume next cycle.
Multi-strategy HL coins: kill-switch fills split by virtual quantity at snapshot time (#469). Per-strategy CB on shared HL coins (#515) does not submit a close — reconcile manually if expected to flatten. Reconciliation (#565/#617): if HL flattens to ~0, sole-SL trigger fires (residual matches non-owner peers' qty), or a single TP tier filled externally (Detector 3, #617), the next cycle closes affected virtual peers automatically; ambiguous gaps still gap-only.
Portfolio drawdown warnings repeat every cycle while in warn band (portfolio_risk.warn_threshold_pct, default 60%). Silence by resolving DD or changing threshold.
Drain/live-exec failure alerts:
journalctl -u go-trader -n 100 | grep "liveExec\|drain"Implementation Patterns
See CLAUDE.md "Key Patterns" for full coding constraints. Notes:
- New trade-recording paths must populate
Trade.PositionID(or rely onRecordTrade's lookup againsts.Positions/s.OptionPositions) so partial closes collapse into one round trip. - New summary-posting paths must thread
lastSummaryPost map[string]time.Timeand callShouldPostSummary(freq, continuous, hasTrades, lastPost, now). FormatCategorySummaryrow labels usesummaryStrategyLabel(fixed width + alias substitution); assert exact text in tests.
Audits:
grep -n "mu\.\(R\)\?Lock\(\)\|mu\.\(R\)\?Unlock\(\)" scheduler/main.go
grep -n "liveExecFailed" scheduler/main.goTests
/opt/homebrew/bin/go -C scheduler test ./...
uv run --no-sync python -m pytest
uv run --no-sync python shared_strategies/open/test_registry_parity.pyIf Go cache needs an explicit writable path:
env GOCACHE=/tmp/go-build-cache /opt/homebrew/bin/go -C scheduler test ./...Go CI should not depend on a Python runtime, so tests for subprocess-based live helpers should extract pure parsers/decision helpers rather than invoking Python.