API Key setup¶
Upgrade from "local dev, no auth" to "production-ready scope + limits + audit".
Concept overview¶
One key, one record; the SHA-256 hash lives in keys.json
(plaintext is printed exactly once when generated).
Each key has several kinds of constraints:
- scope — which endpoints it can call.
qot:read/acc:read/trade:simulate/trade:real/trade:unlock/admin. - allowed_markets / allowed_symbols / allowed_trd_sides — market / symbol / direction whitelists.
- allowed_acc_ids — account whitelist (v1.4.84+, per-key restriction
on which
acc_idvalues can be touched; see §9 below). - max_order_value / max_daily_value — per-order and daily caps.
- max_orders_per_minute — rate (sliding window).
- hours_window — time window (local timezone; cross-midnight OK:
22:00-04:00). - allowed_machines — soft machine binding (prevents whole-file copy to another host).
REST / gRPC / core WS / MCP all four entry points share the same keys.json — no need to configure it in four places.
1. Install futucli¶
Same tarball / cargo workspace that produces futu-opend.
2. Generate a read-only key¶
Output:
✅ Generated key "research"
plaintext: fc_8a3f2b9c1e5d7a4b8c2f9e1d3a5b7c9f ← save this; the terminal output is your only copy
stored in: /Users/you/.config/futu/keys.json
Plaintext is printed only once
keys.json only stores the SHA-256 hash. If you lose the plaintext,
revoke and regenerate — there's no way to recover it.
3. Generate a trading key with limits¶
./futucli gen-key \
--id sim-bot \
--scopes qot:read,acc:read,trade:simulate \
--allowed-markets HK,US \
--allowed-trd-sides SELL \
--max-order-value 100000 \
--max-daily-value 500000 \
--max-orders-per-minute 5 \
--hours-window 09:30-16:00 \
--expires 30d
This key can only:
- Place orders in simulate mode (
trade:simulate, nottrade:real). - Sell, not buy (
--allowed-trd-sides SELLwhitelist). - Trade HK / US.
- Up to 100k per order, 500k daily.
- Up to 5 orders per minute.
- Only between 09:30 and 16:00.
- Expires after 30 days.
4. Start the gateway with keys.json¶
./futu-opend \
--login-account 12345678 --login-pwd 'your_pwd' \
--rest-port 22222 --rest-keys-file ~/.config/futu/keys.json \
--grpc-port 33333 --grpc-keys-file ~/.config/futu/keys.json \
--ws-keys-file ~/.config/futu/keys.json \
--audit-log /var/log/futu-audit.jsonl
Startup log changes:
INFO keys_loaded=2 REST keys file loaded (Bearer auth enabled)
INFO keys_loaded=2 gRPC keys file loaded (Bearer auth enabled)
INFO keys_loaded=2 WS keys file loaded (Bearer/?token auth enabled)
INFO audit JSONL logger enabled (target=futu_audit → file)
5. Call each entry point with the key¶
6. List / edit / revoke keys¶
# list all
./futucli list-keys
# revoke one
./futucli revoke-key sim-bot
# edit machine binding in place (no plaintext change)
./futucli bind-key sim-bot --this-machine
./futucli bind-key sim-bot --replace --machines fp_abc123,fp_def456
./futucli bind-key sim-bot --freeze # temporarily disable
./futucli bind-key sim-bot --clear # remove binding
Hot reload
You don't need to restart after editing keys.json — kill -HUP
<pid> does it. All four entry points (REST / gRPC / core WS / MCP)
support SIGHUP hot reload. New keys work within seconds; revoked
keys are rejected immediately.
7. Query the audit log¶
# recent rejects
jq 'select(.outcome=="reject")' /var/log/futu-audit.jsonl | tail -20
# how many orders did sim-bot place today
jq 'select(.key_id=="sim-bot" and .endpoint|test("order"))' /var/log/futu-audit.jsonl | wc -l
# reject reason histogram (DuckDB works too)
jq -r 'select(.outcome=="reject") | .reason' /var/log/futu-audit.jsonl \
| sort | uniq -c | sort -rn
8. Prometheus scrape¶
The /metrics endpoint sits outside the bearer_auth middleware — no
token required:
curl http://localhost:22222/metrics
# futu_auth_events_total{iface="rest",outcome="allow",key_id="research"} 1234
# futu_auth_limit_rejects_total{iface="grpc",key_id="sim-bot",reason="rate"} 7
# futu_ws_filtered_pushes_total{required_scope="trade",key_id="research"} 42
In production, use firewall rules or bind to 127.0.0.1 to restrict
external access to /metrics.
9. Multi-agent account isolation (v1.4.35+)¶
For multi-agent / multi-strategy setups (e.g. N LLM-driven bots through MCP),
by default any key holding trade:real scope can operate on any unlocked
acc_id. v1.4.35 adds a per-key --allowed-acc-ids whitelist:
# bot-A's key: only 10001 / 10002 allowed
./futucli gen-key --id bot-A \
--scopes trade:real,acc:read \
--allowed-acc-ids 10001,10002 \
--max-order-value 5000 --max-daily-value 20000
# bot-B's key: only 10003
./futucli gen-key --id bot-B \
--scopes trade:real,acc:read \
--allowed-acc-ids 10003
If bot-A's key tries to place an order on 10003 → daemon immediately returns
acc_id 10003 not in allowed list {10001, 10002} without ever hitting the
backend.
4-tier isolation strength¶
--allowed-acc-ids is operational safety, not financial isolation.
The distinction matters when choosing an architecture:
| Tier | Approach | Operational isolation | Financial isolation | Cost | Scenario |
|---|---|---|---|---|---|
| L1 | Single daemon, full-permission key | ❌ | ❌ | 0 | Single-user personal use |
| L2 | Single daemon + multi-key + --allowed-acc-ids |
✅ | Effectively ✅ for cash-only | Low | LLM multi-agent / multi-strategy |
| L3 | Multiple daemons, same union card | ✅ (same as L2) | Same as L2 | Medium | ❌ No extra value, not recommended |
| L4 | Multiple daemons, multiple union cards (multiple login_ids) | ✅ | ✅ (backend customer-level) | High (×N KYC) | Large margin / options / enterprise |
Key insight: what L2 buys, and where its limit is¶
The backend's risk model is customer-level. Under a single union card, Futu backend aggregates total equity / BP / margin / liquidation triggers at the customer (union card) level, not per-session — multiple daemons signing in to the same login_id look to the backend like "one customer with a few open sessions", identical to running the mobile APP + desktop OpenD simultaneously.
But the severity of "financial propagation" depends on account type:
| Account / strategy type | What happens if one sub-account blows up | Propagation? |
|---|---|---|
| Pure cash (no margin / no short) | Sub-account balance zeros, positions clear; account B untouched | ❌ No propagation |
| Margin account, no actual borrowing | Behaves like cash | ❌ No propagation |
| Margin account + actual borrow + blow through principal | A has negative equity; broker may force-liquidate across accounts under unified-account rules | ⚠️ Yes |
| Options combos / cross-product SPAN margin | Risk model aggregates margin across sub-accounts | ⚠️ Yes |
For the mainstream LLM multi-agent scenario (cash-only, small amounts), L2
--allowed-acc-ids is effectively financial isolation — without borrowing,
there's no propagation path.
L2 primarily defends against: - Agent code bugs / LLM hallucinations / prompt injection accidentally touching accounts it shouldn't - Key leaks being used to operate on accounts the key was never meant to see - Multi-agent / multi-strategy shared-daemon setups where per-key audit attribution matters
L2 does NOT defend against (these need L4 or other controls): - Actively enabling margin and actual borrowing causing cross-account margin-call propagation - Options combos / futures margin requirements aggregating across sub-accounts - Extreme events (gap / circuit breaker) where risk-based liquidation fails and produces cross-account deficits