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 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()