用TS无法实盘量化? - 实盘均线策略
从零开始:用 DTrader TS SDK 写一个长期运行的均线买卖策略
本文会使用 DTrader 连接实盘账户和行情数据,完成一个从读 K 线到自动下单的小策略。DTrader 的接入说明和 API 文档可以查看:DTrader 文档。
本文从零开始,只用 TypeScript 和 DTrader v3-api 的 TS SDK,写一个可以长期运行的均线买卖策略。
这次的小目标很简单:
脚本长期运行
每天 14:55 到点执行一次
读取日 K 线
计算短均线和长均线
读取当前持仓
短均线上穿长均线:没有持仓就买入
短均线下穿长均线:有持仓就卖出
用状态文件保证同一天只执行一次
它不讨论复杂量化理论,也不搭建庞大的策略框架。先把一条主线跑顺:读取行情、生成信号、查看持仓、执行交易。
1. 创建 TypeScript 项目
先创建一个目录:
mkdir dtrader-ma-strategy
cd dtrader-ma-strategy
npm init -y
安装 DTrader v3-api 的 TypeScript SDK,以及运行 TypeScript 需要的工具:
npm install @dtrader/v3-sdk
npm install -D typescript tsx @types/node
把 package.json 改成 ESM 项目:
{
"name": "dtrader-ma-strategy",
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "tsx moving-average-live.ts"
},
"dependencies": {
"@dtrader/v3-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}
如果需要在本机 v3-api 仓库里调试 SDK,也可以把依赖临时指向本地路径:
npm install /Users/regan/work/go/src/github.com/DTrader-store/v3-api/sdk/ts
正式项目里,使用 npm install @dtrader/v3-sdk 会更清爽,后续升级也方便。
2. 配置环境变量
策略会连接 DTrader 服务,并在信号触发时调用买卖接口。先把这些环境变量准备好:
export DTRADER_BASE_URL="https://your-endpoint"
export DTRADER_AUTH="your-key"
export DTRADER_CODE="600519"
export DTRADER_SHORT_WINDOW="5"
export DTRADER_LONG_WINDOW="20"
export DTRADER_ORDER_VOLUME="100"
export DTRADER_ORDER_PRICE_OFFSET="0"
export DTRADER_POLL_INTERVAL_MS="30000"
export DTRADER_EXECUTE_AT="14:55"
export DTRADER_TIMEZONE="Asia/Shanghai"
export DTRADER_STATE_FILE=".dtrader-ma-state.json"
这些变量分别表示:
-
DTRADER_BASE_URL:DTrader v3-api 地址。 -
DTRADER_AUTH:认证 key。 -
DTRADER_CODE:策略交易的股票代码。 -
DTRADER_SHORT_WINDOW:短均线窗口,默认 5。 -
DTRADER_LONG_WINDOW:长均线窗口,默认 20。 -
DTRADER_ORDER_VOLUME:每次买入或卖出的数量。 -
DTRADER_ORDER_PRICE_OFFSET:下单价格偏移,默认 0。比如想比当前收盘价高 0.02 买入,可以设成0.02。 -
DTRADER_POLL_INTERVAL_MS:轮询间隔,默认 30 秒。 -
DTRADER_EXECUTE_AT:每天执行策略的时间,默认14:55。 -
DTRADER_TIMEZONE:时间判断使用的时区,默认Asia/Shanghai。 -
DTRADER_STATE_FILE:本地状态文件,用来记录当天是否已经执行过。
示例里的下单代码会调用真实交易接口。连接实盘环境前,先确认账户、标的、价格和数量都符合预期。
3. 先理解 DTrader TS SDK 的基本用法
DTrader TS SDK 的入口是 createClient:
import { createClient } from "@dtrader/v3-sdk";
const client = createClient({
baseUrl: process.env.DTRADER_BASE_URL!,
auth: process.env.DTRADER_AUTH!,
});
读取 K 线:
const kline = await client.kline("600519", { period: "day" });
读取持仓:
const positions = await client.positions();
买入:
await client.buy([{ code: "600519", price: "1500", volume: "100" }]);
卖出:
await client.sell([{ code: "600519", price: "1500", volume: "100" }]);
后面的完整策略,就是把这些 API 按顺序串起来:读 K 线、算信号、看持仓、决定要不要交易。
4. 策略规则
策略规则先用最常见的双均线交叉:
金叉:
上一根 K 线短均线 <= 长均线
当前 K 线短均线 > 长均线
动作:如果当前没有持仓,则买入
死叉:
上一根 K 线短均线 >= 长均线
当前 K 线短均线 < 长均线
动作:如果当前有持仓,则卖出
为什么要看“上一根”和“当前”两组均线?
因为只看当前短均线大于长均线,只能说明现在偏强,不能说明刚刚发生了上穿。策略关心的是“穿越”这个动作,而不是每天看到短均线在长均线上方就重复买入。
5. 为什么每天 14:55 执行一次
长期运行不等于每隔几秒就认真思考一次。这个策略每天做一次决策就够了:
脚本可以从早上就挂着
每 30 秒醒来检查一次时间
没到 14:55:只等待
到了 14:55 且今天还没执行:读取 K 线、算信号、读持仓、决定买卖
今天已经执行过:继续等待明天
这样写会轻松很多:
- 逻辑短,执行路径清楚。
- 轮询可以很勤快,交易不会跟着重复。
- 信号、持仓和下单都在同一轮完成。
- 脚本重启后,也能知道今天已经处理到哪一步。
示例代码先用简化工作日判断:周一到周五执行,周末跳过。真实使用时,可以再接入交易日历,处理节假日、临时休市等情况。
6. 为什么长期运行需要状态文件
脚本长期运行时,14:55 之后还会继续轮询。如果没有状态文件,14:55:00 执行了一次,14:55:30 又可能执行第二次。
所以完整代码会在本地放一个小状态文件:
{
"lastExecutedDate": "2026-04-30",
"lastAction": "buy"
}
每轮策略都会检查:
- 今天是不是工作日?
- 现在是否已经到
DTRADER_EXECUTE_AT? - 状态文件里是否已经记录今天执行过?
只要 lastExecutedDate 等于今天日期,就直接跳过。这个小文件不复杂,但很管用。
7. 完整代码
把下面代码保存为 moving-average-live.ts。
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { createClient } from "@dtrader/v3-sdk";
type KlineRow = {
close?: number | string;
date?: string;
day?: string;
time?: string;
datetime?: string;
[key: string]: unknown;
};
type StrategyState = {
lastExecutedDate?: string;
lastAction?: "buy" | "sell" | "hold";
};
type Signal = {
barKey: string;
currentPrice: number;
previousShortMa: number;
previousLongMa: number;
currentShortMa: number;
currentLongMa: number;
goldenCross: boolean;
deathCross: boolean;
};
type Clock = {
dateKey: string;
weekday: number;
minutes: number;
label: string;
};
type Candle = {
row: KlineRow;
close: number;
};
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Set ${name} before running this strategy.`);
}
return value;
}
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value)) {
throw new Error(`${name} must be an integer.`);
}
return value;
}
function envNumber(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`${name} must be a number.`);
}
return value;
}
function parseHHMM(value: string, name: string): number {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) {
throw new Error(`${name} must use HH:mm format.`);
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) {
throw new Error(`${name} is out of range.`);
}
return hours * 60 + minutes;
}
function currentClock(timeZone: string): Clock {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
hourCycle: "h23",
}).formatToParts(new Date());
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
const weekdayMap: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
const weekday = weekdayMap[get("weekday")] ?? 0;
const hour = Number(get("hour"));
const minute = Number(get("minute"));
const dateKey = `${get("year")}-${get("month")}-${get("day")}`;
return {
dateKey,
weekday,
minutes: hour * 60 + minute,
label: `${dateKey} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`,
};
}
function isWeekday(clock: Clock): boolean {
return clock.weekday >= 1 && clock.weekday <= 5;
}
function hasReachedExecuteTime(clock: Clock, executeAtMinutes: number): boolean {
return clock.minutes >= executeAtMinutes;
}
function movingAverage(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function barKey(row: KlineRow, index: number): string {
const value = row.date ?? row.day ?? row.datetime ?? row.time;
return value ? String(value) : `index:${index}`;
}
function loadState(path: string): StrategyState {
if (!existsSync(path)) return {};
return JSON.parse(readFileSync(path, "utf8")) as StrategyState;
}
function saveState(path: string, state: StrategyState): void {
writeFileSync(path, JSON.stringify(state, null, 2));
}
function extractRows(data: unknown): KlineRow[] {
if (Array.isArray(data)) return data as KlineRow[];
if (!data || typeof data !== "object") return [];
const payload = data as {
klines?: unknown;
list?: unknown;
data?: unknown;
};
if (Array.isArray(payload.klines)) return payload.klines as KlineRow[];
if (Array.isArray(payload.list)) return payload.list as KlineRow[];
if (Array.isArray(payload.data)) return payload.data as KlineRow[];
return [];
}
function extractCandles(rows: KlineRow[]): Candle[] {
const candles: Candle[] = [];
for (const row of rows) {
if (row.close === undefined || row.close === null) continue;
const close = Number(row.close);
if (Number.isFinite(close)) {
candles.push({ row, close });
}
}
return candles;
}
function buildSignal(rows: KlineRow[], shortWindow: number, longWindow: number): Signal | null {
const candles = extractCandles(rows);
const closes = candles.map((item) => item.close);
const requiredCount = longWindow + 1;
if (closes.length < requiredCount) {
console.log(
JSON.stringify(
{
event: "not_enough_kline_data",
required: requiredCount,
actual: closes.length,
},
null,
2,
),
);
return null;
}
const previousShortMa = movingAverage(closes.slice(-shortWindow - 1, -1));
const previousLongMa = movingAverage(closes.slice(-longWindow - 1, -1));
const currentShortMa = movingAverage(closes.slice(-shortWindow));
const currentLongMa = movingAverage(closes.slice(-longWindow));
const currentPrice = closes[closes.length - 1];
const latestCandle = candles[candles.length - 1]!;
return {
barKey: barKey(latestCandle.row, candles.length - 1),
currentPrice,
previousShortMa,
previousLongMa,
currentShortMa,
currentLongMa,
goldenCross: previousShortMa <= previousLongMa && currentShortMa > currentLongMa,
deathCross: previousShortMa >= previousLongMa && currentShortMa < currentLongMa,
};
}
function hasPosition(positions: unknown, code: string): boolean {
if (!Array.isArray(positions)) return false;
return positions.some((item) => {
if (!item || typeof item !== "object") return false;
const row = item as {
stock_code?: string;
vol_hold?: number;
vol_actual?: number;
vol_remain?: number;
};
if (row.stock_code !== code) return false;
const volume = Number(row.vol_hold ?? row.vol_actual ?? row.vol_remain ?? 0);
return volume > 0;
});
}
function orderPriceFrom(currentPrice: number, offset: number): string {
const price = currentPrice + offset;
if (price <= 0) {
throw new Error("Order price must be positive.");
}
return price.toFixed(2);
}
const baseUrl = requiredEnv("DTRADER_BASE_URL");
const auth = requiredEnv("DTRADER_AUTH");
const code = process.env.DTRADER_CODE ?? "600519";
const shortWindow = envInt("DTRADER_SHORT_WINDOW", 5);
const longWindow = envInt("DTRADER_LONG_WINDOW", 20);
const orderVolume = String(envInt("DTRADER_ORDER_VOLUME", 100));
const orderPriceOffset = envNumber("DTRADER_ORDER_PRICE_OFFSET", 0);
const pollIntervalMs = envInt("DTRADER_POLL_INTERVAL_MS", 30_000);
const executeAt = process.env.DTRADER_EXECUTE_AT ?? "14:55";
const executeAtMinutes = parseHHMM(executeAt, "DTRADER_EXECUTE_AT");
const timeZone = process.env.DTRADER_TIMEZONE ?? "Asia/Shanghai";
const stateFile = process.env.DTRADER_STATE_FILE ?? ".dtrader-ma-state.json";
if (shortWindow <= 0 || longWindow <= 0) {
throw new Error("DTRADER_SHORT_WINDOW and DTRADER_LONG_WINDOW must be positive.");
}
if (shortWindow >= longWindow) {
throw new Error("DTRADER_SHORT_WINDOW should be smaller than DTRADER_LONG_WINDOW.");
}
const client = createClient({
baseUrl,
auth,
timeoutMs: envInt("DTRADER_TIMEOUT_MS", 30_000),
});
let stopping = false;
process.on("SIGINT", () => {
stopping = true;
console.log("received SIGINT, stopping after current iteration");
});
process.on("SIGTERM", () => {
stopping = true;
console.log("received SIGTERM, stopping after current iteration");
});
async function runOnce(): Promise<void> {
const clock = currentClock(timeZone);
if (!isWeekday(clock)) {
console.log(`skip ${clock.label}: not a weekday`);
return;
}
if (!hasReachedExecuteTime(clock, executeAtMinutes)) {
console.log(`skip ${clock.label}: wait until ${executeAt}`);
return;
}
const state = loadState(stateFile);
if (state.lastExecutedDate === clock.dateKey) {
console.log(`skip ${clock.label}: already executed today with action ${state.lastAction ?? "unknown"}`);
return;
}
const kline = await client.kline(code, { period: "day" });
const rows = extractRows(kline.data);
const signal = buildSignal(rows, shortWindow, longWindow);
if (!signal) return;
console.log(
JSON.stringify(
{
event: "moving_average_signal",
date: clock.dateKey,
code,
shortWindow,
longWindow,
...signal,
},
null,
2,
),
);
const positions = await client.positions();
const holding = hasPosition(positions.data, code);
const orderPrice = orderPriceFrom(signal.currentPrice, orderPriceOffset);
const order = [{ code, price: orderPrice, volume: orderVolume }];
if (signal.goldenCross && !holding) {
console.log(JSON.stringify({ event: "buy_order", order }, null, 2));
const response = await client.buy(order);
console.log(JSON.stringify({ event: "buy_response", response }, null, 2));
saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });
return;
}
if (signal.deathCross && holding) {
console.log(JSON.stringify({ event: "sell_order", order }, null, 2));
const response = await client.sell(order);
console.log(JSON.stringify({ event: "sell_response", response }, null, 2));
saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "sell" });
return;
}
console.log(
JSON.stringify(
{
event: "hold",
holding,
reason: holding
? "holding position but no death cross"
: "no position and no golden cross",
},
null,
2,
),
);
saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "hold" });
}
async function main(): Promise<void> {
console.log(
JSON.stringify(
{
event: "strategy_started",
code,
shortWindow,
longWindow,
orderVolume,
orderPriceOffset,
pollIntervalMs,
executeAt,
timeZone,
stateFile,
},
null,
2,
),
);
while (!stopping) {
try {
await runOnce();
} catch (error) {
console.error("strategy iteration failed");
console.error(error);
}
if (!stopping) {
await sleep(pollIntervalMs);
}
}
console.log("strategy stopped");
}
await main();
8. 运行策略
启动前确认环境变量已经设置:
export DTRADER_BASE_URL="https://your-endpoint"
export DTRADER_AUTH="your-key"
export DTRADER_CODE="600519"
export DTRADER_SHORT_WINDOW="5"
export DTRADER_LONG_WINDOW="20"
export DTRADER_ORDER_VOLUME="100"
export DTRADER_ORDER_PRICE_OFFSET="0"
export DTRADER_POLL_INTERVAL_MS="30000"
export DTRADER_EXECUTE_AT="14:55"
export DTRADER_TIMEZONE="Asia/Shanghai"
export DTRADER_STATE_FILE=".dtrader-ma-state.json"
启动:
npm start
脚本会一直运行。每轮都会:
- 判断今天是否是工作日。
- 判断当前时间是否已经到
14:55。 - 读取状态文件,判断今天是否已经执行过。
- 如果今天还没执行,就读取日 K。
- 计算均线信号。
- 读取当前持仓。
- 金叉且没有持仓时,直接买入。
- 死叉且有持仓时,直接卖出。
- 没有动作时记录
hold,当天不再重复判断。
停止时按 Ctrl+C。脚本会在当前轮结束后退出。
9. 代码解读
9.1 SDK 初始化
const client = createClient({
baseUrl,
auth,
timeoutMs: envInt("DTRADER_TIMEOUT_MS", 30_000),
});
createClient 来自 @dtrader/v3-sdk。这一步只做三件事:
- 指定 v3-api 地址。
- 带上认证 key。
- 设置请求超时时间。
后面所有交易和行情能力都从 client 发起。
9.2 周期控制
const clock = currentClock(timeZone);
if (!hasReachedExecuteTime(clock, executeAtMinutes)) {
console.log(`skip ${clock.label}: wait until ${executeAt}`);
return;
}
if (state.lastExecutedDate === clock.dateKey) {
console.log(`skip ${clock.label}: already executed today`);
return;
}
这是长期运行策略里最值得留意的部分。
脚本可以全天挂着,但只有到了 DTRADER_EXECUTE_AT=14:55 之后,当天第一次轮询才会进入交易逻辑。执行完成后,代码写入 lastExecutedDate。后面即使脚本继续轮询,也会因为“今天已经执行过”而安静跳过。
这里用 >= 14:55,不是只认 14:55:00 那一秒。脚本是轮询运行的,网络、机器调度和接口耗时都可能让它错过精确秒点。用“14:55 之后当天第一次执行”更顺手。
9.3 读取 K 线
const kline = await client.kline(code, { period: "day" });
const rows = extractRows(kline.data);
这里读取日 K。选择日 K 是为了让策略保持简单:每天只判断一次,不处理分钟级噪音。
如果行情源在 14:55 时还没有把当日数据合入日 K,可以把执行时间调晚,或者把 period 改成 v3-api 支持的更短周期。这个示例先固定采用“14:55 执行一次”的模型。
9.4 计算均线
const previousShortMa = movingAverage(closes.slice(-shortWindow - 1, -1));
const previousLongMa = movingAverage(closes.slice(-longWindow - 1, -1));
const currentShortMa = movingAverage(closes.slice(-shortWindow));
const currentLongMa = movingAverage(closes.slice(-longWindow));
这里同时计算上一根 K 线和当前 K 线对应的短均线、长均线。
如果只计算当前均线,只能知道当前短均线是否大于长均线;但无法知道它是不是刚刚上穿。交易信号关心的是“变化”,所以要比较前后两组均线。
9.5 判断金叉和死叉
goldenCross: previousShortMa <= previousLongMa && currentShortMa > currentLongMa,
deathCross: previousShortMa >= previousLongMa && currentShortMa < currentLongMa,
金叉用于买入,死叉用于卖出。
- 金叉:短均线从弱转强。
- 死叉:短均线从强转弱。
这两个条件只是策略信号,不是收益保证。它们的好处是简单、可解释,很适合作为理解 DTrader 自动交易流程的第一个例子。
9.6 查询持仓
const positions = await client.positions();
const holding = hasPosition(positions.data, code);
策略不只看信号,还会顺手看一下当前是否已经持仓。
- 金叉但已经持仓:不重复买。
- 死叉但没有持仓:不卖不存在的仓位。
这一步之后,脚本就不只是会喊“有信号了”,而是能结合账户状态做决定。
9.7 直接下单
14:55 到点后,代码会在同一轮里完成信号计算、持仓确认和交易执行。
买入:
const order = [{ code, price: orderPrice, volume: orderVolume }];
const response = await client.buy(order);
卖出:
const order = [{ code, price: orderPrice, volume: orderVolume }];
const response = await client.sell(order);
订单格式是:
[{ code, price: orderPrice, volume: orderVolume }]
price 和 volume 都按字符串传入,和 v3-api 的 TS SDK 示例保持一致。
这里直接展示 DTrader API 的使用方式:信号满足时,就调用 client.buy() 或 client.sell()。
9.8 状态文件
saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });
状态文件很小,但对长期运行很有用。没有它,脚本在 14:55 之后每次轮询都可能重复交易。
这份代码只记录两件事:
-
lastExecutedDate:最近执行过的日期。 -
lastAction:最近动作,可能是buy、sell或hold。
如果今天没有金叉或死叉,也会写入 hold。这表示“今天已经看过了,没动作”,后面不再重复判断。
9.9 错误处理和持续运行
while (!stopping) {
try {
await runOnce();
} catch (error) {
console.error("strategy iteration failed");
console.error(error);
}
if (!stopping) {
await sleep(pollIntervalMs);
}
}
长期运行时,请求失败、网络抖动、接口临时错误都可能发生。这里先打印错误,然后等下一轮继续。
如果下单接口抛错,代码不会写入 lastExecutedDate,下一轮还会再试。只有买入、卖出或明确 hold 成功走完之后,才会记录“今天已经执行”。
10. 真实使用前要补的东西
这个例子已经能从零跑起一个长期运行的均线买卖策略。真实使用前,可以继续补这些能力:
- 真实交易日历:替换掉示例里的简化工作日判断,处理节假日和临时休市。
- 订单状态确认:下单后读取
client.orders()或client.order(id)确认成交状态。 - 仓位比例控制:不要只按固定数量交易。
- 失败告警:连续失败时推送到飞书、邮件或短信。
- 日志持久化:把每次信号和订单写入文件或数据库。
- 多标的支持:把
DTRADER_CODE扩展成股票列表。
到这里,主线就跑通了:用 DTrader TS SDK 读取 K 线、生成均线信号、读取持仓,并在每天 14:55 执行一次买卖决策。