# PythonLand 投資×Python シリーズ 第9回
# 「配当金カレンダーを自動生成する（calendar.HTMLCalendar × matplotlib）」
# https://pythonland.tech/dividend-calendar-generator.html
#
# 記事4〜8章のコードをつないだ完成版です（2026-09-03 時点）。
# 第8回で作った dividends.db を読み、指定年の「配当入金カレンダー（HTML）」と
# 「月別入金額のグラフ（PNG）」を書き出します。
#
# ⚠️ 通信について: 既定の動作（HTML と PNG の生成）は完全にローカルで、外部との通信はありません。
#    --fetch-ex-date を付けたときだけ yfinance 経由で Yahoo! Finance と通信します。
#    送るのは銘柄コードだけで、dividends.db の中身は送信しません。
# ⚠️ 出力する HTML には保有株数と入金額が入ります。自分の資産情報なので、
#    共有フォルダ・公開ディレクトリ・Web サーバーの配下に置かないでください。
# ⚠️ 将来月に出る値は「過去の入金実績を写した見込み」で、入金の確約ではありません。
#    増配・減配・無配・配当回数の変更で変わります。正確な支払日は各社の決算短信
#    「配当金支払開始予定日」でご確認ください。
# ⚠️ --demo で入るサンプルデータの銘柄名・株数・金額はすべて架空です。実在の銘柄とは無関係で、
#    推奨銘柄でも運用実績でもありません。本ツールは税額を一切計算しません。
# ライセンス: MIT（https://pythonland.tech/ のコードは MIT ライセンスで公開しています）

"""第8回の dividends.db から、年間の配当入金カレンダー(HTML)と月別グラフ(PNG)を作る。

使い方:
    python dividend_calendar.py                      # 同じフォルダの dividends.db・今年
    python dividend_calendar.py --year 2026          # 年を指定
    python dividend_calendar.py --demo --year 2026   # 架空データのサンプルDBを作って動かす
    python dividend_calendar.py --fetch-ex-date 7203.T   # ここだけ外部と通信する
"""
import argparse
import calendar
import html
import sqlite3
import sys
from collections import defaultdict
from contextlib import closing
from datetime import date
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
DEFAULT_DB = BASE_DIR / "dividends.db"
DEMO_DB = BASE_DIR / "dividends_demo.db"

# 第8回と同じスキーマ（--demo でサンプルDBを作るときだけ使う）
SCHEMA = """
CREATE TABLE IF NOT EXISTS stocks (
    ticker    TEXT PRIMARY KEY,
    name      TEXT NOT NULL,
    name_kana TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dividends (
    id            INTEGER PRIMARY KEY,
    paid_on       TEXT    NOT NULL,
    ticker        TEXT    NOT NULL REFERENCES stocks(ticker),
    shares        INTEGER NOT NULL,
    per_share_sen INTEGER NOT NULL,
    net_sen       INTEGER NOT NULL,
    created_at    TEXT    NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_div_paid_on ON dividends(paid_on);
"""

# 明細をそのまま取る。月の集計はPython側でやる（同じ行から カレンダーとグラフを作るため）
SQL_PAYMENTS = """
SELECT d.paid_on, d.ticker, s.name, d.shares, d.net_sen
  FROM dividends d JOIN stocks s ON s.ticker = d.ticker
 ORDER BY d.paid_on, d.ticker
"""

# 「銘柄ごとの入金月パターン」だけは年をまたいで数えたいので '%m' 単独でよい。
# 年ごとの合計が欲しいときに '%m' を使うと年をまたいで合算される（記事5章）。
SQL_MONTH_PATTERN = """
SELECT s.name, strftime('%m', d.paid_on) AS m, COUNT(*) AS n
  FROM dividends d JOIN stocks s ON s.ticker = d.ticker
 GROUP BY d.ticker, m
 ORDER BY s.name, m
"""


# ---------------------------------------------------------------- 日付ユーティリティ（4章）
def add_months(d, months):
    """d の months か月後。存在しない日はその月の末日に丸める。

    date.replace(month=...) だと 2026-03-31 の3か月後で
    ValueError: day is out of range for month になる。
    """
    total = d.month - 1 + months
    year = d.year + total // 12
    month = total % 12 + 1
    last_day = calendar.monthrange(year, month)[1]   # (1日の曜日, その月の日数)
    return date(year, month, min(d.day, last_day))


def yen(sen):
    """銭(int) -> 表示用の円文字列。956200 -> '9,562'。"""
    return f"{sen // 100:,}"


# ---------------------------------------------------------------- DB層（5章）
def connect(db_path):
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    return conn


def query(db_path, sql, params=()):
    with closing(connect(db_path)) as conn:
        return conn.execute(sql, params).fetchall()


def load_payments(db_path):
    """入金実績を [(date, ticker, name, shares, net_sen)] で返す。"""
    rows = []
    for r in query(db_path, SQL_PAYMENTS):
        rows.append((date.fromisoformat(r["paid_on"]), r["ticker"],
                     r["name"], r["shares"], r["net_sen"]))
    return rows


# ---------------------------------------------------------------- 見込みの推定（6章）
def project_next_year(payments, year):
    """前年の入金を year に写して「見込み」を作る。

    同じ銘柄・同じ月に year の実績がすでにあれば作らない（実績が優先）。
    金額は前年と同額を置いただけの見込みで、入金の確約ではない。
    """
    actual_keys = {(t, d.month) for d, t, *_ in payments if d.year == year}
    projected = []
    for d, ticker, name, shares, net_sen in payments:
        if d.year != year - 1:
            continue
        moved = add_months(d, 12)
        if (ticker, moved.month) in actual_keys:
            continue
        projected.append((moved, ticker, name, shares, net_sen))
    return projected


def build_events(payments, year):
    """{date: [(name, net_sen, is_actual), ...]} と 月別合計(実績/見込み)を返す。"""
    events = defaultdict(list)
    actual_by_month = [0] * 13          # 1〜12月。0番は使わない
    planned_by_month = [0] * 13

    for d, _t, name, _s, net_sen in payments:
        if d.year != year:
            continue
        events[d].append((name, net_sen, True))
        actual_by_month[d.month] += net_sen

    for d, _t, name, _s, net_sen in project_next_year(payments, year):
        events[d].append((name, net_sen, False))
        planned_by_month[d.month] += net_sen

    for day in events:
        events[day].sort(key=lambda e: (not e[2], e[0]))
    return events, actual_by_month, planned_by_month


# ---------------------------------------------------------------- HTMLカレンダー（7章）
class DividendCalendar(calendar.HTMLCalendar):
    """日付のマスに入金の実績と見込みを差し込む HTMLCalendar。"""

    def __init__(self, events, firstweekday=calendar.MONDAY):
        super().__init__(firstweekday)
        self.events = events
        self._year = None

    def formatmonth(self, theyear, themonth, withyear=True):
        # formatday には年月が渡らないので、ここで覚えておく
        self._year, self._month = theyear, themonth
        return super().formatmonth(theyear, themonth, withyear)

    def formatmonthname(self, theyear, themonth, withyear=True):
        # calendar.month_name は英語（ロケール未設定なら 'March'）なのでベタ書きする
        label = f"{theyear}年{themonth}月" if withyear else f"{themonth}月"
        return f'<tr><th colspan="7" class="month">{label}</th></tr>\n'

    def formatweekday(self, day):
        names = "月火水木金土日"
        return f'<th class="{self.cssclasses[day]}">{names[day]}</th>'

    def formatday(self, day, weekday):
        if day == 0:
            return '<td class="noday">&nbsp;</td>'   # 前後の月にはみ出したマス
        items = self.events.get(date(self._year, self._month, day), [])
        css = self.cssclasses[weekday]
        if not items:
            return f'<td class="{css}"><span class="dnum">{day}</span></td>'

        css += " has-div" if any(a for _n, _s, a in items) else " has-plan"
        parts = [f'<span class="dnum">{day}</span>']
        for name, net_sen, is_actual in items:
            mark = "" if is_actual else "見込"
            klass = "ev" if is_actual else "ev plan"
            parts.append(f'<span class="{klass}">{html.escape(name)}<b>'
                         f'{yen(net_sen)}円{mark}</b></span>')
        return f'<td class="{css}">' + "".join(parts) + "</td>"


CSS = """
body { font-family: "Meiryo", "Yu Gothic", sans-serif; margin: 24px; color: #1f2933;
       background: #f7f9fb; }
h1 { font-size: 1.4rem; margin: 0 0 .3rem; }
p.note { font-size: .82rem; color: #52616b; margin: .2rem 0 1rem; max-width: 68em; }
table.summary { border-collapse: collapse; margin: 0 0 1.2rem; background: #fff; }
table.summary th, table.summary td { border: 1px solid #d5dde3; padding: 4px 10px;
       font-size: .82rem; text-align: right; }
table.summary th { background: #eef3f7; text-align: center; }
table.summary td.lab { text-align: left; }
table.year { border-collapse: separate; border-spacing: 9px; }
table.year > tbody > tr > td { vertical-align: top; }
th.year { font-size: 1.1rem; padding: 4px 0 8px; }
table.month { border-collapse: collapse; background: #fff; width: 336px;
       box-shadow: 0 1px 2px rgba(0,0,0,.08); }
table.month th { border: 1px solid #d5dde3; font-size: .72rem; padding: 3px;
       background: #eef3f7; font-weight: normal; }
table.month th.month { background: #2c5f8a; color: #fff; padding: 5px;
       font-size: .88rem; font-weight: bold; }
table.month td { border: 1px solid #e3e9ee; height: 46px; width: 48px;
       vertical-align: top; font-size: .7rem; padding: 1px 2px; }
td .dnum { display: block; color: #52616b; }
td.sat .dnum { color: #2f6f9f; }
td.sun .dnum { color: #b23b3b; }
td.noday { background: #f2f5f8; }
td.has-div { background: #dcecf8; }
td.has-plan { background: #fdf2dc; }
span.ev { display: block; font-size: .58rem; line-height: 1.2; color: #14405f;
       word-break: break-all; }
span.ev b { display: block; font-weight: bold; }
span.ev.plan { color: #8a5a12; }
p.demo { background: #fff3f3; border: 1px solid #e0b4b4; color: #8a2b2b;
       padding: 8px 12px; font-size: .85rem; max-width: 68em; }
.legend { font-size: .78rem; margin: 0 0 1rem; }
.legend i { display: inline-block; width: 14px; height: 14px; border: 1px solid #c3ced6;
       vertical-align: -2px; margin: 0 4px 0 14px; }
.legend i.a { background: #dcecf8; } .legend i.p { background: #fdf2dc; }
"""


def render_html(year, events, actual, planned, generated_on, demo=False):
    cal = DividendCalendar(events)
    rows = []
    for m in range(1, 13):
        rows.append(f"<tr><td class='lab'>{m}月</td>"
                    f"<td>{yen(actual[m])}</td><td>{yen(planned[m])}</td>"
                    f"<td>{yen(actual[m] + planned[m])}</td></tr>")
    summary = (
        "<table class='summary'><tr><th>月</th><th>実績</th><th>見込み</th><th>合計</th></tr>"
        + "".join(rows)
        + f"<tr><td class='lab'>年計</td><td>{yen(sum(actual))}</td>"
          f"<td>{yen(sum(planned))}</td><td>{yen(sum(actual) + sum(planned))}</td></tr></table>")
    banner = ("<p class='demo'>⚠ これは <code>--demo</code> で生成した"
              "<strong>架空のサンプルデータ</strong>です。銘柄名・株数・金額はすべて架空で、"
              "実在の銘柄・運用実績ではありません。</p>\n") if demo else ""
    return f"""<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{year}年 配当入金カレンダー</title>
<style>{CSS}</style>
</head>
<body>
<h1>{year}年 配当入金カレンダー</h1>
{banner}<p class="note">dividends.db の入金実績から生成（{generated_on} 作成）。
色の付いた日が入金日です。<strong>「見込み」は前年の同じ月の入金をそのまま写した推定値で、
入金の確約ではありません</strong>（増配・減配・無配・配当回数の変更で変わります）。
正確な支払日は各社の決算短信「配当金支払開始予定日」でご確認ください。
このファイルには保有株数と金額が入ります。共有フォルダには置かないでください。</p>
<div class="legend"><i class="a"></i>実績（入金済み）<i class="p"></i>見込み（過去実績からの推定）</div>
{summary}
{cal.formatyear(year, width=3)}
</body>
</html>
"""


# ---------------------------------------------------------------- 月別グラフ（8章）
def render_chart(year, actual, planned, out_png):
    import matplotlib
    matplotlib.use("Agg")                      # 画面を出さずPNGに書く
    import matplotlib.pyplot as plt

    months = list(range(1, 13))
    a = [actual[m] / 100 for m in months]
    p = [planned[m] / 100 for m in months]
    with plt.rc_context({"font.family": "Meiryo"}):
        fig, ax = plt.subplots(figsize=(9, 4.2), dpi=110)
        ax.bar(months, a, color="#2c5f8a", label="実績")
        ax.bar(months, p, bottom=a, color="#f0c987", hatch="//",
               edgecolor="#b8860b", label="見込み（推定）")
        ax.set_title(f"{year}年 月別の配当入金額（架空の入力例）")
        ax.set_xlabel("月")
        ax.set_ylabel("入金額（円）")
        ax.set_xticks(months, [f"{m}月" for m in months])
        ax.yaxis.set_major_formatter(lambda v, _pos: f"{v:,.0f}")
        ax.grid(axis="y", alpha=.3)
        ax.set_axisbelow(True)
        ax.legend(loc="upper left")
        fig.tight_layout()
        fig.savefig(out_png)
        plt.close(fig)


# ---------------------------------------------------------------- yfinance突合（3章・通信あり）
def fetch_ex_date(ticker):
    """⚠️ この関数だけ Yahoo! Finance と通信する（送るのは銘柄コードのみ）。

    取れるのは権利落ち日であって支払日ではない。日本株では 'Dividend Date'（支払日）が
    返らないことを確認済み（2026-09-03・yfinance 1.4.1）。
    """
    import yfinance as yf

    t = yf.Ticker(ticker)
    cal = t.calendar or {}
    ex = cal.get("Ex-Dividend Date")            # キーが無い銘柄があるので .get で取る
    pay = cal.get("Dividend Date")              # 日本株では入っていない
    print(f"[{ticker}] Ex-Dividend Date（権利落ち日）: {ex}")
    print(f"[{ticker}] Dividend Date（支払日）      : {pay}")
    if ex and not pay:
        lo, hi = add_months(ex, 2), add_months(ex, 3)
        print("  -> 支払日は取得できません。証券会社の説明にある「権利確定日から2〜3か月後」")
        print(f"     という目安を権利落ち日に当てはめると {lo} 〜 {hi} 頃ですが、あくまで目安で、")
        print("     権利確定日そのものではないぶんのズレも含みます。正確な日付は各社の決算短信")
        print("     『配当金支払開始予定日』を確認してください。")
    hist = t.dividends
    if len(hist):
        print("  直近の1株あたり配当（税引前・index は権利落ち日）:")
        print(hist.tail(3).to_string())


# ---------------------------------------------------------------- サンプルDB（架空データ）
# ⚠️ 銘柄名・株数・金額はすべて架空です。実在の企業とは関係がなく、推奨銘柄でもありません。
DEMO_STOCKS = [
    ("ALPHA.T", "アルファ工業", "アルファコウギョウ"),
    ("BRAVO.T", "ブラボー商事", "ブラボーショウジ"),
    ("CHARLIE.T", "チャーリー電機", "チャーリーデンキ"),
    ("DELTA.T", "デルタ食品", "デルタショクヒン"),
]
DEMO_DIVIDENDS = [
    # (入金日, 銘柄, 株数, 1株配当(銭), 入金額(銭))  すべて架空の入力例
    ("2025-03-25", "BRAVO.T", 300, 3000, 717000),
    ("2025-03-31", "DELTA.T", 400, 1500, 478000),
    ("2025-06-10", "ALPHA.T", 200, 5000, 797000),
    ("2025-06-27", "CHARLIE.T", 100, 12000, 956000),
    ("2025-06-30", "DELTA.T", 400, 1500, 478000),
    ("2025-09-25", "BRAVO.T", 300, 3000, 717000),
    ("2025-09-30", "DELTA.T", 400, 1500, 478000),
    ("2025-12-05", "ALPHA.T", 200, 5500, 876000),
    ("2025-12-26", "DELTA.T", 400, 1600, 510000),
    ("2026-03-25", "BRAVO.T", 300, 3100, 741000),
    ("2026-03-31", "DELTA.T", 400, 1600, 510000),
    ("2026-06-10", "ALPHA.T", 250, 5500, 1096000),
    ("2026-06-26", "CHARLIE.T", 100, 12000, 956000),
    ("2026-06-30", "DELTA.T", 400, 1600, 510000),
]


def create_demo_db():
    """架空データのサンプルDBを作り直す。

    書き込み先は DEMO_DB に固定する（引数で受け取らない）。読者の dividends.db を
    間違って作り直す経路を、コードの側から無くしておく。
    """
    path = DEMO_DB
    if path.exists():
        path.unlink()
    with closing(sqlite3.connect(path)) as conn, conn:
        conn.executescript(SCHEMA)
        conn.executemany("INSERT INTO stocks VALUES (?, ?, ?)", DEMO_STOCKS)
        conn.executemany(
            "INSERT INTO dividends(paid_on, ticker, shares, per_share_sen, net_sen)"
            " VALUES (?, ?, ?, ?, ?)", DEMO_DIVIDENDS)
    print(f"架空データのサンプルDBを作りました: {path}")


# ---------------------------------------------------------------- 実行
def print_month_pattern(db_path):
    """銘柄ごとの入金月パターン。年をまたいで数えたいのでここは '%m' 単独でよい。"""
    pattern = defaultdict(list)
    for r in query(db_path, SQL_MONTH_PATTERN):
        pattern[r["name"]].append(f"{int(r['m'])}月({r['n']}件)")
    print("銘柄ごとの入金月パターン（過去の実績・年をまたいで集計）")
    for name, months in pattern.items():
        print(f"  {name}: {' '.join(months)}")


def main(argv=None):
    ap = argparse.ArgumentParser(description="配当入金カレンダー(HTML)と月別グラフ(PNG)を作る")
    ap.add_argument("--db", type=Path, default=None,
                    help=f"第8回の dividends.db（既定: {DEFAULT_DB.name}）")
    ap.add_argument("--year", type=int, default=date.today().year, help="出力する年")
    ap.add_argument("--out", type=Path, help="出力HTML（既定: dividend_calendar_<年>.html）")
    ap.add_argument("--chart", type=Path, help="出力PNG（既定: dividend_monthly_<年>.png）")
    ap.add_argument("--no-chart", action="store_true", help="PNGを作らない（matplotlib不要）")
    ap.add_argument("--demo", action="store_true",
                    help=f"架空データのサンプルDB（{DEMO_DB.name}）で動かす。--db とは併用不可")
    ap.add_argument("--fetch-ex-date", metavar="TICKER",
                    help="⚠️ここだけ外部と通信する。yfinanceで権利落ち日を取って表示")
    args = ap.parse_args(argv)

    if args.fetch_ex_date:
        fetch_ex_date(args.fetch_ex_date)
        return 0

    # --demo は DEMO_DB を作り直す。--db を一緒に渡せるようにすると、読者が自分の
    # dividends.db のパスを渡したときにそれを消してしまうので、併用は受け付けない。
    if args.demo and args.db is not None:
        ap.error(f"--demo と --db は併用できません。--demo は {DEMO_DB.name} だけを"
                 "作り直します。自分のDBを読むなら --demo を外してください。")

    db_path = DEMO_DB if args.demo else (args.db or DEFAULT_DB)
    if args.demo:
        create_demo_db()
    if not db_path.exists():
        print(f"DBが見つかりません: {db_path}\n"
              f"第8回の dividend_app.py で作るか、--demo で架空データを試してください。",
              file=sys.stderr)
        return 1

    payments = load_payments(db_path)
    if not payments:
        print("dividends テーブルに入金の記録がありません。", file=sys.stderr)
        return 1

    events, actual, planned = build_events(payments, args.year)
    out_html = args.out or BASE_DIR / f"dividend_calendar_{args.year}.html"
    out_html.write_text(
        render_html(args.year, events, actual, planned, date.today().isoformat(), args.demo),
        encoding="utf-8", newline="\n")

    print_month_pattern(db_path)
    print(f"{args.year}年 実績 {yen(sum(actual))}円 / 見込み {yen(sum(planned))}円")
    print(f"カレンダーを書き出しました: {out_html}")

    if not args.no_chart:
        out_png = args.chart or BASE_DIR / f"dividend_monthly_{args.year}.png"
        render_chart(args.year, actual, planned, out_png)
        print(f"グラフを書き出しました: {out_png}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
