English · 简体中文
Complete usage documentation for every public TypeScript/JavaScript interface in
ctp-node. For the architecture and native-hook internals see
native-hooks.md; for the design overview see the
README.
- Install
- Quick start
- Core concepts
MarketData— the market-data (行情) clientTrader— the trading (交易) clientCtpClient— shared base (events, close, backpressure)- Types —
RiskLimits,InstrumentPositionLimit,SessionOptions,ArmSpec,ArmHandle,CallbackOptions,RspInfo,MdLoginReq - Enums & struct types
- Risk controls at a glance
- Recipes — strategy skeleton · reconnect handling · combination orders
import {
MarketData, Trader,
Direction, OffsetFlag, OrderPriceType, // generated enums
type RiskLimits, type SessionOptions, type DepthMarketData,
} from "@hitrading/ctp-node";npm install @hitrading/ctp-nodeSupported Node.js release lines are ^22.22.2 || ^24.15.0 || >=26.0.0, matching
the bundled node-gyp 13 requirement. Prebuilt binaries ship in the npm package
for win32-x64 and linux-x64. macOS and other platforms build from source on
install (a C++ toolchain + Python are required).
import { MarketData } from "@hitrading/ctp-node";
const md = new MarketData("./flow/md/", "tcp://182.254.243.31:30012");
md.on("front-connected", async () => {
await md.login({ brokerId: "9999", userId: "your-id", password: "your-pw" });
md.subscribe(["rb2510", "au2508"]);
});
md.on("rtn-depth-market-data", (tick) => {
console.log(tick.instrumentId, tick.lastPrice, tick.bidPrice1, tick.askPrice1);
});
// keep the process alive; call md.close() once at shutdownimport { Trader, Direction, OffsetFlag } from "@hitrading/ctp-node";
const td = new Trader("./flow/td/", "tcp://182.254.243.31:30002");
td.setRiskLimits({ maxOrderVolume: 10, maxOrdersPerSec: 20 });
td.setInstrumentPositionLimit("rb2510", 100); // never hold more than 100 lots/side
td.on("front-connected", async () => {
await td.session({
brokerId: "9999", userId: "your-id", password: "your-pw",
appId: "your-app", authCode: "your-auth-code",
});
// resolves on submission; the fill arrives via rtn-trade
await td.reqOrderInsert({
instrumentId: "rb2510",
direction: Direction.Buy, // "0"
combOffsetFlag: OffsetFlag.Open, // "0"
limitPrice: 3500,
volumeTotalOriginal: 1,
}).catch((e) => console.error("order refused:", e.message));
});
td.on("rtn-trade", (t) => console.log("filled", t.instrumentId, t.price, t.volume));Hybrid event + promise API. Streaming pushes (ticks, order/trade returns,
connection state) are delivered as EventEmitter events with kebab-case names
(rtn-depth-market-data, rtn-order, …). Request/response calls return a
Promise correlated to the response (login(), reqQry*(), …).
Response-event payloads may be undefined on an error or an empty final row;
inspect options.rspInfo / options.isLast before dereferencing them.
Strict request objects. Requests/configs must be object literals (or
null-prototype dictionaries) with explicit own fields. Unknown, inherited,
misspelled, removed, symbol, and null field values are rejected before a
request reaches CTP; they are never silently encoded as zero. Input arrays must
be dense and contain explicit own entries; sparse or prototype-provided values
are rejected.
Create once, reuse for the life of the process. Construct a MarketData /
Trader once and keep it. CTP reconnects on its own — handle a dropped link
in place by re-running your handshake when front-connected fires again (it
fires on the first connect and every auto-reconnect). Do not destroy and
recreate the client: repeated Windows create/close cycles have been observed to
deadlock inside the vendor DLL's Release(). Call close() once at shutdown (or not at all —
process exit cleans up). See
native-hooks.md → Process lifecycle.
Backpressure. Each client decodes ticks/returns from a native ring. Market data uses bounded keep-newest overflow staging and retains the freshest quote. Trader overflow is lossless and ordered: it spills beyond the 4096-slot ring instead of dropping callbacks. Sustained Trader overload can use unbounded memory, so event handlers still need to remain bounded.
Pre-trade risk runs in C++. All risk limits (setRiskLimits,
setInstrumentPositionLimit, reduceOnly / haltAll, …) are enforced on the order-send path inside the native addon, before
the order reaches CTP — no JS round trip. A blocked order makes
reqOrderInsert() reject; the reason is in the error message.
The market-data (行情) client. Subscribes to depth quotes and streams them as events.
| Parameter | Type | Meaning |
|---|---|---|
flowPath |
string |
Directory CTP uses for its flow files (caches sequence state). Created if missing; use a per-client path, e.g. "./flow/md/". |
fronts |
string | string[] |
One or more front addresses, e.g. "tcp://182.254.243.31:30012". Empty/empty-list throws. |
Construction connects asynchronously; wire your handlers, then act in the
front-connected handler.
// single front
const md = new MarketData("./flow/md/", "tcp://182.254.243.31:30012");
// multiple fronts for failover
const md2 = new MarketData("./flow/md/", [
"tcp://182.254.243.31:30012",
"tcp://182.254.243.31:30011",
]);Log in to the market-data front. Resolves with the login response, or rejects
with a CTP error (err.errorId, err.errorMsg).
Field of req (MdLoginReq, all optional) |
Type | Meaning |
|---|---|---|
brokerId |
string |
Broker id. |
userId |
string |
Investor / user id. |
password |
string |
Password. |
tradingDay |
string |
Trading day (rarely needed). |
userProductInfo |
string |
Product info tag. |
SimNow's market-data front accepts an anonymous
login({}); real fronts require credentials.Await every market-data
login()/logout()before starting another one. Some fronts answer these requests withrequestId = 0, so the binding deliberately permits only one such transition at a time. If one times out, the native cache/armed-order path remains fail-closed: the first late final response is ignored (or a front transition resets the state) before a new authenticated cache epoch can be established.
// with credentials
md.on("front-connected", async () => {
const rsp = await md.login({ brokerId: "9999", userId: "id", password: "pw" });
console.log("logged in, trading day", rsp.tradingDay);
md.subscribe(["rb2510"]);
});
// anonymous (SimNow MD front)
await md.login();
// handle a bad login
try {
await md.login({ brokerId: "9999", userId: "id", password: "wrong" });
} catch (e) {
console.error("login failed", e.errorId, e.errorMsg);
}Log out. req may carry { brokerId?, userId? }.
As with login(), market-data login/logout transitions must not overlap; await
the returned Promise before starting the next one.
await md.logout();
// or scoped to a specific account
await md.logout({ brokerId: "9999", userId: "id" });Query multicast instruments and resolve all response rows as an array. The
request may contain topicId and instrumentId. Calls use the same conservative
per-client query scheduler as other CTP queries, with at least 1100 ms between
sends. No caller-side sleep is required.
const instruments = await md.reqQryMulticastInstrument({ topicId: 1 });Subscribe to depth market data. Returns the CTP send code (0 = accepted,
non-zero / -1 = failed). Quotes arrive as rtn-depth-market-data events.
| Parameter | Type | Meaning |
|---|---|---|
instrumentIds |
string[] |
Instrument ids to subscribe, e.g. ["rb2510", "au2508"]. |
md.on("rsp-sub-market-data", (info) => console.log("subscribed", info.instrumentId));
md.on("rtn-depth-market-data", (t) => console.log(t.instrumentId, t.lastPrice));
const rc = md.subscribe(["rb2510", "au2508"]);
if (rc !== 0) console.warn("subscribe send failed:", rc);
// subscribe more later (e.g. after rolling to a new contract)
md.subscribe(["rb2601"]);Unsubscribe from depth market data. Same shape as subscribe.
md.unsubscribe(["au2508"]); // stop one
md.unsubscribe(["rb2510", "au2508"]); // stop severalSubscribe / unsubscribe to quote-request (询价) notifications, delivered as
rtn-for-quote events (used by options market makers).
md.on("rtn-for-quote", (q) => console.log("quote requested for", q.instrumentId));
md.subscribeForQuote(["IO2508-C-3900"]);
// ...later
md.unsubscribeForQuote(["IO2508-C-3900"]);The latest full depth tick for an instrument, read synchronously from the
C++ last-value cache. A successful MD login starts a new, empty cache epoch;
only subsequent ticks in that authenticated epoch update it before reaching JS.
Connect/disconnect, a new login/logout attempt, successful logout, and close
invalidate and clear the epoch immediately. Returns null outside an active
epoch, if no tick has been seen for that instrument, or after unsubscribe.
| Parameter | Type | Meaning |
|---|---|---|
instrumentId |
string |
Instrument id, e.g. "rb2510". |
md.subscribe(["rb2510"]);
// ...later, anywhere in your code — no event handler needed
const tick = md.snapshot("rb2510");
if (tick) console.log("mid", (tick.bidPrice1 + tick.askPrice1) / 2, "@", tick.updateTime);The latest price for an instrument from the same C++ cache, read
synchronously. Returns 0 outside an authenticated cache epoch, if no tick
has been seen, or after the entry is cleared — see
snapshot.
const px = md.last("rb2510");
if (px > 0) console.log("last traded", px);Route this feed's ticks to a Trader's armed triggers (see
Trader.arm). Usually you call td.arm(md, …),
which calls this for you. A MarketData feeds exactly one live Trader's
triggers; attaching a second live Trader throws. After the old Trader has closed,
its registry may be replaced without retaining the closed JS object.
// explicit (rarely needed — td.arm() does this automatically):
md.attachArm(td);
// a second, different live trader throws:
md.attachArm(td); // ok
md.attachArm(otherTd); // throws: already feeds another TraderThe CTP API version string, and the current trading day (YYYYMMDD), available
after connect.
console.log("CTP MD API", md.apiVersion); // e.g. "6.7.2"
md.on("front-connected", () => console.log("trading day", md.tradingDay));Total ticks discarded by keep-newest backpressure. Monitor it to detect a slow consumer.
setInterval(() => {
if (md.droppedRecords > 0) console.warn("MD dropped", md.droppedRecords, "ticks");
}, 5000);Release the underlying CTP API and free native resources. Idempotent. Call once, at shutdown. See lifecycle.
process.on("SIGINT", () => { md.close(); process.exit(0); });Subscribe with md.on(name, handler). Handlers receive (data, options) where
options is a CallbackOptions. Symbolic names are in the
MarketDataEvent enum; plain strings work too.
| Event | data type |
Fires when |
|---|---|---|
front-connected |
undefined |
Connected (first connect and every reconnect). Re-run login/subscribe here. |
front-disconnected |
number (reason code) |
Connection dropped. In-flight requests reject. |
rsp-user-login |
RspUserLogin |
Login response. |
rsp-user-logout |
UserLogout |
Logout response. |
rsp-qry-multicast-instrument |
MulticastInstrument |
One multicast-instrument query row. |
rsp-sub-market-data |
SpecificInstrument |
Subscription ack. |
rsp-unsub-market-data |
SpecificInstrument |
Unsubscription ack. |
rtn-depth-market-data |
DepthMarketData |
A depth quote tick. |
rtn-for-quote |
ForQuoteRsp |
A quote-request notification. |
rsp-error |
undefined |
A CTP error response (options.rspInfo). |
error |
unknown |
A handler you registered threw — see error handling. |
import { MarketDataEvent } from "@hitrading/ctp-node";
// symbolic name
md.on(MarketDataEvent.RtnDepthMarketData, (t) => {
const mid = (t.bidPrice1 + t.askPrice1) / 2;
});
// reconnect handling
md.on("front-connected", async () => { await md.login(); md.subscribe(["rb2510"]); });
md.on("front-disconnected", (reason) => console.warn("MD disconnected, code", reason));The trading (交易) client: order entry, queries, position/risk tracking, and
latency-critical armed triggers. Extends CtpClient; every
generated reqXxx request method is available (see
request methods).
Same parameters as MarketData. The trader
remembers the credentials from the login response, so sync* need no arguments,
and seeds its auto-OrderRef counter past the broker's maxOrderRef so refs
never collide with a prior session.
const td = new Trader("./flow/td/", "tcp://182.254.243.31:30002");
// failover fronts:
const td2 = new Trader("./flow/td/", ["tcp://182.254.243.31:30002", "tcp://182.254.243.31:30001"]);One-call post-connect handshake: authenticate → login → confirm settlement →
sync multipliers / optional margin rates / orders / positions. Call it from the front-connected
handler (and again after a reconnect). Returns the request-correlated
identity: { frontId, sessionId, maxOrderRef } plus row counts from the sync.
Here maxOrderRef is the value reported by that login response; the Trader's
automatic allocator may retain a higher process-local high-water mark.
Each step just wraps the matching request/sync method, so you can
hand-roll the flow when you need finer control.
At handshake start, held-position and working-order ledgers are marked
uncertain, so all opening inserts fail closed across a possible
reconnect gap. Successful syncPositions() and syncOrders() each clear only
their own uncertainty. Disabling either sync leaves that side fail-closed until
you reconcile it later.
A newly created Trader starts in the same fail-closed state, including the small
window before its first front-connected callback.
opts is a SessionOptions:
The login response must contain a concrete frontId / sessionId and the same
brokerId / userId that were requested. An empty or mismatched success
payload is rejected because those fields are part of the local reservation and
snapshot identity; reusing an old/zero session or reconciling another account
would make the ledger unsafe.
| Field | Type | Meaning |
|---|---|---|
brokerId |
string |
Broker id (required). |
userId |
string |
Investor id (required). |
password |
string |
Password (required). |
appId |
string? |
Terminal app id for authentication. SimNow: "simnow_client_test". |
authCode |
string? |
Terminal auth code. SimNow: 16 zeros. Omit both appId/authCode to skip authentication. |
confirmSettlement |
boolean? |
Confirm the settlement statement after login (real accounts must, or orders are rejected). Default true. |
sync |
object? |
{ multipliers?: boolean | string[], marginRates?: { instrumentIds: string[], hedgeFlag: "1" | "2" | "3" | "5" | "6" | "7" }, positions?: boolean, orders?: boolean }. Multipliers/positions/orders default on; margin rates require an explicit bounded contract list and hedge flag because CTP needs one query per contract and keeps separate schedules per hedge flag. |
// SimNow full handshake
td.on("front-connected", async () => {
const counts = await td.session({
brokerId: "9999", userId: "id", password: "pw",
appId: "simnow_client_test", authCode: "0000000000000000",
});
console.log("synced", counts); // { identity, multipliers, marginRates, positions, orders }
});
// scope the multiplier sync to the symbols you trade (faster cold start)
await td.session({
brokerId: "9999", userId: "id", password: "pw",
sync: {
multipliers: ["rb2510", "au2508"],
marginRates: { instrumentIds: ["rb2510", "au2508"], hedgeFlag: "2" },
positions: true,
orders: true,
},
});
// skip the settlement confirm (environments that don't need it)
await td.session({ brokerId: "9999", userId: "id", password: "pw", confirmSettlement: false });The hand-rolled equivalent:
await td.reqAuthenticate(...)→await td.reqUserLogin(...)→await td.reqSettlementInfoConfirm(...)→await td.syncMultipliers()→await td.syncMarginRates([...])→await td.syncOrders()→await td.syncPositions().
Insert an order through the C++ pre-trade risk gate. Resolves on submission —
CTP sends no success acknowledgement for an accepted order, only rtn-order /
rtn-trade (correlate by orderRef). It rejects only if the send is refused
(risk gate, rate limit, or a CTP API error code). A blank orderRef is assigned
a unique numeric value automatically.
A confirmed login is required. brokerId and investorId are filled from that
session; supplying a different value is rejected before encoding, so the local
position/order snapshots cannot be applied to an order for another account.
req is a Partial<InputOrder>; the fields you almost always set:
| Field | Type | Meaning |
|---|---|---|
instrumentId |
string |
Contract, e.g. "rb2510". |
direction |
Direction ("0"/"1") |
Buy / Sell. |
combOffsetFlag |
string |
Exactly one offset char: "0" open, "1" close, "3" close-today, … Multi-leg flags are rejected. |
limitPrice |
number |
Limit price; must be > 0. Market / any-price orders (limitPrice: 0) are not supported. |
volumeTotalOriginal |
number |
Quantity in lots (positive int32). |
orderRef |
string? |
Leave unset for an auto-assigned unique ref; otherwise 1–12 decimal digits. |
orderPriceType |
OrderPriceType? |
Optional; if set, must be "2" (limit). Defaults to limit. |
Only single-instrument GFD, FAK and FOK limit orders are accepted. Use
timeCondition: "3", volumeCondition: "1"for GFD;"1", "1"for FAK; and"1", "3"for FOK.reqOrderInsertdeliberately rejects (with aTypeError) anything the native risk-reservation ledger cannot represent: other time/volume-condition combinations, min-volume/conditional/stop/swap/force-close variants, and multi-leg offset/hedge flags.
import { Direction, OffsetFlag } from "@hitrading/ctp-node";
// 1) limit buy 1 lot to open
await td.reqOrderInsert({
instrumentId: "rb2510", direction: Direction.Buy,
combOffsetFlag: OffsetFlag.Open, limitPrice: 3500, volumeTotalOriginal: 1,
});
// 2) handle a refusal (risk gate / rate limit / CTP error)
try {
await td.reqOrderInsert({ instrumentId: "rb2510", direction: "0", combOffsetFlag: "0", limitPrice: 9999, volumeTotalOriginal: 1 });
} catch (e) {
console.warn("refused:", e.message); // e.g. "blocked by pre-trade risk: order price deviates too far from reference"
}
// 3) correlate a fill by your own orderRef
await td.reqOrderInsert({ orderRef: "42001", instrumentId: "rb2510", direction: "0", combOffsetFlag: "0", limitPrice: 3500, volumeTotalOriginal: 2 });
td.on("rtn-trade", (t) => { if (t.orderRef === "42001") console.log("my order filled", t.volume); });
// 4) close-today a short position (sell -> close)
await td.reqOrderInsert({ instrumentId: "rb2510", direction: Direction.Sell, combOffsetFlag: OffsetFlag.CloseToday, limitPrice: 3490, volumeTotalOriginal: 1 });Cancel / modify a working order. Resolves on submission. A Delete action remains
available in every risk mode. A Modify action can increase exposure, so it is
treated as an unsupported mutation and is refused unless the Trader is in
NORMAL with every local risk control disabled. Identify the order via
orderRef + frontId + sessionId (from its rtn-order), or by
exchangeId + orderSysId.
import { ActionFlag } from "@hitrading/ctp-node";
let working;
td.on("rtn-order", (o) => { if (o.orderStatus === "3") working = o; }); // NoTradeQueueing
// cancel it
await td.reqOrderAction({
instrumentId: working.instrumentId,
orderRef: working.orderRef, frontId: working.frontId, sessionId: working.sessionId,
actionFlag: ActionFlag.Delete, // "0"
});
// cancel by exchange order id instead
await td.reqOrderAction({
instrumentId: working.instrumentId,
exchangeId: working.exchangeId, orderSysId: working.orderSysId,
actionFlag: "0",
});Every reqQry* resolves with an array of rows (multi-row responses are
accumulated; an empty result resolves []); the two single-response reqQuery*
methods resolve one row. CTP rate-limits queries to roughly one per second.
Every generated query shares a per-Trader scheduler with at least 1100 ms
between attempted sends, including concurrent direct calls; it keeps at most one
query in flight, so callers do not add setTimeout(1000). Request data is
snapshotted when the method is called, and disconnect/login/logout transitions
reject queued work from the old context. The scheduler is per client (not
process-global) and accepts at most 256 active/queued queries. Raw queries are
paced but not retried. The
sync* helpers additionally
retry transient -2/-3 flow-control failures and required empty cold-start
data.
// current positions
const positions = await td.reqQryInvestorPosition({ brokerId: "9999", investorId: "id" });
for (const p of positions) console.log(p.instrumentId, p.posiDirection, p.position);
// account funds
const [account] = await td.reqQryTradingAccount({ brokerId: "9999", investorId: "id" });
console.log("available", account?.available);
// instrument details (multiplier, tick size, …)
const [rb] = await td.reqQryInstrument({ instrumentId: "rb2510" });
console.log("multiplier", rb?.volumeMultiple, "tick", rb?.priceTick);
// today's orders / trades
const orders = await td.reqQryOrder({ brokerId: "9999", investorId: "id" });
const trades = await td.reqQryTrade({ brokerId: "9999", investorId: "id" });See request methods for the full list.
Trigger the instrument query so the C++ risk engine picks up contract multipliers
(合约乘数) — it sources them directly from CTP's OnRspQryInstrument response,
so this no longer calls a JS setter. Needed for multiplier-accurate notional and
position-margin limits. No argument queries all instruments in one request; pass a
symbol list to scope it. An explicit empty list is a no-op returning 0; only an
omitted argument means "query all". Retries through cold-start flow control.
Returns the count of contracts whose final callback row has a usable multiplier.
Starting an instrument refresh invalidates the old multiplier before the request
is sent, so a failed/empty refresh cannot leave stale metadata active. If held
positions exist, run syncPositions() afterwards to refresh their authoritative
UseMargin; monetary opening gates remain fail-closed until then.
const n = await td.syncMultipliers(); // all instruments
await td.syncMultipliers(["rb2510", "au2508"]); // just these
console.log(n, "contracts with a multiplier");Query investor long/short by-money/by-volume schedules for a bounded contract
universe (default hedge flag "1", speculation). A second exchange-base query
is issued only for a relative row; the shared scheduler spaces every query.
Relative investor schedules are added to their exchange base. Missing metadata
fails closed.
Old investor and exchange schedules are invalidated before the refresh. Run
syncPositions() after refreshing schedules when the account already holds
positions; the engine will not treat margin estimated under an old schedule as
authoritative.
Seed open-position margin from CTP (reqQryInvestorPosition, each row's
UseMargin) into the risk engine's position tracker. It always uses the
confirmed login identity; account overrides are rejected. Returns the number of
positions seeded. Empty snapshots must be observed twice; applying the snapshot is an
atomic revision-checked replacement that preserves working reservations. A
realtime callback during the query causes a retry rather than a clear/reseed gap.
const held = await td.syncPositions();
console.log("seeded", held, "positions, margin =", td.accountMargin);Rebuild the in-flight reservation from CTP's currently-working orders
(reqQryOrder) — an authoritative resync. Call after login and after any
reconnect so the position caps account for orders already working at the broker
(placed before a reconnect, or — since CTP delivers them too — from another
terminal on the same account). Run syncMultipliers() first so the reserved cost
uses the right multiplier and query margin schedules before monetary caps are
enabled. Conditional statuses (a/b/c) remain reserved. Applying the
snapshot is revision-checked against realtime callbacks. Returns the number of
working open orders re-reserved.
If the account has a working multi-leg/unsupported order (including one placed
by another terminal), its exposure cannot be represented by this single-leg
ledger. The snapshot is still committed atomically, that order is recorded as
unsupported, and the method throws. Opening inserts then fail
closed until a later successful syncOrders() confirms the order is gone.
syncOrders() deliberately marks the held-position side uncertain too. Run
syncPositions() after it; reversing the order stays fail-closed instead of
opening on two broker snapshots that have no transactional common cut.
await td.syncMultipliers();
const working = await td.syncOrders();
await td.syncPositions();
console.log("re-reserved", working, "working open orders");Publish pre-trade risk limits to the C++ enforcer (takes effect at once). A
non-finite (NaN/Infinity) limit is rejected with a throw rather than
silently disabling the control. config is a RiskLimits; omit a
field or pass 0 to disable that control; negative values are rejected. The object replaces the
whole prior configuration; maxReferenceAgeMs is the exception: it must be
positive and defaults to 3000 ms when omitted. Unknown fields (including removed
legacy names such as maxMargin) are rejected instead of being ignored.
// full set
td.setRiskLimits({
maxOrderVolume: 10, // ≤ 10 lots per order
maxPriceDeviation: 0.02, // ≤ 2% from the reference price
maxNotional: 5_000_000, // ≤ 5M Futures/TAS notional per opening order
maxOrdersPerSec: 20, // token-bucket rate limit
orderBurst: 40, // bucket size (default: maxOrdersPerSec)
maxCloseOrdersPerSec: 10, // optional independent close-order rate
closeOrderBurst: 20,
maxAccountMargin: 20_000_000, // ≤ 20M held + reserved account margin
});
// just a couple of controls (the rest stay disabled)
td.setRiskLimits({ maxOrderVolume: 5, maxOrdersPerSec: 10 });
// disable a control by passing 0
td.setRiskLimits({ maxNotional: 0 });The atomic modes are NORMAL, REDUCE_ONLY, and HALT_ALL. reduceOnly()
blocks normal opens, every advanced mutation, and bank/futures transfers while
keeping cancellations and single-leg normal closes available. haltAll() also
blocks close inserts, clears all armed triggers, and rejects new arm() calls
until resume(). Mode transitions share the
final vendor-API send barrier, so no forbidden armed order can cross after the
transition returns. resume() restores NORMAL.
Advanced mutations (complex inserts, offset setting, spread/hedge confirmation) cannot be represented by the normal reservation ledger, so they fail closed whenever any local risk control is configured. The generator requires every future non-query SDK request to be classified; an unreviewed method fails code generation instead of defaulting to unguarded.
This separation is lifetime-sticky: after CTP accepts any unsupported risk
mutation in pure binding mode, later enabling a local control makes ordinary
opening orders fail closed. syncOrders() covers normal single-leg orders only
and therefore cannot certify that quote/exec/comb/parked exposure has gone away.
Realtime advanced-mutation and funds-transfer returns observed from another
terminal set the same sticky fail-closed marker; they are not mistaken for
ordinary-ledger state merely because this process did not originate them.
Use a Trader instance in one capability mode for its whole lifetime; do not
switch a protocol-binding session into locally managed trading after advanced
mutations have been admitted.
Bank/futures transfers are subject to the same rule. They change available
collateral but are not represented by this local ledger, so they are forwarded
only in NORMAL mode when every local risk control is disabled. Authentication
and transfer authorization remain the caller's responsibility.
// stop risk increases but keep closes/cancels working
process.on("SIGINT", () => td.reduceOnly());
// stop both opens and closes (cancellations still work)
td.haltAll();
// re-enable trading once you've assessed the situation
td.resume();
// example: halt if PnL breaches a limit
td.on("rtn-trade", () => { if (computePnl() < -100000) td.reduceOnly(); });Cap the open position (in lots) per instrument, enforced on every opening order.
limit is an InstrumentPositionLimit: a number caps both sides; { long, short } caps
each side separately. Within { long, short }, omitting a side leaves its
current cap unchanged (pass 0 to clear; negative values are rejected). Long and short are tracked
independently. The check counts committed = held + in-flight (working) volume.
td.setInstrumentPositionLimit("rb2510", 100); // long ≤ 100 and short ≤ 100
td.setInstrumentPositionLimit("au2508", { long: 50, short: 10 }); // asymmetric
// raise only the long cap, leave short as it was
td.setInstrumentPositionLimit("au2508", { long: 80 });
// clear the short cap
td.setInstrumentPositionLimit("au2508", { short: 0 });
// many at once
td.setInstrumentPositionLimits({ rb2510: 100, ru2510: { long: 100, short: 20 }, au2508: 10 });Cap one instrument's open-position margin (price × volume × multiplier ×
by-money + volume × by-volume, long and short summed).
Per-instrument and independent of the account-wide
setRiskLimits({ maxAccountMargin }); both apply. Pass limit = 0 to remove it; negative values are rejected. Until a
contract multiplier and usable absolute margin schedule are known (fed from CTP
callbacks after the corresponding queries), capped opening orders fail closed.
Both by-money and by-volume/per-lot components are included. IsRelative
schedules are added to the matching exchange schedule loaded by
syncMarginRates(); either half missing fails closed.
This formula is enabled only for CTP Futures/TAS product classes. Options, combinations and spot products require product-specific margin models and fail closed while a monetary cap is active.
maxNotional uses the same Futures/TAS allowlist. Option premium and combination
price are not treated as futures notional; unknown/unsupported product metadata
fails closed when the notional guard is enabled.
Notional and working-order margin are valued at the greater of the order price
and a fresh market reference. This prevents a low sell/open limit from
understating exposure. Attach market data with trackMarketData(); missing or
older-than-maxReferenceAgeMs quotes make every enabled monetary gate fail
closed. Existing working orders are revalued before each monetary admission.
td.setInstrumentMarginLimit("au2508", 5_000_000); // ≤ 5M of gold margin
td.setInstrumentMarginLimits({ ag2508: 2_000_000, au2508: 5_000_000 });
td.setInstrumentMarginLimit("au2508", 0); // remove the capProvide the live reference price used by price deviation, notional and
working-order margin checks.
Wires the MarketData feed's C++ snapshot cache straight into this Trader's risk
engine, so the deviation reference is read in C++ on the order-send path — no
JS round-trip, and it covers armed orders that fire from C++ with no JS in the
loop. Without a reference, or when its age exceeds maxReferenceAgeMs (default
3000 ms), every enabled deviation or monetary check fails closed for opening
orders.
td.setRiskLimits({ maxPriceDeviation: 0.02 });
// live reference from the feed (read in C++ on every order, incl. armed orders)
td.trackMarketData(md);Contract multipliers (for notional / cost accounting) and per-contract margin rates (for the
maxAccountMargincap) are not fed from JS: the C++ risk engine sources them directly from CTP'sOnRspQryInstrument/OnRspQryInstrumentMarginRatecallbacks. So anyreqQryInstrumentkeeps multipliers current (seesyncMultipliers), PrefersyncMarginRates()for every contract/hedge flag you trade: it queries both investor and exchange schedules, includes both formula components, and combinesIsRelativecorrectly. Missing metadata is never guessed.
Atomically replace held positions from a complete manually reconciled
snapshot. Each row is { instrumentId, side, hedgeFlag, volume, margin }, where
margin is the actual capital occupied (InvestorPosition.UseMargin). An empty
array is an authoritative flat book. The removed singular seedPosition() is
intentionally not retained: several incremental JS calls would expose a partial
book to native armed orders between calls.
td.seedPositions([
{ instrumentId: "rb2510", side: "long", hedgeFlag: "1", volume: 3, margin: 30_000 },
{ instrumentId: "au2508", side: "short", hedgeFlag: "1", volume: 2, margin: 112_000 },
]);Atomically replace held open-position margin from a complete set of
reqQryInvestorPosition rows (each row's UseMargin) for gross-mode
accounts (posiDirection "2" = long, "3" = short), retaining each
supported HedgeFlag as a separate cost bucket. Net/unknown directions or
hedge flags are rejected because guessing would corrupt per-side volume or
margin accounting; convert those rows explicitly and submit one complete
seedPositions() snapshot.
const rows = await td.reqQryInvestorPosition({ brokerId: "9999", investorId: "id" });
td.seedFromPositions(rows); // complete held snapshot; working reservations remainCurrent held-position margin tracked by the risk engine. It deliberately excludes
working-order reservations, although maxAccountMargin admits against
held + reserved margin. This property is best-effort observability: when
metadata is missing or refreshed it may not be an exact admission value; an
enabled monetary gate still fails closed until an authoritative position sync.
console.log("book margin", td.accountMargin);
td.on("rtn-trade", () => console.log("margin now", td.accountMargin));Clear held position margin only. Working-order reservations are deliberately
preserved. A reset is not proof that the account is flat, so opening inserts
remain fail-closed until seedFromPositions() or syncPositions() completes an
authoritative revision-checked replacement.
td.resetPositions(); // clear held rows, keep reservations; block opens
td.seedFromPositions(freshRows); // atomic complete replacement; reopen the ledgerArm a trigger evaluated and fired entirely in C++ on the market-data callback
thread — when md sees the condition, the order is sent through this Trader's
risk gate with no JS round trip. It is removed after a successful submission;
a halt/rate/metadata/send refusal leaves it armed with exponential retry
backoff (100 ms initially, capped at 5 seconds).
haltAll() is the hard-stop exception: it closes the final send barrier and
clears every existing arm, so resume() cannot fire a historical trigger.
Front connect/disconnect events and every Trader login/logout request also
clear all arms: a mandate never crosses an authenticated session boundary.
Recreate the required triggers only after session() has reconciled the new
session.
The acknowledgement arrives via the normal rtn-order / rsp-order-insert
events (correlate by orderRef).
The supplied md is also attached automatically as this Trader's fresh-price
source for deviation, notional and working-margin checks.
spec is an ArmSpec: { instrumentId, side, triggerPrice, order }.
A buy fires when ask ≤ triggerPrice; a sell when bid ≥ triggerPrice.
The order is a full Partial<InputOrder> and is validated up front (it must
have instrumentId, direction, combOffsetFlag and
volumeTotalOriginal > 0).
Returns an ArmHandle — call handle.disarm() to remove it.
import { Direction, OffsetFlag } from "@hitrading/ctp-node";
// stop-loss: sell rb2510 the instant the bid reaches 3450
const stop = td.arm(md, {
instrumentId: "rb2510",
side: "sell",
triggerPrice: 3450,
order: {
instrumentId: "rb2510", direction: Direction.Sell,
combOffsetFlag: OffsetFlag.CloseToday, limitPrice: 3445, volumeTotalOriginal: 1,
},
});
// breakout entry: buy when the ask reaches 3600
const entry = td.arm(md, {
instrumentId: "rb2510", side: "buy", triggerPrice: 3600,
order: { instrumentId: "rb2510", direction: "0", combOffsetFlag: "0", limitPrice: 3605, volumeTotalOriginal: 2 },
});
// cancel a trigger that hasn't fired
stop.disarm();Observability for armed triggers (they fire in C++ with no JS in the loop):
how many fired and were sent (fired) vs were refused by the risk gate / send
(blocked). Poll this after a trigger you expected to fire — a blocked armed
order is otherwise invisible.
const { fired, blocked } = td.armStats;
console.log(`armed: ${fired} sent, ${blocked} refused`);
if (blocked > 0) console.warn("an armed order was refused by risk");The CTP trader API version and current trading day.
console.log("CTP TD API", td.apiVersion, "day", td.tradingDay);As on CtpClient. The Trader uses an ordered lossless overflow
queue after its fixed ring fills; order/trade/query callbacks are not discarded.
console.log("trader dropped (should stay 0):", td.droppedRecords);
process.on("SIGINT", () => td.close());| Event | data type |
Fires when |
|---|---|---|
front-connected |
undefined |
Connected (first connect and every reconnect). Run session() here. |
front-disconnected |
number |
Connection dropped. In-flight requests reject. |
rsp-user-login |
RspUserLogin |
Login response. |
rtn-order |
Order |
Order status update (accepted, queueing, filled, cancelled, …). |
rtn-trade |
Trade |
A fill. |
err-rtn-order-insert |
InputOrder |
An order insert was rejected by the exchange (options.rspInfo). |
error |
unknown |
A handler you registered threw — see below. |
Plus an rsp-* event for every request method (e.g. rsp-qry-investor-position),
though you'll usually consume those via the request Promise.
td.on("rtn-order", (o, opts) => {
console.log(o.instrumentId, "status", o.orderStatus, "ref", o.orderRef);
});
td.on("rtn-trade", (t) => console.log("FILL", t.instrumentId, t.price, "x", t.volume));
td.on("err-rtn-order-insert", (input, opts) => {
console.error("exchange rejected", input.orderRef, opts.rspInfo?.errorMsg);
});The shared base of MarketData and Trader. You don't construct it directly,
but its members are available on both clients.
Register an event handler (standard EventEmitter). Handlers receive
(data, options); options is a CallbackOptions. All event
names are plain strings (the typed overloads on each subclass list the common
ones); unknown native events surface as event:<id>.
md.on("rtn-depth-market-data", (tick, options) => {
if (options.rspInfo) console.error("error record", options.rspInfo.errorMsg);
else console.log(tick.lastPrice);
});
// one-shot with .once, remove with .off — it's a normal EventEmitter
td.once("front-connected", () => console.log("connected for the first time"));Total records dropped under backpressure since construction. A steadily climbing value means the market-data consumer can't keep up. Market data drops oldest; Trader callbacks spill losslessly and therefore keep this counter at zero.
setInterval(() => console.log("dropped:", md.droppedRecords, td.droppedRecords), 10_000);Release the underlying CTP API and free native resources (the decode ring, SPI,
background threads). Idempotent. In-flight request Promises reject with
"client closed". Call once, at shutdown.
async function shutdown() { td.close(); md.close(); }
process.on("SIGTERM", shutdown);A throw inside one of your event handlers can't be allowed to wedge the data plane, so it is caught and re-surfaced asynchronously:
- if you subscribe to
error, the exception is delivered there (catchable); - otherwise it is re-thrown on the next tick as an
uncaughtException— the normal fate of a throwingEventEmitterlistener, just deferred so the feed stays consistent.
Either way the ring keeps advancing — a buggy handler never re-delivers or stalls records.
md.on("error", (err) => console.error("a market-data handler threw:", err));
td.on("error", (err) => console.error("a trader handler threw:", err));Every CTP request maps to a camelCase method on Trader taking a
Partial<…Field> and returning a Promise:
reqQry*(queries) →Promise<unknown[]>(array of rows;[]if empty), serialized through the shared query scheduler.- Exchange-bound inserts/actions (
reqOrderInsert,reqOrderAction,reqExecOrderInsert,reqQuoteInsert,reqForQuoteInsert,reqOptionSelfCloseInsert,reqCombActionInsert, and their*Actioncancels) →Promise<void>, resolving on submission (CTP returns no success response; outcomes arrive viartn-*events). They reject if the send is refused. - All other requests →
Promise<...>resolving with the singleOnRsp*response row.
// authenticate / login / confirm manually (what session() automates)
await td.reqAuthenticate({ brokerId: "9999", userId: "id", appId: "simnow_client_test", authCode: "0000000000000000" });
await td.reqUserLogin({ brokerId: "9999", userId: "id", password: "pw" });
await td.reqSettlementInfoConfirm({ brokerId: "9999", investorId: "id" });
// the settlement statement text
const [info] = await td.reqQrySettlementInfo({ brokerId: "9999", investorId: "id" });Common ones: reqAuthenticate, reqUserLogin, reqUserLogout,
reqSettlementInfoConfirm, reqQrySettlementInfo, reqQryInstrument,
reqQryInvestorPosition, reqQryInvestorPositionDetail, reqQryTradingAccount,
reqQryOrder, reqQryTrade, reqOrderInsert, reqOrderAction. (The full set
is generated from the CTP headers — 125 request methods.)
Pre-trade risk limits for setRiskLimits. All fields
are optional; omit or 0 = disabled for controls. maxReferenceAgeMs must
be positive and defaults to 3000 ms. NaN/Infinity throws.
| Field | Type | Meaning |
|---|---|---|
maxOrderVolume |
number |
Max lots per single order. |
maxPriceDeviation |
number |
Max |price − reference| / reference ratio (e.g. 0.02 = 2%); missing/stale reference fails closed. |
maxReferenceAgeMs |
number |
Maximum quote age for deviation and monetary valuation; default 3000 ms. |
maxNotional |
number |
Max notional (max(order price, fresh reference) × volume × multiplier) per order; missing/stale reference fails closed. |
maxOrdersPerSec |
number |
Max opening-order sends per second (token bucket). |
orderBurst |
number |
Token-bucket burst size. Default: maxOrdersPerSec. |
maxCloseOrdersPerSec |
number |
Independent close-order send rate; disabled by default. |
closeOrderBurst |
number |
Close-order bucket size. Default: maxCloseOrdersPerSec. |
maxAccountMargin |
number |
Cap on total held + reserved account margin. Requires a fresh reference, multiplier and investor schedule; includes by-money/by-volume and combines relative schedules with the exchange base. Missing/stale inputs fail closed. |
const conservative: RiskLimits = { maxOrderVolume: 5, maxOrdersPerSec: 10, maxNotional: 2_000_000 };
td.setRiskLimits(conservative);number | { long?: number; short?: number } — the limit argument to
setInstrumentPositionLimit.
A number caps both sides; { long, short } caps each (omit a side = unchanged,
0 = clear; negative values are rejected).
const a: InstrumentPositionLimit = 100; // both sides ≤ 100
const b: InstrumentPositionLimit = { long: 50, short: 5 }; // long ≤ 50, short ≤ 5Options for session.
See the table there.
const opts: SessionOptions = {
brokerId: "9999", userId: "id", password: "pw",
appId: "simnow_client_test", authCode: "0000000000000000",
confirmSettlement: true,
sync: { multipliers: ["rb2510"], positions: true, orders: true },
};interface ArmSpec {
instrumentId: string; // contract the trigger watches
side: "buy" | "sell"; // buy: fires when ask ≤ trigger; sell: when bid ≥ trigger
triggerPrice: number; // must be > 0
order: Partial<InputOrder>; // the order to send (needs instrumentId, direction,
// combOffsetFlag, volumeTotalOriginal > 0)
}const spec: ArmSpec = {
instrumentId: "rb2510", side: "sell", triggerPrice: 3450,
order: { instrumentId: "rb2510", direction: "1", combOffsetFlag: "3", limitPrice: 3445, volumeTotalOriginal: 1 },
};
const handle = td.arm(md, spec);interface ArmHandle {
readonly id: number;
disarm(): boolean; // remove the trigger; false if it was already gone/fired
}const handle = td.arm(md, spec);
console.log("armed id", handle.id);
if (!handle.disarm()) console.log("it had already fired");The second argument to every event handler:
interface CallbackOptions {
requestId?: number; // the request this response correlates to (responses only)
isLast?: boolean; // last row of a multi-row response (responses only)
rspInfo?: RspInfo; // present when the record carries a CTP error
}td.on("rsp-qry-investor-position", (row, options) => {
if (options.rspInfo) return console.error(options.rspInfo.errorMsg);
collect(row);
if (options.isLast) finish(); // final row of the multi-row response
});interface RspInfo {
errorId: number; // 0 = success
errorMsg: string; // human-readable (GB18030-decoded)
}A rejected request Promise's error carries these fields too
(err.errorId / err.errorMsg):
try { await td.reqUserLogin({ brokerId: "9999", userId: "id", password: "bad" }); }
catch (e) { console.error(`CTP ${e.errorId}: ${e.errorMsg}`); }interface MdLoginReq {
brokerId?: string; userId?: string; password?: string;
tradingDay?: string; userProductInfo?: string;
}const req: MdLoginReq = { brokerId: "9999", userId: "id", password: "pw" };
await md.login(req);All CTP enums (values) and struct interfaces (types) are generated from the CTP headers and re-exported from the package root.
Enums are string-valued and usable both as the enum member and the raw code:
import { Direction, OffsetFlag, OrderStatus, OrderPriceType, ActionFlag } from "@hitrading/ctp-node";
Direction.Buy; // "0"
Direction.Sell; // "1"
OffsetFlag.Open; // "0"
OffsetFlag.CloseToday; // "3"
OrderStatus.AllTraded; // "0"
ActionFlag.Delete; // "0"
// both forms type-check on a struct field:
const a = { direction: Direction.Buy };
const b = { direction: "0" } as const;Struct types describe the shape of event payloads and request fields — e.g.
DepthMarketData (a tick), Order, Trade, InputOrder,
InvestorPosition, RspUserLogin. Import them as types:
import type { DepthMarketData, Order, Trade, InputOrder } from "@hitrading/ctp-node";
md.on("rtn-depth-market-data", (tick: DepthMarketData) => {
const mid = (tick.bidPrice1 + tick.askPrice1) / 2;
});
function buildOrder(): Partial<InputOrder> {
return { instrumentId: "rb2510", direction: "0", combOffsetFlag: "0", limitPrice: 3500, volumeTotalOriginal: 1 };
}Field names are camelCase versions of the CTP fields (
LastPrice→lastPrice,BidPrice1→bidPrice1). CTP fills unset numeric price fields with a sentinel (Number.MAX_VALUE); guard for it if you read a price that may not have traded yet (the risk engine already does).
| Control | Set via | Scope | Disabled when |
|---|---|---|---|
| Risk mode | reduceOnly() / haltAll() / resume() |
Opens, or all order inserts; haltAll() also clears arms |
resume() |
| Max order volume | setRiskLimits({ maxOrderVolume }) |
Per order | 0 / omitted |
| Max price deviation | setRiskLimits({ maxPriceDeviation }) + reference |
Per order | 0 / omitted (missing reference blocks) |
| Max notional | setRiskLimits({ maxNotional }) |
Per order | 0 / omitted |
| Open rate | setRiskLimits({ maxOrdersPerSec, orderBurst }) |
Per second | 0 / omitted |
| Close rate | setRiskLimits({ maxCloseOrdersPerSec, closeOrderBurst }) |
Per second | 0 / omitted |
| Max position lots | setInstrumentPositionLimit(id, …) |
Per instrument, per side | 0 |
| Max position margin (per instrument) | setInstrumentMarginLimit(id, …) |
Per instrument | 0 |
| Max position margin (account) | setRiskLimits({ maxAccountMargin }) |
Account | 0 / omitted |
Position-lot and position-margin caps count committed = held (filled) +
in-flight (working order) volume, so a burst of opens can't slip past a cap
before the fills report. Feed held positions via syncPositions() /
seedPositions() and working orders via syncOrders() (especially after a
reconnect). Query contract multipliers and both investor/exchange margin schedules before
enabling monetary caps; missing metadata blocks capped opens. All caps
are enforced in C++ on the send path; a breach
makes reqOrderInsert() reject with the reason in the message.
End-to-end patterns that stitch the individual calls above into something runnable.
A minimal but production-shaped skeleton: risk first, handshake on connect,
positions tracked from fills (the source of truth), graceful shutdown. Drop your
own logic into signal().
import {
MarketData, Trader, Direction, OffsetFlag, type DepthMarketData, type Trade,
} from "@hitrading/ctp-node";
const CREDS = {
brokerId: "9999", userId: "your-id", password: "your-pw",
appId: "simnow_client_test", authCode: "0000000000000000",
};
const SYMBOL = "rb2510";
const md = new MarketData("./flow/md/", "tcp://182.254.243.31:30012");
const td = new Trader("./flow/td/", "tcp://182.254.243.31:30002");
// 1) Configure risk BEFORE any order can be sent (enforced in C++).
td.setRiskLimits({ maxOrderVolume: 5, maxNotional: 2_000_000, maxOrdersPerSec: 10, maxPriceDeviation: 0.02, maxAccountMargin: 5_000_000 });
td.setInstrumentPositionLimit(SYMBOL, { long: 10, short: 10 });
td.trackMarketData(md); // fresh reference for deviation + monetary gates
// 2) Never let a buggy handler wedge the feed — log handler throws.
md.on("error", (e) => console.error("[md handler]", e));
td.on("error", (e) => console.error("[td handler]", e));
// 3) Handshake on connect AND every reconnect (CTP reconnects on its own).
td.on("front-connected", async () => {
try { await td.session({ ...CREDS }); console.log("trader ready"); }
catch (e: any) { console.error("session failed", e.errorId, e.errorMsg); }
});
md.on("front-connected", async () => {
await md.login(); // SimNow MD front accepts anonymous login
md.subscribe([SYMBOL]);
});
// 4) Position is derived from FILLS, never assumed from sends.
let position = 0; // net lots (+long / -short)
td.on("rtn-trade", (t: Trade) => {
position += t.direction === Direction.Buy ? t.volume : -t.volume;
console.log("fill", t.price, "x", t.volume, "-> net position", position);
});
// 5) The strategy: turn each tick into a target and trade the difference.
md.on("rtn-depth-market-data", async (tick: DepthMarketData) => {
if (tick.instrumentId !== SYMBOL) return;
const want = signal(tick); // your logic: target net lots, e.g. -1 / 0 / +1
const delta = want - position;
if (delta === 0) return;
const buy = delta > 0;
await td.reqOrderInsert({
instrumentId: SYMBOL,
direction: buy ? Direction.Buy : Direction.Sell,
// open if we're growing the position, close if we're shrinking it
combOffsetFlag: Math.sign(want) === Math.sign(position) || position === 0 ? OffsetFlag.Open : OffsetFlag.CloseToday,
limitPrice: buy ? tick.askPrice1 : tick.bidPrice1, // cross the spread to fill
volumeTotalOriginal: Math.abs(delta),
}).catch((e) => console.warn("order refused:", e.message));
});
function signal(_tick: DepthMarketData): number { return 0; /* TODO your alpha */ }
// 6) Graceful shutdown — halt, then close ONCE.
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, () => { td.haltAll(); td.close(); md.close(); process.exit(0); });
}CTP reconnects on its own; you re-run the handshake in place on every
front-connected — never new Trader() to reconnect (the vendor DLL deadlocks;
see native-hooks.md).
td.on("front-connected", async () => {
// session() re-authenticates, re-logs-in, re-confirms, and crucially re-syncs
// multipliers/positions/orders — so the risk engine's position caps are rebuilt
// to include orders that were working before the drop (and any from another
// terminal on the same account). Then re-arm whatever you need.
await td.session({ ...CREDS });
rearmTriggers();
});
td.on("front-disconnected", (reason) => {
console.warn("trader link down (code", reason, ") — CTP will auto-reconnect");
// pending request Promises have already rejected; pause new signals until
// the next front-connected if your strategy needs a confirmed session.
});
function rearmTriggers() { /* re-create any td.arm(...) triggers here */ }If you prefer to hand-roll instead of session(), the key is to re-sync orders
after a reconnect so in-flight reservations are rebuilt:
td.on("front-connected", async () => {
await td.reqUserLogin({ ...CREDS });
await td.syncMultipliers(); // before syncOrders, so reserved cost uses the right multiplier
await td.syncOrders(); // rebuild reservations; marks positions uncertain
await td.syncPositions(); // final held snapshot; preserve reservations
});Close-today vs close-yesterday. SHFE/INE distinguish them, so "close" isn't
enough — pick CloseToday ("3") or CloseYesterday ("4"). To flatten 5 long
lots that are 2 opened today + 3 from prior days, send two orders:
import { Direction, OffsetFlag } from "@hitrading/ctp-node";
await td.reqOrderInsert({ instrumentId: "rb2510", direction: Direction.Sell, combOffsetFlag: OffsetFlag.CloseToday, limitPrice: px, volumeTotalOriginal: 2 });
await td.reqOrderInsert({ instrumentId: "rb2510", direction: Direction.Sell, combOffsetFlag: OffsetFlag.CloseYesterday, limitPrice: px, volumeTotalOriginal: 3 });Multi-leg offset flags are deliberately unsupported. The local ledger is a
single-instrument/single-side ledger and cannot prove per-leg position or margin
for values such as "00", "30", or "03". reqOrderInsert() and arm()
therefore require exactly one offset character and reject multi-leg templates
before they reach CTP. Use a higher-level gateway with a per-leg ledger if your
strategy needs exchange combination instruments.
Realtime callbacks and syncOrders() also detect unsupported working orders
placed elsewhere; while any remain, opening inserts fail
closed instead of guessing their per-leg exposure.
Building/dissolving a combined position (where the exchange supports it) goes
through reqCombActionInsert with an InputCombAction:
await td.reqCombActionInsert({
brokerId: "9999", investorId: "your-id",
instrumentId: "SPC m2509&m2601", // the combination instrument
combDirection: "0", // 0 = build the combination, 1 = split it
volume: 1,
// ...remaining InputCombAction fields per your exchange
});
reqCombActionInsertis also refused whenever local risk is configured or the mode is notNORMAL; it is outside this binding's quantitative-risk allowlist.