Skip to content

Cheatsheet

Command cheatsheet organized by scenario. Every block is copy-paste ready — replace X / Y with your own account and password.

Conventions

  • X = login account (phone / Futubull ID / moomoo ID / email)
  • Y = password plaintext
  • Z = SMS verification code (sent to phone on first login)

1. Account platform selection

Futu has two independent account systems: Futubull (auth.futunn.com) and moomoo (auth.moomoo.com). Both accept the three formats (numeric ID / phone / email) as --login-account. The same phone / email can have independent accounts on both platforms (with different passwords) — --platform disambiguates.

# Futubull (default, CN / HK) — numeric ID / phone / email (any)
./futu-opend --login-account 12345678 --login-pwd Y
./futu-opend --login-account '+86-13900000000' --login-pwd Y
./futu-opend --login-account 'user@example.com' --login-pwd Y

# moomoo (US / SG / AU / JP / CA) — same three formats + --platform moomoo
./futu-opend --login-account 87654321 --login-pwd Y --platform moomoo
./futu-opend --login-account '+1-4155551234' --login-pwd Y --platform moomoo
./futu-opend --login-account 'user@example.com' --login-pwd Y --platform moomoo

Phone format: region code + dash required

Use +<rc>-<number> strictly, e.g. +86-13900000000 / +1-4155551234. Anything else fails identification.


Strongly recommended: two steps — foreground SMS first, production launch second. This avoids systemd / Docker getting stuck on SMS at startup.

# Step 1: foreground one-off setup (daemon sends SMS to phone immediately,
#         then prompts interactively for the code)
./futu-opend --setup-only \
  --login-account X --login-pwd Y --platform moomoo

# Step 2: normal launch (auto-uses cached credentials, skips SMS)
./futu-opend --login-account X --login-pwd Y --platform moomoo \
  --rest-port 22222 --grpc-port 33333

⚠️ SMS timing (v1.4.84 clarification)

During daemon startup, SMS is sent BEFORE the tty check:

  1. daemon connects to backend → POST /authority/code=20 + device_verify_sig
  2. daemon calls req_device_code which triggers SMS to your phone ← SMS now sent
  3. daemon waits for code — tty mode: stdin prompt; non-tty: aborts with error

Implications: - If daemon runs in systemd / Docker / CI (non-tty), it will abort, but SMS has already been sent to your phone. Restart with --verify-code <CODE> - ⚠️ Do NOT blindly restart daemon: after receiving the SMS, restart promptly with --verify-code <CODE>. Within the short cache window, the daemon reuses the existing verification context instead of requesting another SMS. If the code expired or you restarted repeatedly, use the newest SMS code on your phone - SMS code expires in ~60 seconds, so for Telegram / IM relay scenarios use --verify-code to skip stdin latency

--verify-code workflow for non-tty environments

# 1. First launch without --verify-code → daemon sends SMS then aborts (SMS already received)
./futu-opend --setup-only --login-account X --login-pwd Y --platform moomoo
# daemon log contains "📱 v1.4.84 A1: SMS sent to phone; awaiting verification code via stdin"
# then aborts due to non-tty

# 2. After receiving SMS on phone, restart promptly with the code
./futu-opend --setup-only --login-account X --login-pwd Y --platform moomoo \
  --verify-code 123456

# Expected: within the short cache window, no new SMS is triggered and
# verification succeeds

--setup-only completes auth + credential cache and exits immediately — no servers started. Credentials land in ~/.futu-opend-rs/credentials-<hash>.json and SMS won't be requested again within ~30 days.


3. Production (unattended)

systemd

/etc/systemd/system/futu-opend.service:

[Unit]
Description=FutuOpenD Rust Gateway
After=network-online.target

[Service]
Type=simple
User=futu
Environment=FUTU_ACCOUNT=...
Environment=FUTU_PWD=...
ExecStart=/usr/local/bin/futu-opend \
  --login-account ${FUTU_ACCOUNT} --login-pwd ${FUTU_PWD} \
  --platform moomoo \
  --rest-port 22222 --grpc-port 33333 \
  --rest-keys-file /etc/futu-opend/keys.json \
  --grpc-keys-file /etc/futu-opend/keys.json \
  --audit-log /var/log/futu-opend/audit.jsonl
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Deployment steps:

# 1. Foreground setup to write credentials (as futu user)
sudo -u futu /usr/local/bin/futu-opend --setup-only \
  --login-account X --login-pwd Y --platform moomoo

# 2. Enable the systemd service
sudo systemctl enable --now futu-opend

Docker

docker run -d --name futu-opend --restart unless-stopped \
  -p 11111:11111 -p 22222:22222 -p 33333:33333 \
  -v ~/.futu-opend-rs:/root/.futu-opend-rs \
  -v /etc/futu-opend/keys.json:/etc/futu-opend/keys.json:ro \
  ghcr.io/futuleaf/futu-opend-rs:rs-v1.4.84 \
  --login-account X --login-pwd Y --platform moomoo \
  --rest-port 22222 --grpc-port 33333 \
  --rest-keys-file /etc/futu-opend/keys.json \
  --grpc-keys-file /etc/futu-opend/keys.json

Credential volume mount

-v ~/.futu-opend-rs:/root/.futu-opend-rs is critical — after a container rebuild, device_id and credentials need to persist; otherwise SMS is triggered on every restart.


4. Multi-instance parallel (Futubull + moomoo side by side)

Common scenario: one account on Futubull, one on moomoo; run both gateways at once. Ports must differ — otherwise futucli's default 127.0.0.1:11111 points at the wrong instance.

# Instance A: Futubull (default ports)
./futu-opend --login-account 12345678 --login-pwd Y1 \
  --rest-port 22222 --grpc-port 33333 &

# Instance B: moomoo (ports + 1)
./futu-opend --login-account '+86-13900000000' --login-pwd Y2 --platform moomoo \
  --port 11112 --rest-port 22223 --grpc-port 33334 &

v1.4.16+ prints a WARN on startup if a port is already taken.

Client access:

curl http://localhost:22222/api/accounts   # Futubull
curl http://localhost:22223/api/accounts   # moomoo

5. Debug & development

# Verbose logs (protocol frames + HTTP requests/responses)
./futu-opend --login-account X --login-pwd Y --log-level trace

# Auth flow only (POST raw response / salt / tgtgt / error_code)
./futu-opend --login-account X --login-pwd Y --log-level debug 2>&1 \
  | grep -E "POST.*raw|salt|tgtgt|error_code"

# JSON structured logs (for Loki / ELK / logging pipelines)
./futu-opend --login-account X --login-pwd Y --json-log

# Separate audit log (auth / trade events only; regular logs unchanged)
./futu-opend --login-account X --login-pwd Y \
  --audit-log /var/log/futu-opend/audit.jsonl

6. Recovery

device_id locked (ret_type=15 or ret_type=21)

# Option A: clear files + fresh setup (recommended, thorough)
./futu-opend --reset-device --setup-only \
  --login-account X --login-pwd Y --platform moomoo

# Option B: switch to a specific device_id (e.g. machine migration,
# skips new SMS)
./futu-opend --device-id abcdef0123456789 \
  --login-account X --login-pwd Y

# Option C: nuke everything (when all caches are suspect)
rm -rf ~/.futu-opend-rs/

v1.4.17+ auto-rotates device_id on error_code=21 (wrong SMS code) up to 2 times — usually no manual intervention needed.

Password correct but error_code=2 account/password mismatch

Almost always the wrong platform (moomoo account sent to futunn or vice versa):

# Explicitly specify --platform
./futu-opend --login-account X --login-pwd Y --platform moomoo

Connection stalls (Connection timed out)

v1.4.10 added a 10s timeout, v1.4.11 picks the IP pool per region, v1.4.12 races 3 IPs in parallel. New versions rarely stall. If it still does:

# See where it's stuck
./futu-opend --login-account X --login-pwd Y --log-level debug

# If you're on an overseas account but the regional IP range is fully
# blocked (ISP filtering), you need a VPN.

Client-side common symptoms

Symptom Cause Fix
connect refused :11111 Gateway not up / port taken Check futu-opend console log; v1.4.16+ WARNs on port collision
account/password mismatch Wrong credentials Fix --login-account / --login-pwd, or add --platform moomoo
device not authorized New device needs SMS Run --setup-only in a foreground terminal once
no subscription Must subscribe before quoting POST /api/subscribe before POST /api/quote
gRPC UNAUTHENTICATED No Bearer token or wrong key See the next section on --grpc-keys-file

7. Login password secure storage (v1.4.18+)

Avoid leaking plaintext passwords via ps aux / ~/.bash_history / config-file backups. futu-opend resolves the password via a 7-layer priority chain, and OS keychain is recommended:

# One-time: save to the OS keychain
# (macOS Keychain / Linux Secret Service / Windows Credential Manager)
futucli set-login-pwd --account 12345678
# Interactive password prompt — no echo, no shell history

# Subsequent launches: drop --login-pwd, the keychain is read automatically
./futu-opend --login-account 12345678 --platform moomoo --rest-port 22222

7-layer priority (high → low)

# Method Use case Security
1 --login-pwd-file <path> Docker secrets / systemd LoadCredential ⭐⭐⭐⭐⭐
2 --login-pwd <plain> Legacy scripts (prints a WARN) ⭐ (argv leak)
3 --login-pwd-md5 <hex> Same, md5 is equivalent to plaintext ⭐ (argv leak)
4 FUTU_PWD env var CI / cron / bash export ⭐⭐⭐ (no argv leak)
5 OS keychain Long-term local / server (recommended default) ⭐⭐⭐⭐
6 Interactive prompt (when stdin is a tty) Ad-hoc local dev ⭐⭐⭐⭐ (no echo, no history)
7 None of the above Error out

Deployment scenario guide

# A. Long-term local (most recommended)
futucli set-login-pwd --account 12345678          # one-off
./futu-opend --login-account 12345678 ...         # every launch auto-reads keychain

# B. Docker (secret mounted as a file)
docker run -v pwd-file:/run/secrets/futu-pwd:ro \
  ghcr.io/futuleaf/futu-opend-rs \
  --login-account X --login-pwd-file /run/secrets/futu-pwd ...

# C. systemd (LoadCredential from EncryptedDir / TPM)
# /etc/systemd/system/futu-opend.service
[Service]
LoadCredential=login-pwd:/etc/futu/pwd
ExecStart=/usr/local/bin/futu-opend \
  --login-account X \
  --login-pwd-file ${CREDENTIALS_DIRECTORY}/login-pwd ...

# D. CI / cron (FUTU_PWD env)
FUTU_PWD='...' ./futu-opend --login-account X ...

# E. Ad-hoc local (nothing configured → interactive prompt)
./futu-opend --login-account 12345678 ...
# → "Login password for account 12345678: "

Clear a stored password

futucli clear-login-pwd --account 12345678

8. REST / gRPC / core WebSocket auth

Strongly recommended in production — otherwise /api/* is open to anyone who can reach the port.

# 1. Generate an API key (limited scope + expiration)
futucli gen-key \
  --keys-file ~/.futu-opend-rs/keys.json \
  --scopes quote,trade:read \
  --expires 30d

# 2. Start opend with the keys file
./futu-opend --login-account X --login-pwd Y \
  --rest-keys-file ~/.futu-opend-rs/keys.json \
  --grpc-keys-file ~/.futu-opend-rs/keys.json \
  --ws-keys-file   ~/.futu-opend-rs/keys.json \
  --rest-port 22222 --grpc-port 33333 --websocket-port 44444

# 3. Clients use the Bearer token
curl -H "Authorization: Bearer <key_plaintext>" \
  http://localhost:22222/api/qot/get_global_state

See API Key setup for full scope tables and key management.


9. Restricted networks (advanced)

HTTPS proxy (debug / capture)

# --auth-server explicitly overrides auto-switching (v1.4.15+)
./futu-opend --login-account X --login-pwd Y \
  --auth-server http://127.0.0.1:19998

Combined with a self-written HTTP proxy (e.g. mitmproxy) you can capture the full /authority/salt and POST /authority/ bodies for debugging account identification issues.

Overseas connection domains unreachable

Futu's overseas connection domains are occasionally ISP-blocked. Symptom: every HK / US connection times out (10s each), gateway falls into offline mode.

# First confirm whether it's a network issue
for host in hkconn.futunn.com usconn.moomoo.com; do
  nc -vz $host 9595
done
  • All FAIL, CN connections reachable → ISP blocking overseas connections → use a VPN
  • All FAIL, 443 reachable but 9595 not → firewall blocks 9595 → ask IT to open it
  • Partial → the parallel race will auto-pick a reachable connection

10. Credential file locations

v1.4.17+ puts all persistent files under ~/.futu-opend-rs/:

~/.futu-opend-rs/
├── device-<hash>.dat              # 16-hex device_id (one per account)
├── credentials-<hash>.json        # device_sig + tgtgt + rand_key + uid
└── keys.json                      # (optional) API key file

<hash> = md5(normalized_account)[..16], so +86-13900000000 and 13900000000 share the same file (normalization strips the region code).

When to delete:

Situation What to remove
device_id locked by the server device-<hash>.dat + credentials-<hash>.json (or use --reset-device)
Password changed Keep credentials-<hash>.json so SMS retriggers
Switching platform (futunn ↔ moomoo) Nothing — the two platforms have independent caches
Total cleanup rm -rf ~/.futu-opend-rs/

11. Per-broker unlock-trade (v1.4.47+)

unlock-trade defaults to unlocking all brokers in parallel (the behavior since v1.4.31). If a "hidden" sub-account is blocking the unlock, add --security-firm to scope to one broker and isolate the failure to that one account:

futucli unlock-trade --security-firm hk    # HK only (FutuHK / 1)
futucli unlock-trade --security-firm us    # US / moomoo only (FutuUS / 2)
futucli unlock-trade                       # default: all brokers

--security-firm accepts 1-7 numeric values, canonical names (FutuHK / FutuUS / FutuSG / FutuAU / FutuCA / FutuMY / FutuJP), or short aliases (hk / us / sg / au / ca / my / jp). An invalid value returns a clear error listing the security_firm values your account actually has.


12. FAQ: Why can't I place orders during off-hours? (v1.4.47+)

Symptom: During weekends / holidays / overnight, calls to /api/order or futu_place_order return generic server errors like "please retry later" or "request timeout", and changing order_type doesn't help.

Root cause: OpenD (both Rust and C++ versions) is a protocol pass-through layer — it can only submit your order right now to the backend. The Futu NiuNiu / moomoo mobile APP has a separate app-server-side pre-submit queue that caches orders until market open, but this queue is not shared with OpenD.

Things NOT to try: - HK order_type=AUCTION only applies to same-day pre-open auction (09:00-09:30) — it doesn't cross days - US fill_outside_rth=true allows pre-market / after-hours fills during active sessions — it does NOT mean "can queue on weekends" - Server response is byte-for-byte identical with or without these flags during closed hours. Don't waste time.

Correct approaches: 1. Wait for market open and resubmit via OpenD API 2. Use the APP (its independent queue triggers at open)

Supported STOP / MIT / TRAILING_STOP order types are still orders submitted by OpenD to the backend at call time. They are not an off-hours pre-submit queue, so do not treat conditional orders as an APP queue replacement.

Market hours:

Market Session
HK Mon-Fri 09:30-16:00 HKT (AUCTION allowed 09:00-09:30)
US Mon-Fri 09:30-16:00 ET (HKT: standard time 22:30-05:00, DST 21:30-04:00)
CN A-shares Mon-Fri 09:30-11:30 + 13:00-15:00 CST
SG Mon-Fri 09:00-17:00 SGT
JP Mon-Fri 09:00-11:30 + 12:30-15:00 JST
AU Mon-Fri 10:00-16:00 AEST/AEDT
CA Mon-Fri 09:30-16:00 EST/EDT
MY Mon-Fri 09:00-12:30 + 14:30-17:00 MYT

Starting v1.4.47 the daemon auto-detects "generic server error + market is closed" and appends a 【hint】 section explaining this relationship, so you don't waste cycles on trial-and-error.


Index