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.
- nisa_tracker-0.1.0/LICENSE +21 -0
- nisa_tracker-0.1.0/PKG-INFO +117 -0
- nisa_tracker-0.1.0/README.md +100 -0
- nisa_tracker-0.1.0/app.py +351 -0
- nisa_tracker-0.1.0/db.py +610 -0
- nisa_tracker-0.1.0/fetch.py +134 -0
- nisa_tracker-0.1.0/nisa_tracker.egg-info/PKG-INFO +117 -0
- nisa_tracker-0.1.0/nisa_tracker.egg-info/SOURCES.txt +13 -0
- nisa_tracker-0.1.0/nisa_tracker.egg-info/dependency_links.txt +1 -0
- nisa_tracker-0.1.0/nisa_tracker.egg-info/entry_points.txt +3 -0
- nisa_tracker-0.1.0/nisa_tracker.egg-info/requires.txt +2 -0
- nisa_tracker-0.1.0/nisa_tracker.egg-info/top_level.txt +3 -0
- nisa_tracker-0.1.0/pyproject.toml +34 -0
- nisa_tracker-0.1.0/setup.cfg +4 -0
- nisa_tracker-0.1.0/templates/index.html +403 -0
|
@@ -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()
|