INVESTMENT #11

株主優待カレンダーを作る(tkinter × SQLite)

Python経験者向けの投資分析シリーズ第11回。自分で登録した優待の権利確定月を、12か月のグリッドに並べて見るGUIアプリを作ります。月をカンマ区切りで持つとSQLで引けないweightsticky は両方書かないと伸びないループ内の lambda で12マス全部が12月になる——実行結果つきで。

🎯 対象: Python経験者・投資分析 📦 Python 3.12.10 / SQLite 3.49.1 / Tcl-Tk 8.6.15 🧪 2026-09-05 に実行して確認 ⏱️ 読了: 約23分

1. この記事のゴールと連載の位置づけ

「自分の投資ポートフォリオを管理・可視化するアプリを作る」連載の第11回です。第10回の最後で予告したとおり、自分で登録した株主優待の権利確定月を12か月のグリッドに並べて見るtkinterアプリを作ります(保存先はSQLite・追加インストール不要)。

株主優待カレンダーアプリの起動直後の画面。上部に登録フォーム、中央に12か月のマスが3行4列で並び、1月と12月は薄い橙、2・3・6・8〜11月は薄い青、4・5・7月は灰色で表示されている(銘柄・優待内容・必要株数はすべて架空のサンプルデータ)
▲ 完成形。--demo の架空8銘柄を12か月に配置したところ(色の意味は5章・すべて架空のサンプルデータ)
⚠️
このアプリがやること・やらないこと

やること:自分で入力した優待の情報(銘柄・権利確定月・必要株数・保有株数・条件のメモ)をSQLiteに保存し、月ごとに並べ替えて表示するだけです。

やらないこと:優待の金銭換算、優待利回りの計算、優待の順位づけ、銘柄の探し方、買う時期の案内、長期保有条件の成否の判定、税額の計算。このアプリには優待の「金額」を入れる列がありません(理由は3章)。

連載での位置は、前の3回と並べると分かります。第8回=配当を記録する(tkinter × SQLite)/第9回=入金の見通しを日付のカレンダーで見る/第10回=条件に合う行を機械的に抜き出す(pandas × CSV)/本記事=登録した優待の権利確定月を月のグリッドで見る

既製の優待カレンダーとの違い

権利確定月の一覧は優待情報サイトでも見られますし、網羅性も鮮度もそちらが上です。それでも自作に値打ちがあるのは向いている方向が逆だから。既製のカレンダーは「世の中の優待」を月で並べたもので、自分が何を持っているかは知りません。手元で作れば「この月の登録が3件、うち必要株数に届いていない登録が1件」という自分のデータでしか出ない表示になり(8章)、見たい列も後から足せます。

外部と一切通信しません(読み書きするのは perks.db--demo なら perks_demo.db の1ファイルだけです)。裏返すと、登録が2〜3件ならメモアプリで足ります。

🎯
この記事で作るもの・確認した環境

完成版は perks_app.py(433行・MITライセンス)。python perks_app.py --demo架空データの perks_demo.db を作り直して起動します(前回 --demo で作った perks_demo.db は消えます)。本文のコード・出力・画面写真は Windows 11 / Python 3.12.10 / SQLite 3.49.1 / Tcl-Tk 8.6.152026年9月5日に実行して確認したもの。tkinterの画面はWindows APIの PrintWindow でアプリに描画させてPNGにしました(Pillow 12.2.0・合成や加筆はなし)。

433行はファイルの並び順ではなく、次の順に読むと本文の章と対応します。migrate()(3章)→ month_counts()(5章)→ PerksApp._build_calendar()_paint_cells()(5〜7章のマス生成と塗り分け)→ show_month()(8・9章の一覧表示)→ _build_form()save_perk()pick_font()(10章)の順に読むと、記事と照らしながら追えるはずです。

2. 優待の権利は「日」ではなく「月」で持つ

データの形を決める前に制度の側を3つ確かめます。この3つが、そのままスキーマの形になります

① 優待は「会社が任意で導入する制度」。金融経済教育推進機構(J-FLEC)の用語集は、株主優待を「株式会社が、株主に対し、保有株式数に応じて自社の製品やサービスなどを提供すること」と定義し、「会社が任意で導入する制度です。」と説明しています(J-FLEC 用語集「株主優待」・2026年9月5日確認)。内容の変更も廃止も会社の判断で起こるので、登録した情報は放っておけば古くなる前提で設計します。

② 権利確定日は月末とは限らない。権利確定日・権利付最終日・権利落ち日の関係は第9回で扱ったので繰り返しません。1点だけ足すと、マネックス証券のFAQには「※月中に権利確定日を設定している企業もあり、同じルールが適用されます。」とあります(マネックス証券 FAQ・2026年9月5日確認)。だからこのツールは日付を持たず「月」だけを持ちます。日を持てば「その2営業日前は何日か」に答える義務が生まれ、取引所の休業日カレンダーを自前で持つ話になる。1日ずれた表示が実害に直結するので、持たないと決めるのも設計です

③ 必要株数は「100株」とは限らない。J-FLECの用語集によれば1単元の株式数は「1,000株を超えない範囲で会社が定款で自由に定めることができます」用語集「単元株」)。100株は2018年10月に売買単位が統一された結果の数字です(J-FLEC 投資の時間 Q&A)。さらに大和インベスター・リレーションズの「株主優待ガイド」は、最低売買単位は100株でも500株以上を保有していないと受け取れない例を挙げています(大和IR 株主優待ガイド・いずれも2026年9月5日確認)。min_shares を100固定にすると、500株必要な登録が黙って「届いている」判定になります。

3. テーブルを決める(金額の列は作らない)

持つのは優待そのものの perks と、権利確定月の perk_months の2つ。1つの銘柄が複数の権利確定月を持つ——この1対多が、第8回のスキーマに無かった構造です。

SCHEMA_VERSION = 1
SCHEMA = """
CREATE TABLE IF NOT EXISTS perks (
    id          INTEGER PRIMARY KEY,
    code        TEXT    NOT NULL UNIQUE,   -- 自分で決める識別子('SMPL-01' など)
    name        TEXT    NOT NULL,
    min_shares  INTEGER NOT NULL,          -- 優待に必要な株数(単元株とは別に会社が決める)
    held_shares INTEGER NOT NULL DEFAULT 0,
    rule_times  INTEGER NOT NULL DEFAULT 0,  -- 長期条件: 連続何回の名簿記載が必要か(0=条件なし)
    held_times  INTEGER NOT NULL DEFAULT 0,  -- 現在すでに連続何回か(自分で数えて入力する)
    note        TEXT    NOT NULL DEFAULT ''  -- 優待内容と条件のメモ(会社の記載を書き写す)
);
CREATE TABLE IF NOT EXISTS perk_months (
    perk_id INTEGER NOT NULL REFERENCES perks(id) ON DELETE CASCADE,
    month   INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
    PRIMARY KEY (perk_id, month)
);
CREATE INDEX IF NOT EXISTS idx_perk_months_month ON perk_months(month);
"""

目につくのは無い列のほうだと思います。優待の価値を入れる列がない。書き忘れではなく方針です。優待利回りは証券会社の用語解説では「株主優待の価値(金額換算)÷ 投資金額 × 100」と説明されています(野村證券「優待利回り」・2026年9月5日確認)。分子が「価値(金額換算)」である以上、換算の仕方で値が変わる。プログラムが機械的に決められる数値ではありません「金額を入れる場所が無い」こと自体が、このツールが優待の得失を扱わないという宣言です。持つのは note(自由テキスト)だけ。

第8回の dividends.db に相乗りする案もありました(列定義の違う CREATE TABLE IF NOT EXISTS を流しても既存テーブルは無傷なので、技術的には共存できます)。それでも別ファイルにしたのは第9回で書いた約束を崩さないためで、理由は12章のFAQに書きました。

後から列を足せるよう、版番号も持たせます。SQLite公式は user_version「アプリケーションが好きなように使える」「SQLite自身はuser_versionを使用しない」整数と説明する一方、schema_version のほうは書き換えると古いスキーマでSQLが実行され、誤った結果やDBの破損につながりうると警告しています(SQLite PRAGMA Statements・2026年9月5日確認・当サイト訳)。

def migrate(db_path):
    """スキーマを作る。版は PRAGMA user_version で持つ(何回呼んでも安全)。"""
    with closing(connect(db_path)) as conn, conn:
        ver = conn.execute("PRAGMA user_version").fetchone()[0]
        if ver < SCHEMA_VERSION:
            conn.executescript(SCHEMA)
            # PRAGMA は ? でバインドできないので、自前の整数リテラルを埋め込む
            conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")

最後の行だけ f-string なのは、PRAGMA の引数がプレースホルダで渡せないからです(conn.execute("PRAGMA user_version = ?", (2,))OperationalError: near "?": syntax error)。埋め込む SCHEMA_VERSIONコードに書いた自前の整数で外から入る値ではありません。f-stringでSQLを組むときは、この「外から来ない」が言えるかが分かれ目です。

4. つまずき実演:月を "3,9" で持つとSQLで引けない

月を別テーブルに分けた理由を失敗のほうから見せます。1行で全部見えるほうが楽なので、最初は perks_flat(code TEXT, months TEXT)'3,9' のような文字列を入れたくなる。--demo と同じ8件で3月の一覧を出すと、正しく3件返ります。

SELECT code, months FROM perks_flat WHERE months LIKE '%3%' ORDER BY code;
('SMPL-01', '3,9')   ('SMPL-04', '3,6,9,12')   ('SMPL-07', '3')

ここで安心するのが罠で、同じクエリを1月に向けるとこうなります。

SELECT code, months FROM perks_flat WHERE months LIKE '%1%' ORDER BY code;
('SMPL-02', '1')
('SMPL-03', '10')
('SMPL-04', '3,6,9,12')
('SMPL-06', '12')
('SMPL-08', '8,11')

5件のうち正しいのは SMPL-02 の1件だけLIKE '%1%' は部分文字列の一致なので 101112 の中の 1 にも当たります。月は1〜12なのでこの誤ヒットは必ず起きるのに、3月あたりで試すと正しく見えるので気づくのが遅れます。

区切り文字を前後に足せば回避はできます(WHERE ','||months||',' LIKE '%,1,%' なら1件に戻りました)。ただし3か月後の自分がこの式を読めるかという問題が残り、さらに索引が効かない(列を加工してから比較するため)、月ごとの件数が出せないGROUP BY months では '3,9''3' が別グループ)という実害が付いてきます。結論は1つの列に複数の値を入れないこと。3章の perk_months がその形で、件数はこれだけで出ます。

SQL_MONTH_COUNTS = """
SELECT pm.month AS m,
       COUNT(*) AS n,
       SUM(CASE WHEN p.held_shares >= p.min_shares THEN 0 ELSE 1 END) AS short_n
  FROM perk_months pm JOIN perks p ON p.id = pm.perk_id
 GROUP BY pm.month
 ORDER BY pm.month
"""

おまけとしてDBが間違った月を弾いてくれますCHECK (month BETWEEN 1 AND 12) があるので13月の挿入は IntegrityError: CHECK constraint failed: month BETWEEN 1 AND 12 で止まる。文字列でまとめて持っていたら "13" も素通りでした。

なお '[3,9]' と入れて json_each() で展開する道もあります(JSON関数はSQLite 3.38.0から既定で組み込み公式。検証環境の3.49.1で動作を確認)。それでも正規化を採るのは、索引を効かせるには生成列が要る・JSONの妥当性は自分で確かめる必要がある・読者のSQLiteが3.38.0未満だと使えないためです。

5. 12か月グリッドを描く(なぜTreeviewではないのか)

作りたいのは12個のマスがあって、月ごとに色が変わり、クリックできるもの。第8回で多用した ttk.Treeview は向きません。Tcl/Tk 8.6の ttk::treeview のマニュアルではタグは item(行)の -tags に付けるものとして定義されており、手元のTk 8.6.15で tag_configure()item() の引数を確認しても「この列のこのセルだけ」を指定する引数は用意されていません。行全体を塗ることはできる(第8回で今年の行を薄い青にしたのがそれ)ので、1マスだけ塗り分けたいなら別の作りが要ります。そこでカレンダーは素の tk.Label を12枚 grid() で並べ、下の一覧表はTreeviewのままにします。

ttk.Label を避ける理由はもう1つ。Python公式ドキュメントはfgbg といったスタイリング関連のウィジェットオプションはTtkウィジェットにはもう存在しない」と明記しています(Python公式 tkinter.ttk・当サイト訳)。テーマに無視されるのではなくオプション自体が無い

>>> ttk.Label(root, text="x", bg="#ffe")
TclError: unknown option "-bg"
>>> ttk.Label(root, text="x", background="#ffe")   # フルスペルは通る
<tkinter.ttk.Label object .!label2>

省略形の bg だけが無く、background は例外なく通りました(cget("background")#ffe を返します)。連番が .!label2 なのは、失敗した1行目がウィジェット名 .!label を先に使ってから TclError になるためです(同じセッションで続けて実行した場合)。ただし見え方はテーマ次第で、検証環境の既定テーマは vista12マスを1つずつ塗り分けるなら tk.Label が素直です。

作る順番にも罠があります。DBから返った行でループしてマスを作ってはいけません。登録のない月は行が返らないからです。--demo の8銘柄で GROUP BY を取ると9行しか返らず(4月・5月・7月が無い)、そのまま並べると9マスのカレンダーができます。先に12マスの枠を作り、0を敷いてから上書きします。

def month_counts(db_path):
    """12か月ぶんの (件数, 未達件数) を必ず返す。DBに無い月は 0 で埋める(5章)。"""
    counts = {m: (0, 0) for m in range(1, 13)}
    for r in query(db_path, SQL_MONTH_COUNTS):
        counts[r["m"]] = (r["n"], r["short_n"])
    return counts

マスの生成側も range(1, 13) を回し、divmod(m - 1, 4) で1月を(0,0)、12月を(2,3)へ。

        for m in range(1, 13):
            r, c = divmod(m - 1, 4)
            cell = tk.Label(grid, text=f"{m}月", font=self.cell_font,
                            bd=1, relief="solid", cursor="hand2")
            cell.grid(row=r, column=c, sticky="nsew", padx=3, pady=3)  # ウィジェット側を広げる
            cell.bind("<Button-1>", lambda e, m=m: self.show_month(m))  # m=m が要る(7章)
            self.cells[m] = cell

マスの色は3色だけにする

背景色は3つだけ。登録なし=灰色/登録ぶんがすべて「保有株数 ≧ 必要株数」=薄い青/1件以上が必要株数未満=薄い橙です。件数の多さで色を変えていないのは、多い月を濃くすると「濃い月が良い月」に見えるから。優待の数と、その優待が自分にとって良いかどうかは無関係——同じ注記をアプリの画面にも焼き込んであります。

6. つまずき実演:weightとstickyは両方書かないと伸びない

12枚のLabelを grid() で並べると、たいてい最初はこうなります——ウィンドウを広げてもマスは小さいまま真ん中に留まり、余白だけが増える。原因は2つあり、片方だけ直しても直りません。Tcl/Tk 8.6の grid のマニュアル(grid manual page・2026年9月5日確認)にはこうあります。

  • -weight「ゼロ(0)のweightは、その列が要求サイズから外れないことを意味する」(当サイト訳)。既定は0なので、columnconfigure(i, weight=1) を書かないかぎり列は広がりません
  • -sticky「既定は "" で、これは中身をセル内で要求サイズのまま中央に置く」(当サイト訳)。ns(または ew)を両方指定すると、中身がセルの高さ(幅)いっぱいに引き伸ばされます

役割が違いますweightセルを広げる設定、stickyウィジェットをセルいっぱいに広げる設定。600×400で1月のセルの実寸を測り、900×600に広げて測り直しました(単位px。マスは実際のアプリと同じ2行表示・既定フォント・bd=1padx/pady=3 の条件で測定)。

パターン600×400900×600に拡大後広がったか
A: weightなし・stickyなし22 × 3422 × 34いいえ
B: weightあり・stickyなし22 × 3422 × 34いいえ
C: weightなし・sticky="nsew"22 × 3422 × 34いいえ
D: weightあり・sticky="nsew"139 × 127214 × 194はい

A・B・Cは1pxも変わりません。両方書いたDだけが初期表示から広く、拡大にも追従しました。行と列の両方weight が要ります(rowconfigure を忘れると、横に伸びる帯状のカレンダーになります)。

        for r in range(3):
            grid.rowconfigure(r, weight=1)       # セル側を広げる
        for c in range(4):
            grid.columnconfigure(c, weight=1)

7. つまずき実演:ループ内のlambdaで12マス全部が12月を表示する

マスをクリックしたら、その月の一覧を下に出したい。素直に書くと動きません。

for m in range(1, 13):
    lbl.bind("<Button-1>", lambda e: self.show_month(m))   # ← 動かない

実行するとどのマスを押しても12月が出ます。12枚のLabelにイベントを送り、受け取った月を記録した結果がこれです。

BAD : [12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12]
GOOD: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

Python公式FAQにこの現象の項目があります(Python公式FAQ「ループ内で定義したlambdaが全部同じ結果を返すのはなぜ?」・2026年9月5日確認)。x はlambdaのローカル変数ではなく外側のスコープで定義されていて、lambdaが定義されたときではなく呼ばれたときに参照される」(当サイト訳)。ループが終わった時点で m は12なので、12枚全部が12を読みます。公式が示す対処は「値をlambdaのローカル変数に保存する」こと。デフォルト引数は定義時に評価されるので、その時の m が固定されます(5章の lambda e, m=m: がそれです)。

引数の順番には注意が要り、bind() のコールバックは第1引数にイベントオブジェクトを受け取るので e を先に書きます。逆にすると SyntaxError: parameter without a default follows parameter with a default です。

第8回のコードには、既に同じ形が出ていました。Treeviewの見出しに並べ替えを仕込む command=lambda c=cid, k=key: sort_column(tree, c, False, k) がそれで、あのとき「遅延束縛の対策」と一行触れただけの正体がこれです。command= はイベントを受け取らず、bind() は受け取るという非対称だけが違い、ループの中で作った関数にそのときの値を持たせる点は同じ。公式FAQも「この挙動はlambdaに固有のものではなく、通常の関数にも当てはまる」と付け加えています。

株主優待カレンダーアプリで3月のマスを選んだ画面。3月のマスが太枠になり、下の表にSMPL-01・SMPL-04・SMPL-07の3行が必要株数100株・保有株数100/100/300株とともに表示されている(すべて架空のサンプルデータ)
▲ 3月のマスをクリックしたところ。選択中のマスは枠を太くしている(すべて架空のサンプルデータ)

8. 必要株数に届いているか(引き算しかしない)

月ごとの一覧では、登録した必要株数と、登録した保有株数を並べます。表示の文言を作る関数はこれだけです。

def shares_status(min_shares, held_shares):
    """登録された2つの数値を比べるだけ。買い増しの提案ではない。"""
    if held_shares >= min_shares:
        return "必要株数以上"
    return f"必要株数まで {min_shares - held_shares:,}株"

ここは言葉を選ぶ場所です。「必要株数まで 500株」は自分が入力した2つの数値の引き算の結果であって、買い足しの提案ではありません。実際に優待を受け取れるかは権利確定日の株主名簿の記載や会社ごとの条件で決まるもので、このアプリは何も知りません。配布コードのヘッダにも同じ断りを入れてあります。

12月のマスを選んだ画面。マスに「2件(1件 未達)」と表示され、下の表でSMPL-06が必要株数1,000株・保有株数500株・「必要株数まで 500株」と表示されている(すべて架空のサンプルデータ)
▲ 登録した保有株数が必要株数に届いていない例(架空データ・500株という差は入力値の引き算)

第10回では配当利回りの比較で割り算の丸めによって境界の行が落ちる問題に紙幅を使いましたが、今回は株数どうしの比較なので出てくる数はすべて整数。浮動小数点の話が原理的に出てきません。

ただしNULLは静かに落ちますNULL >= 100 は真でも偽でもなくNULLで、CASE WHEN では ELSE 側に落ち、WHERE に書けばその行は消える(実際に SELECT NULL >= 100 IS NULL, CASE WHEN NULL >= 100 THEN 1 ELSE 0 END(1, 0) を返します)。件数だけ見ていると、消えたことに気づけません——第10回でpandasのNaNについて書いたのと同じ形の罠がSQLにもあります。だから perks の数値列はすべて NOT NULLheld_sharesrule_timesheld_timesDEFAULT 0)にしてあり、保有株数0は「必要株数まで 100株」と表示されて画面から消えません。

9. 長期保有条件は「アプリで判定しない」

優待には「継続して1年以上保有」のような条件が付くことがあります。自動で判定したくなる——そしてここが計算してはいけない場所です。

株主名簿を管理する信託銀行のFAQには「優待の対象になる条件として株式を継続して保有する必要がある場合があります。銘柄によって保有期間やカウント方法が異なるので、各社のウェブサイト(投資家情報等)をご確認ください。」とあります(みずほ信託銀行 FAQ・2026年9月5日確認)。同じFAQは、株主番号が証券保管振替機構からの通知に基づいて自動で判定・採番されるとも説明しています。判定は手元の日数計算ではなく株主名簿の側で行われ、数え方も銘柄ごとに違う。継続保有の条件を日数ではなく、権利確定日ごとに名簿へ載った回数で定めている会社もあります。

日数で数えると、名簿の回数と食い違う

仮に名簿の判定が3月末と9月末に行われ、必要な回数が3回だとします(説明のための一例で、判定月も必要回数も会社ごとに違います)。この前提だと、日数と回数が食い違います。

保有した期間日数で数えると名簿の回数で数えると(3月末・9月末に判定する場合)
2024-04-01 に買って 2025-03-31 まで364日(1年に届かない)9月末・3月末の2回
2024-03-27(3月末基準日の権利付最終日)に買って 2025-03-31 まで369日3月末・9月末・3月末の3回

買った日が5日違うだけで数えられる回数が2回と3回に分かれますdate + timedelta(days=365) で判定を書いていたら、この差は表現できません。ならばアプリはどうするか。成否を出さず、登録された数字を並べるだけにします

def times_status(rule_times, held_times):
    """長期条件は成否を判定せず、登録された回数をそのまま並べて出す。"""
    if rule_times == 0:
        return "登録なし"
    return f"{held_times} / {rule_times} 回(登録値)"

画面には 4 / 5 回(登録値) のように出ます。「あと1回で条件達成」とは書きません。数え方も判定月も会社ごとに違い、株主番号が変わる条件も本記事の範囲外だからです(証券会社の解説では、貸株サービスの利用が株主番号に影響する場合があると案内されています。詳細はご利用の証券会社へ)。条件の中身は note に会社の記載を書き写して使う——アプリの仕事はそこまでです。

⚠️
計算できるからといって、計算してはいけないものがある

長期保有条件の判定はアプリが持っていない情報(株主名簿の記載状況・会社ごとの数え方)に依存しているので、日数から出した答えはもっともらしく間違います。この連載でツールに入れないと決めたものは他にもあります——権利付最終日の自動計算(第9回)、しきい値の既定値(第10回)、税額の計算(第8回)。入れない判断のほうが、たいてい難しい。

10. 登録フォームと、環境まわりの2つの罠

入力フォームは第8回の型の使い回しです(数値の欄は validate="key" で1文字ごとに数字だけを通し、送信時にもう一度検証)。新しいのは12個の tk.IntVar をチェックボックスに割り当てて複数の月を選ぶところだけ。送信時は「1つも選ばれていない」を弾きます。月が0個の優待は、カレンダーのどこにも現れない幽霊レコードになるからです。

登録フォーム部分の拡大。コードSMPL-06・銘柄名サンプル建設・必要株数1000・保有株数500が入力され、権利確定月は12だけにチェックが入っている(すべて架空のサンプルデータ)
▲ 一覧の行を選ぶと、その内容がフォームに戻る(架空のサンプルデータ)。直して「登録する」で上書きできる

更新時に perk_months の差分を計算したくなりますが、1銘柄あたり最大12行なので全部消して入れ直すほうが短く、バグの置き場所も増えません。

        conn.execute("DELETE FROM perk_months WHERE perk_id = ?", (perk_id,))
        conn.executemany("INSERT INTO perk_months (perk_id, month) VALUES (?, ?)",
                         [(perk_id, m) for m in sorted(months)])

この2行は with conn: の内側なので、途中で失敗すれば消える前に戻ります。銘柄を消すときは perks の行だけを消せば ON DELETE CASCADE で月も消えます(4行あった月が0行になるのを確認)。ただし PRAGMA foreign_keys = ON を接続ごとに実行しないと外部キーは働きません——SQLiteでは既定が無効です。

罠①:フォント名を決め打ちすると読者の環境で外れる

マスに大きめの文字を出したいのでフォントを指定します。ここで "Meiryo" と書きたくなりますが、検証環境で tkinter.font.families() を調べたら意外な結果でした。

Yu Gothic UI  True     Yu Gothic  False
Meiryo UI     True     Meiryo     False
MS UI Gothic  True     MS Gothic  False
TkDefaultFont の family = Yu Gothic UI

この環境では「UI」が付くほうだけが一覧に出て、無印の MeiryoYu GothicMS Gothic は返ってきませんでした(当サイトの検証環境での実測)。Microsoftが公開しているWindows 11のフォント一覧で裏づけられるのは、Meiryo が「Japanese Supplemental Fonts」(Feature On Demand)側=入っていないPCがありうることまでです。無印の Yu GothicMS GothicYu Gothic UIMS UI Gothic と同じ同梱フォントの表に載っており(Microsoft Learn「Font List Windows 11」・2026年9月5日確認)、同梱されているはずのこの2つが font.families() に現れなかった理由は当サイトでは確認できていません。未インストールでも列挙の都合でも、結論は同じ——GUIアプリは読者のPCで起動するので、名前は決め打ちしません。

pick_font()PREFERRED_FONTS = ("Yu Gothic UI", "Meiryo UI", "MS UI Gothic") のうち font.families() にあるものを選び、1つも無ければ family を指定せずTkの既定に任せます。「無かったら諦める」を書いておくと、環境が変わっても落ちません。

罠②:DPIの設定は2回目が必ず失敗する

WindowsでtkinterのGUIがぼやける対処として SetProcessDpiAwareness がよく紹介されます。ただしMicrosoftのドキュメントはAPI呼び出しではなくアプリケーションマニフェストでの設定を推奨したうえで、「一度awarenessが設定されると、以降のこのAPIの呼び出しは失敗する」と書いています(Microsoft Learn SetProcessDpiAwareness・2026年9月5日確認・当サイト訳)。実際に2回呼ぶと 1回目は 0(S_OK)、2回目は -21470248910x80070005 = E_ACCESSDENIED)。IDLEのように同じプロセスで何度も走らせると2回目で落ちるので、enable_dpi_awareness()try/except で握りつぶし、Windows以外は sys.platform で先に抜け、tk.Tk() より前に呼びます。正直に書くと、この設定で画面が鮮明になることは確認できていません(検証環境はスケーリング100%で、高DPI環境での違いは未確認)。

11. つまずき一覧と、このツールの限界

やりがちな書き方何が起きるかどうするか
権利確定月を "3,9" の文字列で持つLIKE '%1%' が10・11・12月まで拾う。索引も GROUP BY も効かない1行1値の perk_months に分ける(4章)
DBの結果でループしてマスを作る登録の無い月が消えて9マスのカレンダーになる12マスを先に作り、0で埋めてから上書き(5章)
マスを ttk.Label で作る/1マスだけ色を変えたくて Treeview を使うbg オプションが無く TclError/タグは行単位でセル単位の指定が無いカレンダーは tk.Label、明細はTreeviewで使い分け(5章)
stickyweight の片方だけ書くウィンドウを広げてもマスが広がらない(実測で1pxも変化なし)両方書く。weight=セル/sticky=ウィジェット(6章)
ループ内で lambda e: f(m) を渡す12マス全部が12月を表示するlambda e, m=m: で定義時に束縛(7章)
数値列をNULLのままにする/PRAGMA user_version = ? でバインドするNULL >= 100 はNULLで WHERE から静かに消える/OperationalError: near "?"NOT NULLDEFAULT 0(8章)/自前の整数をf-stringで埋め込む(3章)
フォント名を決め打ちする/SetProcessDpiAwareness を裸で呼ぶ無印の Meiryo が返らない環境がある/2回目が E_ACCESSDENIED で落ちるfont.families() から選ぶ/try/exceptsys.platform(10章)

このツールで分かること/分からないこと

分かること分からないこと
自分が登録した優待の権利確定月の分布登録していない優待のこと(入力した情報がすべて)
登録した保有株数が、登録した必要株数に届いているか実際に優待を受け取れるかどうか(名簿の記載や会社の条件で決まる)
長期条件として登録した回数と現在の回数その条件を満たしているかどうかの判定結果
月ごとの件数(登録ぶん)と note の文面権利確定日が何日か(月しか持っていない)
優待の変更・廃止(会社が任意に決める・2章)/優待の金銭価値・優待利回り(3章)

右の列が結論です。このツールは、自分が入力した情報を並べ替えているだけ。優待の内容も必要株数も会社が変えられるので、入力が古ければ出てくる画面も同じだけ古い。年に一度、各社のIR情報と突き合わせて note を直す手間込みで使う道具です。

見る観点優待情報サイト・証券会社の検索本記事の自作
対象と鮮度世の中の優待を広く網羅・提供元が更新自分が登録した銘柄だけ・入力した時点のまま
自分の保有との紐づけ基本的にない保有株数と並べて出せる

優待を探す用途なら、既製のサービスのほうが速く、情報も整っています。自作が受け持つのは自分の保有と並べて見るところだけです。

📖
次に読むなら:仕様の既定値を読む力をつける

この記事のつまずきはほぼ全部が「既定値」の話でした。weight の既定は0、sticky の既定は空、foreign_keys の既定は無効、ttkbg は無い——どれもエラーにならず、静かに期待と違う結果を返します。ドキュメントの隅にある既定値を読む習慣を鍛えるならEffective Python 第3版が近道です(当サイトのおすすめ本ランキング総合4位・全572ページ)。リンク先は当サイトの書籍紹介ページで、アフィリエイト広告を含みます。

次回:配当再投資シミュレーション

第12回では配当を再投資したときの推移をシミュレーションするツールを扱う予定です。連載の全体像は投資×Python シリーズ一覧にあります。

📌
この連載のスタンス

本連載は「自分の資産を自分で管理・可視化するツールを、Pythonの学習題材として自作する」ことが目的です。特定銘柄の売買や投資手法を勧めるものではなく、記事とコードに出てくる銘柄コード・銘柄名・優待内容・必要株数・保有株数・権利確定月はすべて架空で、実在の企業や優待制度とは関係がありません。当サイトは投資助言業(金融商品取引業)の登録を行っておらず、個別銘柄の推奨・売買タイミングの助言・税務相談には応じられません。税制の適用や確定申告の要否は国税庁の情報か税理士にご確認ください。投資判断はご自身の責任で(詳細は免責事項)。

12. よくある質問(FAQ)

Q. 優待のデータはどこで手に入りますか?

本記事では取得を扱わず、手で入力する前提です。一次情報は各社のIR情報で、当サイトでは、個人が規約違反なく一括で手に入れられる公的な機械可読データを確認できていません。Webページから機械的に集めるなら、第2回で扱った利用規約とrobots.txtの確認が先です。

Q. 第8回の dividends.db に一緒に入れられますか?

技術的にはできます(3章)。別ファイルにしたのは第9回で「第8回のDBには1行も書き込まない」と書いた約束を守るためです。同じDBにしたいなら DEFAULT_DB の1行を書き換えれば動きますが、先に dividends.db のコピーを取ってください

Q. tkcalendar を使えば早いのでは?

ほしいのは日を選ぶピッカーではなく、12か月を1画面に出すグリッドなので用途が違います。加えて第8回で書いたとおり tkcalendar はPyPIの最新リリースが1.6.1・公開日2019年12月28日で更新が止まっており(2026年9月1日確認)、この連載は依存を増やさない方針です。

Q. おすすめの優待銘柄はどれですか?

本記事では扱いません。当サイトは投資助言業の登録をしておらず、個別銘柄の推奨はできません。このアプリも優待の金銭価値を計算する列を持っていません(3章)。並べ替えも銘柄コード順と月順だけで、有利さの順には並びません。

Q. 権利を取るには何日までに買えばいいですか?

権利確定日・権利付最終日・権利落ち日の関係は第9回で扱っています。このアプリは日付を持たず、月しか扱いません(2章)。正確な日付は各社のIR情報とご利用の証券会社のカレンダーでご確認ください。

Q. 長期保有条件の「現在すでに連続何回か」は何を見て入力しますか?

アプリは数えません(9章)。数え方も判定月も会社ごとに違い、判定そのものは株主名簿の側で行われます。各社のIR情報に書かれた数え方を読み、ご自身の保有状況と照らして手で入力してください。分からないうちは「現在(回)」(held_times)と「長期条件(回)」(rule_times)を0のままにして、条件の文面を note に書き写しておけば画面には「登録なし」と出ます。このアプリは「あと何回で条件達成」を計算も表示もしません。

Q. 登録した優待が廃止されたらどうなりますか?

アプリは何も知らないので登録したまま表示され続けます。株主優待は「会社が任意で導入する制度」(J-FLECの用語集)で、内容の変更も廃止も会社の判断で行われます。note に「2026-09-05 時点のIR記載」のように日付ごと書き写しておくと、古さに気づけます。

Q. 優待でもらったものに税金はかかりますか?

国税庁の確定申告書等作成コーナーの案内には「株主優待を受け取った場合は雑所得に該当しますので、『雑所得(その他)』から入力してください。」と記載されています(国税庁 よくある質問・令和元年7月1日現在の法令・通達等に基づく案内・2026年9月5日確認)。申告の要否や評価は個々の事情で変わるため、本記事では扱いません。国税庁の情報を確認するか税理士にご相談ください。このツールは税額を1円も計算しません。

Q. このツールは外部と通信しますか?

通信しません。読み書きするのは perks.db--demo なら perks_demo.db)1ファイルだけで、ネットワークを扱うライブラリはimportしていません。第8回の dividends.db にも接続しません。なお perks.db は「自分が何を持っているか」のリストそのものなので、共有フォルダ・クラウド同期フォルダ・公開ディレクトリには置かないでください

🧰
読み終えたら:まず --demo、そのあと3本

まずは完成版 perks_app.py を保存して python perks_app.py --demo を実行するところまで。架空8銘柄の入った perks_demo.db が作られ、この記事の1枚目の画面がそのまま出ます(自分のDBを開くときは --demo を外してください)。

そのあと手を伸ばすなら3本。tkinterをもう少し触るならtkinterで画像編集アプリを作るガイド、記録する側は第8回の配当金管理ツールtkintersqlite3 の位置づけはPythonライブラリ一覧。連載の続きは投資×Python シリーズ一覧(ブックマーク推奨)か新着記事のRSSで追えます。

本記事は、生成AIを活用して下書きし、運営者が内容を確認・編集したうえで公開しています。掲載しているコード・出力・画像は Windows 11 / Python 3.12.10 / SQLite 3.49.1 / Tcl-Tk 8.6.15 で 2026年9月5日に実行して確認したものです。tkinterの画面はWindows APIの PrintWindow でウィンドウを描画させてPNGにしました(Pillow 12.2.0・合成や加筆はなし)。サンプルの銘柄コード・銘柄名・優待内容・必要株数・保有株数・権利確定月・長期保有条件の回数はすべて架空で、実在の企業や優待制度とは関係がありません。9章の日数と回数の例は説明のための作例であり、判定方法・必要回数・判定月は会社ごとに異なります。本記事はプログラミングの情報提供を目的としたもので、銘柄の売買・投資手法の推奨でも税務上の助言でもありません。投資判断はご自身の責任で。AIの利用方針は免責事項をご覧ください。