Skip to content

Case: Automated trading bot

Python bot places orders via gRPC, runs in simulate mode for a week, then switches to real.

5 minutes: zero to first order

Assuming futu-opend + futucli + Python 3.10+ are installed:

# 1. One-time: stash password in OS keychain (avoid --login-pwd in ps aux)
futucli set-login-pwd --account 12345678

# 2. First run, foreground for SMS verification
futu-opend --setup-only --login-account 12345678 --platform futunn
#   Enter SMS code when prompted; Ctrl-C when you see "credentials cached"

# 3. Start the gateway unattended (systemd / Docker / nohup)
futu-opend --login-account 12345678 --platform futunn \
           --rest-port 22222 --grpc-port 33333 \
           --audit-log /var/log/futu/
#   Listens: FTAPI(11111) / REST(22222) / gRPC(33333)

# 4. List accounts (v1.4.26 fixes show all broker's real/sim accounts)
futucli account
#   8 columns: Acc ID / Card / Env / Broker / Type / Status / Role / Markets
#   Note the acc_id you plan to trade on.

# 5. Verify quotes work
futucli quote HK.00700,HK.09988

# 6. Unlock trading (real only; skip for simulate)
futucli unlock-trade --trade-pwd-md5 <32-char-lowercase-hex>

# 7. Place first order (simulate env first, strongly recommended)
futucli place-order \
  --env simulate --market HK --acc-id <sim_acc_id> \
  --code 00700 --side BUY --qty 100 --price 300 --order-type NORMAL

# 8. Inspect orders & fills
futucli order --env simulate --market HK --acc-id <sim_acc_id>
futucli deal  --env simulate --market HK --acc-id <sim_acc_id>

Once this works end-to-end, the bot just needs to swap futucli for the gRPC client.

New v1.4.26 analysis commands

futucli capital-flow HK.00700 --period-type 1
futucli capital-distribution HK.00700
futucli market-state HK.00700,US.AAPL,HK.09988
futucli owner-plate HK.00700,US.AAPL
futucli option-chain HK.00700 --begin 2026-05-01 --end 2026-06-30 --option-type all

MCP tools: futu_get_capital_flow / futu_get_capital_distribution / futu_get_market_state / futu_get_owner_plate / futu_get_option_chain, plus futu_get_history_kline (with rehab control) and futu_get_reference (related securities). See MCP Integration.


Advanced details below — read after you've completed the 5-minute warmup.

Requirements

  • Bot runs on its own machine, connects to the gateway via gRPC.
  • Strategy: low-frequency limit orders + timed cancellations; max 100 orders/day, ≤50k per order.
  • HK market only.
  • One-week simulate validation before cutover to real.
  • Audit + alerting + circuit breaker all in place.

Key strategy

Two keys, one per environment:

# simulate validation
futucli gen-key \
  --id bot-sim \
  --scopes qot:read,acc:read,trade:simulate \
  --allowed-markets HK \
  --max-order-value 50000 \
  --max-daily-value 500000 \
  --max-orders-per-minute 2 \
  --hours-window 09:30-16:00 \
  --expires 14d      # two weeks covers the validation window

# real production
futucli gen-key \
  --id bot-prod \
  --scopes qot:read,acc:read,trade:real \
  --allowed-markets HK \
  --max-order-value 50000 \
  --max-daily-value 500000 \
  --max-orders-per-minute 2 \
  --hours-window 09:30-16:00 \
  --bind-machines fp_bot_host \
  --expires 30d

The two keys track independently — audit logs, limit counters, and metrics are all keyed by key_id.

Gateway + gRPC

futu-opend \
  --login-account 12345678 --login-pwd "$FUTU_PWD" \
  --grpc-port 33333 \
  --grpc-keys-file /etc/futu/keys.json \
  --audit-log /var/log/futu/

TLS termination via Caddy / nginx (see Production deploy).

Python bot code skeleton

import grpc
from futu_pb2 import FutuRequest
from futu_pb2_grpc import FutuOpenDStub

# read token from env
import os
API_KEY = os.environ["BOT_API_KEY"]

# metadata carries Bearer
metadata_cb = grpc.metadata_call_credentials(
    lambda ctx, cb: cb((("authorization", f"Bearer {API_KEY}"),), None)
)
creds = grpc.composite_channel_credentials(
    grpc.ssl_channel_credentials(), metadata_cb
)
channel = grpc.secure_channel("api.your-domain.com:443", creds)
stub = FutuOpenDStub(channel)

def get_quote(code: str):
    body = encode_get_basic_qot(code)
    resp = stub.Request(FutuRequest(proto_id=3004, body=body))
    return parse_get_basic_qot(resp.body)

def place_order(acc_id: int, code: str, side: int, qty: int, price: float):
    body = encode_place_order(acc_id, code, side, qty, price)
    try:
        resp = stub.Request(FutuRequest(proto_id=2202, body=body))
        return parse_place_order(resp.body)
    except grpc.RpcError as e:
        code = e.code()
        if code == grpc.StatusCode.RESOURCE_EXHAUSTED:
            # limit tripped — rate / daily / market / symbol / side / hours / per-order
            # read the reason bucket from e.details() and decide
            reason = e.details()
            if "rate limit" in reason:
                time.sleep(60)      # rate: wait a minute and retry
                raise TransientError(reason)
            elif "daily value cap" in reason:
                raise StopForToday(reason)   # daily: stop trading for today
            else:
                raise HardFail(reason)       # market / symbol / side / hours: strategy bug
        elif code == grpc.StatusCode.PERMISSION_DENIED:
            raise ConfigError("key scope insufficient")
        raise

# main loop
while trading_window():
    data = get_quote("00700")
    decision = strategy.decide(data)
    if decision:
        place_order(**decision)

Circuit breaker: local rate limiter

Defense in depth — in case the server-side guard has a bug, protect yourself locally too:

import collections, time

class LocalLimiter:
    def __init__(self, max_per_minute=2):
        self.max = max_per_minute
        self.window = collections.deque()

    def try_acquire(self):
        now = time.time()
        while self.window and now - self.window[0] > 60:
            self.window.popleft()
        if len(self.window) >= self.max:
            return False
        self.window.append(now)
        return True

limiter = LocalLimiter(max_per_minute=2)
if not limiter.try_acquire():
    raise TransientError("local rate limit")

Prometheus alerts

alerts.yml (bot side)
- alert: BotRejectSpike
  expr: rate(futu_auth_events_total{key_id=~"bot-(sim|prod)",outcome="reject"}[5m]) > 0.5
  annotations:
    summary: "bot {{ $labels.key_id }} excessive rejects in the last 5m"

- alert: BotDailyCapNearLimit
  expr: futu_auth_limit_rejects_total{key_id=~"bot-.*",reason="daily"} > 3
  annotations:
    summary: "bot {{ $labels.key_id }} hit the daily cap {{ $value }} times today"

Rollout cadence

Week 1:  bot-sim + very tight limits + no real money
 ↓ all green
Week 2:  bot-sim + normal limits + compare signals vs real account (still no real orders)
 ↓ all green
Week 3:  bot-prod + 5% capital + tight limits
 ↓ observe for 2 weeks
Week 5:  bot-prod + full capital + normal limits

Every step includes: audit diff review, metrics reconciliation, strategy replay. No skipping steps.