衍生品交易是金融市场中门槛较高但玩法最丰富的一类交易形式,合约、期权、永续掉期都属于这个范畴。一提到写交易系统,大多数人首先想到的是C++或者Python,其实Node.js在这个领域同样有一席之地。它的异步非阻塞IO模型天然适合处理行情推送和订单并发,JavaScript的事件驱动写法也与交易所的WebSocket接口契合度很高。这篇文章就以一个加密货币永续合约交易为例,从零搭建一个最小可行的衍生品交易系统。

一、系统整体架构设计
在动手写代码之前,先明确一个衍生品交易系统需要哪些模块。从数据流向来看,系统分为四层:行情层负责接收实时价格和深度数据,策略层根据行情产生交易信号,风控层校验保证金和持仓上限,执行层负责下单、撤单和仓位管理。这四层之间通过事件总线通信,任何一个模块都不直接调用另一个模块的方法,这样做的目的是让策略可以随时替换而不影响其他部分。
Node.js的单线程事件循环在这种架构下反而是优势。行情推送、策略计算、下单请求全部在同一个线程内排队执行,不存在多线程环境下的锁竞争问题,状态一致性更容易保证。以一个每秒推送数十次行情的场景为例,主线程只需依次处理每个tick,逻辑简单清晰。当然,如果策略涉及重计算,比如大规模的回测指标计算,就需要考虑把这部分挪到Worker线程,这一点在后面会详细讨论。
先看事件总线的实现,这是整个系统的骨架:
const EventEmitter = require('events');
class EventBus extends EventEmitter {
constructor() {
super();
this.setMaxListeners(100); // 模块多时需要调大监听上限
}
emitAsync(event, payload) {
setImmediate(() => super.emit(event, payload));
}
}
module.exports = new EventBus();这里用emitAsync包装了原生的emit,通过setImmediate把事件派发推迟到下一个事件循环迭代。这个细节很重要:如果策略在处理tick时同步触发下单事件,执行层同步处理又触发新事件,可能导致调用栈过深甚至阻塞行情处理。异步派发可以让每个环节都有机会让出线程,保持事件循环的响应能力。
二、接入行情:REST与WebSocket的配合
行情接入有两种方式。REST接口适合拉取K线历史数据、查询账户信息这类低频请求;WebSocket则用于实时价格推送。以主流交易所为例,先通过REST拉取历史K线用于初始化指标,再订阅WebSocket的tick频道获取实时价格,两者结合才能让策略同时具备历史视角和实时反应能力。
先实现REST部分,这里用axios做HTTP请求,用crypto模块生成签名。绝大多数交易所的私有接口都需要用API密钥对请求参数做HMAC-SHA256签名,签名逻辑是通用的:
const crypto = require('crypto');
const axios = require('axios');
class ExchangeRest {
constructor(config) {
this.baseUrl = config.baseUrl;
this.apiKey = config.apiKey;
this.secret = config.secret;
}
sign(params) {
const query = Object.keys(params)
.sort()
.map(k => `${k}=${params[k]}`)
.join('&');
return crypto
.createHmac('sha256', this.secret)
.update(query)
.digest('hex');
}
async placeOrder({ symbol, side, size, price }) {
const params = {
symbol, side, size, price,
timestamp: Date.now(),
apiKey: this.apiKey
};
params.sign = this.sign(params);
const res = await axios.post(`${this.baseUrl}/order`, params);
return res.data;
}
}签名时要注意参数排序问题。大多数交易所要求参数按字典序排列后再拼接,顺序错了签名就校验失败,这是新手接入时最常踩的坑。另外时间戳必须使用服务器可接受范围内的当前时间,本地时钟偏差超过几十秒就会被拒绝,建议在初始化时先请求一次服务器时间做校准。
WebSocket部分推荐使用ws库。连接建立后要处理三类消息:订阅确认、行情数据、连接心跳。行情数据到达频率很高,如果每条消息都触发完整的事件处理,容易造成积压。一个实用技巧是只在价格变化超过阈值时才向前派发事件:
const WebSocket = require('ws');
class MarketFeed {
constructor(url, symbol) {
this.symbol = symbol;
this.ws = new WebSocket(url);
this.lastPrice = 0;
}
connect() {
this.ws.on('open', () => {
this.ws.send(JSON.stringify({
op: 'subscribe',
args: [`tick.${this.symbol}`]
}));
});
this.ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.price && Math.abs(msg.price - this.lastPrice) / this.lastPrice > 0.0001) {
this.lastPrice = msg.price;
require('./event-bus').emitAsync('tick', {
symbol: this.symbol,
price: msg.price,
ts: msg.timestamp
});
}
});
this.ws.on('close', () => {
console.log('连接断开,5秒后重连');
setTimeout(() => this.reconnect(), 5000);
});
}
reconnect() {
this.ws = new WebSocket(this.ws.url);
this.connect();
}
}断线重连是生产环境必须处理的问题。交易所服务器偶尔会主动断开连接,重连后还需要重新发送订阅请求,否则收不到任何数据。更稳妥的做法是维护一个心跳定时器,超过设定时间没有收到任何消息就主动断开重连,避免出现连接假活的状况。
三、订单与持仓管理:衍生品特有的逻辑
与现货交易不同,衍生品交易的核心在于保证金和杠杆。下单时不是支付全部名义价值,而是只占用一部分保证金,剩余部分由杠杆放大。这意味着持仓期间需要持续计算未实现盈亏,一旦亏损侵蚀完保证金,就会触发强制平仓。风控模块必须实时掌握这两个数值。
下面是持仓计算的实现。假设交易的是反向合约,盈亏以标的货币结算,多头仓位的盈亏计算公式为(1/开仓价 - 1/当前价) * 合约数量,正向合约则是(当前价 - 开仓均价) * 持仓数量。这里以正向合约为例:
class Position {
constructor(symbol, leverage) {
this.symbol = symbol;
this.leverage = leverage;
this.size = 0; // 正数多头,负数空头
this.entryPrice = 0; // 持仓均价
}
update(order) {
const signed = order.side === 'buy' ? order.size : -order.size;
if (this.size === 0) {
this.size = signed;
this.entryPrice = order.price;
return;
}
const sameDirection = this.size > 0 === signed > 0;
if (sameDirection) {
// 加仓:重新计算均价
this.entryPrice =
(this.entryPrice * Math.abs(this.size) + order.price * order.size) /
(Math.abs(this.size) + order.size);
this.size += signed;
} else {
// 减仓或反手
this.size += signed;
if (Math.sign(this.size) !== Math.sign(this.size - signed)) {
this.entryPrice = order.price; // 反手后以新价开仓
}
}
}
unrealizedPnl(price) {
return (price - this.entryPrice) * this.size;
}
margin(price) {
return Math.abs(this.size) * price / this.leverage;
}
liquidationPrice(maintenanceRate = 0.005) {
if (this.size > 0) {
return this.entryPrice * this.leverage /
(this.leverage + 1 - this.leverage * maintenanceRate);
}
return this.entryPrice * this.leverage /
(this.leverage - 1 + this.leverage * maintenanceRate);
}
}这段代码中有几个容易出错的地方。首先是加仓时的均价计算,必须用加权平均而不是简单平均,否则随着加仓次数增加,均价会逐渐失真。其次是反手逻辑,当减仓数量超过当前持仓时,仓位方向翻转,此时开仓价应更新为本次成交价。最后是强平价估算,实际交易所的强平机制还涉及维持保证金阶梯、手续费预留等因素,上面的公式只是简化模型,真实系统中应直接调用交易所的强平价接口而不是自行计算。
四、策略模块与Node.js的性能边界
策略模块订阅tick事件,输出交易信号。以最简单的网格策略为例,在价格区间内等距布置买卖档位,价格下穿某档就买入,上穿就卖出,赚取震荡行情中的差价。策略代码只需监听事件、维护自己的网格状态,完全不用关心订单怎么发出去:
const bus = require('./event-bus');
class GridStrategy {
constructor({ lower, upper, grids, orderSize }) {
this.step = (upper - lower) / grids;
this.levels = Array.from({ length: grids + 1 },
(_, i) => lower + i * this.step);
this.orderSize = orderSize;
this.lastLevel = -1;
}
start() {
bus.on('tick', (tick) => {
const level = this.findLevel(tick.price);
if (level === this.lastLevel) return;
if (this.lastLevel !== -1 && level !== this.lastLevel) {
const direction = level > this.lastLevel ? 'sell' : 'buy';
bus.emitAsync('signal', {
symbol: tick.symbol,
side: direction,
size: this.orderSize,
reason: 'grid'
});
}
this.lastLevel = level;
});
}
findLevel(price) {
return Math.min(this.levels.filter(l => price >= l).length - 1,
this.levels.length - 1);
}
}关于性能,必须客观看待Node.js的能力边界。事件循环机制决定了CPU密集型任务会阻塞所有IO,如果你的策略需要在每个tick上做矩阵运算或者跑一个机器学习模型,主线程很快就会被压垮,行情积压导致信号延迟,这在衍生品交易里可能是致命的。解决办法有两个:一是用worker_threads把重计算放到Worker线程,主线程只负责IO和轻量调度,两者通过postMessage交换数据;二是直接拆成两个进程,策略进程订阅行情、输出信号到消息队列,执行进程消费信号并发单,进程级隔离更彻底,一个进程崩溃也不会拖垮另一个。
另一个容易被忽视的问题是浮点数精度。JavaScript中0.1 + 0.2 !== 0.3,直接用浮点数累加盈亏会慢慢积累误差。涉及金额计算时,要么统一用整数表示最小计价单位,要么引入decimal.js这类高精度库。下单数量、价格档位、保证金计算都必须做精度对齐,否则会遇到下单时提示精度错误的尴尬情况。
五、从原型到生产还需要补什么
上面这套代码构成了一个能跑通闭环的原型,但距离能真金白银上线的系统还有不少距离。首先是数据持久化,重启后持仓状态和订单记录不能丢,需要把关键状态落库,可以选SQLite做单机存储或Redis做内存快照加定期归档。其次是幂等性设计,网络超时后重发订单可能造成重复开仓,每笔订单要带上客户端生成的唯一ID,交易所端据此去重。
监控告警同样不可少。保证金率跌破警戒线、WebSocket长时间无消息、下单连续失败,这些都应该触发告警。日志建议结构化输出,附带订单ID和时间戳,方便事后复盘。最后,任何策略都应该先在模拟盘或者小资金实盘上运行足够长的时间,观察最大回撤和滑点表现,确认符合预期后再逐步放大规模。衍生品带杠杆,收益和风险都被放大,代码写得再漂亮,风控不过关也一样会爆仓,这一点无论如何强调都不为过。