API Gateway+Lambda(Go)+DynamoDBで注文や約定の機能をつくりました

こんにちわ、GX推進グループの平井です。

はじめに

モノリスな証券システムであるGALAXYに代わる、次世代の証券システムをつくろうということで、マイクロサービスな証券システムであるBIGBANGのプロジェクトが2020年にGX推進グループで始動しました。余力を検証するためには複数の商品が必要ということで、まず投信、そして株式のドメインで開発が行われました。僕は投信と株式の双方のドメインで、注文や約定などの機能を担当しました。今回は投信に絞ってその話をしたいと思います。

機能概要

フロントエンドに提供する機能としては、以下の4つがあります。

  • 購入(buy-fund)
  • 解約(sell-fund)
  • 注文取消(cancel-order)
  • 注文取得(get-order)

バックエンドにある機能としては、以下の5つがあります。

  • 締め(seal-order)
  • 概算連絡(contact-order)
  • 約定(contract-order)
  • 確定連絡(contact-order)
  • 注文中金額取得(get-ordering-price)

機能説明

注文

投信の注文には、購入と解約があります。
解約は売却とほぼ同じ意味です。
投信の注文は株式の注文と異なり、数量だけでなく金額で指定することもできます。

締め

投信の各銘柄には締めというものがあります。
締めは委託会社、銘柄ごとに決まっており、
締め切り時刻より前のものは当日の注文として扱い、
締め切り時刻より後のものは翌営業日の注文として扱います。

概算連絡

締め処理をした銘柄の注文内容を委託会社に連絡します。
このときに使用する単価は前営業日のものです。

約定

委託会社から当日の単価が公開されたら、約定を行います。
このとき、残高や顧客勘定の更新を行います。

確定連絡

当日の単価がわかると、数量と正確な約定金額がわかるので、
それらを含めた注文データを委託会社に連絡します。

システム構成

f:id:xgxgh:20210125230628p:plain
クラウドにはAWSやGCPなどがありますが、機能の豊富さや使いやすさ、世界的なシェアや社内での実績などからAWSになりました。AWSでプログラムを実行できるサービスとして、EC2、ECS、Lambdaなどがありますが、ぼくは開発の手軽さからLambdaを選択しました。フロントエンドにはWeb APIとして機能を提供したいので、API Gatewayを使いました。API GatewayやLambdaはスケールしやすいので、DBもスケールしやすいDynamoDBを使いました。
Lambdaで標準的にサポートされているプログラミング言語として、Node.js、Python、Ruby、Java、Go、C#がありますが、実行速度や実装の容易さからGoにしました。
といった感じで基本的にはAPI Gateway+Lambda(Go)+DynamoDBの構成にしました。ほかにも顧客勘定の更新にSNS、概算連絡や確定連絡のファイルのアップロード先としてS3、概算連絡を起動するためにEventBridge(旧CloudWatch Events)やStep Functions、約定連絡を起動するためにSQSやStep Functionsも使いました。

プログラム

いくつか抜粋して、投信の購入、解約、約定の3つのプログラムを紹介します。

投信購入

package main

import (
	"context"
	"encoding/json"
	"errors"
	"io/ioutil"
	"net/http"
	"strconv"
	"time"

	"./constant"
	"./util"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/google/uuid"
)

const (
	minOrderAmount = constant.MinOrderAmount
	maxOrderAmount = constant.MaxOrderAmount
	functionId     = constant.FunctionId
	success        = constant.Success
	orderStatus    = constant.OrderStatus
)

type Request struct {
	ClientId    string `json:"client_id"`
	BrandId     string `json:"brand_id"`
	OrderType   string `json:"order_type"`
	OrderAmount int    `json:"order_amount"`
}

type Response struct {
	Result string `json:"result"`
}

type UnitPrice struct {
	DscrCd      string `json:"dscrCd"`
	LatestPrice struct {
		StdDt string `json:"stdDt"`
		Sell  struct {
			Price int `json:"price"`
			Diff  int `json:"diff"`
		} `json:"sell"`
		Buy struct {
			Price int `json:"price"`
			Diff  int `json:"diff"`
		} `json:"buy"`
	} `json:"latestPrice"`
}

type Dates struct {
	DscrCd      string `json:"dscrCd"`
	AppearLinks bool   `json:"appearLinks"`
	ApplyDt     string `json:"applyDt"` // 基準日
	TradeDt     string `json:"tradeDt"` // 約定日
	ValueDt     string `json:"valueDt"` // 受渡日
	CloseTm     string `json:"closeTm"` // 締切時刻
	Stopped     bool   `json:"stopped"`
}

type Params struct {
	OrderId       string
	ClientId      string
	BrandId       string
	OrderType     string
	OrderAmount   int
	OrderDate     string
	OrderTime     string
	ContractPrice int
	UnitPrice     int
	BaseDate      string
	TradeDate     string
	ValueDate     string
}

type BuyingPowerResponse struct {
	AccountID string `json:"accountId"`
	TradeDt   string `json:"tradeDt"`
	ValueDt   string `json:"valueDt"`
	Price     int    `json:"price"`
	Hold      bool   `json:"hold"`
	Error     string `json:"error"`
}

var config map[string]string

func init() {
	config = util.GetConfig()
}

func main() {
	lambda.Start(LambdaHandler)
}

func LambdaHandler(ctx context.Context, req Request) (Response, error) {
	return buyFund(req)
}

/*
投信購入
*/
func buyFund(req Request) (Response, error) {
	u, err := uuid.NewRandom()
	if err != nil {
		return Response{Result: "NG"}, errors.New("failed at generating uuid")
	}
	orderId := u.String()
	clientId := req.ClientId
	brandId := req.BrandId
	orderType := req.OrderType
	orderAmount := req.OrderAmount

	// 注文数量チェック
	if orderType == "buy-lot" && !checkAmount(orderAmount) {
		return Response{Result: "NG"}, errors.New("order amount is inadequate")
	}

	// 単価取得
	unitPrice, err := getUnitPrice(brandId)
	if err != nil {
		return Response{Result: "NG"}, errors.New("failed at gettting unit price")
	}

	// 約定金額計算
	contractPrice, res := calcContractPrice(orderType, orderAmount, unitPrice)
	if !res {
		return Response{Result: "NG"}, errors.New("failed at calculating contract price")
	}

	// 基準日、約定日、受渡日の取得
	baseDate, tradeDate, valueDate, err := getDates(brandId)
	if err != nil {
		return Response{Result: "NG"}, errors.New("failed at getting base date, trade date, value date")
	}

	// 買付可能チェック
	if err := checkBuyingPower(clientId, contractPrice, tradeDate, valueDate); err != nil {
		return Response{Result: "NG"}, err
	}

	// 注文登録
	if !registerFundOrder(Params{
		OrderId:       orderId,
		ClientId:      clientId,
		BrandId:       brandId,
		OrderType:     orderType,
		OrderAmount:   orderAmount,
		BaseDate:      baseDate,
		TradeDate:     tradeDate,
		ValueDate:     valueDate,
		ContractPrice: contractPrice,
		UnitPrice:     unitPrice}) {
		return Response{Result: "NG"}, errors.New("failed at registering fund order")
	}

	return Response{Result: "OK"}, nil
}

/*
注文数量チェック
*/
func checkAmount(orderAmount int) bool {
	return orderAmount >= minOrderAmount && orderAmount <= maxOrderAmount
}

/*
単価取得
*/
func getUnitPrice(brandId string) (int, error) {
	url := config["api:fund-api:"] + "/fund/" + brandId + "/price/latest"
	method := "GET"

	client := &http.Client{}
	req, err := http.NewRequest(method, url, nil)
	if err != nil {
		return -1, err
	}
	req.Header.Add("Origin", "null")
	req.Header.Add("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return -1, err
	}
	var unitPrice UnitPrice
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return -1, err
	}
	if err := json.Unmarshal(body, &unitPrice); err != nil {
		return -1, err
	}
	return unitPrice.LatestPrice.Buy.Price, nil
}

/*
約定金額計算
*/
func calcContractPrice(orderType string, orderAmount int, unitPrice int) (int, bool) {
	switch orderType {
	case "buy-lot":
		return unitPrice * orderAmount, true
	case "buy-price":
		return orderAmount, true
	default:
		return 0, false
	}
}

/*
基準日、約定日、受渡日の取得
*/
func getDates(brandId string) (string, string, string, error) {
	url := config["api:fund-api:"] + "/fund/" + brandId + "/sales"
	res, err := http.Get(url)
	if err != nil {
		return "", "", "", err
	}
	defer res.Body.Close()
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return "", "", "", err
	}
	var dates Dates
	if err := json.Unmarshal(body, &dates); err != nil {
		return "", "", "", err
	}
	return dates.ApplyDt,
		dates.TradeDt,
		dates.ValueDt, nil
}

/*
注文登録
*/
func registerFundOrder(params Params) bool {
	now := time.Now()
	orderDate := now.Format("2006-01-02")
	orderTime := now.Format("15:04:05")
	input := &dynamodb.PutItemInput{
		Item: map[string]*dynamodb.AttributeValue{
			"order_id": {
				S: aws.String(params.OrderId),
			},
			"client_id": {
				S: aws.String(params.ClientId),
			},
			"brand_id": {
				S: aws.String(params.BrandId),
			},
			"order_type": {
				S: aws.String(params.OrderType),
			},
			"order_amount": {
				S: aws.String(strconv.Itoa(params.OrderAmount)),
			},
			"order_status": {
				S: aws.String(orderStatus),
			},
			"order_date": {
				S: aws.String(orderDate),
			},
			"order_time": {
				S: aws.String(orderTime),
			},
			"base_date": {
				S: aws.String(params.BaseDate),
			},
			"trade_date": {
				S: aws.String(params.TradeDate),
			},
			"value_date": {
				S: aws.String(params.ValueDate),
			},
			"unit_price": {
				S: aws.String(strconv.Itoa(params.UnitPrice)),
			},
			"contract_price": {
				S: aws.String(strconv.Itoa(params.ContractPrice)),
			},
			"update_date": {
				S: aws.String(orderDate),
			},
			"update_time": {
				S: aws.String(orderTime),
			},
		},
		TableName: aws.String("fund-order"),
	}
	_, err := dynamodb.New(session.New()).PutItem(input)
	if err != nil {
		return false
	}
	return true
}

/*
買付可能チェック
*/
func checkBuyingPower(accountId string, contractPrice int, tradeDt string, valueDt string) error {
	url := config["api:bp-api:"] + "/buying-power/" + accountId + "?trade_dt=" + tradeDt + "&value_dt=" + valueDt + "&price=" + strconv.Itoa(contractPrice)
	method := "GET"

	client := &http.Client{}
	req, err := http.NewRequest(method, url, nil)
	if err != nil {
		return err
	}
	req.Header.Add("Origin", "null")
	req.Header.Add("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return err
	}
	var buyingPowerResponse BuyingPowerResponse
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return err
	}
	if err := json.Unmarshal(body, &buyingPowerResponse); err != nil {
		return err
	}
	if !buyingPowerResponse.Hold {
		return errors.New(buyingPowerResponse.Error)
	}
	return nil
}

投信解約

package main

import (
	"context"
	"encoding/json"
	"errors"
	"io/ioutil"
	"net/http"
	"strconv"
	"time"

	"./constant"
	"./util"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/google/uuid"
)

const (
	minOrderAmount = constant.MinOrderAmount
	maxOrderAmount = constant.MaxOrderAmount
	functionId     = constant.FunctionId
	success        = constant.Success
	orderStatus    = constant.OrderStatus
)

type Request struct {
	ClientId    string `json:"client_id"`
	BrandId     string `json:"brand_id"`
	OrderType   string `json:"order_type"`
	OrderAmount int    `json:"order_amount"`
}

type Response struct {
	Result string `json:"result"`
}

type UnitPrice struct {
	DscrCd      string `json:"dscrCd"`
	LatestPrice struct {
		StdDt string `json:"stdDt"`
		Sell  struct {
			Price int `json:"price"`
			Diff  int `json:"diff"`
		} `json:"sell"`
		Buy struct {
			Price int `json:"price"`
			Diff  int `json:"diff"`
		} `json:"buy"`
	} `json:"latestPrice"`
}

type Dates struct {
	DscrCd      string `json:"dscrCd"`
	AppearLinks bool   `json:"appearLinks"`
	ApplyDt     string `json:"applyDt"` // 基準日
	TradeDt     string `json:"tradeDt"` // 約定日
	ValueDt     string `json:"valueDt"` // 受渡日
	CloseTm     string `json:"closeTm"` // 締切時刻
	Stopped     bool   `json:"stopped"`
}

type Params struct {
	OrderId       string
	ClientId      string
	BrandId       string
	OrderType     string
	OrderAmount   int
	OrderDate     string
	OrderTime     string
	ContractPrice int
	UnitPrice     int
	BaseDate      string
	TradeDate     string
	ValueDate     string
}

type BalanceEntity []struct {
	AccountID          int    `json:"accountId"`
	BranchCd           int    `json:"branchCd"`
	ClientCd           int    `json:"clientCd"`
	DscrCd             string `json:"dscrCd"`
	GeneralAccmInvKbn  int    `json:"generalAccmInvKbn"`
	SpAccKbn           int    `json:"spAccKbn"`
	SubGuarantyKbn     int    `json:"subGuarantyKbn"`
	TodayTdOpenNominal int    `json:"todayTdOpenNominal"`
	TodayStdDt         string `json:"todayStdDt"`
	FrontUpdTm         string `json:"frontUpdTm"`
	DoFoClassKbn       int    `json:"doFoClassKbn"`
}

var config map[string]string

func init() {
	config = util.GetConfig()
}

func main() {
	lambda.Start(LambdaHandler)
}

func LambdaHandler(ctx context.Context, req Request) (Response, error) {
	return sellFund(req)
}

/*
投信解約
*/
func sellFund(req Request) (Response, error) {
	u, err := uuid.NewRandom()
	if err != nil {
		return Response{Result: "NG"}, errors.New("failed at generating uuid")
	}
	orderId := u.String()
	clientId := req.ClientId
	brandId := req.BrandId
	orderType := req.OrderType
	orderAmount := req.OrderAmount

	// 注文数量チェック
	if !checkAmount(orderAmount) {
		return Response{Result: "NG"}, errors.New("order amount is inadequate")
	}

	// 残高チェック
	if !checkBalance(clientId, brandId, orderAmount) {
		return Response{Result: "NG"}, errors.New("error at checking balance")
	}

	// 単価取得
	unitPrice, status := getUnitPrice(brandId)
	if !status {
		return Response{Result: "NG"}, errors.New("failed at gettting unit price")
	}

	// 約定金額計算
	contractPrice, status := calcContractPrice(orderType, orderAmount, unitPrice)
	if !status {
		return Response{Result: "NG"}, errors.New("failed at calculating contract price")
	}

	// 基準日、約定日、受渡日の取得
	baseDate, tradeDate, valueDate, err := getDates(brandId)
	if err != nil {
		return Response{Result: "NG"}, errors.New("failed at getting base date, trade date, value date")
	}

	// 注文登録
	if !registerFundOrder(Params{
		OrderId:       orderId,
		ClientId:      clientId,
		BrandId:       brandId,
		OrderType:     orderType,
		OrderAmount:   orderAmount,
		BaseDate:      baseDate,
		TradeDate:     tradeDate,
		ValueDate:     valueDate,
		ContractPrice: contractPrice,
		UnitPrice:     unitPrice}) {
		return Response{Result: "NG"}, errors.New("failed at registering fund order")
	}

	return Response{Result: "OK"}, nil
}

/*
注文数量チェック
*/
func checkAmount(orderAmount int) bool {
	return orderAmount >= minOrderAmount && orderAmount <= maxOrderAmount
}

/*
残高チェック
*/
func checkBalance(clientId string, brandId string, orderAmount int) bool {
	url := config["api:fund-api:"] + "/balance/" + clientId + "/" + brandId
	response, err := http.Get(url)
	if err != nil {
		return false
	}

	defer response.Body.Close()
	byteBody, err := ioutil.ReadAll(response.Body)
	if err != nil {
		return false
	}

	var structBody BalanceEntity
	if err := json.Unmarshal(byteBody, &structBody); err != nil {
		return false
	}

	if structBody[0].TodayTdOpenNominal >= orderAmount {
		return true
	}
	return false
}

/*
単価取得
*/
func getUnitPrice(brandId string) (int, bool) {
	url := config["api:fund-api:"] + "/fund/" + brandId + "/price/latest"
	response, err := http.Get(url)
	if err != nil {
		return 0, false
	}
	defer response.Body.Close()
	byteBody, err := ioutil.ReadAll(response.Body)
	if err != nil {
		return 0, false
	}
	var structBody UnitPrice
	if err := json.Unmarshal(byteBody, &structBody); err != nil {
		return 0, false
	}
	return structBody.LatestPrice.Buy.Price, true
}

/*
約定金額計算
*/
func calcContractPrice(orderType string, orderAmount int, unitPrice int) (int, bool) {
	switch orderType {
	case "sell-lot":
		return unitPrice * orderAmount, true
	case "sell-price":
		return orderAmount, true
	default:
		return 0, false
	}
}

/*
基準日、約定日、受渡日の取得
*/
func getDates(brandId string) (string, string, string, error) {
	url := config["api:fund-api:"] + "/fund/" + brandId + "/sales"
	res, err := http.Get(url)
	if err != nil {
		return "", "", "", err
	}
	defer res.Body.Close()
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return "", "", "", err
	}
	var dates Dates
	if err := json.Unmarshal(body, &dates); err != nil {
		return "", "", "", err
	}
	return dates.ApplyDt, dates.TradeDt, dates.ValueDt, nil
}

/*
注文登録
*/
func registerFundOrder(params Params) bool {
	now := time.Now()
	orderDate := now.Format("2006-01-02")
	orderTime := now.Format("15:04:05")
	input := &dynamodb.PutItemInput{
		Item: map[string]*dynamodb.AttributeValue{
			"order_id": {
				S: aws.String(params.OrderId),
			},
			"client_id": {
				S: aws.String(params.ClientId),
			},
			"brand_id": {
				S: aws.String(params.BrandId),
			},
			"order_type": {
				S: aws.String(params.OrderType),
			},
			"order_amount": {
				S: aws.String(strconv.Itoa(params.OrderAmount)),
			},
			"order_status": {
				S: aws.String(orderStatus),
			},
			"order_date": {
				S: aws.String(orderDate),
			},
			"order_time": {
				S: aws.String(orderTime),
			},
			"base_date": {
				S: aws.String(params.BaseDate),
			},
			"trade_date": {
				S: aws.String(params.TradeDate),
			},
			"value_date": {
				S: aws.String(params.ValueDate),
			},
			"unit_price": {
				S: aws.String(strconv.Itoa(params.UnitPrice)),
			},
			"contract_price": {
				S: aws.String(strconv.Itoa(params.ContractPrice)),
			},
			"update_date": {
				S: aws.String(orderDate),
			},
			"update_time": {
				S: aws.String(orderTime),
			},
		},
		TableName: aws.String("fund-order"),
	}
	_, err := dynamodb.New(session.New()).PutItem(input)
	if err != nil {
		return false
	}
	return true
}

約定

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io/ioutil"
	"net/http"
	"strconv"
	"strings"
	"time"

	"./util"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/sns"
)

const (
	// 投資区分
	GENERAL_TYPE     = 1 // 一般型
	ACCUMULATED_TYPE = 2 // 累投型
	// 口座区分
	ACC_TYPE_NOT_SET  = 0 // 未設定
	ACC_TYPE_SPECIAL  = 1 // 特定口座
	ACC_TYPE_GENERAL  = 2 // 一般口座
	ACC_TYPE_TAX_FREE = 3 // 非課税口座
	// 代用差入先区分
	GUARANTEE_TYPE_NOT_SET = 0 // 未設定
	GUARANTEE_TYPE_NORMAL  = 1 // 普通預り
	GUARANTEE_TYPE_MARGIN  = 2 // 信用保証金代用
	GUARANTEE_TYPE_DEPOSIT = 4 // 証拠金代用
	// 取引区分
	TRADE_TYPE_SELL   = 1 // 解約
	TRADE_TYPE_BUY    = 2 // 購入
	TRADE_TYPE_CANCEL = 3 // 取消
)

type Request struct {
	BrandIds  []string `json:"brand_ids"`
	TradeDate string   `json:"trade_date"`
}

type Response struct {
	Result string `json:"result"`
}

type Order struct {
	OrderId        string `dynamodbav:"order_id"`
	ClientId       string `dynamodbav:"client_id"`
	BrandId        string `dynamodbav:"brand_id"`
	OrderType      string `dynamodbav:"order_type"`
	OrderAmount    int    `dynamodbav:"order_amount"`
	OrderStatus    string `dynamodbav:"order_status"`
	UnitPrice      int    `dynamodbav:"unit_price"`
	ExecutionPrice int    `dynamodbav:"execution_price"`
	CreateDatetime string `dynamodbav:"create_datetime"`
	UpdateDatetime string `dynamodbav:"update_datetime"`
}

type UnitPrice struct {
	DscrCd      string `json:"dscrCd"`
	LatestPrice struct {
		StdDt string `json:"stdDt"`
		Sell  struct {
			Price int `json:"price"`
			Diff  int `json:"diff"`
		} `json:"sell"`
		Buy struct {
			Price int `json:"price"`
			Diff  int `json:"diff"`
		} `json:"buy"`
	} `json:"latestPrice"`
}

type UnitPricePair struct {
	BuyPrice  int `json:"buy_price"`
	SellPrice int `json:"sell_price"`
}

type BalanceParameter struct {
	AccountID int `json:"accountId"`
	// BranchCd          int    `json:"branchCd"`
	// ClientCd          int    `json:"clientCd"`
	DscrCd            string `json:"dscrCd"`
	GeneralAccmInvKbn int    `json:"generalAccmInvKbn"`
	SpAccKbn          int    `json:"spAccKbn"`
	SubGuarantyKbn    int    `json:"subGuarantyKbn"`
	TradeTypeCd       int    `json:"tradeTypeCd"`
	Nominal           int    `json:"nominal"`
	TradeDt           string `json:"tradeDt"`
	NameDt            string `json:"nameDt"`
	ExecutionPrice    int    `json:"executionPrice"`
}

type ContactOrderParameter struct {
	BrandIds    []string `json:"brand_ids"`
	ContactType string   `json:"contact_type"`
}

type LedgerUpdateParameter struct {
	UID       string `json:"uid"`
	Product   string `json:"product"`
	AccountId string `json:"accountId"`
	ValueDt   string `json:"valueDt"`
	Deposit   int    `json:"deposit"`
	Sell      bool   `json:"sell"`
}

var config map[string]string

func init() {
	config = util.GetConfig()
}

func main() {
	lambda.Start(LambdaHandler)
}

func LambdaHandler(ctx context.Context, req Request) (Response, error) {
	return execute(req)
}

func execute(req Request) (Response, error) {
	// 単価取得
	brandId2UnitPrice, err := getUnitPrices(req.BrandIds)
	if err != nil {
		return Response{Result: "NG"}, err
	}

	// 約定処理+残高更新+客勘更新
	err = contractOrder(req.BrandIds, req.TradeDate, brandId2UnitPrice)
	if err != nil {
		return Response{Result: "NG"}, err
	}

	return Response{Result: "OK"}, nil
}

/*
単価取得
*/
func getUnitPrices(brandIds []string) (map[string]UnitPricePair, error) {
	brandId2UnitPrice := make(map[string]UnitPricePair)
	var unitPrices []UnitPrice
	for _, brandId := range brandIds {
		url := config["api:fund-api:"] + "/fund/" + brandId + "/price/latest"
		method := "GET"

		client := &http.Client{}
		req, err := http.NewRequest(method, url, nil)
		if err != nil {
			return brandId2UnitPrice, err
		}
		req.Header.Add("Origin", "null")
		req.Header.Add("Content-Type", "application/json")
		res, err := client.Do(req)
		if err != nil {
			return brandId2UnitPrice, err
		}
		var unitPrice UnitPrice
		body, err := ioutil.ReadAll(res.Body)
		if err != nil {
			return brandId2UnitPrice, err
		}
		if err := json.Unmarshal(body, &unitPrice); err != nil {
			return brandId2UnitPrice, err
		}
		unitPrices = append(unitPrices, unitPrice)
	}
	for _, unitPrice := range unitPrices {
		brandId2UnitPrice[unitPrice.DscrCd] = UnitPricePair{unitPrice.LatestPrice.Buy.Price, unitPrice.LatestPrice.Sell.Price}
	}
	return brandId2UnitPrice, nil
}

/*
約定処理+残高更新+客勘更新
*/
func contractOrder(brandIds []string, tradeDate string, brandId2UnitPrice map[string]UnitPricePair) error {
	for _, brandId := range brandIds {
		// 約定対象のアイテム抽出
		results, err := dynamodb.New(session.New()).Scan(&dynamodb.ScanInput{
			TableName: aws.String("fund-order"),
			ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
				":brand_id": {
					S: aws.String(brandId),
				},
				":trade_date": {
					S: aws.String(tradeDate),
				},
				":order_status": {
					S: aws.String("ordered"),
				},
			},
			FilterExpression: aws.String("brand_id = :brand_id AND trade_date = :trade_date AND order_status = :order_status"),
		})
		if err != nil {
			return err
		}
		for _, item := range results.Items {
			orderAmount, err := strconv.Atoi(*item["order_amount"].S)
			if err != nil {
				return err
			}
			// 単価
			var unitPrice int
			if strings.Contains(*item["order_type"].S, "buy") {
				unitPrice = brandId2UnitPrice[brandId].BuyPrice
			} else if strings.Contains(*item["order_type"].S, "sell") {
				unitPrice = brandId2UnitPrice[brandId].SellPrice
			}
			// 約定金額と数量
			var contractPrice, nominal int
			// 金額指定の場合
			if strings.Contains(*item["order_type"].S, "price") {
				contractPrice = orderAmount
				nominal = orderAmount * 10000 / unitPrice
			// 口数指定の場合
			} else {
				contractPrice = unitPrice * orderAmount
				nominal = orderAmount * 10000
			}

			now := time.Now()
			date := now.Format("2006-01-02")
			time := now.Format("15:04:05")

			// 注文ステータスの更新および単価と約定金額の追加
			_, err = dynamodb.New(session.New()).PutItem(&dynamodb.PutItemInput{
				TableName: aws.String("fund-order"),
				Item: map[string]*dynamodb.AttributeValue{
					"order_id": {
						S: aws.String(*item["order_id"].S),
					},
					"client_id": {
						S: aws.String(*item["client_id"].S),
					},
					"brand_id": {
						S: aws.String(*item["brand_id"].S),
					},
					"order_type": {
						S: aws.String(*item["order_type"].S),
					},
					"order_amount": {
						S: aws.String(*item["order_amount"].S),
					},
					"order_status": {
						S: aws.String("contracted"),
					},
					"order_date": {
						S: aws.String(*item["order_date"].S),
					},
					"order_time": {
						S: aws.String(*item["order_time"].S),
					},
					"base_date": {
						S: aws.String(*item["base_date"].S),
					},
					"trade_date": {
						S: aws.String(*item["trade_date"].S),
					},
					"value_date": {
						S: aws.String(*item["value_date"].S),
					},
					"update_date": {
						S: aws.String(date),
					},
					"update_time": {
						S: aws.String(time),
					},
					"unit_price": {
						S: aws.String(strconv.Itoa(unitPrice)),
					},
					"contract_price": {
						S: aws.String(strconv.Itoa(contractPrice)),
					},
				},
			})
			if err != nil {
				return err
			}

			// 残高更新
			if !updateBalance(item, contractPrice, nominal) {
				return errors.New("failed at updateBalance")
			}

			// 客勘更新
			if !updateLedger(item, contractPrice) {
				return errors.New("failed at updateLedger")
			}
		}
	}
	return nil
}

/*
残高更新
*/
func updateBalance(item map[string]*dynamodb.AttributeValue, contractPrice int, nominal int) bool {
	// データの設定
	url := config["api:fund-api:"] + "/balance"
	method := "POST"

	var tradeType int
	if strings.Contains(*item["order_type"].S, "buy") {
		tradeType = TRADE_TYPE_BUY
	} else if strings.Contains(*item["order_type"].S, "sell") {
		tradeType = TRADE_TYPE_SELL
	}

	accountId, err := strconv.Atoi(*item["client_id"].S)
	if err != nil {
		return false
	}
	data, err := json.Marshal(BalanceParameter{
		AccountID:         accountId,
		DscrCd:            *item["brand_id"].S,
		GeneralAccmInvKbn: ACCUMULATED_TYPE,
		SpAccKbn:          ACC_TYPE_SPECIAL,
		SubGuarantyKbn:    GUARANTEE_TYPE_NORMAL,
		TradeTypeCd:       tradeType,
		Nominal:           nominal,
		NameDt:            *item["value_date"].S,
		TradeDt:           *item["trade_date"].S,
		ExecutionPrice:    contractPrice,
	})

	// HTTPリクエスト
	client := &http.Client{}

	req, err := http.NewRequest(method, url, bytes.NewBuffer(data))
	if err != nil {
		return false
	}

	req.Header.Add("Origin", "null")
	req.Header.Add("Content-Type", "application/json")
	_, err = client.Do(req)
	if err != nil {
		return false
	}

	return true
}

/*
客勘更新(客勘=顧客勘定、元帳とも呼ばれる)
*/
func updateLedger(item map[string]*dynamodb.AttributeValue, contractPrice int) bool {
	var tradeType bool
	if strings.Contains(*item["order_type"].S, "buy") {
		tradeType = false
	} else if strings.Contains(*item["order_type"].S, "sell") {
		tradeType = true
	}

	sess, err := session.NewSession(&aws.Config{
		Region: aws.String(config["aws:region:"]),
	})
	if err != nil {
		return false
	}
	svc := sns.New(sess)
	message, err := json.Marshal(LedgerUpdateParameter{
		UID:       *item["order_id"].S,
		Product:   "fund",
		AccountId: *item["client_id"].S,
		ValueDt:   *item["value_date"].S,
		Deposit:   contractPrice,
		Sell:      tradeType,
	})
	if err != nil {
		return false
	}
	input := &sns.PublishInput{
		Message:  aws.String(string(message)),
		TopicArn: aws.String(config["topic:ledger-update:"]),
	}
	_, err = svc.Publish(input)
	if err != nil {
		return false
	}
	return true
}

購入と解約はほぼ同じで、相違点は購入では余力チェック(買付可能かどうかのチェック)、解約では残高チェックがあるといったところです。約定では購入の場合はお金を減らして残高を増やし、逆に解約ではお金を増やして残高を減らすといったことをします。

ビルドおよびデプロイなど

Lambdaの関数ごとにディレクトリを分けていて、各ディレクトリにはbuild.sh、deploy.sh、run.shの3つを置きました。

build.sh

#!/bin/bash
goimports -w main.go
go fmt
GOOS=linux go build main.go
  • goimportsはソースコードの内容に応じてimport文を追加、削除、整列してくれるツールです。goimportsを使えば人間はimport文について何も考えなくて済みます(ちなみにですがGoでは使われていないパッケージまでimportしているとコンパイル時にエラーになります)。go getで簡単にインストールして使えます。
  • go fmtはソースコードをフォーマット(整形)してくれるツールです。これはgoコマンドにある機能で、goをインストールすれば使えます。
  • AWS LambdaのランタイムOSはプログラミング言語とそのバージョンの組み合わせによって異なりますが、Amazon LinuxまたはAmazon Linux 2のどちらかなのでLinux用にビルドする必要があり、そのためにはシェル変数GOOSの値をlinuxにしてビルドする必要があります。

deploy.sh

#!/bin/bash
zip -rq func.zip .
aws --profile fund lambda update-function-code --function-name buy-fund --zip-file fileb://func.zip
rm func.zip
rm main

デプロイはzipファイルにまとめてLambdaの関数にアップロードするだけです。更新するドメインのアカウントに応じてプロファイル名、Lambda関数に応じてLambda関数名をそれぞれ指定する感じです。

run.sh

#!/bin/bash
./build.sh && ./deploy.sh

run.shを実行するとbuild.shとdeploy.shを実行する仕組みです。build.shが成功したときだけdeploy.shを実行するようにしています。こうすることでビルドが失敗したものをアップロードするということがなくなります。テストについて今回はAWSの画面上で手動テストしていたのですが、ロジックの単体テストを書いて、ビルドとデプロイの間でテスト(単体テスト)をするようにするとなお良いですよね〜。以下、理想。

#!/bin/bash
./build.sh && ./test.sh && ./deploy.sh

まとめ

今回はAPI Gateway+Lambda(Go)+DynamoDBでの開発例を紹介しました。Goの魅力のひとつとして「プログラムの実行速度が高速」などが有名ですが、それ以外にもGoコマンドやGoによるCLIツールが便利だということを知ってもらえたら幸いです。それではまた。