阅读视图

发现新文章,点击刷新页面。

用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

脚本会一直运行。每轮都会:

  1. 判断今天是否是工作日。
  2. 判断当前时间是否已经到 14:55
  3. 读取状态文件,判断今天是否已经执行过。
  4. 如果今天还没执行,就读取日 K。
  5. 计算均线信号。
  6. 读取当前持仓。
  7. 金叉且没有持仓时,直接买入。
  8. 死叉且有持仓时,直接卖出。
  9. 没有动作时记录 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 }]

pricevolume 都按字符串传入,和 v3-api 的 TS SDK 示例保持一致。

这里直接展示 DTrader API 的使用方式:信号满足时,就调用 client.buy()client.sell()

9.8 状态文件

saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });

状态文件很小,但对长期运行很有用。没有它,脚本在 14:55 之后每次轮询都可能重复交易。

这份代码只记录两件事:

  • lastExecutedDate:最近执行过的日期。
  • lastAction:最近动作,可能是 buysellhold

如果今天没有金叉或死叉,也会写入 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 执行一次买卖决策。

❌