MariaDB(MySQL)から取り出した株価からテクニカル指標を計算してチャートをJupyter NotebookとExcel両方で描いてみた。

システムトレードプログラムの作り方の参考になるだろうと、Rubyではじめる本を買って読んだ。全体の半分ほどはスクレイピングでした。 自分もここで進捗50%まできたと思う。

まあ、システムトレードプログラムはExcelで充分なんだけどね、linuxサーバ上で動かせない問題があるけれど。 少し進めてGITHUBにあげておきました。

データーベース(MariaDB)投入

ローカルのCSVファイルで大量の情報を扱うのはつらいからね、データーベースが必要だろうと。 この時はDatabase.MySQLを使ったけれども、今回はDatabase.Persistを使います。(マイグレーション機能が便利なんで)

データーベースのモデルを決定

前に作った変なテーブルはすべて削除して、このように定義した。

Model.hs

DB.share [DB.mkPersist DB.sqlSettings, DB.mkMigrate "migrateQuotes"] [DB.persistLowerCase|
-- | 株式銘柄テーブル
Portfolio
    ticker      TickerSymbol        -- ^ ティッカー
    caption     Text Maybe          -- ^ 銘柄名
    updateAt    UTCTime Maybe       -- ^ 価格情報を取り込んだ日付時間
    deriving Show

-- | 初値, 高値, 安値, 終値, 出来高, 売買代金テーブル
Ohlcvt
    ticker      TickerSymbol        -- ^ ティッカー
    tf          TimeFrame           -- ^ 時間枠
    at          UTCTime             -- ^ 日付時間
    open        Double Maybe        -- ^ 初値
    high        Double Maybe        -- ^ 高値
    low         Double Maybe        -- ^ 安値
    close       Double Maybe        -- ^ 終値
    volume      Double Maybe        -- ^ 出来高
    turnover    Double Maybe        -- ^ 売買代金
    source      Text Maybe          -- ^ 情報の入手元
    deriving Show

-- | テクニカル指標テーブル
TechInds
    ohlcvt      OhlcvtId        -- ^ 紐付け
    ind         TechnicalInds   -- ^ テクニカル指標
    val         Double          -- ^ 値
    deriving Show
|]

こうしておくと勝手にportfolio, ohlcvt, tech_indsの3テーブルが作られる。

それぞれティッカーはここで。 時間枠はここで。 テクニカル指標はここで定義した。 見てのとおりデータベースにある時間はUTC時間です、日本時間ではありません。

集計処理

コードの正確性の確認はしていませんので、あしからずご了承くださいませ。

収集処理で取ってきているのは1時間足(初回なら日足もついでに)なので、それを集計して日足を作り出す処理を作りました。

aggregateOfOHLCVT - Aggregate.hs

-- | 始値, 高値, 安値, 終値, 出来高, 売買代金を集計する関数
aggregateOfOHLCVT   :: (Ohlcvt -> Maybe Ohlcvt) -- ^ 値の判定関数
                    -> [Ohlcvt]                 -- ^ 時系列通りで与えること
                    -> Maybe Ohlcvt
aggregateOfOHLCVT _ [] = Nothing                -- 空リストの結果は未定義
aggregateOfOHLCVT decide serials@(first:_) =
    decide -- 集計結果が有効か無効かの判定をゆだねる。
        Ohlcvt
            { ohlcvtTicker      = ohlcvtTicker first
            , ohlcvtTf          = ohlcvtTf first
            , ohlcvtAt          = ohlcvtAt first
            -- 6本値は欠損値を許容する
            , ohlcvtOpen        = open  $ Mb.mapMaybe ohlcvtOpen serials
            , ohlcvtHigh        = high  $ Mb.mapMaybe ohlcvtHigh serials
            , ohlcvtLow         = low   $ Mb.mapMaybe ohlcvtLow serials
            , ohlcvtClose       = close $ Mb.mapMaybe ohlcvtClose serials
            , ohlcvtVolume      = vtSum $ Mb.mapMaybe ohlcvtVolume serials
            , ohlcvtTurnover    = vtSum $ Mb.mapMaybe ohlcvtTurnover serials
            -- 
            , ohlcvtSource      = T.append "agg from " <$> ohlcvtSource first
            }
    where
    -- | 集計期間中の初値
    open :: [Double] -> Maybe Double
    open = Safe.headMay
    -- | 集計期間中の最高値
    high :: [Double] -> Maybe Double
    high = Safe.maximumMay
    -- | 集計期間中の最安値
    low :: [Double] -> Maybe Double
    low = Safe.minimumMay
    -- | 集計期間中の終値
    close :: [Double] -> Maybe Double
    close = Safe.headMay . reverse
    -- | 集計期間中の総和
    vtSum :: [Double] -> Maybe Double
    vtSum [] = Nothing
    vtSum xs = Just $ sum xs
  • 初値(open)の定義
    リストの先頭
  • 高値(high)の定義
    全要素の最高値
  • 安値(low)の定義
    全要素の最安値
  • 終値(close)の定義
    リストの最終
  • 出来高(volume)及び売買代金(turnover)の定義
    全要素の和

そのままの定義を書いています。

テクニカル指標を作る

コードの正確性の確認はしていませんので、あしからずご了承くださいませ。

portfolioテーブルにある銘柄を対象にテクニカル指標を計算する処理を作りました。

runAggregateOfPortfolios - Aggregate.hs

-- | Portfolio上の情報を集計する関数
runAggregateOfPortfolios :: M.MonadIO m => Conf.Info -> C.Source m TL.Text
runAggregateOfPortfolios conf = do
    -- 処理対象銘柄の数
    counts <- M.liftIO . runStderrLoggingT . MR.runResourceT . MySQL.withMySQLConn connInfo . MySQL.runSqlConn $ do
        DB.runMigration migrateQuotes
        DB.count ([] :: [DB.Filter Portfolio])

    C.yield . TB.toLazyText $
        case counts of
        0 -> "集計処理の対象銘柄がありません。"
        x -> "集計処理の対象銘柄は全部で" <> TB.decimal x <> "個有ります。"

    -- 処理対象銘柄のリストを得る
    ps  <- M.liftIO . runStderrLoggingT . MR.runResourceT . MySQL.withMySQLConn connInfo . MySQL.runSqlConn $ do
        DB.runMigration migrateQuotes
        map DB.entityVal <$> DB.selectList [] [DB.Asc PortfolioId]
    -- 銘柄毎に集計処理
    M.forM_ (zip ps [1..]) $ \(val, number) -> do
        let ticker = portfolioTicker val
        let textCaption = Mb.fromMaybe (T.pack . show $ portfolioTicker val) $ portfolioCaption val
        let progress= TB.singleton '[' <> TB.decimal (number :: Int)
        <> TB.singleton '/' <> TB.decimal counts
        <> TB.singleton ']'
        C.yield . TB.toLazyText $ TB.fromText textCaption <> " を集計中。" <> progress
        M.liftIO $ do
    aggregate connInfo ticker
    --
    let calc = calculate connInfo ticker
    --
    calc TF1d (TISMA 5)
    calc TF1d (TISMA 10)
    calc TF1d (TISMA 25)
    calc TF1d (TISMA 75)
    --
    calc TF1d (TIEMA 5)
    calc TF1d (TIEMA 10)
    calc TF1d (TIEMA 25)
    calc TF1d (TIEMA 75)
    --
    calc TF1d (TIRSI 9)
    calc TF1d (TIRSI 14)
    --
    calc TF1d (TIMACD 12 26)
    calc TF1d (TIMACDSIG 12 26 9)
    --
    calc TF1d (TIPSYCHOLO 12)

    C.yield "集計処理を終了します。"
    where
    --
    connInfo :: MySQL.ConnectInfo
    connInfo =
    let mdb = Conf.mariaDB conf in
    MySQL.defaultConnectInfo
    { MySQL.connectHost = Conf.host mdb
    , MySQL.connectPort = Conf.port mdb
    , MySQL.connectUser = Conf.user mdb
    , MySQL.connectPassword = Conf.password mdb
    , MySQL.connectDatabase = Conf.database mdb
    }

上の関数から呼ばれる、この関数がテクニカル指標を作る関数を呼び出す。(今回は全て終値を使って計算する)

runAggregateOfPortfolios - Aggregate.hs

-- | 指定の指標を計算する
calcIndicator :: TechnicalInds -> [DB.Entity Ohlcvt] -> [(DB.Entity Ohlcvt, TechInds)]
calcIndicator indicator entities =
    case indicator of
    TISMA period                -> ohlcvtClose `apply` (TI.sma period)
    TIEMA period                -> ohlcvtClose `apply` (TI.ema period)
    TIRSI period                -> ohlcvtClose `apply` (TI.rsi period)
    TIMACD fastP slowP          -> ohlcvtClose `apply` (TI.macd fastP slowP)
    TIMACDSIG fastP slowP sigP  -> ohlcvtClose `apply` (TI.macdSignal fastP slowP sigP)
    TIPSYCHOLO period           -> ohlcvtClose `apply` (TI.psycologicalLine period)
    where
    apply :: (Ohlcvt -> Maybe Double) -> ([Double] -> [Maybe Double]) -> [(DB.Entity Ohlcvt, TechInds)]
    apply price formula =
        let entAndPrices = [(e,pr) | e<-entities, let (Just pr)=price $ DB.entityVal e] in
        let (es,ps) = unzip entAndPrices in
        let entAndValues = zip es $ formula ps in
        [(e, makeTI e v) | (e, Just v)<-entAndValues]
    -- 
    makeTI e v = TechInds
                    { techIndsOhlcvt    = DB.entityKey e
                    , techIndsInd       = indicator
                    , techIndsVal       = v
                    }

単純移動平均(SMA)

テクニカル分析ABC > 第3回 移動平均をよく読んでSMA関数を作る。

sma - TechnicalIndicators.hs

-- | 単純移動平均(SMA)
--   リストの先頭から計算する版
sma :: Int -> [Double] -> [Maybe Double]
sma period prices
    | period < 1 = error "期間は1以上でね"
    | length prices < period = replicate (length prices) Nothing
    | otherwise =
        let (seeds, ps) = splitAt period prices in
        let na = replicate (period-1) Nothing in
        let val = snd $ sma' period (reverse seeds) ps in
        na ++ map Just val

type SmaAccumlator = [Double]
-- | 単純移動平均(SMA)
--   初期のアキュムレータを入力と別々に渡して計算する版
sma'    :: Int
        -> SmaAccumlator                -- ^| 最新の値が先頭の並びで渡す事
        -> [Double]                     -- ^| 入力は時系列通りで渡す事
        -> (SmaAccumlator, [Double])    -- ^| 計算を打ち切った時のアキュムレータの値と結果のタプル
sma' period seeds prices
    | period < 1               = error "期間は1以上でね"
    | length seeds /= period   = error "アキュムレータの数と周期が一致していないよ"
    | otherwise =
        let (acc, xs) = List.mapAccumL go seeds prices in
        let lst = snd $ go acc 0.0 in  -- 最後にアキュムレータに残った値を押し出す
        ( acc
        , xs ++ [lst]
        )
    where
    go :: SmaAccumlator -> Double -> (SmaAccumlator, Double)
    go acc price =  ( price : init acc
                    , sum acc / realToFrac period
                    )

mapAccum関数によってアキュムレータを持ち回りながら
sum acc / realToFrac period
これを計算するということ。

サイコロジカル・ライン

テクニカル分析ABC > 第7回 サイコロジカル・ラインをよく読んでサイコロジカル・ライン関数を作る。

psycologicalLine - TechnicalIndicators.hs

-- | サイコロジカルライン
--   リストの先頭から計算する版
psycologicalLine :: Int -> [Double] -> [Maybe Double]
psycologicalLine period prices
    | period < 1 = error "期間は1以上でね"
    | length prices <= period = replicate (length prices) Nothing
    | otherwise =
        let (seeds, ps) = splitAt (period+1) prices in
        let diffs = zipWith (-) (drop 1 seeds) seeds in
        let seed = PsycoloAccumlator
                    { psycoloDiffs      = reverse diffs
                    , psycoloLastPrice  = Safe.lastNote "たぶんこの例外は発生しないかな" seeds
                    }
        in
        let val = snd $ psycologicalLine' period seed ps in
        let na = replicate period Nothing in
        na ++ map Just val

data PsycoloAccumlator = PsycoloAccumlator
    { psycoloDiffs      :: [Double]
    , psycoloLastPrice  :: Double
    } deriving (Show, Eq, Ord)
-- | サイコロジカルライン
--   初期のアキュムレータを入力と別々に渡して計算する版
psycologicalLine'   :: Int
                    -> PsycoloAccumlator                -- ^| 最新の値が先頭の並びで渡す事
                    -> [Double]                         -- ^| 入力は時系列通りで渡す事
                    -> (PsycoloAccumlator, [Double])    -- ^| 計算を打ち切った時のアキュムレータの値と結果のタプル
psycologicalLine' period seed prices
    | period < 1 = error "期間は1以上でね"
    | otherwise =
        let (acc, xs) = List.mapAccumL go seed prices in
        let lst = snd $ go acc 0.0 in  -- 最後にアキュムレータに残った値を押し出す
        ( acc
        , xs ++ [lst]
        )
    where
    -- 
    go :: PsycoloAccumlator -> Double -> (PsycoloAccumlator, Double)
    go (PsycoloAccumlator diffs lastPrice) price =
        let t = PsycoloAccumlator
                    { psycoloDiffs      = (price - lastPrice) : init diffs
                    , psycoloLastPrice  = price
                    }
        in
        ( t
        , sum [1 | x<-diffs, x>=0] / realToFrac period * 100.0
        )

サイコロジカル・ライン= 12日間の内上昇した日 ÷ 12 × 100(%)

テクニカル分析ABC > 第7回 サイコロジカル・ライン

mapAccum関数によってアキュムレータを持ち回りながら
sum [1 | x<-diffs, x>=0] / realToFrac period * 100.0
これを計算するということ。

[1 | x<-diffs, x>=0] これは上がった日は1、下がった日はリストから除かれる(つまり後のsum計算に入らない)という意味。

RSI(Relative Strength Index)

テクニカル分析ABC > 第8回 RSI(Relative Strength Index)をよく読んでRSI関数を作る。

rsi - TechnicalIndicators.hs

-- | 相対力指数(RSI)
--   リストの先頭から計算する版
rsi :: Int -> [Double] -> [Maybe Double]
rsi period prices
    | period < 1 = error "期間は1以上でね"
    | length prices <= period = replicate (length prices) Nothing
    | otherwise =
        let (seeds, ps) = splitAt (period+1) prices in
        let diffs = zipWith (-) (drop 1 seeds) seeds in
        let seed = RsiAccumlator
                    { rsiAscend     = sum [abs v | v<-diffs, v >= 0] / realToFrac period
                    , rsiDescend    = sum [abs v | v<-diffs, v < 0] / realToFrac period
                    , rsiLastPrice  = Safe.lastNote "たぶんこの例外は発生しないかな" seeds
                    }
        in
        let val = snd $ rsi' period seed ps in
        let na = replicate period Nothing in
        na ++ map Just val

data RsiAccumlator = RsiAccumlator
    { rsiAscend     :: Double
    , rsiDescend    :: Double
    , rsiLastPrice  :: Double
    } deriving (Show, Eq, Ord)
-- | 相対力指数(RSI)
--   初期のアキュムレータを入力と別々に渡して計算する版
rsi'    :: Int
        -> RsiAccumlator
        -> [Double]
        -> (RsiAccumlator, [Double])    -- ^| 計算を打ち切った時のアキュムレータの値と結果のタプル
rsi' period seed prices
    | period < 1 = error "期間は1以上でね"
    | otherwise =
        let (acc, xs) = List.mapAccumL go seed prices in
        let lst = snd $ go acc 0.0 in  -- 最後にアキュムレータに残った値を押し出す
        ( acc
        , xs ++ [lst]
        )
    where
    -- 
    go :: RsiAccumlator -> Double -> (RsiAccumlator, Double)
    go acc price =
        let a = rsiAscend acc * realToFrac (period - 1) in
        let d = rsiDescend acc * realToFrac (period - 1) in
        let t = case price - rsiLastPrice acc of
                x | x >= 0      -> RsiAccumlator
                                    { rsiAscend     = (a + abs x) / realToFrac period
                                    , rsiDescend    = d / realToFrac period
                                    , rsiLastPrice  = price
                                    }
                  | otherwise   -> RsiAccumlator
                                    { rsiAscend     = a / realToFrac period
                                    , rsiDescend    = (d + abs x) / realToFrac period
                                    , rsiLastPrice  = price
                                    }
        in
        ( t
        , rsiAscend acc / (rsiAscend acc + rsiDescend acc) * 100.0
        )

最初に14日間RSIを求める式(公式1)

AA+B×100A: 14日間の値上がり幅の平均B: 14日間の値下がり幅の平均\frac{A}{A+B} \times 100 \\\\[1em] \text{A: 14日間の値上がり幅の平均} \\\\[1em] \text{B: 14日間の値下がり幅の平均}

2日目以降の14日間RSIを求める式(公式2)

AA+B×100A’: 14日間の値上がり幅の平均B’: 14日間の値下がり幅の平均\frac{A'}{A'+B'} \times 100 \\\\[1em] \text{A': 14日間の値上がり幅の平均} \\\\[1em] \text{B': 14日間の値下がり幅の平均}

テクニカル分析ABC > 第8回 RSI(Relative Strength Index)

mapAccum関数によってアキュムレータを持ち回りながら
rsiAscend acc / (rsiAscend acc + rsiDescend acc) * 100.0
これを計算するということ。

最初は(公式1)

rsiAscend     = sum [abs v | v<-diffs, v >= 0] / realToFrac period
rsiDescend    = sum [abs v | v<-diffs, v < 0] / realToFrac period  

次回からは(公式2)

        let t = case price - rsiLastPrice acc of
                x | x >= 0      -> RsiAccumlator
                                    { rsiAscend     = (a + abs x) / realToFrac period
                                    , rsiDescend    = d / realToFrac period
                                    , rsiLastPrice  = price
                                    }
                  | otherwise   -> RsiAccumlator
                                    { rsiAscend     = a / realToFrac period
                                    , rsiDescend    = (d + abs x) / realToFrac period
                                    , rsiLastPrice  = price
                                    }
% stack ghci
*Main Aggregate Conf DataBase Lib Model Scraper SinkSlack StockQuotesCrawler TechnicalIndicators TickerSymbol TimeFrame WebBot> TechnicalIndicators.rsi 14 [1000, 1020, 1010, 1030, 1040, 1050, 1080, 1070, 1050, 1090, 1100, 1120, 1110, 1120, 1100, 1080]
[Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Nothing,Just 70.83333333333334,Just 65.0]

70.83, 65.0, 合っているんじゃないかな。

MACD(Moving Average Convergence and Divergence)

テクニカル分析ABC > 第13回 MACD(Moving Average Convergence and Divergence)をよく読んでMACD関数を作る。 まず、MACD関数の前に指数平滑移動平均(EMA)関数を用意する。

ema - TechnicalIndicators.hs

-- | 指数平滑移動平均(EMA)
--   リストの先頭から計算する版
ema :: Int -> [Double] -> [Maybe Double]
ema period prices
    | period < 1 = error "期間は1以上でね"
    | length prices < period = replicate (length prices) Nothing
    | otherwise =
        let (seeds, ps) = splitAt period prices in
        let seed = sum seeds / realToFrac period in
        let na = replicate (period-1) Nothing in
        let val = ema' period seed ps in
        na ++ map Just val

-- | 指数平滑移動平均(EMA)
--   種を入力と別々に渡して計算する版
ema' :: Int -> Double -> [Double] -> [Double]
ema' period seed prices
    | period < 1 = error "期間は1以上でね"
    | otherwise =
        scanl formula seed prices
    where
    -- 
    alpha = 2.0 / realToFrac (period + 1)
    -- 
    formula :: Double -> Double -> Double
    formula yesterdayEMA todayPrice =
        yesterdayEMA + alpha * (todayPrice - yesterdayEMA)

EMAを求める式は以下の通り。
EMA = 前日のEMA × (1 - α) + 当日の指数 × α
または
EMA = 前日のEMA + α × (当日の指数 − 前日のEMA)

α=2n+1α:平滑定数n:平滑期間\alpha = \frac{2}{n+1} \\\\[1em] \alpha: \text{平滑定数} \\\\[1em] n: \text{平滑期間}

テクニカル分析ABC > 第13回 MACD(Moving Average Convergence and Divergence)

移動平均(MA)といいつつこれは移動平均ではないので、scan関数によって

    -- 
    alpha = 2.0 / realToFrac (period + 1)
    -- 
    formula :: Double -> Double -> Double
    formula yesterdayEMA todayPrice =
        yesterdayEMA + alpha * (todayPrice - yesterdayEMA)

これを計算するということ。

短期のEMAと長期のEMAを求めた後にMACDは以下の式で求めます。

MACD = 短期EMA - 長期EMA

テクニカル分析ABC > 第13回 MACD(Moving Average Convergence and Divergence)

macd - TechnicalIndicators.hs

-- | MACD
--   リストの先頭から計算する版
macd :: Int -> Int -> [Double] -> [Maybe Double]
macd fastPeriod slowPeriod prices =
    zipWith (M.liftM2 formula) fast slow
    where
    fast = ema fastPeriod prices
    slow = ema slowPeriod prices
    formula f s = f - s
  • fast
    短期EMA
  • slow
    長期EMA
  • formula
    短期EMA - 長期EMA

こんな関数を書いていると、Haskellの関数っていう物は(数学の)関数なんだなと思う。

MACDシグナル(MACDの単純移動平均)

macdSignal - TechnicalIndicators.hs

-- | MACD signal
--   リストの先頭から計算する版
macdSignal :: Int -> Int -> Int -> [Double] -> [Maybe Double]
macdSignal fastPeriod slowPeriod signalPeriod prices =
    let (na, mbvs) = span Mb.isNothing thisMACD in
    let vs = map (Safe.fromJustNote "SMAの定義により、中間にNothingは無いはず") mbvs in
    na ++ sma signalPeriod vs
    where
    thisMACD = macd fastPeriod slowPeriod prices

上で定義したMACDとSMAを使って定義した。

夕方のバッチ処理

立ち会いが終了して、1時間足が配信されてから収集して集計してテクニカル指標を作る夕方のバッチ処理を用意した。
本来はこの時間に開始するようになっているけれども、利便性のために強制的に外部からバッチ処理エンジンを駆動できるようにした。

使い方

githubからダウンロードして conf.jsonとかの名前で設定ファイルを用意する。(*の所は自分の物を入れておく)

{
    "recordAssetsInterval"  : 10,
    "sendReportInterval"    : 10,
    "loginURL"              : "https://www.deal.matsui.co.jp/ITS/login/MemberLogin.jsp",
    "loginID"               : "********",
    "loginPassword"         : "********",
    "dealingsPassword"      : "*******",
    "userAgent"             : "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0",
    "slack" : {
        "webHookURL"    : "*****************************************************************************",
        "channel"       : "#*******",
        "userName"      : "********"
    },
    "mariaDB" : {
        "host"      : "localhost",
        "port"      : 3306,
        "user"      : "********",
        "password"  : "********",
        "database"  : "stockdb"
    }
}

それからシェルで

% stack build
% stack install
% tractor --batch

—batch付きで強制的に外部からバッチ処理エンジンを駆動する。(立ち会い時間中に動かすと、今日の確定していない株価がデーターベースに入るから注意)
初回起動時はこれでテーブルを作って終了する。(mariaDB上のデーターベースはすでにある物とする。ここでは”stockdb”が有る物とする)
テーブルが出来たら”portfolio”テーブルのtickerに、例えば”TSNI225”とかを入れる。これは日経平均株価のティッカーとここで定義した。他はNULL値でOK

% tractor --batch

とすると今度は収集と集計とテクニカル指標を作る作業を開始するので終わるまで待つとデーターベースに結果が入る。

mariaDBサーバーにアクセスして結果を取り出す

Rlogin のポートフォワード設定

サーバーのport3306(MySQL / MariaDB)は開けてないので、Rloginでポートフォワードして接続します。

HeidiSQLの設定

mariaDBクライアントはHeidiSQLをつかっているので、こう設定してRloginでフォワードしたローカルのポートごくろうさんにつなげばリモート側の3306とつながる。

OHLCVTテーブル テクニカル指標テーブル portfolioテーブル