LINE NotifyでPythonから自分にメッセージを送る

前回に続いて通知を作る話です。
今回はLINE通知を作ります。

この記事は、企業が運用している本格的な公式アカウントやBotサービスのようなものではなく、個人的に運用しているサーバーのバッチでエラーが起きたときなどに自分宛に通知するという小規模な利用を想定して書きます。

利用するサービスはこちらの LINE Notify です。
参考: https://notify-bot.line.me/ja/

この種のSNSに付随したサービスに対しては認証周りで面倒なコードを書かないといけないイメージがあったのですが、LINE Notifyは非常にコンパクトな実装で手軽に使えました。

事前準備として、こちらのサービスからトークンを入手します。

これ専用のアカウントは必要なく、普段使っているLINEのアカウントでログインできます。右上のログインリンクからログインしましょう。(LINE認証用のコードが表示され、LINEアプリから入力が必要なので、スマホも用意しておきましょう。)

ログインしたら、右上のログインリンクが自分のLINEアカウント名になっているので、それを押してマイページへ遷移します。
そして、「アクセストークンの発行(開発者向け) 」のところから「トークンを発行」ボタンをクリックします。

トークン名を入力し、通知を送るトークルームを選択して、「発酵する」ボタンをクリックするとトークンが発行されて1度だけ表示されます。もう二度と表示されないのでこの時点で確実に記録しておきましょう。

先に書いておきますが、通知を実装した後実際にLINEに届くメッセージは、
[トークン名] メッセージ本文
というフォーマットになります。トークン名が長いと毎回邪魔なのでコンパクトでわかりやすい名前にしておきましょう。

さて、トークンが発行できたらこれを使ってみます。
ドキュメントはこちらです。
参考: LINE Notify API Document
このドキュメントの「通知系」のところにある、https://notify-api.line.me/api/notify が通知を送るAPIです。

リクエストパラメーターがいろいろ書かれていますが、「必須」と指定されているのはmessageだけなので非常に簡単に使えます。

CURLで動かすサンプルコードもあるのでちょっとやってみましょう。{自分のトークン}の部分は先ほど発行したトークンを入れてください。

# コマンド
$ curl -X POST -H 'Authorization: Bearer {自分のトークン}' -F 'message=CURLで通知' https://notify-api.line.me/api/notify

# 結果
{"status":200,"message":"ok"}

これで、「[トークン名] CURLで通知」というメッセージが、 LINE Notify のアカウントから届きます。

あとは、このcurlコマンドをPythonに書き直していきましょう。使うライブラリはrequestsあたりで良いと思います。

import requests


line_notify_token = "{自分のトークン}"
api_url = "https://notify-api.line.me/api/notify"
message = "メッセージ本文"
headers = {"Authorization": f"Bearer {line_notify_token}"}
data = {"message": message}
requests.post(
    api_url,
    headers = headers,
    data = data,
)

たったこれだけで、LINEにメッセージが届きます。

もう少し丁寧に実装するなら、postの戻り値のstatusコードを確認してエラー処理を入れたりするとよさそうですね。

LINEのインターフェース的に、あまりにも長文を送ったりするのには適さず、長文になるなら先日のメール通知の方が良いかなと思うのですが、
メールよりLINEの方が通知に気付きやすいので、速報性が必要な場面で重宝しそうです。

PythonでYahooメールのアカウントからメール送信

個人的に運用しているソフトウェアに通知機能を作りたかったので、メールの送信方法を調べました。本当はSlack通知とかの方が使いやすいのですが、最近のSlackの無料プランは一定期間でメッセージが消えるなどイケてないですからね。

メールの利用を検討し出した当初はAmazon SESを使おうかとも思っていたのですが、標準ライブラリだけで実装できることと、料金もかからないのでPythonでやることにしました。

使用するライブラリは以下の二つです。
– SMTPプロトコルクライアント – smtplib
– メールメッセージの管理 – email
email の方は 使用例のページを見た方がいいです。

使い方はYahooメールやGmail, Outlookなどアカウントによって微妙に違うので今回はYahooメールを例に取り上げて説明します。

メールサーバーやポート番号の情報はこちらにあります。
参考: メールソフトで送受信するには(Yahoo!メールアドレス、@ymail.ne.jpアドレスの場合)

必要な情報は以下の2つです。
– 送信メール(SMTP)サーバー : smtp.mail.yahoo.co.jp
– 送信メール(SMTP)ポート番号 : 465

実はこの情報は、2020年8月に変わっていて、他所の古い技術記事等ではポート番号が違っていたりします。他サイトのコードをコピペしたが動かなかったという人は以下のアナウンスを読んでください。SMTPの通信方法がSSLになっているというのもライブラリで呼び出す関数が変わるので重要な点です。
参考: Yahoo!メールをより安全にご利用いただくためのメールソフト設定(送受信認証方式)変更のお願い

メールソフトで送受信するにはのページに記載がありますが、「Yahoo! JAPAN公式サービス以外からのアクセスも有効にする」の設定をやっておかないと動かないので気をつけてください。(とはいえ、普段スマホでメールを見れるようにしているのであれば設定済みだと思います。)

それではやっていきましょう。自分のスクリプトから自分のメールアドレス宛の通知としての利用を想定しているので飾りも何もないテキストメールを送ります。

まず、各変数に必要な値を格納しておきます。悪用は厳禁ですが、toだけでなくfromのメールアドレスも自由に設定できます。まともに使うならfromは自分のアドレスでしょう。

# Yahooのログイン情報
username = "{YahooのユーザーID}"
password = "{Yahooのパスワード}"

# メールアドレス情報
from_address = "{差出人のアドレス}@yahoo.co.jp"
to_address = "{宛先のアドレス}@yahoo.co.jp"

# SMTPサーバーの情報。値はYahooメールのヘルプページから取得。
smtp_host = "smtp.mail.yahoo.co.jp"
smtp_port = 465

続いて、メールの情報を作ります。smtpのドキュメント末尾では文字列で直接データを作ってますが、その直下の注釈でemailパッケージを推奨されているのでそちらに従います。

from email.message import EmailMessage


msg = EmailMessage()
msg.set_content("メール本文")
msg["Subject"] = "メールタイトル"
msg["From"] = from_address
msg["To"] = to_address

メールデータできたので、これを送信します。ドキュメントの一番下のサンプルコードではローカルのSMTPサーバーでメール送信しているのでログインも何もしていませんが、Yahooメールを使うなら最初にログインが必要です。smtplib.SMTP ではなく、smtplib.SMTP_SSLを使うのもポイントですね。コードは以下のようになります。

import smtplib


server = smtplib.SMTP_SSL(smtp_host, smtp_port)
server.login(username, password)
server.send_message(msg)
server.quit()

たったこれだけでメール送信が実装できました。

DataFrameを特定列の値が連続してる行ごとにグルーピングする方法

このブログでは何度も使っているのでお馴染みですが、pandasのDataFrameはgroupbyというメソッドを持っていて、特定列の値を基準にグループ化して各種集計を行えます。
今回はこれを、特定の列の値が等しいではなく、連続する整数によってグループ化したかったのでその方法を考えました。

具体的にいうと、例えば、[2, 3, 4, 6, 9, 10, 15, 16, 17, 18] というデータがあったときに、
[2, 3, 4], [6], [9, 10], [15, 16, 17, 18] というようにグループに分けたいわけです。

やり方はいろいろあると思いますし、自分も昔はfor文で上から順番にデータをみて2以上値が離れてたらそこで切る、みたいなやり方をしていましたが今回いい感じの方法を見つけたので紹介します。

サンプルとして次のようなDataFrameを作っておきます。(“foo”って列はただのダミーです。1列だけだとDataFrame感がなかったのでつけました。)

import pandas as pd


df = pd.DataFrame({
    "foo": ["bar"]*10,
    "values": [2, 3, 4, 6, 9, 10, 15, 16, 17, 18],
})

print(df)
"""
   foo  values
0  bar       2
1  bar       3
2  bar       4
3  bar       6
4  bar       9
5  bar      10
6  bar      15
7  bar      16
8  bar      17
9  bar      18
"""

これの、valuesの値が変わったところで切りたいのですが、次のようにしてshiftとcumsum(累積和)を使ってgroupごとにidを振ることができました。

df["group_id"] = (df["values"] != df["values"].shift()+1).cumsum()

print(df)
"""
   foo  values  group_id
0  bar       2         1
1  bar       3         1
2  bar       4         1
3  bar       6         2
4  bar       9         3
5  bar      10         3
6  bar      15         4
7  bar      16         4
8  bar      17         4
9  bar      18         4
"""

あとはこのgroup_id 列を使って groupby することで、連番をひとまとまりにした集計ができます。実務で遭遇した事例ではこの連番を使ってグルーピングしたあと、別の列が集計対象だったのですが今回のサンプルではとりあえずグルーピングしたvalues列でも集計して、最小値、最大値、件数、でも表示しておきましょう。

print(df.groupby("group_id")["values"].agg(["min", "max", "count"]))
"""
          min  max  count
group_id                 
1           2    4      3
2           6    6      1
3           9   10      2
4          15   18      4
"""

2~4とか15~18がグループになってるのがわかりますね。

これの少し応用で、値が3以上飛んだら別グループとして扱う、って感じのグループ化の閾値を変えることも簡単にできます。

df["group_id"] = (df["values"] - df["values"].shift() >= 3).cumsum()

print(df)
"""
   foo  values  group_id
0  bar       2         0
1  bar       3         0
2  bar       4         0
3  bar       6         0
4  bar       9         1
5  bar      10         1
6  bar      15         2
7  bar      16         2
8  bar      17         2
9  bar      18         2
"""

これを数値ではなくタイムスタンプで行うと、ユーザーのアクセスログデータに対して30分以内で連続したアクセスをひとまとまりとして扱う、といったセッション化のような集計を実装することもできます。意外と応用の幅が広いテクニックなので、機会があれば使ってみてください。

numpyのtileとついでにrepeatを紹介

numpyのarrayを繰り返して並べることによって新しいarrayを生成するnumpy.tileって関数があるのでその紹介です。また、名前が紛らわしいのですが全く違う挙動をするnumpy.repeatって関数もあるのでついでにそれも紹介します。

tileの方は、先日時系列データの季節分解のアルゴリズムを紹介した記事の中でこっそり使いました。
参考: statsmodelsの季節分解で実装されているアルゴリズム

それぞれの関数のドキュメントは以下です。
numpy.tile — NumPy v1.23 Manual
numpy.repeat — NumPy v1.23 Manual

さて、何か元になる配列があってそれを繰り返して何か新しい配列を作ると言う操作はnumpyのarrayよりPythonの標準のlistの方がやりやすいと言う珍しい操作になります。とりあえずリストでの挙動見ておきましょうかね。一方で同じ実装をnumpyでやると挙動が変わってしまうことも。

import numpy as np


list_sample = [0, 1, 2]
# list は * (積)で繰り返しを作れる
print(list_sample * 3)
# [0, 1, 2, 0, 1, 2, 0, 1, 2]

# 縦に繰り返したい場合 [] で囲んでから3倍
print([list_sample] * 3)
# [[0, 1, 2], [0, 1, 2], [0, 1, 2]]

# mumpyでやると要素への積になってしまう。
ary = np.array([0, 1, 2])
print(ary * 3)
# [0 3 6]

arrayは積で連結できないとはいえ、listメソッドでarrayをlistに変換しちゃったら済む話なので、何がなんでもnumpyのメソッドでやらなきゃいけないってことはないのですが、せっかく用意されているのがあるので使い方を覚えておくと便利です。

そして、それを実装するnumpyの関数ですが、僕は完全にnp.repeatがそれだと勘違いしていました。しかしこのrepeat、要素をそれぞれ繰り返す、という挙動をするので期待してたのと全く違う動きするのですよね。ただ、こう言うメソッドがあるんだと知っていれば使える場面もあるかもしれないので先に見ておきます。

ary = np.array([0, 1, 2])
# 元のarrayと繰り返したい回数を渡す。
print(np.repeat(ary, 3))
# [0 0 0 1 1 1 2 2 2]

いかがでしょう。大体上記の例でイメージ掴めたでしょうか。

このrepeatは2次元以上のarrayに対しても使えます。その際、axisという引数で繰り返し方を指定できるのでちょっと見ていきますね。

ary_2d = np.array([[0, 1, 2], [3, 4, 5]])
print(ary_2d)  # 元のデータを表示しておく
"""
[[0 1 2]
 [3 4 5]]
"""
print(np.repeat(ary_2d, 2))  # axis指定無しだと1次元に変換してから要素を繰り返す
"""
[0 0 1 1 2 2 3 3 4 4 5 5]
"""

print(np.repeat(ary_2d, 2, axis=0))
"""
[[0 1 2]
 [0 1 2]
 [3 4 5]
 [3 4 5]]
"""

print(np.repeat(ary_2d, 2, axis=1))
"""
[[0 0 1 1 2 2]
 [3 3 4 4 5 5]]
"""

axis を省略した場合(Noneを渡すと同じ)の場合と、axis=0の場合で結果が違うのも要注意ですね。axisに渡した値と結果の関係がイメージつきにくいですが、元のshapeが(2, 3)だったのが、axis=0だと(4, 3)に、axis=1だと(2, 6)にと、axisで指定した次元が繰り返し回数倍になると考えるとわかりやすいです。

さて、repeatが要素の繰り返しであって配列の繰り返しではない、と言うのをここまでみてきました。

では配列の繰り返しはどうやるのかとなったときに使えるのがtileです。これがlistへの整数の掛け算と同じような挙動をしてくれます。これ繰り返し回数を整数ではなくタプルで指定することで別次元への繰り返しもできます。

# tile で 指定回数arrayを繰り返したarrayを生成できる
print(np.tile(ary, 3))
# [0 1 2 0 1 2 0 1 2]

# 繰り返し回数はタプルでも指定でき、新しい軸方向への繰り返しもできる。
print(np.tile(ary, (3, 1)))
"""
[[0 1 2]
 [0 1 2]
 [0 1 2]]
"""

# タプルで指定する例2つ目
print(np.tile(ary, (2, 3)))
"""
[[0 1 2 0 1 2 0 1 2]
 [0 1 2 0 1 2 0 1 2]]
"""

2次元以上のarrayに対しても使えます。名前通りタイル貼りのような動きをするのでこちらの方がイメージしやすいかもしれませんね。ちなみに画像データに対してこれを使うと元の画像を繰り返す画像が作れたりします。

print(ary_2d)  # 元のデータ
"""
[[0 1 2]
 [3 4 5]]
"""

# 整数で繰り返しを指定した場合
print(np.tile(ary_2d, 3))
"""
[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]]
"""

# タプルで指定した場合
print(np.tile(ary_2d, (3, 1)))
"""
[[0 1 2]
 [3 4 5]
 [0 1 2]
 [3 4 5]
 [0 1 2]
 [3 4 5]]
"""

# タプルで指定した場合その2。タプル(1, 3)と整数で3と指定するのが同じ挙動
print(np.tile(ary_2d, (1, 3)))
"""
[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]]
"""

# もちろん、タプルでは1以外の数値も使える
print(np.tile(ary_2d, (2, 3)))
"""
[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]
 [0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]]
"""

以上で、repeatとtileの紹介を終えます。
どちらを使うかであったり、繰り返し方向の指定などを間違えがちだと思うので、よく確認しながら使いましょう。

ipywidgetsのDropdownやSliderで値を変えたときに関数を実行する

jupyterでウィジェット(ipywidgets)を使う記事の4記事目くらいです。1個は実例紹介みたいなやつなので使い方の記事としては3記事目になります。
1記事目: Jupyter Notebookでインタラクティブに関数を実行する
2記事目: Jupyter Notebook でボタンを使う

ボタンの使い方紹介したし他のUIも似たような感じで使えるやろって思い込んで放置していたのと、Sider等でぐりぐり操作したい場合は1記事目のinteractで十分なケースが多かったので触れてきませんでしたが、最近ある用途でipywidgets.IntSliderを使ったとき、思ったような動きをせずに苦戦しました。

先に結論を書いておくと、SliderやDropdownをインタラクティブに使いたいならobserveってメソッドに実行したい関数をセットし、names引数に”value”を渡して値の変更だけ監視するようにします。この記事ではDropdownとSlider (IntSlider/ FloatSlider) を例に取り上げますが、他のトグルボタンとかテキストボックス等でも事情は同じです。

さて、結論先に書いちゃいましたが自分が何に苦戦したのかを書いておきます。まず、Buttonを使うときは、インスタンスのon_clickメソッドにクリックしたときに実行したいメソッドを渡せば動作がセットされて、押すたびにそれが実行されるのでした。
なので、どうせSliderにはon_changeみたいなメソッドがあるんだろ、ってことで探すとon_trait_changeってメソッドが見つかります。で、これをやるとDeprecationWarningが出ます。今はobserveを使えということらしいです。

from ipywidgets import IntSlider
from IPython.display import display


def print_value():
    print(int_slider.value)


int_slider = IntSlider(min=0, max=100, step=10, value=50)
int_slider.on_trait_change(print_value)
display(int_slider)

# 以下出力される警告文
"""
/var/folders/g1/l4hsxb_54gsc0zgyczfb_xvm0000gn/T/ipykernel_1150/2385673427.py:9: DeprecationWarning: on_trait_change is deprecated in traitlets 4.1: use observe instead
  int_slider.on_trait_change(print_value)
"""

じゃぁ、observeを使うとどうなるかというと、次はスライダーを動かしたときにエラーが出ます。

def print_value():
    print(int_slider.value)


int_slider = IntSlider(min=0, max=100, step=10, value=50)
int_slider.observe(print_value)
display(int_slider)

# これでIntSliderは表示されるが、動かすと以下のエラーが出る
"""
TypeError: print_value() takes 0 positional arguments but 1 was given
"""

observeに渡すメソッドは引数を一個受け取るようです。ドキュメントを見ると変更に関する情報を関数に渡してくれるようですね。ありがたい。ちょっとその引数で渡される情報をprintするようにしてみましょう。

def print_value(change):
    print(change)


int_slider = IntSlider(min=0, max=100, step=10, value=50)
int_slider.observe(print_value)
display(int_slider)

これでSliderが表示されるのですが、値をちょっと変えると、なんかセットした関数(print_value)が3回実行されるのですよ。

ただ、chengeって引数にoldとnewってキーで新旧の値が入るのは便利ですね。ドキュメントを見ると、値が変わったときに一回だけ動かしたいなら、names=’value’って指定すると良いようです。上の画像で言うところの’name’: ‘_property_lock’ の変更はこれで出てこなくなります。

また、Sliderのような連続的に値を変えるUIは、例えば50から100へ値を変えようとすると途中の60,70,80なども通過します。ここで全部発火すると大変だ、最後に止まったところでだけ動いたらいい、と言う場合は、ウィジェットのインスタンス作るときにcontinuous_update=Falseを指定すると良いです。
結果コードは以下のようになります。

def print_value(change):
    print(change["old"], "から", change["new"], "に変化しました。")


int_slider = IntSlider(min=0, max=100, step=10, value=50, continuous_update=False)
int_slider.observe(print_value, names="value")
display(int_slider)

結果は省略しますが、Dropdownなどの他のウィジェットも同じようにして値の変化を検知できます。

from ipywidgets import Dropdown


drop_down = Dropdown(options=["high", "middle", "low"])
drop_down.observe(print_value, names="value")
display(drop_down)

Dropdownはvalueだけなく、labelやindexも変化するので、names=”value”を指定しない場合は、5回メソッドが実行されますね。用途によってはnames=”index”とか”label”などの方が使いやすい場面もあると思いますので確認しながら使ってみてください。

pprintでデータを整形して出力する

前回の記事がtextwrapだったので、文字列の見栄えを整えるつながりで今回はpprintを紹介しようと思います。
参考: pprint — データ出力の整然化 — Python 3.11.0b5 ドキュメント

自分はもっぱらdictやlistの表示に使うのですが、ドキュメントを見ると任意のデータ構造に使えるようなことが書いてありますね。

使い方は簡単で、printすると結果が少しちょっと見にくくなるようなdict等のデータを渡すだけです。値が少し長いデータを使ってprintと見比べてみます。

import pprint


# サンブルデータ作成
sample_data = {
    1: "1つ目のキーの値",
    2: "2つ目のキーの値",
    3: "3つ目のキーの値",
    4: "4つ目のキーの値",
    5: "5つ目のキーの値",
    6: "6つ目のキーの値",
    7: "7つ目のキーの値",
}
# printした結果
print(sample_data)
"""
{1: '1つ目のキーの値', 2: '2つ目のキーの値', 3: '3つ目のキーの値', 4: '4つ目のキーの値', 5: '5つ目のキーの値', 6: '6つ目のキーの値', 7: '7つ目のキーの値'}
"""

# pprintした結果
pprint.pprint(sample_data)
"""
{1: '1つ目のキーの値',
 2: '2つ目のキーの値',
 3: '3つ目のキーの値',
 4: '4つ目のキーの値',
 5: '5つ目のキーの値',
 6: '6つ目のキーの値',
 7: '7つ目のキーの値'}
"""

pformat というメソッドもあって、こちらを使うと整形したものをprintするのではなく文字列として返してくれます。一応試しますが、文字列で戻ってきてるのをみないといけないので一旦変数に格納して通常のprintで出力します。

p_str = pprint.pformat(sample_data)
# 結果確認
print(p_str)
"""
{1: '1つ目のキーの値',
 2: '2つ目のキーの値',
 3: '3つ目のキーの値',
 4: '4つ目のキーの値',
 5: '5つ目のキーの値',
 6: '6つ目のキーの値',
 7: '7つ目のキーの値'}
"""

さて、このpprintですが、基本的にはそのまま使えば十分なのですが細かい調整ができるようにいろんな引数を取れます。

例えば、 indent= (デフォルト1)でインデントの文字数を指定できますし、width= (デフォルト80)で、横幅の文字数の最大値を指定できます。ただしwidthはベストエフォートでの指定なので、データによっては収めることできずにはみ出します。ちょっとwidthの指定によって結果が変わる例も見ておきましょう。さっきのdictはwidthが大きくても改行されたので、もう少しコンパクトなのを使います。

sample_data_mini = {
    1: '1つ目のキーの値',
    2: '2つ目のキーの値',
    3: '3つ目のキーの値',
}

# 80文字に収まるので、width未指定だと1行で出力
pprint.pprint(sample_data_mini, indent=4)
"""
{1: '1つ目のキーの値', 2: '2つ目のキーの値', 3: '3つ目のキーの値'}
"""

# width   が小さいと収まるように改行される。
pprint.pprint(sample_data_mini, indent=4, width=30)
"""
{   1: '1つ目のキーの値',
    2: '2つ目のキーの値',
    3: '3つ目のキーの値'}
"""

また、データの構造によっては、辞書やリスト、タプルの入れ子になっていることもあると思います。そのようなとき、depthという引数を指定することにより何階層目まで出力するか指定することもできます。オーバーした分は省略記号… になります。ドキュメントのサンプルでちょっとやってみます。

tup = ('spam', ('eggs', ('lumberjack', ('knights',
       ('ni', ('dead', ('parrot', ('fresh fruit',))))))))

# depth未指定
pprint.pprint(tup, width=20)
"""
('spam',
 ('eggs',
  ('lumberjack',
   ('knights',
    ('ni',
     ('dead',
      ('parrot',
       ('fresh '
        'fruit',))))))))
"""

# depth=3を指定
pprint.pprint(tup, width=20, depth=3)
"""
('spam',
 ('eggs',
  ('lumberjack',
   (...))))
"""

何かAPIとか叩いて巨大なJSONが帰ってきたとき、中身を確認するのに先立って上の階層のkeyだけちょっと見たい、って場面で非常に便利です。

このほかにも、辞書の出力をするときにkeyでソートしてくれるsort_key= (デフォルトでTrue)や、widthの範囲に収まるならばできるだけ1行にまとめてくれるcompact= (デフォルトでTrue)などのオプションもあります。正直のこの二つはわざわざFalseを指定することはないかなと追うので結果は省略します。

Pythonで複数行の文字列の行頭の空白を削除する

textwrapという標準ライブラリを最近知り、その中にdedentという便利なメソッドがあったのでその紹介です。
参考: textwrap — テキストの折り返しと詰め込み — Python 3.11.0b5 ドキュメント

ドキュメントのページタイトルにある通り、本来は長いテキストを折り返すためのライブラリです。

さて、Pythonでは基本的な技術ですが、三重引用符(“””か、”’)で囲むことによって、複数行のテストオブジェクトを生成できます。
参考: テキストシーケンス型

これをやるときに、コードの見た目をきれいにするためにインデントをつけると、こんな感じになってしまいます。(あくまでも例として出してるサンプルコードであって、走れメロスの本文を属性に持つクラスを作りたかったわけではありません。)

class foo():
    def __init__(self):
        self.text = """
            メロスは激怒した。
            必ず、かの邪智暴虐の王を除かなければならぬと決意した。
            メロスには政治がわからぬ。
            メロスは、村の牧人である。
            笛を吹き、羊と遊んで暮して来た。
            けれども邪悪に対しては、人一倍に敏感であった。
        """


obj = foo()
print(obj.text)
# 以下出力

            メロスは激怒した。
            必ず、かの邪智暴虐の王を除かなければならぬと決意した。
            メロスには政治がわからぬ。
            メロスは、村の牧人である。
            笛を吹き、羊と遊んで暮して来た。
            けれども邪悪に対しては、人一倍に敏感であった。
        

これをやると、各行の先頭に要らない半角スペースが入ってしまいます。上記のコードの例であれば各行12個入ってます。ついでに前後に不要な改行があり、空白行がそれぞれできています。これを避けるには次のように書かなければいけません。

class bar():
    def __init__(self):
        self.text = """メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。"""


obj = bar()
print(obj.text)
# 以下出力
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。

上のコードくらい短ければいいのですが、長いコードにこういうのが入ると非常に不恰好です。実際日本語の文章がこんなダラダラコード中にハードコーディングされることは滅多にないのですがそれはさておき。

ここで、先述のtextwrap.dedentを使うと、そのメソッドが行頭の空白を消してくれます。

良い点でもあるのですが、テキスト中の「各行に共通する空白」だけ消します。空白が4個の行と8個の行が混在していたら、各行から4個消えて、元々8個存在してた行には4個スペースが残るので、相対的なインデントは保持されるということです。これは結構良い仕様です。

ちなみに、前後の改行コードは消してくれないので、それはそれで、strip()から何かで消します。

これを使うと次のようになります。

import textwrap


# 最初のコード例のクラスのインスタンスで実験
print(textwrap.dedent(obj.text).strip())
# 以下出力
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。

まずdedentを適用して、その結果に対してstrip()をするのが大事です。逆にすると意図せぬ結果になります。

これで、不要な行頭の空白が消えました。

おまけですが、逆に行頭に空白に限らず何かしらの文字列を挿入する、textwrap.indent もあります。これは、テキストと、挿入したい文字列を入れたらいいですね。例えば、 果物の名前の先頭に – (ハイフン) でも差し込みましょうか。

sample_text = """
    りんご
    みかん
    もも
    なし
"""
sample_text = textwrap.dedent(sample_text).strip()  # まず不要な空白消す

print(textwrap.indent(sample_text, "- "))  # 先頭に - 挿入
# 以下出力
"""
- りんご
- みかん
- もも
- なし
"""

このほかにも、textwrapには文字列を折り返したり切り詰めたりするなどの便利なメソッドが用意されています(というより本来そのためのライブラリです)ので、そのうち紹介しようと思います。

mplfinanceで1枚の画像に複数のチャートを描く方法

mplfinanceの4記事目です。今後また書くかもしれないけど一旦、連続でmplfinanceを扱うのは今回までにしようと思います。
今回は1枚の画像に複数のグラフを描く方法です。いろんな銘柄を並べて分析する際には必須の技術ですね。

ドキュメントはこちらになります。
参考: mplfinance/subplots.md at master · matplotlib/mplfinance

The Panels Method と、External Axes Method があると書いてありますね。

一つ目のパネルメソッドは特に新しい手法ではなく、以下の記事で紹介した、ローソク足の下にどんどん指標を追加していく方法のことです。
参考: mplfinanceの株価チャートに指標を追加する

ドキュメントにもありますが、この方法はx軸を共有することとか、32個までしか追加できないなどの制限があります。ただ、1銘柄ずつ分析するのであれば手軽で十分な方法だと思います。

今回の記事で紹介するのは、axesを追加していくもう一つの方法です。これはmatplotlibに近い使い方をします。figure(正確には、Mpf_Figure)というオブジェクトを作って、それに対して、subplotを追加し、その中にチャートを書いていきます。

注意しないといけないのは、matplotlibの mpl.figure ではなく、mpfの、mpf.figureを使うことと、plot するときに、ax引数でsubplotを指定することですね。

ドキュメントのサンプルコードでは、次のように4個ハードコーディングした実装が紹介されていますね。

fig = mpf.figure(figsize=(12,9))
<Mpf_Figure size 1200x900 with 0 Axes>
ax1 = fig.add_subplot(2,2,1,style='blueskies')
ax2 = fig.add_subplot(2,2,2,style='yahoo')

s   = mpf.make_mpf_style(base_mpl_style='fast',base_mpf_style='nightclouds')
ax3 = fig.add_subplot(2,2,3,style=s)

ax4 = fig.add_subplot(2,2,4,style='starsandstripes')
mpf.plot(df,ax=ax1,axtitle='blueskies',xrotation=15)
mpf.plot(df,type='candle',ax=ax2,axtitle='yahoo',xrotation=15)
mpf.plot(df,ax=ax3,type='candle',axtitle='nightclouds')
mpf.plot(df,type='candle',ax=ax4,axtitle='starsandstripes')
fig

まぁ、上記のサンプルコードはスタイルの紹介も兼ねてると思いますが、チャートごとにスタイルを変えたいってこともあまりないと思うのでもう少し実用的な例をやってみましょう。

ランダムに選抜した20社のデータを揃えておきました。

print(len(price_df))
# 1680
print(price_df.head(5))
"""
   code        date   open   high    low  close   volume
0  1712  2022-06-01  962.0  989.0  957.0  982.0  94300.0
1  1712  2022-06-02  970.0  970.0  958.0  961.0  65400.0
2  1712  2022-06-03  968.0  976.0  955.0  965.0  79400.0
3  1712  2022-06-06  960.0  969.0  950.0  964.0  83100.0
4  1712  2022-06-07  968.0  978.0  962.0  962.0  65700.0
"""
print(price_df["code"].nunique())
# 20

また、company_name_dict という辞書に “証券コード”: “企業名” という形でデータがあるとします。ラベルに使います。

この20社のデータを1枚の画像にプロットするコードは次のようになります。
なお、日本語が文字化けするので、前回の記事で紹介した対策をやります。
参考: mplfinanceで日本語文字が表示されない問題について
これは複数チャートを描く場合は、mpf.plot ではなく、 mpf.figure のタイミングでstyleを設定しないといけないという罠がありますので注意してください。

出来上がったコードは次のようになります。

import mplfinance as mpf
import matplotlib.pyplot as plt


font_family = plt.rcParams["font.family"][0]  # ファイルで設定したIPAPGothicが入る。
s = mpf.make_mpf_style(
    base_mpf_style='default',
    rc={"font.family": font_family},
)

# styleはこの時点で設定する。
fig = mpf.figure(figsize=(24,35), style=s)
i = 1
for code, sub_df in price_df.groupby("code"):
    ax = fig.add_subplot(5,4,i, title=code + ":" + company_name_dict[code])
    mpf.plot(
        sub_df,
        ax=ax,
        type='candle',
    )
    i+=1

出力される図がこちらです。

しっかりかけましたね。

パネルメソッドではなくaxesを作成する方法のデメリットとして、volume=True を指定するだけでは出来高のグラフを追加できなくなるということが挙げられます。(エラーになります。)

この手法で出来高も表示したい場合は、出来高用にもaxesを作成し、それをvolume引数に渡す必要があります。

さっとサンプルを作ると次のような感じでしょうか。少し狭くてラベルの重なりが発生したりしていますし、何番目のaxesに四本値と出来高を表示するかの指定がトリッキーなコードになっていますがいったん役目は果たすと思います。

font_family = plt.rcParams["font.family"][0]  # ファイルで設定したIPAPGothicが入る。
s = mpf.make_mpf_style(
    base_mpf_style='default',
    rc={"font.family": font_family},
)

# styleはこの時点で設定する。
fig = mpf.figure(figsize=(20, 50), style=s)
i = 1
for code, sub_df in price_df.groupby("code"):
    ax = fig.add_subplot(10,4,i, title=code + ":" + company_name_dict[code])
    ax_volume = fig.add_subplot(10,4,i+4)
    mpf.plot(
        sub_df,
        ax=ax,
        type='candle',
        volume=ax_volume,
    )
    if i % 4 == 0:
        i+=5
    else:
        i+=1

mplfinanceで日本語文字が表示されない問題について

3回続けてになりますが今回もmplfinanceの話です。本当は1枚のfigureに複数チャートを表示する方法について書いてそれで終わりにしようと思っていたのですが、ラベルやタイトルの表示で詰まったので今回先にその記事を書きます。

将来のバージョンでは修正される可能性もあると思うので、この記事で想定しているバージョンを書いておきます。

$ pip freeze # の結果を抜粋
matplotlib==3.5.2
mplfinance==0.12.9b1
jupyterlab==3.4.3

また、matplotlibには以下の記事の設定がされており、標準状態では日本語文字が表示できるとします。(以下の記事の設定を行なっていない場合はこの記事の対応を行なっても表示できません。)
前提記事: matplotlibのデフォルトのフォントを変更する

mplfinanceではmatplotlibのグラフと同じようにタイトルやy軸のラベルの表示ができます。チャートをズラズラと何枚も並べる場合は銘柄や期間の情報が必須なのでとても重要な機能です。

この時、証券コードとか英単語は問題なく表示されるのですが、日本語の文字については設定したstyleによっては表示できないことがあります。

問題について言及する前に、ラベル等を設定する方法について説明します。ドキュメントにはあまり親切なガイドがないので、ソースコードで引数を確認するのが早いと思います。該当箇所はこちらのバリデーション部分

使うのは次の3つです。
– title : タイトル
– ylabel : チャートのy軸のラベル
– ylabel_lower : 出来高のパネルのy軸のラベル

データは次のように適当に用意しました。

print(len(price_df))
# 84
print(price_df.head())
"""
            code    open    high     low   close     volume
date                                                       
2022-06-01  9434  1488.5  1497.0  1477.5  1481.5  7555300.0
2022-06-02  9434  1481.0  1484.5  1471.0  1479.5  5307700.0
2022-06-03  9434  1481.0  1482.0  1472.0  1475.0  5942800.0
2022-06-06  9434  1470.0  1474.5  1466.0  1473.0  5791300.0
2022-06-07  9434  1481.5  1482.0  1465.0  1465.0  7202900.0
"""

二つのstyleでサンプルをお見せします。
style を未指定(‘default’を指定するのと同じ) と、 ‘yahoo’を指定して出してみたのが次です。

import mplfinance as mpf
import matplotlib.pyplot as plt


mpf.plot(
    price_df,
    type="candle",
    title="ソフトバンク",
    ylabel="株価",
    ylabel_lower="出来高",
    volume=True,
)
plt.show()
mpf.plot(
    price_df,
    type="candle",
    title="ソフトバンク",
    ylabel="株価",
    ylabel_lower="出来高",
    volume=True,
    style='yahoo'
)
plt.show()

ご覧の通り、2枚目のstyle=’yahoo’の方は日本語が表示できていますが1枚目の未設定(デフォルト)の方は白い箱になっています。

一番簡単な対応は、日本語が使えるstyleを使うと決めてしまうことですね。お手軽なのでそれでも良いと思います。どのstyleなら使えるのかって判断は試すのが早いです。

ただ、僕はなぜこんな現象が起きるのか気になったので、ソースを読んで原因を調査しました。以降はその調査結果の話になります。

まず、mplfinance の style というのは、本来 mpf.make_mpf_style というメソッドを使って作った辞書によって指定するものです。毎回全部指定して作るのは大変なので、ライブラリでdefault とか yahoo といった手軽に使える設定のセットが用意されていて、ここまで使っていたのがそれです。その設定の中身なのですが、こちらのディレクトリのファイル群の中に記述されています。
参考: mplfinance/src/mplfinance/_styledata at master · matplotlib/mplfinance

_始まりのディレクトリなので、Pythonのお作法的には import されることは想定してないはずですが、次のようにして中身を見ることができます。

# style='default'の設定
print(mpf._styledata.default.style)
"""
{'style_name': 'default',
 'base_mpl_style': 'seaborn-darkgrid',
 'marketcolors': {'candle': {'up': 'w', 'down': 'k'},
  'edge': {'up': 'k', 'down': 'k'},
  'wick': {'up': 'k', 'down': 'k'},
  'ohlc': {'up': 'k', 'down': 'k'},
  'volume': {'up': '#1f77b4', 'down': '#1f77b4'},
  'vcedge': {'up': '#1f77b4', 'down': '#1f77b4'},
  'vcdopcod': False,
  'alpha': 0.9},
 'mavcolors': ['#40e0d0',
  '#ff00ff',
  '#ffd700',
  '#1f77b4',
  '#ff7f0e',
  '#2ca02c',
  '#e377c2'],
 'y_on_right': False,
 'gridcolor': None,
 'gridstyle': None,
 'facecolor': '#DCE3EF',
 'rc': [('axes.edgecolor', 'black'),
  ('axes.linewidth', 1.5),
  ('axes.labelsize', 'large'),
  ('axes.labelweight', 'semibold'),
  ('lines.linewidth', 2.0),
  ('font.weight', 'medium'),
  ('font.size', 12.0),
  ('figure.titlesize', 'x-large'),
  ('figure.titleweight', 'semibold')],
 'base_mpf_style': 'default'}
"""

# style='yahoo'の設定
print(mpf._styledata.yahoo.style)
"""
{'base_mpl_style': 'fast',
 'marketcolors': {'candle': {'up': '#00b060', 'down': '#fe3032'},
  'edge': {'up': '#00b060', 'down': '#fe3032'},
  'wick': {'up': '#606060', 'down': '#606060'},
  'ohlc': {'up': '#00b060', 'down': '#fe3032'},
  'volume': {'up': '#4dc790', 'down': '#fd6b6c'},
  'vcedge': {'up': '#1f77b4', 'down': '#1f77b4'},
  'vcdopcod': True,
  'alpha': 0.9},
 'mavcolors': None,
 'facecolor': '#fafafa',
 'gridcolor': '#d0d0d0',
 'gridstyle': '-',
 'y_on_right': True,
 'rc': {'axes.labelcolor': '#101010',
  'axes.edgecolor': 'f0f0f0',
  'axes.grid.axis': 'y',
  'ytick.color': '#101010',
  'xtick.color': '#101010',
  'figure.titlesize': 'x-large',
  'figure.titleweight': 'semibold'},
 'base_mpf_style': 'yahoo'}
"""

default の方は、font.weight とか font.size とか指定されていますが、yahooの方はそれはないですね。でもどちらもフォントを指定するfont.familyは指定されておらず、この両者でフォントの挙動が変わるのは不思議でちょっと悩みました。

結局わかったことは、base_mpl_style で指定されているmatplotlibのスタイルの影響で動きが変わってるってことでした。
default では seaborn-darkgrid が指定され、 yahoo では fast になっています。

matplotlibのリポジトリで確認すると、seaborn-darkgrid を指定すると、font.familyがsans-serifに書き換えられてしまうってことがわかりました。これによって、せっかく設定ファイルで指定してたIPAフォントが使えなくなってしまっていたのですね。
参考: matplotlib/seaborn-v0_8-darkgrid.mplstyle at main · matplotlib/matplotlib

一方で、fastの方 ではfont.familyが指定されていないので僕が設定ファイルで指定していたIPAフォントが使われていたようです。

以上で原因が分かりましたのでここから対応編です。
全体的にstyle=’default’のデザインを使いたくて、フォントだけ日本語にしたいのであれば、font.familyだけをもう一回設定し直したら良いのです。
次のようなコードで実現できました。引数の base_mpf_style はスペルミスしないように気をつけてください。 さっきまで話題にしてたbase_mpl_styleとは1文字だけ違います。

font.familyの新しい設定値は ‘IPAGothic’ とか ‘IPAexGothic’ とか直接指定しても大丈夫です。ただ、僕はAWS/EC2とMacでフォントが違って書き分けるの面倒なので、rcParamsから取得するようにしています。

# styleの設定値を作る
s = mpf.make_mpf_style(
    # 基本はdefaultの設定値を使う。
    base_mpf_style='default',
    # font.family を matplotlibに設定されている値にする。
    rc={"font.family": plt.rcParams["font.family"][0]},
)

mpf.plot(
    price_df,
    type="candle",
    title="ソフトバンク",
    ylabel="株価",
    ylabel_lower="出来高",
    volume=True,
    style=s,
)

これで以下の図が得られます。

これで、デフォルトのスタイルでも日本語文字が使えるようになりました。

mplfinanceの株価チャートに指標を追加する

前回紹介した、mplfinanceの使い方の続編です。前回はただ単純に四本値でローソクチャートを書きましたが、今回はそれに各種テクニカル指標等を追加する方法を紹介します。
参考: mplfinanceで株価チャートを描く

免責事項
本記事では株式や為替などの金融商品の価格に関するデータをサンプルとして利用しますが、効果や正当性を保証するものではありません。本ブログを利用して損失を被った場合でも一切の責任を負いません。そもそも、この記事ではライブラリを使って図に線や点を追加する方法を紹介しているだけであり、著者自身がこの記事で登場している指標の投資における有用性を検証していませんし、投資にも利用していません。あくまでもこういうコードを書いたらこう動くという例の紹介です。

おきまりの文章が終わったのでやってきましょう。前回同様、こんな感じのデータがあるとします。

print("データ件数:", len(price_df))
# データ件数: 182

print(price_df.head(5))
"""
              open    high     low   close   volume
date                                               
2022-01-04  3330.0  3340.0  3295.0  3335.0  75300.0
2022-01-05  3355.0  3370.0  3320.0  3360.0  62200.0
2022-01-06  3345.0  3370.0  3305.0  3305.0  67200.0
2022-01-07  3315.0  3330.0  3265.0  3295.0  84500.0
2022-01-11  3300.0  3300.0  3215.0  3220.0  87400.0
"""

前回はこれをただそのまま表示しましたが、今回はそれに追加して以下の情報を追加で書いていこうと思います。追記する場所として、四本値のローソク足のパネル、出来高のグラフのパネル、新しいパネルを用意してそこに書き込む、の3種類ができるということ、何かしらの指標を線で表示することや特定の点にマーカーをプロットできる、ということを例示するために以下のような例を考えました。

  • 20日間の高値、安値の線 (四本値のパネル)
  • 高値、安値を更新した点のプロット(四本値のパネル)
  • 過去3日間の出来高の合計(出来高のパネル)
  • 前日の終値と当日の終値の差分(新規のパネル)

まずプロットするデータを作ります。データは四本値のデータと同じ長さで、インデックスの日付が共通のDataFrameかSeriesである必要があります。高値線や安値線などのテクニカル指標の場合は大丈夫雨だと思うのですが、1~2ヶ所点をプロットしたいだけ、といった場合であっても、点をプロットない場所は全部NaN値を入れる形で、同じ長さのデータを作らなければなりません。それだけ気をつければ特に詰まるところはないと思います。
まぁ、元のデータのDataFrameに列を追加する形で作っていけば確実でしょう。

高値安値とその更新した点は次のように作りました。更新点はちょっと上下にずれた位置にプロットしたかったので、それぞれ1.02倍/0.98倍しています。

import pandas as pd
import numpy as np


# 高値線、安値線
price_df["h_line"] = price_df.rolling(20, min_periods=1).max()["high"]
price_df["l_line"] = price_df.rolling(20, min_periods=1).min()["low"]

# 高値、安値を更新した日付を算出
h_breakout_flg = price_df["h_line"].shift(1) < price_df["high"]
l_breakout_flg = price_df["l_line"].shift(1) > price_df["low"]

# 更新日にプロットする値を用意する。
price_df["h_breakout"] = np.nan
price_df["l_breakout"] = np.nan
price_df.loc[h_breakout_flg, "h_breakout"] = price_df.loc[h_breakout_flg, "high"] * 1.02
price_df.loc[l_breakout_flg, "l_breakout"] = price_df.loc[l_breakout_flg, "low"] * 0.98

出来高の3日の和と終値の前日との差分はそれぞれ次のように作れます。

price_df["volume_sum"] = price_df.rolling(3)["volume"].sum()
price_df["close_diff"] = price_df["close"].diff()

これで、サンプルデータが出揃ったので可視化していきます。ドキュメントは前回同様Githubのサンプルコードを参照します。今回見るのはこれです。
参考: mplfinance/addplot.ipynb at master · matplotlib/mplfinance

ドキュメントは頼りないので必要に応じてソースコードもみましょう。

チャートに指標を追加するには、plotメソッドを呼び出すときに、addplot 引数に追加したい指標の情報をdictで渡します。複数追加したい時は追加したい指標の数だけのdictをlistにまとめて渡します。(今回やるのはこちら)。

addplot に渡す辞書というのは以下の例のような結構大掛かりな辞書です。

{'data': date
 2022-01-04       NaN
 2022-01-05    3437.4
 2022-01-06       NaN
 2022-01-07       NaN
 2022-01-11       NaN
                ...  
 2022-09-26       NaN
 2022-09-27       NaN
 2022-09-28       NaN
 2022-09-29       NaN
 2022-09-30       NaN
 Name: h_breakout, Length: 182, dtype: float64,
 'scatter': False,
 'type': 'scatter',
 'mav': None,
 'panel': 0,
 'marker': '^',
 'markersize': 18,
 'color': None,
 'linestyle': None,
 'linewidths': None,
 'edgecolors': None,
 'width': None,
 'bottom': 0,
 'alpha': 1,
 'secondary_y': 'auto',
 'y_on_right': None,
 'ylabel': None,
 'ylim': None,
 'title': None,
 'ax': None,
 'yscale': None,
 'stepwhere': 'pre',
 'marketcolors': None,
 'fill_between': None}

このdictデータを自分で作るのは大変です。そこで、mplfinance が専用のメソッド、make_addplot というのを持っているのでこれを使います。これを使って、追加する指標のデータと、どのグラフに書き込むのか(panel)可視化の方法(type, ‘line’, ‘bar’, ‘scatter’, ‘step’から選択)、マーカーや線のスタイル、大きや色、などの情報と合わせて渡すことでデータを作ってくれます。 (メインの四本値のデータよりそこに加筆する指標を先にライブラリに渡すのって微妙に直感的で無くて使いにくいですね。)

例えば、以下のようにすることで加筆データを生成できます。make_addplotに指定できる引数は上のサンプル辞書のkeyを見るのが早いと思います。だいたいイメージ通り動きます。

import mplfinance as mpf


adp = [
    # 高値安値線。 panel=0 と指定し、四本値と同じパネルを指定
    mpf.make_addplot(price_df[["h_line", "l_line"]], type='step',panel=0),
    # 高値更新位置。 scatter を指定し、markerで上向三角形を指定
    mpf.make_addplot(price_df["h_breakout"], type='scatter',panel=0, marker="^"),
    # 安値更新位置。 scatter を指定し、markerで下向三角形を指定
    mpf.make_addplot(price_df["l_breakout"], type='scatter',panel=0, marker="v"),
    # 出来高の和。 panel=1 とすると 出来高のパネルを指定したことになる。 line を指定して折れ線グラフに。
    mpf.make_addplot(price_df["volume_sum"], type='line',panel=1, linestyle="--", color="g"),
    # 終値の前日差分。棒グラフのサンプルも欲しかったのでbarを指定。panel=2とすると新規のパネルが追加される
    mpf.make_addplot(price_df.close.diff(), type='bar',panel=2),
]

さて、これで加筆データができました。 注意する点としてはpanel ですね。panel=0は四本値のグラフで固定ですが、panel=1は次のチャート描写のメソッドで、出来高の表示をするかどうか指定するvolume引数の値で挙動が変わります。Trueなら、出来高のグラフがpanel=1で、panel=2以降が新規のグラフです。一方Falseなら、panel=1から新規のグラフです。番号を飛ばすことができず、panel=1がないとpanel=2は指定できないので注意してください。

では、前回のグラフに追加して、上記のadpの値も渡してみます。今回、パネルが3個になりますが、panel_ratios でそれぞれの幅の調整ができます。メインの四本値のパネルを大きめにしておきました。

mpf.plot(
    price_df,
    volume=True,  # 出来高も表示
    mav=[10, 20],  # 移動平均線
    addplot=adp,  # 追加指標
    figratio=(3, 2),  # 図全体の縦横比
    panel_ratios=(2, 1, 1),  # パネルの縦幅の比率
)

出来上がった図形がこちらです。

いい感じですね。細かい話ですが、高値安値線はtypeをstepにしてカクカクした線にしていて、高値の方に引いた線はtypeをlineにして斜めにつながる線にしています。好みの問題ですがこういう微調整ができるのが良いですね。

最初はデータの準備等に戸惑ったり、思うような微調整に苦戦したりするかもしれませんが、一回作ってしまうと、あとは銘柄を入れ替えたり期間を変えたりしながらパラパラといろんな検証ができます。scatterで自分の仕掛けと手仕舞いのポイント等を入れて検証したりってことも可能ですね。