AWKを使ってテキストからjsonを生成する実用例 〜 株価日足編


2020/06/11

AWKを使った実践例を思いついた時に紹介するコーナーです。

今回は、株式チャートで利用される日足のテキスト生データをJSON形式へパースさせてみる手順を模索しました。


実験用のテキスト

今回はコンマやスペース切りされていない未整形のテキストからjsonファイルに落とし込む手順を模索しました。

たとえば以下のように縦一列に、
日付、始値、高値、低値、終値、売買高の順に並んでいる日経平均株価の日足データセットを試します。

なお空白行を1つのデータセットの区切りとして利用します。

            
            2020-04-22
19109.18
19137.95
18858.25
19137.95
1247290000

2020-04-21
19479.83
19529.06
19193.22
19280.78
1280090000

2020-04-20
19689.85
19784.38
19611.79
19669.12
1065420000

2020-04-17
19575.85
19922.07
19554.70
19897.26
1409050000

2020-04-16
19311.30
19362.17
19154.41
19290.20
1298590000
        
とりあえず5日分の日足データを用意しておきます。

これをテキストファイルとして適当なフォルダへ
input.datという名前で保存します。

            
            $ cat << EOF > input.dat

2020-04-22
19109.18
19137.95
18858.25
19137.95
1247290000

2020-04-21
19479.83
19529.06
19193.22
19280.78
1280090000

2020-04-20
19689.85
19784.38
19611.79
19669.12
1065420000

2020-04-17
19575.85
19922.07
19554.70
19897.26
1409050000

2020-04-16
19311.30
19362.17
19154.41
19290.20
1298590000

EOF
        

目標のjsonファイル

先んじて結果からみてもらうと早いので、このテキストデータから抽出した目的のjsonファイルは、

            
            [
  {
    "date": "2020-04-22",
    "open": 19109.18,
    "high": 19137.95,
    "low": 18858.25,
    "close": 19137.95,
    "volume": 1247290000
  },
  {
    "date": "2020-04-21",
    "open": 19479.83,
    "high": 19529.06,
    "low": 19193.22,
    "close": 19280.78,
    "volume": 1280090000
  },
  {
    "date": "2020-04-20",
    "open": 19689.85,
    "high": 19784.38,
    "low": 19611.79,
    "close": 19669.12,
    "volume": 1065420000
  },
  {
    "date": "2020-04-17",
    "open": 19575.85,
    "high": 19922.07,
    "low": 19554.7,
    "close": 19897.26,
    "volume": 1409050000
  },
  {
    "date": "2020-04-16",
    "open": 19311.3,
    "high": 19362.17,
    "low": 19154.41,
    "close": 19290.2,
    "volume": 1298590000
  }
]
        
とまぁこんな感じです。

結果だけみるとAWK一つでこんなパワフルな変換ができる...というのが中々すごいコマンドです。

それでは具体的なAWKを使ったスクリプトの具体的な実装を以下で考えていきましょう。


BEGINとEND

AWKも以前解説したSEDと同じように基本的に1行づつ読み出して処理を流していくように動作します。

SEDはストリームエディタであるのに対し、AWKは一種のスクリプト言語ですのでより複雑な処理ができる分、プログラミングをする必要があります。

AWKの処理の流れでの定石パターンとしてまず頭に入れておきたいのが、
BEGIN{...前処理...}{...テキストの中身の処理...}END{...後処理...}という形です。

まずはテキストの中身の処理はおいといて、テキストの前処理のスクリプトを記述する
BEGINと、後処理を担当するENDから作成したいと思います。

            
            $ cat input.dat | awk '
    BEGIN{
        conv_json = "[";
    }
    {
        print NR "行目" $0
    }
    END{
        conv_json = conv_json "]";
        print conv_json
    }
'
        
これを実行すると以下のような出力になります。

            
            1行目
2行目2020-04-22
3行目19109.18
4行目19137.95
5行目18858.25
6行目19137.95
7行目1247290000
8行目
9行目2020-04-21
10行目19479.83
11行目19529.06
12行目19193.22
13行目19280.78
14行目1280090000
15行目
16行目2020-04-20
17行目19689.85
18行目19784.38
19行目19611.79
20行目19669.12
21行目1065420000
22行目
23行目2020-04-17
24行目19575.85
25行目19922.07
26行目19554.70
27行目19897.26
28行目1409050000
29行目
30行目2020-04-16
31行目19311.30
32行目19362.17
33行目19154.41
34行目19290.20
35行目1298590000
36行目
[]
        
まだこのスクリプトはテキスト処理としては何もせずに、空の配列[]を処理の最後に吐き出しているだけです。

今回のスクリプトは最終的に配列でJSONファイルを出したいので、BEGINで結果の文字列を
[で初期化させ、テキストの中身の処理パートで日足のオブジェクトを作成、すべてのオブジェクトが詰め終わったら、ENDの後処理工程で]を追加して閉じる。

というようなスクリプトに仕上げる必要があります。

NRと$0

上でさらっと使いましたがAWKで最もよく使うスクリプト変数がいくつかあります。

NRは行数を示す変数です。

行のカウントは1から始まります。

また
$0という変数は、現在の行を読み込んだときの行そのものを丸ごと返します。

ちなみに現在の行を読み込んだときのn列目の要素を操作したい場合には、
$1, $2, ..., $n, ...という変数を用います。

個別の列要素のアクセスには1から始まるので、全体を返す意味の0と区別して、使うときには注意したいところです。

今回は各行で1つの列要素しかないので
$0以外は利用しません。

csvのようなコンマ切りのファイルでは
$1, $2, ...を利用します。


テキスト部分の処理

それではテキストの中身から日足をJSONオブジェクト形式の文字列に変換していくスクリプトを追加します。

            
            $ cat input.dat | awk '
    BEGIN{
        conv_json = "[";
    }
    /[0-9]{4}-[0-9]{2}-[0-9]{2}/{
        print "日付は" NR "行目" $0
    }
    /^[0-9\.]+$/{
        print "指標値は" NR "行目" $0
    }
    NF == 0{
        print "空行は" NR "行目" $0
    }
    END{
        conv_json = conv_json "]";
        print conv_json
    }
'
        
これを実行すると、

            
            空行は1行目
日付は2行目2020-04-22
指標値は3行目19109.18
指標値は4行目19137.95
指標値は5行目18858.25
指標値は6行目19137.95
指標値は7行目1247290000
空行は8行目
日付は9行目2020-04-21
指標値は10行目19479.83
指標値は11行目19529.06
指標値は12行目19193.22
指標値は13行目19280.78
指標値は14行目1280090000
空行は15行目
日付は16行目2020-04-20
指標値は17行目19689.85
指標値は18行目19784.38
指標値は19行目19611.79
指標値は20行目19669.12
指標値は21行目1065420000
空行は22行目
日付は23行目2020-04-17
指標値は24行目19575.85
指標値は25行目19922.07
指標値は26行目19554.70
指標値は27行目19897.26
指標値は28行目1409050000
空行は29行目
日付は30行目2020-04-16
指標値は31行目19311.30
指標値は32行目19362.17
指標値は33行目19154.41
指標値は34行目19290.20
指標値は35行目1298590000
空行は36行目
[]
        
というような出力を得ます。

先ほどと違うのは、テキスト処理のスクリプト部分
{}から、いくつかの<行を限定する条件>{}にスクリプトパートが分岐している点です。

/[0-9]{4}-[0-9]{2}-[0-9]{2}/とか、/[0-9]+/とか、NF == 0の3つの条件を中括弧{}の直前に指定することで、その条件にマッチした行に処理が来たときだけ処理を順次実行する、という意味になります。

条件を省略することの意味

逆説的に考えると、<行を限定する条件>の部分を省略して空にして使うと、全ての行を逐次評価するスクリプトパートであると解釈できます。

上記でも実際に
BEGIN{...前処理...}{...テキストの中身の処理...}END{...後処理...}のスクリプトを走らせています。

真ん中の条件なしのテキストの中身処理部分はすべての行数を順次処理しているのが分かると思います。

正規表現で条件を指定する

AWKの便利なところは条件文に正規表現が利用できて、その正規表現にマッチした行だけに限定した処理を実行できるところです。

例えば
/[0-9]{4}-[0-9]{2}-[0-9]{2}/の条件では、2020-04-19のような日付のパターンにマッチした際に真となり、その真となった行だけを評価するスクリプトが処理されるようになります。

余談ですが、AWKで使える正規表現は単純なものなので、簡単なパターンだけに留めておいた方が無難です。

また、
GNU版AWKBSD版AWKのような方言の違いのような些細なものでも正常に動作しない場合があります。

一例を挙げると、数字文字を表す
\dGNU版AWKでは識別可能ですがBSD版AWKでは認識してくれません。

AWKスクリプトを違うバージョンでも併用したい場合には、
\dではなく[0-9]に置き換える必要があります。

こういったプログラミング的方言の違いやAWK独特の正規表現規約もあり、AWKではあまり複雑な正規表現は利用すべきではないと思います。

AWKでの正規表現をより極めたい方に、以下のサイトなどが参考になるかと思います。

Effective AWK Programming - 正規表現

Awk プログラミング入門 - 正規表現とパターンマッチング

ちなみに
/^[0-9\.]+$/は数字と小数点を含む数にマッチします。

この場合、文字の先頭と末尾を表す
^$がない場合には思うように数字にマッチしないので、ちゃんと^...$でパターンを囲う必要があります。

個人的にはより複雑な正規表現を行いたい場合には、SEDで予め適切な前処理を行っておくことをおすすめします。

特定の条件で指定する

前述のNRと同様にAWKで使える組み込みの変数も条件文内で利用できます。

NFは処理中の行の列要素の数(カラム数)をカウントしてくれる組み込み変数です。

つまり
NF == 0とは列の要素がゼロ、転じて空行だったときに真を返し、空行に入ったら処理を実行します。


テキストからJSONオブジェクトを抽出

ここからはとりあえず深いことは考えずに、テキスト部分からJSONオブジェクトを一つ一つ抜き出して、配列に再構成していきます。

ちなみにAWKスクリプト内部でもコメントアウトとして
#が利用できます。

            
            $ cat input.dat | awk '
    BEGIN{
        s_pos = 0; #初期値がFalseやゼロの場合、明示な変数の初期化はなくても良い
        conv_json = "[";
    }
    /[0-9]{4}-[0-9]{2}-[0-9]{2}/{
        s_pos = NR; #日付の行に来たらオブジェクトの最初の行数として記録
        if(NR == s_pos) {
            date = "\"date\":\"" $0 "\""
        }
    }
    /^[0-9\.]+$/{
        if(NR == s_pos+1) {
            open = "\"open\":" $0
        }
        if(NR == s_pos+2) {
            high = "\"high\":" $0
        }
        if(NR == s_pos+3) {
            low = "\"low\":" $0
        }
        if(NR == s_pos+4) {
            closed = "\"close\":" $0
        }
        if(NR == s_pos+5) {
            volume = "\"volume\":" $0
        }
    }
    NF == 0{
        if(NR == s_pos+6) {
            #空行を読み込んだら記録した変数を元にJSONオブジェクトを作成
            content = "{" date "," open "," high "," low "," closed "," volume "}";
            #配列の末尾にオブジェクトを追加
            conv_json = conv_json content ","
        }
    }
    END{
        sub(/,$/, "", conv_json); # 末尾の余分な","を排除
        conv_json = conv_json "]";
        print conv_json
    }
'
        
これを実行した生の出力は以下になります。

            
            [{"date":"2020-04-22","open":19109.18,"high":19137.95,"low":18858.25,"close":19137.95,"volume":1247290000},{"date":"2020-04-21","open":19479.83,"high":19529.06,"low":19193.22,"close":19280.78,"volume":1280090000},{"date":"2020-04-20","open":19689.85,"high":19784.38,"low":19611.79,"close":19669.12,"volume":1065420000},{"date":"2020-04-17","open":19575.85,"high":19922.07,"low":19554.70,"close":19897.26,"volume":1409050000},{"date":"2020-04-16","open":19311.30,"high":19362.17,"low":19154.41,"close":19290.20,"volume":1298590000}]
        

if文を使う

AWKでもif(条件) {...} else if(条件) {...} else {...}が利用できます。

Shellの中で動くからといってもShell特有のifの文法とは違い、c言語寄りな感じに書けて結構柔軟性があります。

if(条件){...}だけでも、if(条件) {...} else {...}でも動作します。

また
&&||で複数の条件をパイプできるのも便利です。

組み込み関数sub()

AWKスクリプト内で文字列の置換を行う際には、sub()関数が利用できます。

用法としては
sub(<置換文字(正規表現で最初にマッチする文字)>, <置換後の文字>, <入力文字列>)と3つの引数をとります。

返り値は無くそのまま置換後の結果が入力文字列に上書きされます。

今回のケースでは、JSONオブジェクトの最後の要素に余分なコンマ
,が付くのでこれを除外するのに以下のような感じに用いました。

            
            $ echo '{},{},{},' | awk '{sub(/,$/,"",$0);print $0}'
{},{},{}
        

一部のデータが壊れている場合の対応

テキストデータがいつも100%正しいとは限りません。

時にはタイポなどが潜んでいて、日付や数値のフォーマットが崩れていたりして、読み込みが不可の場合があります。

ちょっと分かりにくいですが上のスクリプトにはバグが存在しています。

日付に間違いがあればその日足インスタンスの生成を自然とスキップしてくれますが、日付フォーマットは正常で、その他の数値に誤植があった場合に(更新されずに残った)前回の値がそのまま次のインスタンスに入力されてしまう問題です。

そんな不慮のデータをそのまま読み込んでしまうので、各行の検証を追加してもうちょっとだけ安全な処理に改良してみます。

            
            $ cat input.dat | awk '
    BEGIN{
        s_pos = 0;
        conv_json = "[";
    }
    /[0-9]{4}-[0-9]{2}-[0-9]{2}/{
        s_pos = NR;
        if(NR == s_pos) {
            date = "\"date\":\"" $0 "\""
        }
        #各値の初期化(異常があった場合-1が返る)
        open = "\"open\":-1"
        high = "\"high\":-1"
        low = "\"low\":-1"
        closed = "\"close\":-1"
        volume = "\"volume\":-1"
    }
    /^[0-9\.]+$/{
        if(NR == s_pos+1) {
            open = "\"open\":" $0
        }
        if(NR == s_pos+2) {
            high = "\"high\":" $0
        }
        if(NR == s_pos+3) {
            low = "\"low\":" $0
        }
        if(NR == s_pos+4) {
            closed = "\"close\":" $0
        }
        if(NR == s_pos+5) {
            volume = "\"volume\":" $0
        }
    }
    NF == 0{
        if(NR == s_pos+6) {
            content = "{" date "," open "," high "," low "," closed "," volume "}";
            conv_json = conv_json content ","
        }
    }
    END{
        sub(/,$/, "", conv_json);
        conv_json = conv_json "]";
        print conv_json
    }
'
        
こうすることで数値に異常があった場合には、"-1"が反映され、データに誤りのあった箇所が検証できるようになりました。


最終的なスクリプト

以上を組み合わせると、スクリプトの全体は以下のようになります。

            
            #!/bin/bash

DATA_DIR=./path/to/datafile/

cat ${DATA_DIR}input.dat | awk '
    BEGIN{
        s_pos = 0;
        conv_json = "[";
    }
    /[0-9]{4}-[0-9]{2}-[0-9]{2}/{
        s_pos = NR;
        if(NR == s_pos) {
            date = "\"date\":\"" $0 "\""
        }
        open = "\"open\":-1"
        high = "\"high\":-1"
        low = "\"low\":-1"
        closed = "\"close\":-1"
        volume = "\"volume\":-1"
    }
    /^[0-9\.]+$/{
        if(NR == s_pos+1) {
            open = "\"open\":" $0
        }
        if(NR == s_pos+2) {
            high = "\"high\":" $0
        }
        if(NR == s_pos+3) {
            low = "\"low\":" $0
        }
        if(NR == s_pos+4) {
            closed = "\"close\":" $0
        }
        if(NR == s_pos+5) {
            volume = "\"volume\":" $0
        }
    }
    NF == 0{
        if(NR == s_pos+6) {
            content = "{" date "," open "," high "," low "," closed "," volume "}";
            conv_json = conv_json content ","
        }
    }
    END{
        sub(/,$/, "", conv_json);
        conv_json = conv_json "]";
        print conv_json
    }
' > ${DATA_DIR}output.json
        
入力ファイルと出力ファイルなどを作業フォルダ./path/to/datafile/に入れてつかうようにしていますが、その辺はお好みで。


まとめ

AWKを使うとパターンの定かなデータの形成が非常に楽になります。

正規表現での文字操作はAWK単体ではあまり高機能とは言えませんので、SEDでテキストを前処理した後で、AWKへデータを食わせるやり方が好ましいと思います。

個人的なビッグデータに対応したテキストマイニング用のアプリ開発のお供に欠かせない最強ツールとして、CURL、SED、AWK、JQの4つがコマンドはやはり押さえておきたいところです。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。