# PythonLand 投資×Python シリーズ 第12回
# 「配当再投資シミュレーターを作る（numpy × matplotlib）」
# https://pythonland.tech/dividend-reinvestment-simulator.html
#
# 記事3〜9章のコードをつないだ完成版です（2026-09-05 時点）。
# 入力した仮定（1株配当・増配率・株価上昇率・税率・年数・初期保有）から、
# 配当を再投資した場合としなかった場合の資産推移を機械的に計算します。
#
# ⚠️ これは将来を予測するプログラムではありません。出てくる数字は「入力した仮定が
#    そのまま続いたら計算上どうなるか」であって、予測でも見通しでも保証でもありません。
# ⚠️ 配当利回り・増配率・株価上昇率・税率・年数・初期保有に既定値は用意していません
#    （すべて必須引数）。既定値を置くとその数字が推奨に見えるためです。
# ⚠️ このモデルは、売買手数料・スプレッド・配当の入金時期（権利確定から2〜3か月後）・
#    減配や無配・株価の変動・インフレ・制度改正を一切扱いません。実際の運用結果とは違います。
# ⚠️ 通信について: このスクリプトは外部と一切通信しません。完全にローカルで動きます。
# ⚠️ --from-db は第8回の dividends.db を読み取り専用（mode=ro）で開き、1行も書き込みません。
#    読むのは「直近1年の入金合計」だけで、計算には使わず参考として表示します。
# ⚠️ 出力するCSV・PNGは「自分の資産についてどんな仮定を置いたか」の記録です。
#    共有フォルダ・クラウド同期・Webサーバーの公開ディレクトリには置かないでください。
# ライセンス: MIT（https://pythonland.tech/ のコードは MIT ライセンスで公開しています）

"""配当を再投資した場合の資産推移を年単位で計算する CLI シミュレーター。

使い方（数値はすべて動作確認用の仮の値で、推奨値ではありません）:
    python reinvest_sim.py --shares 1000 --price 2000 --dps 60 \\
        --dividend-growth 5 --price-growth 2 --tax-rate 20.315 --years 20
    python reinvest_sim.py ... --csv result.csv --png assets.png   # CSVと図に書き出す
    python reinvest_sim.py ... --compare-models --models-png m.png # 3モデルを並べる
    python reinvest_sim.py ... --price-growth -3                   # 株価が下がる仮定
    python reinvest_sim.py ... --from-db dividends.db              # 第8回DBを読むだけ

7つの引数はすべて必須です。何を入れるかは利用者ご自身が決めてください。
本ツールは入力された仮定を機械的に計算するだけで、値の妥当性は判断しません。
"""
import argparse
import csv
import sqlite3
import sys
import unicodedata
from pathlib import Path

import numpy as np

# 3つのモデル。どれが正しいかではなく「どれを選んだか」で答えが変わる（記事7章）
MODEL_LABELS = {
    "pretax": "税引前で再投資（税を無視した楽観モデル）",
    "posttax": "税引後で再投資（1株単位・端数は現金）",
    "lot": "税引後で再投資（単元単位でしか買えない）",
}


def growth_series(first: float, rate: float, count: int) -> np.ndarray:
    """初項 first・成長率 rate の等比数列を count 個返す（float64 固定）。

    経路に依存しない系列なので np.cumprod で一度に作れる。
    float32 にすると資産1,677万円あたりで1円単位が表現できなくなるため使わない。
    """
    if count <= 0:
        return np.empty(0, dtype=np.float64)
    factors = np.full(count - 1, 1.0 + rate, dtype=np.float64)
    return first * np.concatenate([[1.0], np.cumprod(factors)])


def simulate(shares0: float, prices: np.ndarray, dps: np.ndarray,
             tax_rate: float, lot: int, reinvest: bool) -> dict:
    """年を1ステップとして資産推移を計算する。

    prices[y] は y年目末の株価、dps[y - 1] は y年目に受け取る1株あたり配当。
    再投資は「受け取った税引後配当で、その年末の株価で買えるだけ買う」という簡略モデル。
    買えるのは lot 株単位で、端数は現金として翌年に繰り越す。

    株価と配当は cumprod でまとめて作れるが、保有株数は前年の株数に依存する
    （＝経路依存）ので、ここだけはループで回す（記事4章）。
    """
    years = len(dps)
    shares = np.zeros(years + 1, dtype=np.float64)
    cash = np.zeros(years + 1, dtype=np.float64)
    gross = np.zeros(years + 1, dtype=np.float64)
    net = np.zeros(years + 1, dtype=np.float64)
    bought = np.zeros(years + 1, dtype=np.float64)
    shares[0] = shares0

    for y in range(1, years + 1):
        gross[y] = shares[y - 1] * dps[y - 1]
        net[y] = gross[y] * (1.0 - tax_rate)
        purse = cash[y - 1] + net[y]
        held = shares[y - 1]
        if reinvest:
            unit_cost = prices[y] * lot          # 1回の買付に必要な金額
            if unit_cost > 0:
                units = np.floor(purse / unit_cost)   # 端株は買えない
                bought[y] = units * lot
                purse -= bought[y] * prices[y]
                held += bought[y]
        shares[y] = held
        cash[y] = purse

    return {
        "year": np.arange(years + 1),
        "price": prices,
        "dps": np.concatenate([[0.0], dps]),
        "gross": gross,
        "net": net,
        "bought": bought,
        "shares": shares,
        "cash": cash,
        "asset": shares * prices + cash,
    }


def pad(text: str, width: int) -> str:
    """全角を2桁と数えて右詰めする（見出しと数字の桁がずれないように）。"""
    shown = sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
    return " " * max(0, width - shown) + text


def build_rows(res: dict) -> list:
    """CSV とコンソール表示に使う行データ（表示用の丸めはここだけで行う）。"""
    rows = []
    for i in range(len(res["year"])):
        rows.append({
            "year": int(res["year"][i]),
            "price": round(float(res["price"][i]), 2),
            "dps": round(float(res["dps"][i]), 4),
            "gross_dividend": round(float(res["gross"][i])),
            "net_dividend": round(float(res["net"][i])),
            "bought_shares": int(res["bought"][i]),
            "shares": int(res["shares"][i]),
            "cash": round(float(res["cash"][i])),
            "asset": round(float(res["asset"][i])),
        })
    return rows


def write_csv(rows: list, path: Path, header_note: str) -> None:
    """結果をCSVに書き出す。Excel で開くことを想定して utf-8-sig。"""
    with path.open("w", encoding="utf-8-sig", newline="") as f:
        f.write(f"# {header_note}\n")
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)


def assumption_text(args: argparse.Namespace, lot_note: str = "") -> str:
    """図とCSVに焼き込む前提条件の1行。図だけ切り取られても条件が分かるように。"""
    return (f"前提（すべて入力値・動作確認用の仮の値）: 初期 {args.shares:,}株 / "
            f"株価 {args.price:,.0f}円 / 1株配当 {args.dps:,.2f}円 / "
            f"増配率 {args.dividend_growth}% / 株価上昇率 {args.price_growth}% / "
            f"税率 {args.tax_rate}% / {args.years}年 / "
            f"{lot_note or f'{args.lot}株単位'}")


def japanese_font_context():
    """日本語ラベルを豆腐にしないための設定（Windows の Meiryo を使う）。"""
    import matplotlib
    return matplotlib.rc_context({"font.family": "Meiryo", "axes.unicode_minus": False})


def man_yen_formatter():
    """目盛りを「万円」表記にする。関数は (値, 位置) の2引数が必須。"""
    from matplotlib.ticker import FuncFormatter
    return FuncFormatter(lambda x, pos: f"{x / 10000:,.0f}")


def year_locator():
    """年の目盛りを整数だけにする（2.5年目は存在しないため）。"""
    from matplotlib.ticker import MaxNLocator
    return MaxNLocator(integer=True)


def plot_two_series(with_re: dict, without_re: dict, args: argparse.Namespace,
                    path: Path) -> None:
    """再投資した場合としなかった場合を並べて描く（色は良し悪しを表さない）。"""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    with japanese_font_context():
        fig, ax = plt.subplots(figsize=(9.6, 6.0))
        fig.subplots_adjust(left=0.10, right=0.97, top=0.92, bottom=0.22)
        ax.plot(with_re["year"], with_re["asset"], marker="o", markersize=3.5,
                color="#3f6fb5", label="配当を再投資した場合")
        ax.plot(without_re["year"], without_re["asset"], marker="s", markersize=3.5,
                color="#7a7f88", linestyle="--", label="再投資せず現金で持った場合")
        ax.set_title("入力した仮定にもとづく資産推移の計算結果（予測ではありません）")
        ax.set_xlabel("経過年数（年）")
        ax.set_ylabel("株式評価額＋現金（万円）")
        ax.yaxis.set_major_formatter(man_yen_formatter())
        ax.xaxis.set_major_locator(year_locator())
        ax.grid(alpha=0.3)
        ax.legend(loc="best")
        fig.text(0.5, 0.015, assumption_text(args) + "\n"
                 "この図は入力した仮定を機械的に計算した結果です。将来の結果を予測・保証するものではありません。",
                 ha="center", va="bottom", fontsize=8,
                 bbox=dict(facecolor="white", alpha=0.9, edgecolor="#c9ced6",
                           boxstyle="round,pad=0.4"))
        fig.savefig(path, dpi=110)
        plt.close(fig)


def plot_models(results: dict, args: argparse.Namespace, path: Path) -> None:
    """3つのモデルを同じ入力で並べる。モデルの選択で答えが変わることを示す図。"""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    styles = {"pretax": ("#3f6fb5", "-"), "posttax": ("#5d8f6a", "--"),
              "lot": ("#8a6f9e", "-.")}
    with japanese_font_context():
        fig, ax = plt.subplots(figsize=(9.6, 6.0))
        fig.subplots_adjust(left=0.10, right=0.97, top=0.92, bottom=0.22)
        for key, res in results.items():
            color, style = styles[key]
            ax.plot(res["year"], res["asset"], color=color, linestyle=style,
                    marker="o", markersize=3.0, label=MODEL_LABELS[key])
        ax.set_title("同じ入力・違うモデルの計算結果（どれが正しいかではありません）")
        ax.set_xlabel("経過年数（年）")
        ax.set_ylabel("株式評価額＋現金（万円）")
        ax.yaxis.set_major_formatter(man_yen_formatter())
        ax.xaxis.set_major_locator(year_locator())
        ax.grid(alpha=0.3)
        ax.legend(loc="best", fontsize=9)
        fig.text(0.5, 0.015,
                 assumption_text(args, lot_note="上2本は1株単位・下1本は100株単位") + "\n"
                 "3本とも同じ入力です。差はモデルの前提の違いだけで、将来を予測するものではありません。",
                 ha="center", va="bottom", fontsize=8,
                 bbox=dict(facecolor="white", alpha=0.9, edgecolor="#c9ced6",
                           boxstyle="round,pad=0.4"))
        fig.savefig(path, dpi=110)
        plt.close(fig)


def read_recent_dividends(db_path: Path) -> float | None:
    """第8回の dividends.db から直近1年の入金合計（円）を読むだけ。書き込みはしない。

    mode=ro は読み取り専用で開くURIモード。書き込もうとすると
    sqlite3.OperationalError: attempt to write a readonly database になり、
    ファイルが無ければ作らずに例外になる。
    """
    uri = f"file:{db_path.as_posix()}?mode=ro"
    try:
        with sqlite3.connect(uri, uri=True) as conn:
            row = conn.execute(
                "SELECT COALESCE(SUM(net_sen), 0) FROM dividends "
                "WHERE paid_on >= date('now', '-1 year')").fetchone()
    except sqlite3.OperationalError as e:
        print(f"[from-db] 読み取れませんでした: {e}", file=sys.stderr)
        return None
    return float(row[0]) / 100.0          # 銭 → 円（第8回は金額を銭の整数で持つ）


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="配当を再投資した場合の資産推移を、入力した仮定から機械的に計算します"
                    "（予測ではありません）。")
    # 将来に関わる7つの前提には default を置かない（既定値は推奨値に見えるため）
    p.add_argument("--shares", type=int, required=True, help="初期の保有株数（株）")
    p.add_argument("--price", type=float, required=True, help="初期の株価（円）")
    p.add_argument("--dps", type=float, required=True, help="1株あたりの年間配当（円・税引前）")
    p.add_argument("--dividend-growth", type=float, required=True,
                   help="1株配当が毎年変化する率（％・マイナス可）")
    p.add_argument("--price-growth", type=float, required=True,
                   help="株価が毎年変化する率（％・マイナス可）")
    p.add_argument("--tax-rate", type=float, required=True,
                   help="配当にかかる税率（％・口座や制度で変わります）")
    p.add_argument("--years", type=int, required=True, help="計算する年数（年）")
    # 以下は計算の前提ではなく道具の設定なので既定値を置く
    p.add_argument("--lot", type=int, default=1,
                   help="1回に買える最小の株数（既定は1＝1株単位。100なら単元単位）")
    p.add_argument("--csv", type=Path, help="年次の計算結果を書き出すCSVのパス")
    p.add_argument("--png", type=Path, help="再投資あり／なしの図を書き出すPNGのパス")
    p.add_argument("--compare-models", action="store_true",
                   help="税引前・税引後・単元単位の3モデルを比べる（3本目は100株単位で固定）")
    p.add_argument("--models-png", type=Path, help="3モデル比較の図を書き出すPNGのパス")
    p.add_argument("--from-db", type=Path,
                   help="第8回の dividends.db を読み取り専用で開き、直近1年の入金合計を表示する")
    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    if args.years < 1:
        build_parser().error("--years は1以上で指定してください")
    if args.lot < 1:
        build_parser().error("--lot は1以上で指定してください")
    if args.price <= 0:
        build_parser().error("--price は0より大きい値で指定してください")

    dg = args.dividend_growth / 100.0
    pg = args.price_growth / 100.0
    tax = args.tax_rate / 100.0

    prices = growth_series(args.price, pg, args.years + 1)   # 0年目〜N年目の株価
    dps = growth_series(args.dps, dg, args.years)            # 1年目〜N年目に受け取る配当

    with_re = simulate(args.shares, prices, dps, tax, args.lot, reinvest=True)
    without_re = simulate(args.shares, prices, dps, tax, args.lot, reinvest=False)

    print("=" * 74)
    print("配当再投資シミュレーター（入力した仮定の機械計算・将来の予測ではありません）")
    print("=" * 74)
    print(assumption_text(args))
    if prices[-1] <= 0:
        print("※ 株価が0以下になる仮定です。計算は続けますが、結果は現実の値ではありません。")
    print("-" * 74)
    print(f"{pad('年', 3)} {pad('株価(円)', 10)} {pad('受取配当(円)', 12)} "
          f"{pad('買増(株)', 9)} {pad('保有(株)', 10)} {pad('現金(円)', 10)} "
          f"{pad('評価額(円)', 14)}")
    rows = build_rows(with_re)
    for row in rows:
        print(f"{row['year']:>3} {row['price']:>10,.0f} {row['net_dividend']:>12,} "
              f"{row['bought_shares']:>9,} {row['shares']:>10,} "
              f"{row['cash']:>10,} {row['asset']:>14,}")
    print("-" * 74)

    final_re = float(with_re["asset"][-1])
    final_no = float(without_re["asset"][-1])
    gap = (final_re / final_no - 1.0) * 100.0 if final_no else float("nan")
    print(f"再投資しなかった場合との差: {gap:+.2f}%"
          "（同じ仮定どうしの比較で、どちらが良いかを示すものではありません）")

    if args.compare_models:
        print("-" * 74)
        print("同じ入力・3つのモデル（相対差は税引前モデルを基準）")
        results = {
            "pretax": simulate(args.shares, prices, dps, 0.0, 1, reinvest=True),
            "posttax": simulate(args.shares, prices, dps, tax, 1, reinvest=True),
            "lot": simulate(args.shares, prices, dps, tax, 100, reinvest=True),
        }
        base = float(results["pretax"]["asset"][-1])
        for key, res in results.items():
            last = float(res["asset"][-1])
            diff = (last / base - 1.0) * 100.0 if base else float("nan")
            print(f"  {MODEL_LABELS[key]:<34} 基準との差 {diff:+7.2f}%")
        if args.models_png:
            plot_models(results, args, args.models_png)
            print(f"  図を書き出しました: {args.models_png}")

    if args.from_db:
        print("-" * 74)
        total = read_recent_dividends(args.from_db)
        if total is not None:
            print(f"[from-db] {args.from_db} の直近1年の入金合計: {total:,.0f}円"
                  "（読み取り専用で開きました。1行も書き込んでいません）")
            print("[from-db] この値は参考表示です。計算には使いません"
                  "（--dps などの入力はご自身で決めてください）。")

    if args.csv:
        write_csv(rows, args.csv, assumption_text(args) +
                  " / 入力した仮定の機械計算であり、将来の予測ではありません")
        print(f"CSVを書き出しました: {args.csv}")
    if args.png:
        plot_two_series(with_re, without_re, args, args.png)
        print(f"図を書き出しました: {args.png}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
