nisa-tracker 0.1.0__py3-none-any.whl
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.
- app.py +351 -0
- db.py +610 -0
- fetch.py +134 -0
- nisa_tracker-0.1.0.data/data/templates/index.html +403 -0
- nisa_tracker-0.1.0.dist-info/METADATA +117 -0
- nisa_tracker-0.1.0.dist-info/RECORD +10 -0
- nisa_tracker-0.1.0.dist-info/WHEEL +5 -0
- nisa_tracker-0.1.0.dist-info/entry_points.txt +3 -0
- nisa_tracker-0.1.0.dist-info/licenses/LICENSE +21 -0
- nisa_tracker-0.1.0.dist-info/top_level.txt +3 -0
app.py
ADDED
|
@@ -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()
|