iT邦幫忙

2022 iThome 鐵人賽

DAY 29
1
Software Development

從 Node.js 開發者到量化交易者:打造屬於自己的投資系統系列 第 29

Day 29 - 長線獲利之道:定期定額投資系統

  • 分享至 

  • xImage
  •  

目前國內已經有許多券商提供定期定額投資的服務,而且在一定的金額內,還提供了較優惠的手續費用。不過這些券商提供的定期定額服務,多數都有扣款日期以及標的的限制,無法更彈性地選擇定期定額投入的日期以及投資標的。此外,海外券商提供股息再投資計畫(Dividend Reinvestment Plan)的服務已行之有年,臺灣證券市場這方面仍較缺乏,還尚未看到有券商提供此功能。為了打破這些限制,我們可以設計一個定期定額投資系統,執行自己的系統性投資計劃。

什麼是定期定額投資?

定期定額投資(Systematic Investment Plan)是一種投資策略,投資人在每個特定的時間將相同數量的資金投資於特定的投資標的。因為每次投入的金額是固定的,當投資標的價格下降時,買進的數量就比較多;當投資標的價格上漲時,買進的數量就比較少。當執行定期定額一段時間後,買進的成本就會趨向平均值。

定期定額特別適用於「指數化投資」以及「買進並持有」的長期投資策略。伴隨經濟發展,整體股票市場市值也持續增長,如果相信人類的經濟的活動將不斷成長,並且我們想參與其中、享受人類經濟成長果實最簡單的方法,就如同指數化投資之父、先鋒集團(Vanguard)創辦人 約翰.柏格 所說:「買進投資整體市場股市組合的基金,並永久持有。」

ETF 是常用於指數化投資的工具,在臺股最具代表性的就是 元大臺灣50(0050)以及 富邦台50(006208)。事實上,打敗大盤績效並不是簡單的事,如果尚未找到適合的投資策略,那麼參與市場報酬就是最簡單、也最穩健的投資方式。

Trader 定期定額系統概觀

我們將定期定額系統實作在既有的 Trader 應用伺服器上。為了理解定期定額系統的運作流程,我們先描繪出 Trader 應用伺服器的系統環境圖:

https://ithelp.ithome.com.tw/upload/images/20220929/20150150MbFv5w9aLK.png

Trader 應用伺服器,主要包含以下元件:

  • Plan API:提供 REST 風格的 HTTP API,使用者可以向 Plan API 發送請求,建立定期定額委託設定。
  • Plan Service:處理 Plan API 請求,並在交易日盤中於指定的時間查詢應執行的定期定額委託單。
  • Trader Service:整合富果交易 SDK 並處理 Trader API 請求,當收到委託及成交回報時,透過 LINE Notify 請求發送推播訊息。
  • Trader API:提供 REST 風格的 HTTP API,使用者可以向 Trader 應用程式發送請求,進行下單委託、並查詢成交明細、帳戶庫存、交割資訊等。
  • MongoDB:作為數據持久化的資料庫,儲存定期定額設定。

Trader 定期定額系統處理資料的流程可以分成三大部分,包含使用者向 Plan API 請求定期定額委託設定、 Plan Service 執行定期定額委託,以及 Plan Service 向 Trader Service 下達委託指令。

使用者向 Plan API 請求定期定額委託設定:

  • ① User → Plan API:使用者向 Plan API 發送請求,建立定期定額委託設定。
  • ② Plan API → Plan Service:Plan Service 處理使用者向 Plan API 發送的請求。
  • ③ Plan Service → MongoDB:Plan Service 將定期定額委託設定儲存至 MongoDB 資料庫。

Plan Service 執行定期定額委託:

  • ① MongoDB → Plan Service:在交易日盤中,Plan Service 從 MongoDB 取得應該被執行的定期定額委託設定。
  • ② Plan Service → Fugle Realtime:Plan Service 向 Fugle Realtime 取得定期定額委託股票的最新成交價及最佳五檔報價。
  • ③ Fugle Realtime → Plan Service:Fugle Realtime 回應股票的最新成交價及最佳五檔報價。
  • ④ Plan Service → LINE Notify:Plan Service 根據股票報價計算定期定額委託單的委託價格及數量,並向 LINE Notify 請求發送推播訊息。
  • ⑤ LINE Notify → User:LINE Notify 收到推播請求後,會將訊息發送給使用者。

Plan Service 向 Trader Service 請求下單委託:

  • ① Plan Service → Trader Service:Plan Service 向 Trader Service 發送請求,進行下單委託。
  • ② Trader Service → FugleTrade:Trader Service 向富果交易系統請求下單委託。
  • ③ FugleTrade → E.SUN Securities:富果交易系統向玉山證券驗章並轉送下單委託。
  • ④ E.SUN Securities → Exchange:玉山證券向交易所發出下單委託電文。

當 Plan Service 完成下單後,會更新 MongoDB 資料庫,將該筆定期定額委託設定的預約單更新為已執行。當下單委託成功後, Trader Service 亦會收到委託回報,並且透過 LINE Notify 將回報訊息發送給使用者。

建立 Plan Module

暸解定期定額系統的運作流程後,我們開始進行實作。請打開終端機,使用 Nest CLI 在 trader 應用程式目錄下建立 PlanModule:

$ nest g module plan -p trader

執行後,Nest CLI 會在專案下建立 apps/trader/src/plan 目錄,在該目錄下新增 plan.module.ts 檔案,並且將 PlanModule 加入至 AppModuleimports 設定。

然後開啟建立的 apps/trader/src/plan/plan.module.ts 檔案,我們要匯入 FugleRealtimeModule 透過富果行情 API 查詢股票即時報價。

import { Module } from '@nestjs/common';
import { FugleRealtimeModule } from '@fugle/realtime-nest';

@Module({
  imports: [
    FugleRealtimeModule.registerAsync({
      useFactory: () => ({
        apiToken: process.env.FUGLE_REALTIME_API_TOKEN,
      }),
    }),
  ],
})
export class PlanModule {}

這裡使用環境變數讀取富果行情 API Token,在專案目錄下的 .env 檔案加入 FUGLE_REALTIME_API_TOKEN 環境變數:

FUGLE_REALTIME_API_TOKEN=

下一步,為了啟用排程任務,在專案下開啟 apps/trader/src/app.module.ts 檔案,在根模組 AppModule 匯入 ScheduleModule

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { MongooseModule } from '@nestjs/mongoose';
import { FugleTradeModule } from '@fugle/trade-nest';
import { LineNotifyModule } from 'nest-line-notify';
import { IpFilter } from 'nestjs-ip-filter';
import { TraderModule } from './trader/trader.module';
import { PlanModule } from './plan/plan.module';

@Module({
  imports: [
    ConfigModule.forRoot(),
    ScheduleModule.forRoot(), // <--- 加入 ScheduleModule
    MongooseModule.forRoot(process.env.MONGODB_URI),
    FugleTradeModule.forRoot({
      config: {
        apiUrl: process.env.FUGLE_TRADE_API_URL,
        certPath: process.env.FUGLE_TRADE_CERT_PATH,
        apiKey: process.env.FUGLE_TRADE_API_KEY,
        apiSecret: process.env.FUGLE_TRADE_API_SECRET,
        aid: process.env.FUGLE_TRADE_AID,
        password: process.env.FUGLE_TRADE_PASSWORD,
        certPass: process.env.FUGLE_TRADE_CERT_PASS,
      },
    }),
    LineNotifyModule.forRoot({
      accessToken: process.env.LINE_NOTIFY_ACCESS_TOKEN,
    }),
    IpFilter.register({
      whitelist: String(process.env.ALLOWED_IPS).split(','),
    }),
    TraderModule,
    PlanModule,
  ],
})
export class AppModule {}

定義定期定額委託設定

apps/trader/src/plan 目錄下新增 plan.schema.ts 檔案,我們要定義 PlanSchema,這代表的是一個 Mongoose Schema,定義定期定額委託的設定:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type PlanDocument = Plan & Document;

@Schema({ timestamps: true })
export class Plan {
  @Prop()
  symbol: string;

  @Prop()
  price: number;

  @Prop()
  days: number[];

  @Prop()
  startDate: string;

  @Prop()
  endDate: string;

  @Prop()
  preorders: Array<{ date: string; placed: boolean; }>;

  @Prop({ default: false })
  completed: boolean;
}

export const PlanSchema = SchemaFactory.createForClass(Plan);

PlanSchema 中,各欄位的說明如下:

  • symbol:股票代碼
  • price:定期投入的金額
  • days:定期扣款日
  • startDate:開始執行的時間
  • endDate:結束執行的時間
  • preorders:預約委託單
  • completed:預約委託單是否已被全數執行

完成 PlanSchema 後,我們繼續在 apps/trader/src/plan 目錄下建立 plan.repository.ts 檔案,實作 PlanRepository 作為對資料庫存取的介面。我們需要在 PlanRepository 實作以下方法:

  • getPlans():取得未執行完畢的定期定額委託設定。
  • createPlan():建立一個定期定額委託設定。
  • removePlan():刪除一個定期定額委託設定。
  • updateExecutedPlan():更新定期定額委託設定,變更已執行的預約委託單。

實作 PlanRepository 程式碼如下:

import { DateTime } from 'luxon';
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Plan, PlanDocument } from './plan.schema';
import { CreatePlanDto } from './dto/create-plan.dto';

@Injectable()
export class PlanRepository {
  constructor(
    @InjectModel(Plan.name) private readonly model: Model<PlanDocument>,
  ) {}

  async getPlans(): Promise<PlanDocument[]> {
    return this.model.find({ completed: false })
      .select('-__v -createdAt -updatedAt')
      .lean();
  }

  async createPlan(createPlanDto: CreatePlanDto): Promise<PlanDocument> {
    const { symbol, days, price, startDate, endDate } = createPlanDto;

    const start = DateTime.fromISO(startDate);
    const end = DateTime.fromISO(endDate);
    const preorders = [];

    for (let current = start; current <= end; current = current.plus({ day: 1 })) {
      if (days.includes(current.get('day'))) {
        preorders.push({ date: current.toISODate(), placed: false });
      }
    }

    return this.model.create({ symbol, days, price, startDate, endDate, preorders });
  }

  async removePlan(id: string): Promise<void> {
    await this.model.deleteOne({ _id: id });
  }

  async getPlansToExecute(date: string): Promise<PlanDocument[]> {
    return this.model.find({
      completed: false,
      startDate: { $lte: date },
      endDate: { $gte: date },
      preorders: {
        $elemMatch: {
          date: { $lte: date },
          placed: false,
        },
      },
    });
  }

  async updateExecutedPlan(id: string, date: string): Promise<PlanDocument> {
    return this.model.findOneAndUpdate(
      { _id: id, 'preorders.date': date },
      { $set: { 'preorders.$.placed': true } },
      { new: true },
    );
  }
}

createPlan() 方法中,接收參數 createPlanDto 是資料傳輸物件(Data Transfer Object)用來建立定期定額委託設定。在 apps/trader/src/plan 目錄下建立 dto 資料夾,並在目錄下新增 create-plan.dto.ts 檔案, 實作 CreatePlanDto 表示新增定期定額委託設定的資料傳輸物件:

import { IsString, IsNumber, IsArray, IsDateString } from 'class-validator';

export class CreatePlanDto {
  @IsString()
  symbol: string;

  @IsNumber()
  price: number;

  @IsArray()
  days: number[];

  @IsDateString()
  startDate: string;

  @IsDateString()
  endDate: string;
}

完成後,開啟 apps/trader/src/plan/plan.module.ts 檔案,在 PlanModule 加入 PlanSchemaPlanRepository

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { FugleRealtimeModule } from '@fugle/realtime-nest';
import { Plan, PlanSchema } from './plan.schema';
import { PlanRepository } from './plan.repository';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: Plan.name, schema: PlanSchema },
    ]),
    FugleRealtimeModule.registerAsync({
      useFactory: () => ({
        apiToken: process.env.FUGLE_REALTIME_API_TOKEN,
      }),
    }),
  ],
  providers: [PlanRepository],
})
export class PlanModule {}

實作 Plan Service

我們已經在 PlanSchema 定義定期定額委託設定的資料欄位,並且在 PlanRepository 實作系統性投資計劃的資料庫存取方式,下一步要實作 PlanService,完成實現定期定額委託的各項方法。請打開終端機,使用 Nest CLI 建立 PlanService

$ nest g service plan -p trader --no-spec

執行後,Nest CLI 會在 apps/trader/src/plan 目錄下建立 plan.service.ts 檔案,開啟該檔案在 PlanService 實作以下可供外部存取的方法:

  • getPlans():取得所有定期定額委託設定。
  • createPlan():建立一個定期定額委託設定。
  • removePlan():移除一個定期定額委託設定。

實作 PlanService 程式碼如下:

import { omit } from 'lodash';
import { DateTime } from 'luxon';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { HttpClient } from '@fugle/realtime';
import { FugleTrade, Order } from '@fugle/trade';
import { InjectHttpClient } from '@fugle/realtime-nest';
import { InjectFugleTrade } from '@fugle/trade-nest';
import { InjectLineNotify, LineNotify } from 'nest-line-notify';
import { PlanRepository } from './plan.repository';
import { CreatePlanDto } from './dto/create-plan.dto';

@Injectable()
export class PlanService {
  constructor(
    @InjectHttpClient() private readonly client: HttpClient,
    @InjectFugleTrade() private readonly fugle: FugleTrade,
    @InjectLineNotify() private readonly lineNotify: LineNotify,
    private readonly PlanRepository: PlanRepository,
  ) {}

  async getPlans() {
    return this.PlanRepository.getPlans();
  }

  async createPlan(createPlanDto: CreatePlanDto) {
    const plan = await this.PlanRepository.createPlan(createPlanDto);
    return omit(plan.toJSON(), ['__v', 'createdAt', 'updatedAt']);
  }

  async removePlan(id: string) {
    return this.PlanRepository.removePlan(id);
  }

  @Cron('0 29 13 * * *')
  async execute() {
    const dt = DateTime.local();
    const date = dt.toISODate();
    const time = dt.toFormat('yyyy/MM/dd HH:mm:ss');

    try {
      const plans = await this.PlanRepository.getPlansToExecute(date);

      for (const plan of plans) {
        const res = await this.client.intraday.quote({ symbolId: plan.symbol, oddLot: true });
        if (res.data.info.date !== date) continue;

        const price = res.data.quote.order.asks.pop().price;
        const quantity =  Math.floor(plan.price / price);

        const order = new Order({
          stockNo: plan.symbol,
          buySell: Order.Side.Buy,
          price,
          quantity,
          apCode: Order.ApCode.IntradayOdd,
          priceFlag: Order.PriceFlag.Limit,
          bsFlag: Order.BsFlag.ROD,
          trade: Order.Trade.Cash,
        });

        // 設定推播訊息
        const message = [
          '',
          `<<定期定額>>`,
          `股票代號: ${plan.symbol}`,
          `價格: ${price}`,
          `數量: ${quantity}`,
          `時間: ${time}`,
        ].join('\n');

        // 透過 LINE Notify 推播訊息
        await this.lineNotify.send({ message });

        // 執行下單委託
        await this.fugle.placeOrder(order);

        // 將預約訂單更新為已執行
        const preorder = plan.preorders.find(preorder => preorder.date <= date && preorder.placed === false);
        await this.PlanRepository.updateExecutedPlan(plan.id, preorder.date);
      }
    } catch (err) {
      Logger.error(err.message, err.stack, PlanService.name);
    }
  }
}

執行定期定額委託

考量流動性與即時的五檔報價,我們以「盤中零股交易」的方式下委託單。臺股的盤中零股交易與一般交易稍有不同,在盤中零股交易在上午 09:10 時進行第一筆撮合,之後每 3 分鐘都是以集合競價方式撮合成交。依照這個規則,13:30 最後一盤的撮合前,在 13:28 盤中零股交易會有一次最新的零股撮合成交價。

註:證交所已宣布自 2022 年 12 月 19 日起,盤中零股交易撮合間隔時間由現行 3 分鐘縮短至 1 分鐘。

execute() 方法,我們使用 @Cron() 裝飾器聲明排程任務,將執行定期定額委託的時間設定在 13:29:00,目的是在最後一盤完成撮和成交。

execute() 方法中,我們從資料庫取得在程式執行當下要被執行的定期定額委託設定。取出匹配的定期定額委託設定後,再使用 this.client.intraday.quote() 方法,透過富果即時行情 API 取得定期定額標的 (股票或ETF)的最新成交價以及最佳五檔報價。如果取得最新報價的日期,不等於程式執行當下的日期,則代表程式執行當下的日期非交易日,因此就結束函式。

基於定期定額投資的目的,我們希望委託單可按照計畫在設定金額買入對應的股數,而零股交易買賣成交優先順序是以價格優先,因此我們使用取得報價揭露的「最後一檔委賣價」作為委買價,並算出買入對應的股數,讓委託單可以盡可能完成成交。

完成下單後,我們這筆定期定額計畫的委託單就已經被執行了,最後呼叫 this.PlanRepository.updateExecutedPlan() 方法更新該定期定額計畫,將最近一次預約委託單更新為已下單。

當時間至 13:30 所有股票完成最後一盤成交後,主動回報就會通知這筆零股委託單是否已經撮和成交了。

實作 Plan API

完成 Plan Service 後,我們要實作 Plan API,提供使用者透過 HTTP API 請求設定定期定額委託。打開終端機,使用 Nest CLI 建立 PlanController

$ nest g controller plan -p trader --no-spec

執行後,Nest CLI 會在 apps/trader/src/plan 目錄下建立 plan.controller.ts 檔案,開啟該檔案在 PlanController 實作以下方法:

  • getPlans():取得未執行完畢的定期定額委託設定。
  • createPlan():建立一個定期定額委託設定。
  • removePlan():刪除一個定期定額委託設定。

實作 PlanController 程式碼如下:

import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { PlanService } from './plan.service';
import { CreatePlanDto } from './dto/create-plan.dto';

@Controller('sip')
export class PlanController {
  constructor(private readonly PlanService: PlanService) {}

  @Get('/plans')
  async getPlans() {
    return this.PlanService.getPlans();
  }

  @Post('/plans')
  async createPlan(@Body() createPlanDto: CreatePlanDto) {
    return this.PlanService.createPlan(createPlanDto);
  }

  @Delete('/plans/:id')
  async removePlan(@Param('id') id: string) {
    return this.PlanService.removePlan(id);
  }
}

測試 Plan API

完成 PlanController 後,就可以測試 Plan API 了。首先啟動 Trader 應用程式:

$ npm start trader

然後在終端機透過 curl 指令來測試以下 API endpoints:

  • GET /sip/plans:取得所有定期定額委託設定。
  • POST /sip/plans:建立一個定期定額委託設定。
  • DELETE /sip/plans/:id:移除一個定期定額委託設定。

建立定期定額委託設定

使用以下 curl 指令建立一個定期定額委託設定,以元大臺灣50(0050)為例,在 2022 年 9 月 5 日、15 日以及 25 日每次投入 5000 元:

$ curl --request POST \
    --url http://localhost:3001/sip/plans \
    --header 'Content-Type: application/json' \
    --data '{
      "symbol": "0050",
      "price": 5000,
      "days": [5,15,25],
      "startDate": "2022-09-01",
      "endDate": "2022-09-30"
    }'

成功建立一個定期定額委託設定,以 Plan ID 6333b894919c4223830e0ff1 為例:

{"symbol":"0050","price":5000,"days":[5,15,25],"startDate":"2022-09-01","endDate":"2022-09-30","preorders":[{"date":"2022-09-05","placed":false},{"date":"2022-09-15","placed":false},{"date":"2022-09-25","placed":false}],"completed":false,"_id":"6333b894919c4223830e0ff1"}

取得所有定期定額委託設定

使用以下 curl 指令取得所有定期定額委託設定:

$ curl --request GET \
    --url http://localhost:3001/sip/plans

成功取得所有定期定額委託設定:

[{"_id":"6333b894919c4223830e0ff1","symbol":"0050","price":5000,"days":[5,15,25],"startDate":"2022-09-01","endDate":"2022-09-30","preorders":[{"date":"2022-09-05","placed":false},{"date":"2022-09-15","placed":false},{"date":"2022-09-25","placed":false}],"completed":false}]

刪除定期定額委託設定

使用以下 curl 指令刪除一個定期定額委託設定,以 Plan ID 6333b894919c4223830e0ff1 為例:

$ curl --request DELETE \
    --url http://localhost:3001/sip/plans/6333b894919c4223830e0ff1

LINE Notify 訊息推播

完成後,我們就可以請求 Plan API 新增定期定額委託設定。當觸發下單委託條件時,Plan Service 就會向 Trader Service 發送下單請求,並透過 LINE Notify 發送推播訊息。

下圖是執行定期定額委託的通知訊息,定期定額投入 5000 元買進元大臺灣50(0050)。

https://ithelp.ithome.com.tw/upload/images/20220929/20150150yV88dW9iRv.png

以上,我們已經完成了定期定額投資系統。需要注意的是,在我們的實作範例並沒有把手續費納入其中。如果想要將手續費列入計算,可以您自行實現滿足這個需求。

本日小結

  • 定期定額投資是一種投資策略,投資人在每個特定的時間將相同數量的資金投資於特定的投資標的。
  • 定期定額每次投入的金額是固定的,當投資標的價格下降時,買進的數量就比較多;當投資標的價格上漲時,買進的數量就比較少。
  • 定期定額投資法特別適用於指數化投資以及買進並持有長期投資策略。
  • 在 Trader 應用程式實作 Plan API,使用者可以透過 HTTP API 建立定期定額委託設定。
  • 在 Trader 應用程式實作 Plan Service,在交易日盤中執行定期定額委託單。

Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰
本系列文已正式出版為《Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰》。本書新增了全新內容和實用範例,為你提供更深入的學習體驗!歡迎參考選購,開始你的量化投資之旅!
天瓏網路書店連結:https://www.tenlong.com.tw/products/9786263336070


上一篇
Day 28 - 自動化下單:富果行情與交易 API 的整合應用
下一篇
Day 30 - 應用程式部署及結語
系列文
從 Node.js 開發者到量化交易者:打造屬於自己的投資系統31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言