iT邦幫忙

2022 iThome 鐵人賽

DAY 20
1
Software Development

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

Day 20 - 我的市場觀察:建立自己的盤後報告(上)

  • 分享至 

  • xImage
  •  

在本系列文前面的篇章,我們已經介紹如何從交易所取得公開資料,並示範如何運用這些數據,從大盤、產業及個股,由上而下進行股市分析。接下來我們要將這些數據進行有系統地整理,打造個人化的股市資料庫。我們會花三天的時間,分成「上」、「中」、「下」部分,一步步建立屬於自己的市場觀察報告。

Scraper 應用程式概觀

為了理解之後實作的內容,我們先描繪出 Scraper 應用程式的系統環境圖:

https://ithelp.ithome.com.tw/upload/images/20220920/20150150hHVSdM8CbQ.png

Scraper 應用程式指就是我們透過 Nest CLI 建立的 scraper application。在 Scraper 應用程式中,主要包含以下模組:

  • Scraper Module:負責向交易所及相關網站取得資料。在本系列文前面的篇章,我們已經完成了這個部分。
  • Market Stats Module:透過 Scraper Module 取得大盤籌碼數據,並存至 MongoDB 資料庫。
  • Ticker Module:透過 Scraper Module 取得指數及個股行情資訊,並存至 MongoDB 資料庫。
  • Report Module:從 MongoDB 資料庫存取大盤籌碼數據、指數及個股行情資訊,並產生市場觀察報告。產生的報告會透過 Gmail SMTP 寄送至使用者信箱。

我們會在本系列的「上」、「中」、「下」部分,分別完成這些服務。在今日的「上」篇,我們將會完成「Market Stats Module」與「Ticker Module」。

建立 MongoDB 連線

在本系列文的第一天,我們已經進行開發環境的準備,因此 MongoDB 已經安裝就緒並且可隨時建立資料庫連線。

Mongoose 在 Node.js 社群是非常知名的 MongoDB ODM(Object-Document Mapping),可在 MongoDB 和 Node.js 環境之間建立連線,並提供 API 方法便於處理 MongoDB 的各項操作。Nest 也提供官方套件支援 mongoose,請打開終端機,我們安裝以下依賴套件:

$ npm install --save @nestjs/mongoose mongoose

套件安裝完成後,在專案目錄開啟 src/app.module.ts 檔案,將 MongooseModule 匯入至根模組 AppModule

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ScraperModule } from './scraper/scraper.module';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/speculator'),
    ScraperModule,
  ],
})
export class AppModule {}

AppModule 中,匯入 MongooseModule.forRoot() 方法的參數,即是 MongoDB 資料庫的連線位址。假設我們在本機使用的資料庫位置是:

mongodb://localhost:27017/speculator

為了進行更靈活的配置,我們繼續安裝 Nest 官方提供的 @nestjs/config 套件:

$ npm install --save @nestjs/config

安裝完成後,回到 src/app.module.ts 檔案,將 ConfigModule 匯入 AppModule,並調整設定如下:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ScraperModule } from './scraper/scraper.module';

@Module({
  imports: [
    ConfigModule.forRoot(),
    MongooseModule.forRoot(process.env.MONGODB_URI),
    ScraperModule,
  ],
})
export class AppModule {}

透過使用 ConfigModule,我們可以配置環境變數,在程式碼中隱藏敏感資訊,並在不同環境下,給予不同的設定值。

請在專案根目錄下新增 .env 檔案,並配置以下變數設定:

MONGODB_URI=mongodb://localhost:27017/speculator

當應用程式執行時,MongoDB 連線位址就可以透過存取環境變數 process.env.MONGODB_URI 的方式讀出。

@nestjs/config 套件中,提供了各種不同的配置方法。詳細使用方式可參考 官方文件 的說明。

定義大盤籌碼資訊

在本系列文前面的篇章,我們介紹了許多評估大盤狀況的方法,現在我們要整理這些數據,建立自己的大盤觀察指標。

我們建立一個 MarketStatsModule 處理大盤籌碼相關數據,打開終端機,使用 Nest CLI 建立 MarketStatsModule

$ nest g module market-stats

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

然後在 src/market-stats 目錄下新增 market-stats.schema.ts 檔案,我們要定義 MarketStatsSchema,這代表的是一個 Mongoose Schema,定義每個交易日要收集的大盤籌碼數據:

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

export type MarketStatsDocument = MarketStats & Document;

@Schema({ timestamps: true })
export class MarketStats {
  @Prop({ required: true })
  date: string;

  @Prop()
  taiexPrice: number;

  @Prop()
  taiexChange: number;

  @Prop()
  taiexTradeValue: number;

  @Prop()
  finiNetBuySell: number;

  @Prop()
  sitcNetBuySell: number;

  @Prop()
  dealersNetBuySell: number;

  @Prop()
  marginBalance: number;

  @Prop()
  marginBalanceChange: number;

  @Prop()
  shortBalance: number;

  @Prop()
  shortBalanceChange: number;

  @Prop()
  finiTxfNetOi: number;

  @Prop()
  finiTxoCallsNetOiValue: number;

  @Prop()
  finiTxoPutsNetOiValue: number;

  @Prop()
  top10SpecificTxfFrontMonthNetOi: number;

  @Prop()
  top10SpecificTxfBackMonthsNetOi: number;

  @Prop()
  retailMxfNetOi: number;

  @Prop()
  retailMxfLongShortRatio: number;

  @Prop()
  txoPutCallRatio: number;

  @Prop()
  usdtwd: number;
}

export const MarketStatsSchema = SchemaFactory.createForClass(MarketStats)
  .index({ date: -1 }, { unique: true });

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

  • date:日期
  • taiexPrice:加權指數
  • taiexChange:加權指數漲跌
  • taiexTradeValue:集中市場成交金額
  • finiNetBuySell:集中市場外資買賣超
  • sitcNetBuySell:集中市場投信買賣超
  • dealersNetBuySell:集中市場自營商買賣超
  • marginBalance:集中市場融資餘額
  • marginBalanceChange:集中市場融資餘額增減
  • shortBalance:集中市場融券餘額
  • shortBalanceChange:集中市場融資餘額增減
  • finiTxfNetOi:外資臺股期貨淨口數
  • finiTxoCallsNetOiValue:外資臺指選擇權買權淨金額
  • finiTxoPutsNetOiValue:外資臺指選擇權賣權淨金額
  • top10SpecificTxfFrontMonthNetOi:十大特定法人近月臺股期貨淨部位
  • top10SpecificTxfBackMonthsNetOi:十大特定法人遠月臺股期貨淨部位
  • retailMxfNetOi:散戶小台淨部位
  • retailMxfLongShortRatio:散戶小台多空比
  • txoPutCallRatio:臺指選擇權 Put/Call Ratio
  • usdtwd:美元兌新臺幣匯率

我們在前面介紹的大盤相關數據不只於此,此處使用以上資料欄位做為範例說明,您可以根據自己的需要自行增減欄位,畢竟「多算勝,少算不勝」。

完成 MarketStatsSchema 後,我們繼續在 src/market-stats 目錄下建立 market-stats.repository.ts 檔案,實作 MarketStatsRepository 作為對資料庫存取的介面。我們在 MarketStatsRepository 實作 updateMarketStats() 方法,用來更新大盤籌碼數據:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { MarketStats, MarketStatsDocument } from './market-stats.schema';

@Injectable()
export class MarketStatsRepository {
  constructor(
    @InjectModel(MarketStats.name) private readonly model: Model<MarketStatsDocument>,
  ) {}

  async updateMarketStats(marketStats: Partial<MarketStats>) {
    const { date } = marketStats;
    return this.model.updateOne({ date }, marketStats, { upsert: true });
  }
}

updateMarketStats() 方法中,接收的 marketStats 參數物件必須指定 date 代表日期,表示要更新特定日期的大盤籌碼數據。

完成 MarketStatsRepository 後,打開終端機,使用 Nest CLI 建立 MarketStatsService

$ nest g service market-stats --no-spec

執行命令後,Nest CLI 會在 src/market-stats 目錄下建立 market-stats.service.ts 檔案,並且將 MarketStatsService 加入至 MarketStatsModuleproviders 設定。

在實作 MarketStatsService 之前,我們先開啟 src/market-stats/market-stats.module.ts 檔案,調整 MarketStatsModule 將完成的 MarketStatsSchemaMarketStatsRepository 加入至 MarketStatsModule 設定,並且匯入 ScraperModule

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { MarketStats, MarketStatsSchema } from './market-stats.schema';
import { MarketStatsRepository } from './market-stats.repository';
import { MarketStatsService } from './market-stats.service';
import { ScraperModule } from '../scraper/scraper.module';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: MarketStats.name, schema: MarketStatsSchema },
    ]),
    ScraperModule,
  ],
  providers: [MarketStatsRepository, MarketStatsService],
  exports: [MarketStatsRepository, MarketStatsService],
})
export class MarketStatsModule {}

MarketStatsService 中,我們會運用到 ScraperModule 的 services 取得大盤資訊。為了在 MarketStatsModule 使用 ScraperModule 的 services,記得要在 ScraperModule 中將所有的 providers 透過 exports 匯出:

import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TwseScraperService } from './twse-scraper.service';
import { TpexScraperService } from './tpex-scraper.service';
import { TaifexScraperService } from './taifex-scraper.service';
import { TdccScraperService } from './tdcc-scraper.service';
import { MopsScraperService } from './mops-scraper.service';
import { UsdtScraperService } from './usdt-scraper.service';
import { YahooFinanceService } from './yahoo-finance.service';

@Module({
  imports: [HttpModule],
  providers: [
    TwseScraperService,
    TpexScraperService,
    TaifexScraperService,
    TdccScraperService,
    MopsScraperService,
    UsdtScraperService,
    YahooFinanceService,
  ],
  exports: [
    TwseScraperService,
    TpexScraperService,
    TaifexScraperService,
    TdccScraperService,
    MopsScraperService,
    UsdtScraperService,
    YahooFinanceService,
  ],
})
export class ScraperModule {}

在 Nest Framework 的設定中,需要將 provider 使用 exports 匯出後,才可以被其他外部模組透過依賴注入的方式使用該 provider。

加入排程任務取得大盤籌碼數據

排程任務可以在特定的時間執行程式,透過設定排程任務,我們可以指定在特定的時間從交易所或相關網站取得資料。

Nest 官方提供了 @nestjs/schedule 套件,方便我們用裝飾器(decorator)聲明的方式實現排程任務。請開啟終端機安裝以下依賴套件:

$ npm install --save @nestjs/schedule
$ npm install --save-dev @types/cron

@nestjs/schedule 使用類似 crontab 的描述,來設定重複性的排程任務。

為了啟用排程任務,在專案下開啟 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 { ScraperModule } from './scraper/scraper.module';
import { MarketStatsModule } from './market-stats/market-stats.module';

@Module({
  imports: [
    ConfigModule.forRoot(),
    ScheduleModule.forRoot(),
    MongooseModule.forRoot(process.env.MONGODB_URI),
    ScraperModule,
    MarketStatsModule,
  ],
})
export class AppModule {}

然後開啟 src/market-stats/market-stats.service.ts 檔案,在 MarketStatsService 新增 updateMarketStats() 方法,更新大盤籌碼數據,並使用 @Cron() 裝飾器來聲明排程任務,分別執行取得大盤資訊的方法:

import { DateTime } from 'luxon';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { MarketStatsRepository } from './market-stats.repository';
import { TwseScraperService } from '../scraper/twse-scraper.service';
import { TaifexScraperService } from '../scraper/taifex-scraper.service';

@Injectable()
export class MarketStatsService {
  constructor(
    private readonly marketStatsRepository: MarketStatsRepository,
    private readonly twseScraperService: TwseScraperService,
    private readonly taifexScraperService: TaifexScraperService,
  ) {}

  async updateMarketStats(date: string = DateTime.local().toISODate()) {
    const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

    await this.updateTaiex(date).then(() => delay(5000));
    await this.updateInstInvestorsTrades(date).then(() => delay(5000));
    await this.updateMarginTransactions(date).then(() => delay(5000));
    await this.updateFiniTxfNetOi(date).then(() => delay(5000));
    await this.updateFiniTxoNetOiValue(date).then(() => delay(5000));
    await this.updateLargeTradersTxfNetOi(date).then(() => delay(5000));
    await this.updateRetailMxfPosition(date).then(() => delay(5000));
    await this.updateTxoPutCallRatio(date).then(() => delay(5000));
    await this.updateUsdTwdRate(date).then(() => delay(5000));

    Logger.log(`${date} 已完成`, MarketStatsService.name);
  }

  @Cron('0 0 15 * * *')
  async updateTaiex(date: string) {
    const updated = await this.twseScraperService.fetchMarketTrades(date)
      .then(data => data && {
        date,
        taiexPrice: data.price,
        taiexChange: data.change,
        taiexTradeValue: data.tradeValue,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data))

    if (updated) Logger.log(`${date} 集中市場加權指數: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 集中市場加權指數: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('0 30 15 * * *')
  async updateInstInvestorsTrades(date: string) {
    const updated = await this.twseScraperService.fetchInstInvestorsTrades(date)
      .then(data => data && {
        date,
        finiNetBuySell: data.foreignInvestorsNetBuySell,
        sitcNetBuySell: data.sitcNetBuySell,
        dealersNetBuySell: data.dealersNetBuySell,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data))

    if (updated) Logger.log(`${date} 集中市場三大法人買賣超: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 集中市場三大法人買賣超: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('0 30 21 * * *')
  async updateMarginTransactions(date: string) {
    const updated = await this.twseScraperService.fetchMarginTransactions(date)
      .then(data => data && {
        date,
        marginBalance: data.marginBalance,
        marginBalanceChange: data.marginBalanceChange,
        shortBalance: data.shortBalance,
        shortBalanceChange: data.shortBalanceChange,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 集中市場信用交易: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 集中市場信用交易: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('0 0 15 * * *')
  async updateFiniTxfNetOi(date: string) {
    const updated = await this.taifexScraperService.fetchInstInvestorsTxfTrades(date)
      .then(data => data && {
        date,
        finiTxfNetOi: data.finiNetOiVolume,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 外資臺股期貨未平倉淨口數: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 外資臺股期貨未平倉淨口數: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('5 0 15 * * *')
  async updateFiniTxoNetOiValue(date: string) {
    const updated = await this.taifexScraperService.fetchInstInvestorsTxoTrades(date)
      .then(data => data && {
        date,
        finiTxoCallsNetOiValue: data.finiCallsNetOiValue,
        finiTxoPutsNetOiValue: data.finiPutsNetOiValue,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 外資臺指選擇權未平倉淨金額: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 外資臺指選擇權未平倉淨金額: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('10 0 15 * * *')
  async updateLargeTradersTxfNetOi(date: string) {
    const updated = await this.taifexScraperService.fetchLargeTradersTxfPosition(date)
      .then(data => data && {
        date,
        top10SpecificTxfFrontMonthNetOi: data.top10SpecificFrontMonthNetOi,
        top10SpecificTxfBackMonthsNetOi: data.top10SpecificBackMonthsNetOi,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 十大特法臺股期貨未平倉淨口數: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 十大特法臺股期貨未平倉淨口數: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('15 0 15 * * *')
  async updateRetailMxfPosition(date: string) {
    const updated = await this.taifexScraperService.fetchRetailMxfPosition(date)
      .then(data => data && {
        date,
        retailMxfNetOi: data.retailMxfNetOi,
        retailMxfLongShortRatio: data.retailMxfLongShortRatio,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 散戶小台淨部位: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 散戶小台淨部位: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('20 0 15 * * *')
  async updateTxoPutCallRatio(date: string) {
    const updated = await this.taifexScraperService.fetchTxoPutCallRatio(date)
      .then(data => data && {
        date,
        txoPutCallRatio: data.txoPutCallRatio,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 臺指選擇權 Put/Call Ratio: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 臺指選擇權 Put/Call Ratio: 尚無資料或非交易日`, MarketStatsService.name);
  }

  @Cron('0 0 17 * * *')
  async updateUsdTwdRate(date: string) {
    const updated = await this.taifexScraperService.fetchExchangeRates(date)
      .then(data => data && {
        date,
        usdtwd: data.usdtwd,
      })
      .then(data => data && this.marketStatsRepository.updateMarketStats(data));

    if (updated) Logger.log(`${date} 美元兌新臺幣匯率: 已更新`, MarketStatsService.name);
    else Logger.warn(`${date} 美元兌新臺幣匯率: 尚無資料或非交易日`, MarketStatsService.name);
  }
}

MarketStatsService 中,我們實作 updateMarketStats() 方法更新大盤資訊,並且實作了以下方法及排程任務,定時取得各項大盤籌碼數據:

  • updateTaiex():更新集中市場加權指數
  • updateInstInvestorsTrades():更新集中市場三大法人買賣超
  • updateMarginTransactions():更新集中市場信用交易
  • updateFiniTxfNetOi():更新外資臺股期貨未平倉淨口數
  • updateFiniTxoNetOiValue():更新外資臺指選擇權未平倉淨金額
  • updateLargeTradersTxfNetOi():更新十大特法臺股期貨未平倉淨口數
  • updateRetailMxfPosition():更新散戶小台淨部位
  • updateTxoPutCallRatio():更新臺指選擇權 Put/Call Ratio
  • updateUsdTwdRate():更新美元兌新臺幣匯率

以上方法皆需指定 date 日期參數,以更新特定日期的大盤資訊。呼叫完每個方法後,會延遲 5 秒再進行下一個更新任務,避免請求過於頻繁,被交易所或相關網站禁止存取。

定義指數及個股行情資訊

完成 MarketStatsModule 處理大盤籌碼相關數據後,接下來我們要繼續完成 TickerModule 處理產業指數與個股行情資訊。

每一個產業分類股價指數和個股都有一組代號可以用來識別,我們稱作「Ticker」。為了整理產業指數及個股行情資訊,我們使用 Nest CLI 建立 TickerModule

$ nest g module ticker

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

然後在 src/ticker 目錄下新增 ticker.schema.ts 檔案,我們要定義 TickerSchema,這代表的是一個 Mongoose Schema,定義產業指數或個股的行情資訊:

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

export type TickerDocument = Ticker & Document;

@Schema({ timestamps: true })
export class Ticker {
  @Prop({ required: true })
  date: string;

  @Prop()
  type: string;

  @Prop()
  exchange: string;

  @Prop()
  market: string;

  @Prop()
  symbol: string;

  @Prop()
  name: string;

  @Prop()
  openPrice: number;

  @Prop()
  highPrice: number;

  @Prop()
  lowPrice: number;

  @Prop()
  closePrice: number;

  @Prop()
  change: number;

  @Prop()
  changePercent: number;

  @Prop()
  tradeVolume: number;

  @Prop()
  tradeValue: number;

  @Prop()
  transaction: number;

  @Prop()
  tradeWeight: number;

  @Prop()
  finiNetBuySell: number;

  @Prop()
  sitcNetBuySell: number;

  @Prop()
  dealersNetBuySell: number;
}

export const TickerSchema = SchemaFactory.createForClass(Ticker)
  .index({ date: -1, symbol: 1 }, { unique: true });

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

  • date:日期
  • type: 類型
  • exchange:所屬交易所
  • market:所屬市場別
  • symbol:指數或股票代號
  • name:指數或股票名稱
  • openPrice:開盤價
  • highPrice:最高價
  • lowPrice:最低價
  • closePrice:收盤價
  • change:漲跌
  • changePercent:漲跌幅
  • tradeVolume:成交量
  • tradeValue:成交金額
  • transaction:成交筆數
  • tradeWeight:成交比重
  • finiNetBuySell:外資買賣超
  • sitcNetBuySell:投信買賣超
  • dealersNetBuySell:自營商買賣超

完成 TickerSchema 後,我們繼續在 src/ticker 目錄下建立 ticker.repository.ts 檔案,實作 TickerRepository 作為對資料庫存取的介面。我們在 TickerRepository 實作 updateTicker() 方法,用來更新每日行情數據:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Ticker, TickerDocument } from './ticker.schema';

@Injectable()
export class TickerRepository {
  constructor(
    @InjectModel(Ticker.name) private readonly model: Model<TickerDocument>,
  ) {}

  async updateTicker(ticker: Partial<Ticker>) {
    const { date, symbol } = ticker;
    return this.model.updateOne({ date, symbol }, ticker, { upsert: true });
  }
}

updateTicker() 方法中,接收的 ticker 參數物件必須指定 date 代表日期,以及 symbol 代表指數或股票代號,表示要更新特定日期的指數或個股行情數據。

完成 TickerRepository 後,打開終端機,使用 Nest CLI 建立 TickerService

$ nest g service ticker --no-spec

執行命令後,Nest CLI 會在 src/ticker 目錄下新增 ticker.service.ts 檔案,並且將 TickerService 加入至 TickerModuleproviders 設定。

然後開啟 src/ticker/ticker.module.ts 檔案,調整 TickerModule 將完成的 TickerSchemaTickerRepository 加入至 TickerModule 設定,並且匯入 ScraperModule

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Ticker, TickerSchema } from './ticker.schema';
import { TickerRepository } from './ticker.repository';
import { TickerService } from './ticker.service';
import { ScraperModule } from '../scraper/scraper.module';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: Ticker.name, schema: TickerSchema },
    ]),
    ScraperModule,
  ],
  providers: [TickerRepository, TickerService],
  exports: [TickerRepository, TickerService],
})
export class TickerModule {}

在實作 TickerService 之前,我們先在 libs/common 加入幾組列舉(Enum)型別,作為定義資料的常數。

libs/common/src/enums 目錄下新增 ticker-type.enum.ts 檔案,實作 TickerType 列舉型別,表示 Ticker 的類型:

export enum TickerType {
  Equity = 'EQUITY',
  Index = 'INDEX',
}

Equity 表示 證券 類型 Ticker;INDEX 表示 指數 類型 Ticker。

libs/common/src/enums 目錄下新增 exchange.enum.ts 檔案,實作 Exchange 列舉型別,表示 Ticker 所屬的交易所:

export enum Exchange {
  TWSE = 'TWSE',
  TPEx = 'TPEx',
}

TWSE 表示 臺灣證券交易所TPEx 表示 證券櫃檯買賣中心

libs/common/src/enums 目錄下新增 market.enum.ts 檔案,實作 Market 列舉型別,表示 Ticker 所屬的市場別:

export enum Market {
  TSE = 'TSE',
  OTC = 'OTC',
  ESB = 'ESB',
  TIB = 'TIB',
  PSB = 'PSB',
}

TSE 表示 上市OTC 表示 上櫃ESB 表示 興櫃一般板TIB 表示 臺灣創新板PSB 表示 興櫃戰略新板

下一步,在 libs/common/src/enums/index.ts 將這些列舉型別匯出:

export * from './ticker-type.enum';
export * from './exchange.enum';
export * from './market.enum';

然後我們就可以透過以下方式引用這些列舉型別資料:

import { TickerType, Exchange, Market } from '@speculator/common';

加入排程任務取得指數及個股行情數據

完成列舉的定義後,開啟 src/ticker/ticker.service 檔案,在 TickerService 新增 updateTickers() 方法更新產業指數及個股行情,並使用 @Cron() 裝飾器來聲明排程任務,分別執行取得指數及個股行情的方法:

import { DateTime } from 'luxon';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { TickerType, Exchange, Market, Index } from '@speculator/common';
import { TickerRepository } from './ticker.repository';
import { TwseScraperService } from '../scraper/twse-scraper.service';
import { TpexScraperService } from '../scraper/tpex-scraper.service';

@Injectable()
export class TickerService {
  constructor(
    private readonly tickerRepository: TickerRepository,
    private readonly twseScraperService: TwseScraperService,
    private readonly tpexScraperService: TpexScraperService,
  ) {}

  async updateTickers(date: string = DateTime.local().toISODate()) {
    const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

    await Promise.all([
      this.updateTwseIndicesQuotes(date),
      this.updateTpexIndicesQuotes(date),
    ]).then(() => delay(5000));

    await Promise.all([
      this.updateTwseMarketTrades(date),
      this.updateTpexMarketTrades(date),
    ]).then(() => delay(5000));

    await Promise.all([
      this.updateTwseIndicesTrades(date),
      this.updateTpexIndicesTrades(date),
    ]).then(() => delay(5000));

    await Promise.all([
      this.updateTwseEquitiesQuotes(date),
      this.updateTpexEquitiesQuotes(date),
    ]).then(() => delay(5000));

    await Promise.all([
      this.updateTwseEquitiesInstInvestorsTrades(date),
      this.updateTpexEquitiesInstInvestorsTrades(date),
    ]).then(() => delay(5000));

    Logger.log(`${date} 已完成`, TickerService.name);
  }

  @Cron('0 0 14 * * *')
  async updateTwseIndicesQuotes(date: string = DateTime.local().toISODate()) {
    const updated = await this.twseScraperService.fetchIndicesQuotes(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Index,
        exchange: Exchange.TWSE,
        market: Market.TSE,
        symbol: ticker.symbol,
        name: ticker.name,
        openPrice: ticker.openPrice,
        highPrice: ticker.highPrice,
        lowPrice: ticker.lowPrice,
        closePrice: ticker.closePrice,
        change: ticker.change,
        changePercent: ticker.changePercent,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上市指數收盤行情: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上市指數收盤行情: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 0 14 * * *')
  async updateTpexIndicesQuotes(date: string = DateTime.local().toISODate()) {
    const updated = await this.tpexScraperService.fetchIndicesQuotes(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Index,
        exchange: Exchange.TPEx,
        market: Market.OTC,
        symbol: ticker.symbol,
        name: ticker.name,
        openPrice: ticker.openPrice,
        highPrice: ticker.highPrice,
        lowPrice: ticker.lowPrice,
        closePrice: ticker.closePrice,
        change: ticker.change,
        changePercent: ticker.changePercent,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上櫃指數收盤行情: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上櫃指數收盤行情: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 30 14 * * *')
  async updateTwseMarketTrades(date: string = DateTime.local().toISODate()) {
    const updated = await this.twseScraperService.fetchMarketTrades(date)
      .then(data => data && {
        date,
        type: TickerType.Index,
        exchange: Exchange.TWSE,
        market: Market.TSE,
        symbol: Index.TAIEX,
        tradeVolume: data.tradeVolume,
        tradeValue: data.tradeValue,
        transaction: data.transaction,
      })
      .then(ticker => ticker && this.tickerRepository.updateTicker(ticker));

    if (updated) Logger.log(`${date} 上市大盤成交量值: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上市大盤成交量值: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 30 14 * * *')
  async updateTpexMarketTrades(date: string = DateTime.local().toISODate()) {
    const updated = await this.tpexScraperService.fetchMarketTrades(date)
      .then(data => data && {
        date,
        type: TickerType.Index,
        exchange: Exchange.TPEx,
        market: Market.OTC,
        symbol: Index.TPEX,
        tradeVolume: data.tradeVolume,
        tradeValue: data.tradeValue,
        transaction: data.transaction,
      })
      .then(ticker => ticker && this.tickerRepository.updateTicker(ticker));

    if (updated) Logger.log(`${date} 上櫃大盤成交量值: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上櫃大盤成交量值: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 0 15 * * *')
  async updateTwseIndicesTrades(date: string = DateTime.local().toISODate()) {
    const updated = await this.twseScraperService.fetchIndicesTrades(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Index,
        exchange: Exchange.TWSE,
        market: Market.TSE,
        symbol: ticker.symbol,
        tradeVolume: ticker.tradeVolume,
        tradeValue: ticker.tradeValue,
        tradeWeight: ticker.tradeWeight,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上市類股成交量值: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上市類股成交量值: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 0 15 * * *')
  async updateTpexIndicesTrades(date: string = DateTime.local().toISODate()) {
    const updated = await this.tpexScraperService.fetchIndicesTrades(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Index,
        exchange: Exchange.TPEx,
        market: Market.OTC,
        symbol: ticker.symbol,
        tradeVolume: ticker.tradeVolume,
        tradeValue: ticker.tradeValue,
        tradeWeight: ticker.tradeWeight,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上櫃類股成交量值: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上櫃類股成交量值: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 0 15-21/2 * * *')
  async updateTwseEquitiesQuotes(date: string = DateTime.local().toISODate()) {
    const updated = await this.twseScraperService.fetchEquitiesQuotes(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Equity,
        exchange: Exchange.TWSE,
        market: Market.TSE,
        symbol: ticker.symbol,
        name: ticker.name,
        openPrice: ticker.openPrice,
        highPrice: ticker.highPrice,
        lowPrice: ticker.lowPrice,
        closePrice: ticker.closePrice,
        change: ticker.change,
        changePercent: ticker.changePercent,
        tradeVolume: ticker.tradeVolume,
        tradeValue: ticker.tradeValue,
        transaction: ticker.transaction,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上市個股收盤行情: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上市個股收盤行情: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 0 15-21/2 * * *')
  async updateTpexEquitiesQuotes(date: string = DateTime.local().toISODate()) {
    const updated = await this.tpexScraperService.fetchEquitiesQuotes(date)
      .then(data => data && data.map(ticker => ({ ...ticker,
        date: ticker.date,
        type: TickerType.Equity,
        exchange: Exchange.TPEx,
        market: Market.OTC,
        symbol: ticker.symbol,
        name: ticker.name,
        openPrice: ticker.openPrice,
        highPrice: ticker.highPrice,
        lowPrice: ticker.lowPrice,
        closePrice: ticker.closePrice,
        change: ticker.change,
        changePercent: ticker.changePercent,
        tradeVolume: ticker.tradeVolume,
        tradeValue: ticker.tradeValue,
        transaction: ticker.transaction,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上櫃個股收盤行情: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上櫃個股收盤行情: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 30 16 * * *')
  async updateTwseEquitiesInstInvestorsTrades(date: string = DateTime.local().toISODate()) {
    const updated = await this.twseScraperService.fetchEquitiesInstInvestorsTrades(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Equity,
        exchange: Exchange.TWSE,
        market: Market.TSE,
        symbol: ticker.symbol,
        finiNetBuySell: ticker.foreignInvestorsNetBuySell,
        sitcNetBuySell: ticker.sitcNetBuySell,
        dealersNetBuySell: ticker.dealersNetBuySell,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上市個股法人進出: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上市個股法人進出: 尚無資料或非交易日`, TickerService.name);
  }

  @Cron('0 30 16 * * *')
  async updateTpexEquitiesInstInvestorsTrades(date: string = DateTime.local().toISODate()) {
    const updated = await this.tpexScraperService.fetchEquitiesInstInvestorsTrades(date)
      .then(data => data && data.map(ticker => ({
        date: ticker.date,
        type: TickerType.Equity,
        exchange: Exchange.TPEx,
        market: Market.OTC,
        symbol: ticker.symbol,
        finiNetBuySell: ticker.foreignInvestorsNetBuySell,
        sitcNetBuySell: ticker.sitcNetBuySell,
        dealersNetBuySell: ticker.dealersNetBuySell,
      })))
      .then(data => data && Promise.all(data.map(ticker => this.tickerRepository.updateTicker(ticker))));

    if (updated) Logger.log(`${date} 上櫃個股法人進出: 已更新`, TickerService.name);
    else Logger.warn(`${date} 上櫃個股法人進出: 尚無資料或非交易日`, TickerService.name);
  }
}

TickerService 中,我們實作 updateTickers() 方法,更新指數與個股行情資訊,並且實作了以下方法及排程任務,定時取得各項行情數據:

  • updateTwseIndicesQuotes():更新集中市場指數行情
  • updateTpexIndicesQuotes():更新櫃買市場指數行情
  • updateTwseMarketTrades():更新集中市場成交量值
  • updateTpexMarketTrades():更新櫃買市場成交量值
  • updateTwseIndicesTrades():更新集中市場產業指數成交量值
  • updateTpexIndicesTrades():更新櫃買市場產業指數成交量值
  • updateTwseEquitiesQuotes():更新上市個股行情
  • updateTpexEquitiesQuotes():更新上櫃個股行情
  • updateTwseEquitiesInstInvestorsTrades():更新上市個股法人進出
  • updateTpexEquitiesInstInvestorsTrades():更新上櫃個股法人進出

以上方法皆需指定 date 日期參數,以更新特定日期的行情資訊。呼叫完每個方法後,會延遲 5 秒再進行下一個更新任務,避免請求過於頻繁,被交易所或相關網站禁止存取。

初始化 Scraper 應用程式

完成 MarketStatsModuleTickerModule 後,幾乎已經完成我們的股市資料庫了。不過在第一次執行應用程式時,我們需要進行初始化工作。

開啟 src/app.module 檔案,調整 AppModule,並加入 onApplicationBootstrap() 方法:

import { DateTime } from 'luxon';
import { Module, OnApplicationBootstrap, Logger } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ScraperModule } from './scraper/scraper.module';
import { MarketStatsModule } from './market-stats/market-stats.module';
import { TickerModule } from './ticker/ticker.module';
import { MarketStatsService } from './market-stats/market-stats.service';
import { TickerService } from './ticker/ticker.service';

@Module({
  imports: [
    ConfigModule.forRoot(),
    ScheduleModule.forRoot(),
    MongooseModule.forRoot(process.env.MONGODB_URI),
    ScraperModule,
    MarketStatsModule,
    TickerModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements OnApplicationBootstrap {
  constructor(
    private readonly marketStatsService: MarketStatsService,
    private readonly tickerService: TickerService
  ) {}

  async onApplicationBootstrap() {
    if (process.env.SCRAPER_INIT === 'true') {
      Logger.log('正在初始化應用程式...', AppModule.name);

      for (let dt = DateTime.local(), days = 0; days < 31; dt = dt.minus({ day: 1 }), days++) {
        await this.marketStatsService.updateMarketStats(dt.toISODate());
        await this.tickerService.updateTickers(dt.toISODate());
      }

      Logger.log('應用程式初始化完成', AppModule.name);
    }
  }
}

透過讀取環境變數 process.env.SCRAPER_INIT 決定是否要進行初始化。當要進行初始化時,我們只要在專案根目錄下的 .env 檔案加入:

SCRAPER_INIT=true

當應用程式啟動時,就會進行 Scraper 應用程式的初始化工作。透過 MarketStatsServiceupdateMarketStats() 方法,取得近一個月的大盤籌碼;透過 TickerServiceupdateTickers() 方法,取得近一個月的指數與個股行情資訊。

至此,我們已經完成了「Market Stats Module」與「Ticker Module」,明天我們將繼續完成「Report Module」以產出我們的市場觀察報告。

本日小結

  • 建立 Scraper 應用程式與 MongoDB 資料庫的連線。
  • 定義大盤籌碼以及指數、個股行情的資料欄位。
  • 完成 Market Stats Module,並設定排程任務,定時取得大盤籌碼數據。
  • 完成 Ticker Module,並設定排程任務,定時取得指數及個股行情資訊。
  • 完成 Scraper 應用程式初始化的功能。

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


上一篇
Day 19 - 跟著大戶走:集保戶股權分散表
下一篇
Day 21 - 我的市場觀察:建立自己的盤後報告(中)
系列文
從 Node.js 開發者到量化交易者:打造屬於自己的投資系統31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言