nisa-tracker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jerome Perrin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: nisa-tracker
3
+ Version: 0.1.0
4
+ Summary: Follow the value of a Japanese NISA account
5
+ Author: Jerome Perrin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/perrinjerome/nisa-tracker
8
+ Project-URL: Repository, https://github.com/perrinjerome/nisa-tracker
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: Flask>=3.0
15
+ Requires-Dist: requests>=2.28
16
+ Dynamic: license-file
17
+
18
+ # NISA Account Tracker
19
+
20
+ Track a Japanese NISA account: import purchase history exported from the SBI
21
+ Securities website, fetch daily fund NAV from the Fund Library, and view on a
22
+ local web dashboard the current value, historical value/profit graph,
23
+ NISA limit usage, and a 10-year forecast. UI is available in Japanese and
24
+ English.
25
+
26
+ ## Files
27
+
28
+ - `input.csv` — purchase history exported from SBI Securities (see below)
29
+ - `db.py` — SQLite storage, CSV import, valuation, NISA limit usage, forecast
30
+ - `fetch.py` — downloads daily NAV from toushin-lib.fwg.ne.jp into the DB
31
+ - `app.py` — Flask web app
32
+ - `nisa.db` — SQLite database (created on first run; delete to start over)
33
+
34
+ ## Export purchases from SBI Securities
35
+
36
+ SBI証券 のマイページ → 取引・投信 → 「取引履歴」から絞り込んでCSVダウンロードします。
37
+ The exported file should have these columns, in this order:
38
+
39
+ ```
40
+ 約定日,銘柄,銘柄コード,市場,取引,期限,預り,課税,約定数量,約定単価,手数料/諸経費等,税額,受渡日
41
+ ```
42
+
43
+ Save it as `input.csv` in this folder (replace the bundled sample).
44
+
45
+ Notes:
46
+
47
+ - Dates use **M/D/Y** (e.g. `8/4/2026` = 2026-08-04). The import assumes this
48
+ format; it is NOT D/M/Y.
49
+ - `銘柄` is matched by normalizing spaces and case, so you can keep the
50
+ full-width names as exported (`eMAXIS Slim 米国株式(S&P500)`).
51
+ - `約定単価` is the NAV per 10,000 units (万口), so invested cost is computed as
52
+ `約定数量 × 約定単価 ÷ 10,000`.
53
+ - Rows are deduplicated on (date, fund, quantity, unit price, account);
54
+ re-importing the same file is safe.
55
+
56
+ Each fund needs an entry in `db.FUNDS` (ISIN + Fund Library code). The bundled
57
+ eMAXIS Slim funds are already mapped; add new ones there. The CSV import
58
+ explains which names could not be matched.
59
+
60
+ ## Setup
61
+
62
+ ```bash
63
+ python3 -m venv .venv
64
+ .venv/bin/pip install -e .
65
+ ```
66
+
67
+ This installs Flask + requests into the venv (requirements live in
68
+ `pyproject.toml`) and provides the `nisa-app` / `nisa-fetch` commands.
69
+
70
+ ## Run
71
+
72
+ ```bash
73
+ .venv/bin/python app.py
74
+ ```
75
+
76
+ Opens at http://localhost:5000.
77
+
78
+ On startup the app imports `input.csv` (only when the DB is empty), refreshes
79
+ prices, then keeps refreshing in the background every 24h. Use the 「価格更新」
80
+ button to refresh manually.
81
+
82
+ ## Using the dashboard
83
+
84
+ - **Cards** show total invested, current value, and profit (¥ and %, green/red).
85
+ - **Value graph** plots total value and total profit with **1Y / 5Y / All**
86
+ buttons to set the time range (no reload, instant).
87
+ - **Tsumitate simulation** (stacked chart): cumulative invested over time plus
88
+ a 10-year projection at the monthly amounts and assumed annual return from
89
+ the forecast settings; projected profit is stacked on top of invested.
90
+ - **NISA limits** show yearly usage for つみたて/成長/combined vs. the new-NISA
91
+ caps, including a forecast of when you would exceed them.
92
+ - **Forecast** (10 years) shows projected value, contributions, and profit
93
+ from the settings below it.
94
+ - **Forecast settings** (monthly つみたて amount, monthly 成長 amount, assumed
95
+ annual return) are editable on the page and stored in the DB.
96
+ - **Buy a fund** (`+` button or `POST /api/buy` with
97
+ `{"date":"YYYY-MM-DD","amount":100000,"fund":"<ISIN>"}`) adds a purchase using
98
+ the fund's NAV on or before that date; duplicates are rejected. Costs count
99
+ against NISA limits by account type.
100
+
101
+ ## CLI tools
102
+
103
+ - Import / refresh manually:
104
+ ```bash
105
+ .venv/bin/python db.py -i input.csv # import purchases
106
+ .venv/bin/python fetch.py # fetch latest NAV for all funds
107
+ ```
108
+ - Or use the installed commands: `nisa-app` / `nisa-fetch`.
109
+
110
+ ## NISA limits (new NISA)
111
+
112
+ - Tsumitate yearly: ¥1,200,000
113
+ - Growth yearly: ¥2,400,000
114
+ - Combined yearly: ¥3,600,000
115
+ - Lifetime: ¥18,000,000 (growth: ¥12,000,000)
116
+
117
+ Constants live in `db.py`.
@@ -0,0 +1,100 @@
1
+ # NISA Account Tracker
2
+
3
+ Track a Japanese NISA account: import purchase history exported from the SBI
4
+ Securities website, fetch daily fund NAV from the Fund Library, and view on a
5
+ local web dashboard the current value, historical value/profit graph,
6
+ NISA limit usage, and a 10-year forecast. UI is available in Japanese and
7
+ English.
8
+
9
+ ## Files
10
+
11
+ - `input.csv` — purchase history exported from SBI Securities (see below)
12
+ - `db.py` — SQLite storage, CSV import, valuation, NISA limit usage, forecast
13
+ - `fetch.py` — downloads daily NAV from toushin-lib.fwg.ne.jp into the DB
14
+ - `app.py` — Flask web app
15
+ - `nisa.db` — SQLite database (created on first run; delete to start over)
16
+
17
+ ## Export purchases from SBI Securities
18
+
19
+ SBI証券 のマイページ → 取引・投信 → 「取引履歴」から絞り込んでCSVダウンロードします。
20
+ The exported file should have these columns, in this order:
21
+
22
+ ```
23
+ 約定日,銘柄,銘柄コード,市場,取引,期限,預り,課税,約定数量,約定単価,手数料/諸経費等,税額,受渡日
24
+ ```
25
+
26
+ Save it as `input.csv` in this folder (replace the bundled sample).
27
+
28
+ Notes:
29
+
30
+ - Dates use **M/D/Y** (e.g. `8/4/2026` = 2026-08-04). The import assumes this
31
+ format; it is NOT D/M/Y.
32
+ - `銘柄` is matched by normalizing spaces and case, so you can keep the
33
+ full-width names as exported (`eMAXIS Slim 米国株式(S&P500)`).
34
+ - `約定単価` is the NAV per 10,000 units (万口), so invested cost is computed as
35
+ `約定数量 × 約定単価 ÷ 10,000`.
36
+ - Rows are deduplicated on (date, fund, quantity, unit price, account);
37
+ re-importing the same file is safe.
38
+
39
+ Each fund needs an entry in `db.FUNDS` (ISIN + Fund Library code). The bundled
40
+ eMAXIS Slim funds are already mapped; add new ones there. The CSV import
41
+ explains which names could not be matched.
42
+
43
+ ## Setup
44
+
45
+ ```bash
46
+ python3 -m venv .venv
47
+ .venv/bin/pip install -e .
48
+ ```
49
+
50
+ This installs Flask + requests into the venv (requirements live in
51
+ `pyproject.toml`) and provides the `nisa-app` / `nisa-fetch` commands.
52
+
53
+ ## Run
54
+
55
+ ```bash
56
+ .venv/bin/python app.py
57
+ ```
58
+
59
+ Opens at http://localhost:5000.
60
+
61
+ On startup the app imports `input.csv` (only when the DB is empty), refreshes
62
+ prices, then keeps refreshing in the background every 24h. Use the 「価格更新」
63
+ button to refresh manually.
64
+
65
+ ## Using the dashboard
66
+
67
+ - **Cards** show total invested, current value, and profit (¥ and %, green/red).
68
+ - **Value graph** plots total value and total profit with **1Y / 5Y / All**
69
+ buttons to set the time range (no reload, instant).
70
+ - **Tsumitate simulation** (stacked chart): cumulative invested over time plus
71
+ a 10-year projection at the monthly amounts and assumed annual return from
72
+ the forecast settings; projected profit is stacked on top of invested.
73
+ - **NISA limits** show yearly usage for つみたて/成長/combined vs. the new-NISA
74
+ caps, including a forecast of when you would exceed them.
75
+ - **Forecast** (10 years) shows projected value, contributions, and profit
76
+ from the settings below it.
77
+ - **Forecast settings** (monthly つみたて amount, monthly 成長 amount, assumed
78
+ annual return) are editable on the page and stored in the DB.
79
+ - **Buy a fund** (`+` button or `POST /api/buy` with
80
+ `{"date":"YYYY-MM-DD","amount":100000,"fund":"<ISIN>"}`) adds a purchase using
81
+ the fund's NAV on or before that date; duplicates are rejected. Costs count
82
+ against NISA limits by account type.
83
+
84
+ ## CLI tools
85
+
86
+ - Import / refresh manually:
87
+ ```bash
88
+ .venv/bin/python db.py -i input.csv # import purchases
89
+ .venv/bin/python fetch.py # fetch latest NAV for all funds
90
+ ```
91
+ - Or use the installed commands: `nisa-app` / `nisa-fetch`.
92
+
93
+ ## NISA limits (new NISA)
94
+
95
+ - Tsumitate yearly: ¥1,200,000
96
+ - Growth yearly: ¥2,400,000
97
+ - Combined yearly: ¥3,600,000
98
+ - Lifetime: ¥18,000,000 (growth: ¥12,000,000)
99
+
100
+ Constants live in `db.py`.
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import sys
7
+ import threading
8
+ import time
9
+
10
+ from flask import (
11
+ Flask,
12
+ jsonify,
13
+ make_response,
14
+ redirect,
15
+ render_template,
16
+ request,
17
+ )
18
+
19
+ import db
20
+ import fetch
21
+
22
+
23
+ def _template_folder():
24
+ local = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
25
+ if os.path.isdir(local):
26
+ return local
27
+ return os.path.join(sys.prefix, "templates")
28
+
29
+
30
+ app = Flask(__name__, template_folder=_template_folder())
31
+ app.config["TEMPLATES_AUTO_RELOAD"] = True
32
+
33
+ STRINGS = {
34
+ "ja": {
35
+ "title": "NISA 口座トラッカー",
36
+ "lang_ja": "日本語",
37
+ "lang_en": "English",
38
+ "refresh": "価格更新",
39
+ "last_update": "最終更新",
40
+ "no_data": "データがありません。価格を更新してください。",
41
+ "current_value": "現在評価額",
42
+ "total_cost": "投資元本",
43
+ "total_profit": "評価損益",
44
+ "portfolio": "ポートフォリオ",
45
+ "fund": "ファンド",
46
+ "cost": "元本",
47
+ "value": "評価額",
48
+ "profit": "損益",
49
+ "profit_pct": "損益率",
50
+ "per_fund": "ファンド別",
51
+ "price_history": "推移",
52
+ "yearly_limit": "年間投資上限",
53
+ "lifetime_limit": "生涯投資上限",
54
+ "used": "使用",
55
+ "remaining": "残り",
56
+ "forecast": "10年予測",
57
+ "forecast_value": "10年後の予測金額",
58
+ "forecast_contributed": "予測追加投資",
59
+ "forecast_profit": "予測利益",
60
+ "tsumitate_projection": "積立投資シミュレーション(今後10年)",
61
+ "invested": "投資額",
62
+ "projected_profit": "予測利益",
63
+ "monthly": "毎月積立額",
64
+ "monthly_growth": "毎月成長投資額",
65
+ "annual_return": "想定年間利回り(%)",
66
+ "save": "保存",
67
+ "tsumitate": "つみたて",
68
+ "growth": "成長",
69
+ "purchases": "購入履歴",
70
+ "date": "約定日",
71
+ "pending_warning": "1年以内に上限を超える見込みです",
72
+ "no_warning": "上限内で計画可能",
73
+ "account": "口座区分",
74
+ "quantity": "口数",
75
+ "unit_price": "基準価額",
76
+ "yen": "円",
77
+ "over": "超過見込み",
78
+ "lifetime": "生涯",
79
+ "buy": "買付",
80
+ "buy_amount": "金額 (円)",
81
+ "buy_submit": "買付を記録",
82
+ "buy_placeholder": "例: 50000",
83
+ "today": "今日",
84
+ },
85
+ "en": {
86
+ "title": "NISA Account Tracker",
87
+ "lang_ja": "日本語",
88
+ "lang_en": "English",
89
+ "refresh": "Refresh prices",
90
+ "last_update": "Last update",
91
+ "no_data": "No data. Click refresh to fetch prices.",
92
+ "current_value": "Current value",
93
+ "total_cost": "Total invested",
94
+ "total_profit": "Total profit",
95
+ "portfolio": "Portfolio",
96
+ "fund": "Fund",
97
+ "cost": "Cost",
98
+ "value": "Value",
99
+ "profit": "Profit",
100
+ "profit_pct": "Return",
101
+ "per_fund": "Per fund",
102
+ "price_history": "Price history",
103
+ "yearly_limit": "Yearly limit",
104
+ "lifetime_limit": "Lifetime limit",
105
+ "used": "Used",
106
+ "remaining": "Remaining",
107
+ "forecast": "10-year forecast",
108
+ "forecast_value": "Projected value",
109
+ "forecast_contributed": "Projected contributions",
110
+ "forecast_profit": "Projected profit",
111
+ "tsumitate_projection": "Tsumitate simulation (next 10 years)",
112
+ "invested": "Invested",
113
+ "projected_profit": "Projected profit",
114
+ "monthly": "Monthly tsumitate",
115
+ "monthly_growth": "Monthly growth investment",
116
+ "annual_return": "Assumed annual return (%)",
117
+ "save": "Save",
118
+ "tsumitate": "Tsumitate",
119
+ "growth": "Growth",
120
+ "purchases": "Purchase history",
121
+ "date": "Trade date",
122
+ "pending_warning": "Projected to exceed the limit within 12 months",
123
+ "no_warning": "Within limits over the next year",
124
+ "account": "Account",
125
+ "quantity": "Units",
126
+ "unit_price": "NAV per 10k units",
127
+ "yen": "yen",
128
+ "over": "exceeded",
129
+ "lifetime": "Lifetime",
130
+ "buy": "Buy",
131
+ "buy_amount": "Amount (¥)",
132
+ "buy_submit": "Record purchase",
133
+ "buy_placeholder": "e.g. 50000",
134
+ "today": "Today",
135
+ },
136
+ }
137
+
138
+ _BUSY = threading.Lock()
139
+
140
+
141
+ def get_lang():
142
+ lang = request.cookies.get("lang", "ja")
143
+ return lang if lang in STRINGS else "ja"
144
+
145
+
146
+ def _fmt(n):
147
+ if n is None:
148
+ return "-"
149
+ return f"¥{n:,.0f}"
150
+
151
+
152
+ def _fmt_pct(n):
153
+ if n is None:
154
+ return "-"
155
+ return f"{n:+.1f}%"
156
+
157
+
158
+ def refresh_prices():
159
+ if not _BUSY.acquire(blocking=False):
160
+ return False
161
+ try:
162
+ records = []
163
+ for isin, fund in db.FUNDS.items():
164
+ records.extend(fetch.get_nav(isin, fund["code"], fund["name"]))
165
+ if records:
166
+ db.update_prices(
167
+ [
168
+ {"isin": r["isin"], "date": r["date"], "nav": r["nav"]}
169
+ for r in records
170
+ ]
171
+ )
172
+ return True
173
+ finally:
174
+ _BUSY.release()
175
+
176
+
177
+ def scheduler():
178
+ while True:
179
+ time.sleep(24 * 60 * 60)
180
+ refresh_prices()
181
+
182
+
183
+ @app.route("/")
184
+ def index():
185
+ lang = get_lang()
186
+ t = STRINGS[lang]
187
+
188
+ val = db.valuation()
189
+ hist = db.history()
190
+ usage = db.nisa_usage()
191
+ fc = db.forecast()
192
+ proj = db.tsumitate_projection()
193
+ purchases = db.get_purchases()
194
+
195
+ fc_display = dict(fc)
196
+ if proj["points"]:
197
+ last = proj["points"][-1]
198
+ fc_display["projected_value"] = last["total"]
199
+ fc_display["projected_contributed"] = last["invested"]
200
+ fc_display["projected_profit"] = last["profit"]
201
+
202
+ yearly_limit_rows = []
203
+ for y in usage["yearly"]:
204
+ d = usage["yearly"][y]
205
+ yearly_limit_rows.append(
206
+ {
207
+ "year": y,
208
+ "tsumitate": d["tsumitate"],
209
+ "growth": d["growth"],
210
+ "total": d["tsumitate"] + d["growth"],
211
+ }
212
+ )
213
+
214
+ lifetime = {
215
+ "tsumitate": usage["lifetime_tsumitate"],
216
+ "growth": usage["lifetime_growth"],
217
+ "total": usage["lifetime_total"],
218
+ }
219
+
220
+ return render_template(
221
+ "index.html",
222
+ t=t,
223
+ lang=lang,
224
+ val=val,
225
+ fmt=_fmt,
226
+ fmt_pct=_fmt_pct,
227
+ funds=val["funds"],
228
+ hist=hist,
229
+ hist_json=json.dumps(hist),
230
+ yearly_limit_rows=yearly_limit_rows,
231
+ lifetime=lifetime,
232
+ fc=fc_display,
233
+ fc_json=json.dumps(fc),
234
+ proj=proj,
235
+ proj_json=json.dumps(proj),
236
+ purchases=purchases,
237
+ catalog=[
238
+ {"isin": isin, "name": fund["name"]}
239
+ for isin, fund in db.FUNDS.items()
240
+ ],
241
+ limits={
242
+ "tsumitate": db.TSUMITATE_LIMIT,
243
+ "growth": db.GROWTH_LIMIT,
244
+ "yearly": db.YEARLY_LIMIT,
245
+ "lifetime": db.LIFETIME_LIMIT,
246
+ "growth_lifetime": db.GROWTH_LIFETIME_LIMIT,
247
+ },
248
+ )
249
+
250
+
251
+ @app.route("/lang/<lang>")
252
+ def set_lang(lang):
253
+ lang = lang if lang in STRINGS else "ja"
254
+ resp = make_response(redirect(request.referrer or "/"))
255
+ resp.set_cookie("lang", lang)
256
+ return resp
257
+
258
+
259
+ @app.route("/refresh")
260
+ def refresh():
261
+ refresh_prices()
262
+ return redirect("/")
263
+
264
+
265
+ @app.route("/settings", methods=["POST"])
266
+ def settings():
267
+ db.set_setting("monthly_tsumitate", max(0, float(request.form["monthly_tsumitate"])))
268
+ db.set_setting("monthly_growth", max(0, float(request.form["monthly_growth"])))
269
+ db.set_setting("annual_return_pct", max(0, float(request.form["annual_return_pct"])))
270
+ return redirect(request.referrer or "/")
271
+
272
+
273
+ def map_fund(value):
274
+ if value in db.FUNDS:
275
+ return value
276
+ key = db.normalize(value)
277
+ for isin in db.FUNDS:
278
+ if db.normalize(db.FUNDS[isin]["name"]) == key:
279
+ return isin
280
+ return None
281
+
282
+
283
+ def _valid_date(value):
284
+ m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", value)
285
+ if not m:
286
+ return False
287
+ year, month, day = (int(g) for g in m.groups())
288
+ return 2000 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31
289
+
290
+
291
+ def _valid_amount(amount):
292
+ if isinstance(amount, bool) or amount is None:
293
+ return None
294
+ if isinstance(amount, (int, float)):
295
+ value = float(amount)
296
+ return value if value > 0 else None
297
+ if isinstance(amount, str) and re.fullmatch(r"\d+(\.\d+)?", amount):
298
+ value = float(amount)
299
+ return value if value > 0 else None
300
+ return None
301
+
302
+
303
+ @app.route("/api/buy", methods=["POST"])
304
+ def api_buy():
305
+ data = request.get_json(silent=True)
306
+ if not isinstance(data, dict):
307
+ return jsonify(error="invalid request body"), 400
308
+
309
+ date_label = str(data.get("date") or "")
310
+ amount = _valid_amount(data.get("amount"))
311
+ account = str(data.get("account") or "NISA (つみたて)").strip()
312
+
313
+ if not _valid_date(date_label):
314
+ return jsonify(error="invalid date"), 400
315
+ if amount is None:
316
+ return jsonify(error="invalid amount"), 400
317
+ if not account:
318
+ return jsonify(error="invalid account"), 400
319
+
320
+ isin = map_fund(str(data.get("fund") or ""))
321
+ if isin is None:
322
+ return jsonify(error="unknown fund"), 404
323
+
324
+ nav = db.nav_for_date(isin, date_label)
325
+ if nav is None:
326
+ return jsonify(error="no price on or before date"), 422
327
+
328
+ purchase = db.add_purchase(date_label, isin, account, amount, nav)
329
+ if purchase is None:
330
+ return jsonify(error="duplicate purchase"), 409
331
+
332
+ return jsonify(purchase), 201
333
+
334
+
335
+ def main():
336
+ db.init_db()
337
+ if not db.get_purchases():
338
+ print("No purchases — importing input.csv...")
339
+ added, skipped = db.load_purchases_csv()
340
+ print(f"Imported {added} purchases ({skipped} duplicates).")
341
+
342
+ print("Refreshing prices at startup...")
343
+ refresh_prices()
344
+
345
+ threading.Thread(target=scheduler, daemon=True).start()
346
+
347
+ app.run(host="0.0.0.0", port=5000)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()