前一天我們以 TradingView 為例,示範如何對交易策略進行績效回測,不過一個成功的交易策略仍必須經由市場驗證才知道是否可行。今天我們就要實作程式交易系統,透過富果交易 API 進行下單委託,開啟進入程式交易的大門。
我們在前面的篇章已經介紹過 富果股市 API,並使用 行情 API 完成即時行情監控系統。富果也提供 交易 API 並提供 Node.js SDK 與 Nest Framework 的支援,因此對於 Node.js 開發者建構臺股交易系統是首選。
富果交易 API 是由富果技術團隊與玉山證券合作開發的程式交易 API。使用者完成 玉山證券富果帳戶 開戶程序後,只要簽署「API 服務申請同意書」並線上提出申請,就可以在 Windows、Mac 及 Linux 平台上使用富果提供的 SDK 進行程式交易。
請參考富果交易 API 事前準備 的流程,申請使用交易 API 服務、進行模擬測試後,就可以開通正式環境交易權限,然後我們就可以進行後續的實作。
我們要整合富果交易 API 的下單委託以及帳務查詢功能,因此我們會建立一個「Trader」應用伺服器(Application Server)。為了理解之後實作的內容,我們先描繪出 Trader 應用伺服器的系統環境圖:
在 Trader 應用伺服器中,主要包含以下元件:
使用者透過 Trader 應用伺服器進行下單委託,資料處理流程如下:
暸解 Trader 應用伺服器概觀後,我們開始進行實作。首先我們要建立一個新的 Nest Application 來實作程式交易系統。打開終端機,在我們的專案目錄下使用 Nest CLI 建立名為 trader
的 Nest application:
$ nest g app trader
執行完成後,Nest CLI 會在 apps
目錄下新增 trader
應用程式。我們需要將 trader
的應用程式結構稍作調整。Nest CLI 預設在 apps/trader/src
目錄下會建立 main.ts
、trader.module.ts
、trader.controller.ts
、trader.controller.spec.ts
與 trader.service.ts
檔案。我們保留 main.ts
與 trader.module.ts
檔案,並將 trader.module.ts
更名為 app.module.ts
,然後開啟該檔案,將 TraderModule
更名為 AppModule
,作為 trader
應用程式的根模組:
import { Module } from '@nestjs/common';
@Module({})
export class AppModule {}
接著開啟 apps/trader/src/main.ts
檔案,將匯入的模組名稱改為 AppModule
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
為了使用富果交易 API,我們安裝富果提供的 Node.js 交易 SDK 以及 Nest Module:
$ npm install --save @fugle/trade @fugle/trade-nest
安裝完成後,我們在專案目錄下開啟 .env
檔案,加入以下環境變數:
FUGLE_TRADE_API_URL=
FUGLE_TRADE_CERT_PATH=
FUGLE_TRADE_API_KEY=
FUGLE_TRADE_API_SECRET=
FUGLE_TRADE_AID=
FUGLE_TRADE_PASSWORD=
FUGLE_TRADE_CERT_PASS=
LINE_NOTIFY_ACCESS_TOKEN=
這些環境變數代表的意義如下:
FUGLE_TRADE_API_URL
:富果交易 API URL。FUGLE_TRADE_API_KEY
:富果交易 API 私鑰。FUGLE_TRADE_API_SECRET
:富果交易 API 私鑰。FUGLE_TRADE_AID
:證券帳號。FUGLE_TRADE_PASSWORD
:證券帳號密碼。FUGLE_TRADE_CERT_PATH
:憑證路徑。FUGLE_TRADE_CERT_PASS
:憑證密碼。LINE_NOTIFY_ACCESS_TOKEN
:LINE Notify Access Token。這些環境變數的值,如
FUGLE_TRADE_API_URL
、FUGLE_TRADE_API_KEY
、FUGLE_TRADE_API_SECRET
可以從 玉山證券富果交易 API 網站下載設定檔取得,並且匯出憑證檔案。
開啟在 apps/trader/src/app.module.ts
檔案,在 AppModule
匯入 ConfigModule
、LineNotifyModule
及 FugleTradeModule
:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { FugleTradeModule } from '@fugle/trade-nest';
import { LineNotifyModule } from 'nest-line-notify';
@Module({
imports: [
ConfigModule.forRoot(),
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,
}),
],
})
export class AppModule {}
我們先使用 Nest CLI 建立 TraderModule
:
$ nest g module trader -p trader
執行後,Nest CLI 會在 apps/trader/src/trader
目錄下建立 trader.module.ts
檔案,並且將 TraderModule
加入至 AppModule
的 imports
設定。
然後再使用 Nest CLI 建立 TraderService
:
$ nest g service trader -p trader --no-spec
執行命令後,Nest CLI 會在 apps/trader/src/trader
目錄下新增 trader.service.ts
檔案,並且將 TraderService
加入至 TraderModule
的 providers
設定。
我們要在 TraderService
整合富果交易 SDK 的功能。開啟 apps/trader/src/trader/trader.service.ts
檔案,在 TraderService
實作以下方法:
getOrders()
:取得當日所有委託單。placeOrder()
:下委託單。replaceOrder()
:修改委託單。cancelOrder()
:取消委託單。getTransactions()
:取得交易明細。getInventories()
:取得帳戶庫存。getSettlements()
:取得交割資訊。onConnect()
:當 Streamer 連線建立時的處理。onDisconnect()
:當 Streamer 連線關閉時的處理。onOrder()
:當委託確認時的處理。onTrade()
:當執行委託時的處理。onError()
:當 Streamer 發生錯誤時的處理。實作 TraderService
程式碼如下:
import { Injectable, Logger, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { InjectFugleTrade, Streamer } from '@fugle/trade-nest';
import { InjectLineNotify, LineNotify } from 'nest-line-notify';
import { FugleTrade, Order, OrderPayload } from '@fugle/trade';
import { PlaceOrderDto } from './dto/place-order.dto';
import { ReplaceOrderDto } from './dto/replace-order.dto';
import { GetTransactionsDto } from './dto/get-transactions.dto';
import { getOrderSideName, getOrderTypeName, getTradeTypeName, getPriceTypeName } from './utils';
@Injectable()
export class TraderService {
private readonly logger = new Logger(TraderService.name);
constructor(
@InjectFugleTrade() private readonly fugle: FugleTrade,
@InjectLineNotify() private readonly lineNotify: LineNotify,
) { }
async getOrders() {
return this.fugle.getOrders()
.then(orders => orders.map(order => order.payload))
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
async placeOrder(placeOrderDto: PlaceOrderDto) {
const payload = placeOrderDto as OrderPayload;
const order = new Order(payload);
return this.fugle.placeOrder(order)
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
async replaceOrder(id: string, replaceOrderDto: ReplaceOrderDto) {
const orders = await this.fugle.getOrders();
const order = orders.find(order =>
[order.payload.ordNo, order.payload.preOrdNo].includes(id)
);
if (!order) throw new NotFoundException('order not found');
return this.fugle.replaceOrder(order, replaceOrderDto)
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
async cancelOrder(id: string) {
const orders = await this.fugle.getOrders();
const order = orders.find(order =>
[order.payload.ordNo, order.payload.preOrdNo].includes(id)
);
if (!order) throw new NotFoundException('order not found');
return this.fugle.cancelOrder(order)
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
async getTransactions(getTransactionsDto: GetTransactionsDto) {
const { range } = getTransactionsDto;
return this.fugle.getTransactions(range)
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
async getInventories() {
return this.fugle.getInventories()
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
async getSettlements() {
return this.fugle.getSettlements()
.catch(err => {
throw new InternalServerErrorException(err.message);
});
}
@Streamer.OnConnect()
async onConnect() {
this.logger.log('Streamer.onConnect');
}
@Streamer.OnDisconnect()
async onDisconnect() {
this.logger.log('Streamer.onDisconnect');
this.fugle.streamer.connect();
}
@Streamer.OnOrder()
async onOrder(data) {
this.logger.log(`Streamer.OnOrder ${JSON.stringify(data)}`);
const { action, stockNo, buySell, bsFlag, trade, odPrice, orgQty, afterQty, apCode, priceFlag } = data;
const actionName = action === 'M' ? '改量' : action === 'C' ? '刪單' : action === 'R' ? '改價' : '委託';
const side = getOrderSideName(buySell);
const orderType = getOrderTypeName(bsFlag) || '';
const tradeType = getTradeTypeName(trade);
const isOddLot = apCode === Order.ApCode.Odd || apCode === Order.ApCode.Emg || apCode === Order.ApCode.IntradayOdd;
const price = (() => {
const price = Number(odPrice);
if (action === 'R') return '';
if (apCode === Order.ApCode.AfterMarket) return '收盤價';
return (price === 0) ? getPriceTypeName(priceFlag) : price;
})();
const priceUnit = (action === 'R' || Number(odPrice) === 0) ? '' : '元';
const size = action === 'O' ? Number(orgQty) : Number(afterQty);
const sizeUnit: string = isOddLot ? '股' : '張';
const info = (() => {
const actions = {
'刪單': '已刪單',
'改量': `已改為 ${size} ${sizeUnit}`,
'改價': `已改為 ${Number(odPrice)} 元`,
'委託': `${size} ${sizeUnit}`,
};
return actions[actionName];
})();
const message = [
'',
`<<委託回報>>`,
`${stockNo}:${price} ${priceUnit} ${orderType} ${tradeType} ${side} ${info}`,
].join('\n');
await this.lineNotify.send({ message })
.catch((err) => this.logger.error(err.message, err.stack));
}
@Streamer.OnTrade()
async onTrade(data) {
this.logger.log(`Streamer.OnTrade ${JSON.stringify(data)}`);
const { stockNo, buySell, trade, matPrice, matQty } = data;
const side = getOrderSideName(buySell);
const tradeType = getTradeTypeName(trade);
const price: string | number = Number(matPrice);
const priceUnit: string = price === 0 ? '' : '元';
const size = Number(matQty);
const sizeUnit = '股';
const message = [
'',
`<<成交回報>>`,
`${stockNo}:${price} ${priceUnit} ${tradeType} ${side} ${size} ${sizeUnit} 已成交`,
].join('\n');
await this.lineNotify.send({ message })
.catch((err) => this.logger.error(err.message, err.stack));
}
@Streamer.OnError()
async onError(err) {
this.logger.error(err.message, err.stack);
this.fugle.streamer.disconnect();
}
}
我們還需要實作 PlaceOrderDto
、ReplaceOrderDto
以及 GetTransactionsDto
,分別表示新增委託單、修改委託單以及取得成交明細的資料傳輸物件(Data Transfer Object)。
在 apps/trader/src/trader
目錄下建立 dto
資料夾,並在目錄下新增 place-order.dto.ts
檔案, 實作 PlaceOrderDto
表示新增委託單的資料傳輸物件:
import { IsString, IsNumber, IsEnum } from 'class-validator';
import { Order } from '@fugle/trade';
export class PlaceOrderDto {
@IsString()
stockNo: string;
@IsEnum(Order.Side)
buySell: string;
@IsNumber()
price?: number
@IsNumber()
quantity: number;
@IsEnum(Order.ApCode)
apCode: string;
@IsEnum(Order.PriceFlag)
priceFlag: string;
@IsEnum(Order.BsFlag)
bsFlag: string;
@IsEnum(Order.Trade)
trade: string;
}
然後,在 apps/trader/src/trader/dto
目錄下建立 replace-order.dto.ts
檔案, 實作 ReplaceOrderDto
表示修改委託單的資料傳輸物件:
import { IsNumber } from 'class-validator';
export class ReplaceOrderDto {
@IsNumber()
price: number;
@IsNumber()
quantity: number;
}
接著,在 apps/trader/src/trader/dto
目錄下建立 get-transactions.dto.ts
檔案, 實作 GetTransactionsDto
表示取得交易明細的資料傳輸物件,目前富果行情 API 可以查詢日內、3 日內、1 個月內以及 3 個月內的交易紀錄:
import { IsIn } from 'class-validator';
type Range = '0d' | '3d' | '1m' | '3m';
export class GetTransactionsDto {
@IsIn(['0d', '3d', '1m', '3m'])
range: Range;
}
收到委託及成交回報時,我們會透過 LINE Notify 推播通知訊息,因此需要將收到的委託或成交回報資訊轉換成文字訊息。在 apps/trader/src/trader
目錄下建立 utils
資料夾,並在該資料夾下新增 get-names.util.ts
檔案,開啟檔案並實作 getOrderSideName()
、getOrderTypeName()
、getTradeTypeName()
、getPriceTypeName()
等工具函式:
import { Order } from '@fugle/trade';
export function getOrderSideName(side: string) {
const names = {
[Order.Side.Buy]: '買進',
[Order.Side.Sell]: '賣出',
}
return names[side];
}
export function getOrderTypeName(bsFlag: string) {
const names = {
[Order.BsFlag.ROD]: 'ROD',
[Order.BsFlag.IOC]: 'IOC',
[Order.BsFlag.FOK]: 'FOK',
}
return names[bsFlag];
}
export function getTradeTypeName(trade: string) {
const names = {
[Order.Trade.Cash]: '現股',
[Order.Trade.DayTrading]: '信用當沖',
[Order.Trade.DayTradingSell]: '現股當沖',
[Order.Trade.Margin]: '融資',
[Order.Trade.Short]: '融券',
}
return names[trade];
}
export function getPriceTypeName(priceFlag: string) {
const names = {
[Order.PriceFlag.Limit]: '限價',
[Order.PriceFlag.Flat]: '平盤價',
[Order.PriceFlag.LimitDown]: '跌停價',
[Order.PriceFlag.LimitUp]: '漲停價',
[Order.PriceFlag.Market]: '市價價',
}
return names[priceFlag];
}
完成上述資料傳輸物件以及工具函式後,TraderService
就可以正常運作了。
在 TraderService
中,我們使用了 @Streamer
的裝飾器處理與 Streamer 的連線,Streamer 是一個向 Fugle 客戶端提供即時數據的應用程式,當我們想要主動接收委託或成交回報,就需要連接 Streamer。當 TraderService
收到 Stramer 的委託及成交回報後,就會透過 LINE Notify 向使用者推播回報通知。
實作 Trader Service 後,接下來要完成 Trader API。請打開終端機,使用 Nest CLI 建立 TraderController
:
$ nest g controller trader -p trader --no-spec
執行後,Nest CLI 會在 apps/trader/src/trader
目錄下建立 trader.controller.ts
檔案,開啟該檔案在 TraderController
實作以下方法:
getOrders()
:取得當日所有委託單。placeOrder()
:新增委託單。replaceOrder()
:修改委託單。cancelOrder()
:取消委託單。getTransactions()
:取得交易明細。getInventories()
:取得帳戶庫存。getSettlements()
:取得交割資訊。實作 TraderController
程式碼如下:
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { TraderService } from './trader.service';
import { PlaceOrderDto } from './dto/place-order.dto';
import { ReplaceOrderDto } from './dto/replace-order.dto';
import { GetTransactionsDto } from './dto/get-transactions.dto';
@Controller('trader')
export class TraderController {
constructor(private readonly traderService: TraderService) {}
@Get('/orders')
async getOrders() {
return this.traderService.getOrders();
}
@Post('/orders')
async placeOrder(@Body() placeOrderDto: PlaceOrderDto) {
return this.traderService.placeOrder(placeOrderDto);
}
@Put('/orders/:id')
async replaceOrder(@Param('id') id: string, @Body() replaceOrderDto: ReplaceOrderDto) {
return this.traderService.replaceOrder(id, replaceOrderDto);
}
@Delete('/orders/:id')
async cancelOrder(@Param('id') id: string) {
return this.traderService.cancelOrder(id);
}
@Get('/transactions')
async getTransactions(@Query() getTransactionsDto: GetTransactionsDto) {
return this.traderService.getTransactions(getTransactionsDto);
}
@Get('/inventories')
async getInventories() {
return this.traderService.getInventories();
}
@Get('/settlements')
async getSettlements() {
return this.traderService.getSettlements();
}
}
為了讓每個路由都通過 ValidationPipe
進行驗證程序,我們在 apps/trader/src/mian.ts
加入 useGlobalPipes()
方法使其作用為全域範圍:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
await app.listen(3000);
}
bootstrap();
最後,為了確保交易命令是我們本人執行,因此需要設定「白名單」限制特定 IP 存取 Trader API。請安裝以下套件:
$ npm install --save nestjs-ip-filter
安裝完成後,在專案目錄下的 .env
檔案加入 ALLOWED_IPS
環境變數,將每個接受的 IP 位址以逗號 ,
分隔:
ALLOWED_IPS=
然後開啟 apps/trader/src/app.module.ts
檔案,在 AppModule
加入 IpFilter
Module:
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 { IpFilter } from 'nestjs-ip-filter';
import { LineNotifyModule } from 'nest-line-notify';
import { TraderModule } from './trader/trader.module';
@Module({
imports: [
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
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,
],
})
export class AppModule {}
IpFilter
Module 設定完成後,當使用者向 Trader 發送請求,trader
應用程式會去檢查請求來源位址。只有在白名單上的 IP 位址,才可以存取 Trader API。
完成 Trader API 後,我們就可以對其進行測試。打開終端機,啟動 Trader 應用伺服器:
$ npm start trader
然後在終端機透過 curl
指令來測試以下 API endpoints:
GET /trader/orders
:取得當日所有委託單。POST /trader/orders
:新增委託單。PUT /trader/orders/:id
:修改委託單。DELETE /trader/orders/:id
:取消委託單。GET /trader/transactions
:取得交易明細。GET /trader/inventories
:取得帳戶庫存。GET /trader/settlements
:取得交割資訊。使用以下 curl
指令取得所有委託單:
$ curl --request GET \
--url http://localhost:3000/trader/orders
使用以下 curl
指令新增委託單,以 500 元 ROD 現股,買進 1 張台積電(2330)為例:
$ curl --request POST \
--url http://localhost:3000/trader/orders \
--header 'Content-Type: application/json' \
--data '{"stockNo": "2330","buySell": "B","price": 500,"quantity": 1,"apCode": "1","priceFlag": "0","bsFlag": "R","trade": "0"}'
使用以下 curl
指令修改委託單,以改價 499 為例:
$ curl --request PUT \
--url http://localhost:3001/trader/orders/<ORDER_ID> \
--header 'Content-Type: application/json' \
--data '{"price": 499}'
使用以下 curl
指令取消委託單:
$ curl --request DELETE \
--url http://localhost:3001/trader/orders/<ORDER_ID> \
--header 'Content-Type: application/json'
使用以下 curl
指令取得交易明細,以近 3 個月為例:
$ curl --request GET \
--url http://localhost:3000/trader/transactions?range=3m
使用以下 curl
指令取得帳戶庫存:
$ curl --request GET \
--url http://localhost:3000/trader/inventories
使用以下 curl
指令取得交割資訊:
$ curl --request GET \
--url http://localhost:3000/trader/settlements
我們在 TraderService
實作了委託或成交回報的處理,當我們收到委託或成交回報後,就會收到 LINE Notify 推播訊息的通知。
至此,我們已經在 Trader 應用伺服器上整合了富果行情 API,完成程式交易系統。使用者可以透過請求 Trader API,進行下單委託、帳務查詢等功能。
本系列文已正式出版為《Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰》。本書新增了全新內容和實用範例,為你提供更深入的學習體驗!歡迎參考選購,開始你的量化投資之旅!
天瓏網路書店連結:https://www.tenlong.com.tw/products/9786263336070