better-moomoo-api
v0.0.2
Published
A clean, typed, reconnect-aware TypeScript SDK for the moomoo/futu OpenD binary protocol
Readme
better-moomoo-api
A clean, typed, reconnect-aware TypeScript SDK for the moomoo/futu OpenD binary
protocol. Pure ESM, zero runtime dependencies beyond @bufbuild/protobuf (protobuf-es).
Design rationale and protocol analysis: see DESIGN.md.
Why not the official package?
- Transparent reconnect: subscriptions, trade unlock and account-push registration are desired state — replayed automatically after every reconnect.
- Fail-fast semantics: requests during a disconnect reject with
DisconnectedErrorimmediately; nothing is silently queued or retried (especially not orders). - A real error model:
TransportError(retryable) /ProtocolError(bug) /ApiError(OpenD said no, withretType/retMsg) are distinct classes. - Typed end to end: generated from the official proto files; enums exported as
values (
QotMarket.QotMarket_US_Security), no magic numbers. - Layered kernel (
codec → crypto → conn → client → qot/trd): the wire kernel knows nothing about business protocols; every layer is unit-tested against a mock OpenD, including padding edge cases of the FTAES_ECB cipher.
Install & build
Requires Node.js >= 24 (current LTS). Built with TypeScript 7.
pnpm install
pnpm gen:proto # buf + protoc-gen-es: proto/*.proto -> src/proto/gen/ (committed output optional)
pnpm gen:registry # protocol id table -> src/proto/registry.ts
pnpm test
pnpm buildTry it
pnpm demo runs examples/demo.mjs against a running OpenD.
By default it is pure request/response — snapshot, static info, market state, quota
— with no subscription at all, so it works outside market hours.
pnpm demo # 127.0.0.1:11111, HK.00700
pnpm demo --code 00001
pnpm demo --host 192.168.1.10 --port 11111
pnpm demo --subscribe # add the subscription + push feedRestart OpenD mid-run to watch reconnect replay subscriptions.
Do I need a subscription?
If you just want a number, no — use getSecuritySnapshot. It needs no
subscription, works outside market hours, and returns strictly more than
getBasicQot: the same OHLC and volume, plus PE/PB/EPS, market cap, dividend and
bid/ask. Subscribe only when you want changes pushed to you as they happen.
Why some reads fail without one
Ask: does OpenD already have this data in hand?
getSecuritySnapshot and friends go out to the server on every call, so they always
work. getBasicQot and friends never leave the machine — they read the copy of the
realtime data that OpenD keeps in memory, and OpenD only maintains that copy for
symbols you have subscribed to. No subscription, no copy:
ApiError: Qot_GetBasicQot(3004) failed: retType=-1
00700 请求获取实时报价接口前,请先订阅Basic数据That memory-resident set is also why subscriptions belong to a connection rather than an account, and therefore why a reconnect has to replay them.
Which reads are gated
Exactly the six kinds OpenD holds in memory. Qot_Common.SubType enumerates them, so
that enum is the test — in it, you need a subscription first; not in it, plain
request/response. Which side an API falls on is OpenD's rule, not this SDK's.
| SubType value | gated read |
| ------------------------------ | ------------------ |
| SubType_Basic | qot.getBasicQot |
| SubType_OrderBook (+ _Odd) | qot.getOrderBook |
| SubType_Ticker | qot.getTicker |
| SubType_RT | qot.getRT |
| SubType_KL_* | qot.getKL |
| SubType_Broker | Qot_GetBroker |
Everything else is plain request/response — getSecuritySnapshot, getStaticInfo,
getMarketState, getSubInfo, requestHistoryKL (quota-limited), and all of
client.trd.*.
A tempting but wrong test is "does it have a Qot_Update* push protocol". That set
is strictly larger: Qot_UpdatePriceReminder and Qot_UpdateOptionEvent are pushes
too, but they are registered by Qot_SetPriceReminder / Qot_SetOptionEventAlert
instead of Qot_Sub — so Qot_GetPriceReminder needs no subscription despite having
a live feed.
Quick start
import {
MoomooClient,
QotMarket,
SubType,
TrdEnv,
TrdMarket,
TrdSide,
OrderType,
} from 'better-moomoo-api';
const client = new MoomooClient({ host: '127.0.0.1', port: 11111 });
await client.connect();
// --- market data ---
const tencent = { market: QotMarket.QotMarket_HK_Security, code: '00700' };
await client.qot.subscribe([tencent], [SubType.SubType_Basic]);
for await (const q of client.qot.basicQotStream()) {
console.log(q.security?.code, q.curPrice);
}
// --- trading ---
await client.trd.unlockTrade({ password: '******' });
const [acc] = await client.trd.getAccList();
const header = { trdEnv: TrdEnv.TrdEnv_Real, accID: acc!.accID, trdMarket: TrdMarket.TrdMarket_HK };
await client.trd.placeOrder({
header,
trdSide: TrdSide.TrdSide_Buy,
orderType: OrderType.OrderType_Normal,
code: '00700',
qty: 100,
price: 500,
});Encrypted OpenD
import { readFileSync } from 'node:fs';
const client = new MoomooClient({
rsaPrivateKey: readFileSync('/path/to/rsa_private_key.pem', 'utf8'),
// packetEncAlgo defaults to FTAES_ECB, matching OpenD's default
});Anything not wrapped yet
All 144 known protocols are callable through the generic escape hatch:
import { Proto, proto } from 'better-moomoo-api';
const s2c: proto.Qot_GetIpoList.S2C = await client.request(Proto.QotGetIpoList, { market: 1 });Module map
| module | responsibility |
| --------- | ---------------------------------------------------------------------- |
| codec/ | 44-byte header pack/unpack, streaming de-framer (pure functions) |
| crypto/ | Cipher implementations: None / RSA / FTAES_ECB / AES_ECB / AES_CBC |
| proto/ | generated protobuf module + protocol id registry |
| conn/ | one TCP connection: handshake, cipher swap, heartbeat, serial matching |
| client/ | reconnect with backoff, desired-state replay, middleware, push routing |
| qot/ | market data API, refcounted subscriptions, push streams |
| trd/ | trading API, unlock management, PacketID injection |
Caveats
- Push protocol format is always Protobuf (
pushProtoFmt = 0); JSON mode is not implemented. - The FTAES_ECB implementation round-trips against itself and follows the documented padding scheme, but has not yet been verified against an encryption-enabled OpenD.
- WebSocket transport (
InitWebSocket) is not implemented; TCP only.
