在本系列文前面的篇章,我們已經介紹如何從交易所取得公開資料,並示範如何運用這些數據,從大盤、產業及個股,由上而下進行股市分析。接下來我們要將這些數據進行有系統地整理,打造個人化的股市資料庫。我們會花三天的時間,分成「上」、「中」、「下」部分,一步步建立屬於自己的市場觀察報告。
為了理解之後實作的內容,我們先描繪出 Scraper 應用程式的系統環境圖:
Scraper 應用程式指就是我們透過 Nest CLI 建立的 scraper
application。在 Scraper 應用程式中,主要包含以下模組:
我們會在本系列的「上」、「中」、「下」部分,分別完成這些服務。在今日的「上」篇,我們將會完成「Market Stats Module」與「Ticker Module」。
在本系列文的第一天,我們已經進行開發環境的準備,因此 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
加入至 AppModule
的 imports
設定。
然後在 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 Ratiousdtwd
:美元兌新臺幣匯率我們在前面介紹的大盤相關數據不只於此,此處使用以上資料欄位做為範例說明,您可以根據自己的需要自行增減欄位,畢竟「多算勝,少算不勝」。
完成 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
加入至 MarketStatsModule
的 providers
設定。
在實作 MarketStatsService
之前,我們先開啟 src/market-stats/market-stats.module.ts
檔案,調整 MarketStatsModule
將完成的 MarketStatsSchema
與 MarketStatsRepository
加入至 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 RatioupdateUsdTwdRate()
:更新美元兌新臺幣匯率以上方法皆需指定 date
日期參數,以更新特定日期的大盤資訊。呼叫完每個方法後,會延遲 5 秒再進行下一個更新任務,避免請求過於頻繁,被交易所或相關網站禁止存取。
完成 MarketStatsModule
處理大盤籌碼相關數據後,接下來我們要繼續完成 TickerModule
處理產業指數與個股行情資訊。
每一個產業分類股價指數和個股都有一組代號可以用來識別,我們稱作「Ticker」。為了整理產業指數及個股行情資訊,我們使用 Nest CLI 建立 TickerModule
:
$ nest g module ticker
執行後,Nest CLI 會建立 src/ticker
目錄,並在該目錄下新增 ticker.module.ts
檔案,並且將 TickerModule
加入至 AppModule
的 imports
設定。
然後在 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
加入至 TickerModule
的 providers
設定。
然後開啟 src/ticker/ticker.module.ts
檔案,調整 TickerModule
將完成的 TickerSchema
與 TickerRepository
加入至 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 秒再進行下一個更新任務,避免請求過於頻繁,被交易所或相關網站禁止存取。
完成 MarketStatsModule
與 TickerModule
後,幾乎已經完成我們的股市資料庫了。不過在第一次執行應用程式時,我們需要進行初始化工作。
開啟 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 應用程式的初始化工作。透過 MarketStatsService
的 updateMarketStats()
方法,取得近一個月的大盤籌碼;透過 TickerService
的 updateTickers()
方法,取得近一個月的指數與個股行情資訊。
至此,我們已經完成了「Market Stats Module」與「Ticker Module」,明天我們將繼續完成「Report Module」以產出我們的市場觀察報告。
本系列文已正式出版為《Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰》。本書新增了全新內容和實用範例,為你提供更深入的學習體驗!歡迎參考選購,開始你的量化投資之旅!
天瓏網路書店連結:https://www.tenlong.com.tw/products/9786263336070