mohammedafeef commited on
Commit
11bf00c
Β·
verified Β·
1 Parent(s): 4f7a72b

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +379 -0
  2. pension_engine.py +411 -0
  3. requirements.txt +4 -0
app.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PensionSight β€” FastAPI Backend
3
+ Run: uvicorn main:app --reload --port 8000
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import re
9
+ from typing import Optional, List
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from pydantic import BaseModel
13
+ import google.generativeai as genai
14
+ import uvicorn
15
+
16
+ from pension_engine import (
17
+ SubscriberProfile,
18
+ PensionCalculationEngine,
19
+ NPS_SCHEMES,
20
+ SCENARIO_RETURNS,
21
+ )
22
+
23
+ # ── App Setup ─────────────────────────────────────────────────────────────────
24
+ app = FastAPI(title="PensionSight API", version="1.0.0")
25
+
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ engine = PensionCalculationEngine()
34
+
35
+ API_KEY = os.getenv("GEMINI_API_KEY", "AIzaSyA1SFSUmkkTtqwv3WLlFujfRU3KUu1pyog")
36
+ if API_KEY:
37
+ genai.configure(api_key=API_KEY)
38
+
39
+ # ── Pydantic Models ───────────────────────────────────────────────────────────
40
+ class ProfileInput(BaseModel):
41
+ current_age: int
42
+ retirement_age: int
43
+ monthly_contribution: float
44
+ existing_corpus: float = 0
45
+ sector: str = "non_government"
46
+ scheme: str = "LC50"
47
+ annuity_percent: float = 0.40
48
+ annuity_rate: float = 0.06
49
+ annual_contribution_increase: float = 0
50
+ employer_contribution: float = 0
51
+ is_gig_worker: bool = False
52
+ monthly_incomes: List[float] = []
53
+ deferral_age: Optional[int] = None
54
+ desired_monthly_pension: Optional[float] = None
55
+
56
+ class ReversePlanInput(BaseModel):
57
+ desired_monthly_pension: float
58
+ current_age: int
59
+ retirement_age: int
60
+ scheme: str = "LC50"
61
+ scenario: str = "realistic"
62
+ annuity_percent: float = 0.40
63
+ existing_corpus: float = 0
64
+ annual_step_up: float = 0
65
+
66
+ class ChatMessage(BaseModel):
67
+ role: str
68
+ content: str
69
+
70
+ class ChatRequest(BaseModel):
71
+ messages: List[ChatMessage]
72
+ session_id: Optional[str] = None
73
+
74
+ # ── Helpers ───────────────────────────────────────────────────────────────────
75
+ def fmt_inr(amount: float) -> str:
76
+ if amount >= 1e7:
77
+ return f"β‚Ή{amount/1e7:.2f} Cr"
78
+ elif amount >= 1e5:
79
+ return f"β‚Ή{amount/1e5:.2f} Lakh"
80
+ return f"β‚Ή{amount:,.0f}"
81
+
82
+ def safe_float(v, d=0.0): return float(v) if v is not None else d
83
+ def safe_int(v, d=0): return int(v) if v is not None else d
84
+
85
+ def profile_from_input(p: ProfileInput) -> SubscriberProfile:
86
+ return SubscriberProfile(
87
+ current_age=p.current_age,
88
+ retirement_age=p.retirement_age,
89
+ monthly_contribution=p.monthly_contribution,
90
+ existing_corpus=p.existing_corpus,
91
+ sector=p.sector,
92
+ scheme=p.scheme,
93
+ annuity_percent=p.annuity_percent,
94
+ annuity_rate=p.annuity_rate,
95
+ annual_contribution_increase=p.annual_contribution_increase,
96
+ employer_contribution=p.employer_contribution,
97
+ is_gig_worker=p.is_gig_worker,
98
+ monthly_incomes=p.monthly_incomes,
99
+ deferral_age=p.deferral_age,
100
+ desired_monthly_pension=p.desired_monthly_pension,
101
+ )
102
+
103
+ def scenario_to_dict(r, profile: SubscriberProfile) -> dict:
104
+ ap = int(profile.annuity_percent * 100)
105
+ lp = 100 - ap
106
+ return {
107
+ "scenario": r.scenario,
108
+ "label": r.label,
109
+ "blended_return": r.blended_return,
110
+ "projected_corpus": r.projected_corpus,
111
+ "real_corpus": r.real_corpus,
112
+ "lumpsum": r.lumpsum_withdrawal,
113
+ "lumpsum_pct": lp,
114
+ "annuity_corpus": r.annuity_corpus,
115
+ "annuity_pct": ap,
116
+ "monthly_pension": r.monthly_pension,
117
+ "real_monthly_pension": r.real_monthly_pension,
118
+ "total_contributions": r.total_contributions,
119
+ "wealth_gained": r.wealth_gained,
120
+ "investment_years": r.investment_years,
121
+ }
122
+
123
+ # ── Routes ────────────────────────────────────────────────────────────────────
124
+ @app.get("/")
125
+ def root():
126
+ return {"status": "PensionSight API running", "version": "1.0.0"}
127
+
128
+ @app.post("/api/project")
129
+ def project(profile_input: ProfileInput):
130
+ try:
131
+ profile = profile_from_input(profile_input)
132
+ results = engine.project_all_scenarios(profile)
133
+ nudges = engine.generate_nudges(profile)
134
+ return {
135
+ "success": True,
136
+ "profile": {
137
+ "current_age": profile.current_age,
138
+ "retirement_age": profile.retirement_age,
139
+ "investment_years": profile.investment_years,
140
+ "monthly_sip": profile.total_monthly_contribution,
141
+ "scheme": NPS_SCHEMES[profile.scheme]["name"],
142
+ "scheme_key": profile.scheme,
143
+ "annual_step_up": profile.annual_contribution_increase,
144
+ "annuity_percent": profile.annuity_percent,
145
+ },
146
+ "scenarios": {
147
+ key: scenario_to_dict(r, profile)
148
+ for key, r in results.items()
149
+ },
150
+ "nudges": [
151
+ {
152
+ "type": n.nudge_type,
153
+ "message": n.message,
154
+ "impact": n.impact_rupees,
155
+ "impact_fmt": fmt_inr(n.impact_rupees),
156
+ "current": n.current_value,
157
+ "improved": n.improved_value,
158
+ }
159
+ for n in nudges
160
+ ],
161
+ }
162
+ except Exception as e:
163
+ raise HTTPException(status_code=400, detail=str(e))
164
+
165
+
166
+ @app.post("/api/reverse-plan")
167
+ def reverse_plan(inp: ReversePlanInput):
168
+ try:
169
+ result = engine.reverse_plan(
170
+ desired_monthly_pension=inp.desired_monthly_pension,
171
+ current_age=inp.current_age,
172
+ retirement_age=inp.retirement_age,
173
+ scheme=inp.scheme,
174
+ scenario=inp.scenario,
175
+ annuity_percent=inp.annuity_percent,
176
+ existing_corpus=inp.existing_corpus,
177
+ annual_step_up=inp.annual_step_up,
178
+ )
179
+ return {
180
+ "success": True,
181
+ "desired_monthly_pension": result.desired_monthly_pension,
182
+ "desired_fmt": fmt_inr(result.desired_monthly_pension),
183
+ "required_corpus": result.required_corpus,
184
+ "required_corpus_fmt": fmt_inr(result.required_corpus),
185
+ "required_monthly_sip": result.required_monthly_sip,
186
+ "required_sip_fmt": fmt_inr(result.required_monthly_sip),
187
+ "scenario": result.scenario,
188
+ "investment_years": result.investment_years,
189
+ "current_age": result.current_age,
190
+ "retirement_age": result.retirement_age,
191
+ }
192
+ except Exception as e:
193
+ raise HTTPException(status_code=400, detail=str(e))
194
+
195
+
196
+ @app.get("/api/schemes")
197
+ def get_schemes():
198
+ return {
199
+ key: {
200
+ "name": val["name"],
201
+ "max_equity": int(val["max_equity"] * 100),
202
+ }
203
+ for key, val in NPS_SCHEMES.items()
204
+ }
205
+
206
+
207
+ # ── Gemini Chat ───────────────────────────────────────────────────────────────
208
+ SYSTEM_INSTRUCTION = """
209
+ You are PensionSight, India's smartest NPS retirement planning assistant for PFRDA Innovate4NPS 2026.
210
+
211
+ YOUR MOST IMPORTANT RULE:
212
+ You MUST NEVER calculate, estimate, or guess any financial numbers (corpus, pension, SIP amounts).
213
+ When you have all required data, emit a tool_call JSON block. The engine calculates β€” you explain.
214
+
215
+ TOOL CALL FORMAT:
216
+ ```tool_call
217
+ {
218
+ "tool": "project",
219
+ "profile": {
220
+ "current_age": 20,
221
+ "retirement_age": 60,
222
+ "monthly_contribution": 20000,
223
+ "existing_corpus": 0,
224
+ "sector": "non_government",
225
+ "scheme": "LC75",
226
+ "annuity_percent": 0.40,
227
+ "annuity_rate": 0.06,
228
+ "annual_contribution_increase": 5,
229
+ "employer_contribution": 0,
230
+ "is_gig_worker": true,
231
+ "monthly_incomes": [80000, 80000, 80000],
232
+ "deferral_age": null,
233
+ "desired_monthly_pension": null
234
+ }
235
+ }
236
+ ```
237
+
238
+ For reverse planning:
239
+ ```tool_call
240
+ {
241
+ "tool": "reverse_plan",
242
+ "desired_monthly_pension": 40000,
243
+ "current_age": 28,
244
+ "retirement_age": 60,
245
+ "scheme": "LC50",
246
+ "annuity_percent": 0.40,
247
+ "existing_corpus": 0,
248
+ "annual_step_up": 5
249
+ }
250
+ ```
251
+
252
+ DATA TO COLLECT (1-2 questions at a time):
253
+ 1. current_age (18–74)
254
+ 2. retirement_age (60–75)
255
+ 3. monthly_contribution (β‰₯500)
256
+ 4. sector (government/non_government; gig workers = non_government)
257
+ 5. existing_corpus (0 if new)
258
+ 6. scheme (LC25/LC50/LC75; recommend LC75 if age<35)
259
+ 7. annuity_percent (0.40–1.0; min 40%)
260
+ 8. annual_contribution_increase (0–20%; suggest 5%)
261
+ 9. employer_contribution (0 for gig/self-employed)
262
+ 10. is_gig_worker (true/false); if true β†’ monthly_incomes list
263
+
264
+ NPS 2025 RULES:
265
+ - Min retirement: 60, max deferral: 75
266
+ - Min annuity: 40% (max lumpsum 60%, tax-free)
267
+ - Government: employer 14% of basic (Budget 2025)
268
+ - Schemes: LC25 (low), LC50 (moderate), LC75 (high equity), AGGRESSIVE, ACTIVE
269
+
270
+ TONE: Warm, concise, no jargon. Max 2 questions per reply. Use β‚Ή symbol.
271
+ After projection results, offer: (1) reverse planning, (2) nudges, (3) annuity split change.
272
+ """
273
+
274
+ GEMINI_MODEL = None
275
+ if API_KEY:
276
+ GEMINI_MODEL = genai.GenerativeModel(
277
+ model_name="gemini-2.5-flash-lite",
278
+ system_instruction=SYSTEM_INSTRUCTION
279
+ )
280
+
281
+ def extract_tool_call(text: str):
282
+ for pattern in [
283
+ r"```tool_call\s*([\s\S]*?)```",
284
+ r"```json\s*([\s\S]*?)```",
285
+ r"```\s*(\{[\s\S]*?\})\s*```",
286
+ ]:
287
+ m = re.search(pattern, text)
288
+ if m:
289
+ try:
290
+ d = json.loads(m.group(1).strip())
291
+ if "tool" in d:
292
+ return d
293
+ except Exception:
294
+ pass
295
+ return None
296
+
297
+ def strip_code_blocks(text: str) -> str:
298
+ return re.sub(r"```[\w]*\s*[\s\S]*?```", "", text).strip()
299
+
300
+ def run_tool(tj: dict) -> dict:
301
+ tool = tj.get("tool")
302
+ if tool == "project":
303
+ p = tj["profile"]
304
+ inp = ProfileInput(
305
+ current_age=safe_int(p.get("current_age"), 30),
306
+ retirement_age=safe_int(p.get("retirement_age"), 60),
307
+ monthly_contribution=safe_float(p.get("monthly_contribution"), 1000),
308
+ existing_corpus=safe_float(p.get("existing_corpus")),
309
+ sector=p.get("sector", "non_government"),
310
+ scheme=p.get("scheme", "LC50"),
311
+ annuity_percent=safe_float(p.get("annuity_percent"), 0.40),
312
+ annuity_rate=safe_float(p.get("annuity_rate"), 0.06),
313
+ annual_contribution_increase=safe_float(p.get("annual_contribution_increase")),
314
+ employer_contribution=safe_float(p.get("employer_contribution")),
315
+ is_gig_worker=bool(p.get("is_gig_worker", False)),
316
+ monthly_incomes=list(p.get("monthly_incomes") or []),
317
+ deferral_age=safe_int(p["deferral_age"]) if p.get("deferral_age") else None,
318
+ )
319
+ return {"type": "project", "data": project(inp)}
320
+ elif tool == "reverse_plan":
321
+ inp = ReversePlanInput(
322
+ desired_monthly_pension=safe_float(tj["desired_monthly_pension"]),
323
+ current_age=safe_int(tj["current_age"]),
324
+ retirement_age=safe_int(tj["retirement_age"]),
325
+ scheme=tj.get("scheme", "LC50"),
326
+ annuity_percent=safe_float(tj.get("annuity_percent"), 0.40),
327
+ existing_corpus=safe_float(tj.get("existing_corpus")),
328
+ annual_step_up=safe_float(tj.get("annual_step_up")),
329
+ )
330
+ return {"type": "reverse_plan", "data": reverse_plan(inp)}
331
+ return {"type": "error", "data": {"message": f"Unknown tool: {tool}"}}
332
+
333
+
334
+ @app.post("/api/chat")
335
+ def chat(req: ChatRequest):
336
+ if not GEMINI_MODEL:
337
+ raise HTTPException(status_code=500, detail="GEMINI_API_KEY not configured")
338
+
339
+ try:
340
+ # Build Gemini history (exclude last user message)
341
+ history = []
342
+ messages = req.messages
343
+ for msg in messages[:-1]:
344
+ history.append({"role": msg.role if msg.role != "assistant" else "model",
345
+ "parts": [msg.content]})
346
+
347
+ chat_session = GEMINI_MODEL.start_chat(history=history)
348
+ last_msg = messages[-1].content
349
+ response = chat_session.send_message(last_msg)
350
+ bot_text = response.text
351
+
352
+ tool_json = extract_tool_call(bot_text)
353
+ display_text = strip_code_blocks(bot_text)
354
+ tool_result = None
355
+ follow_up = None
356
+
357
+ if tool_json:
358
+ tool_result = run_tool(tool_json)
359
+ # Get Gemini to interpret the result
360
+ result_summary = json.dumps(tool_result["data"], indent=2)[:3000]
361
+ fu_resp = chat_session.send_message(
362
+ f"[TOOL RESULT]\n{result_summary}\n\n"
363
+ "Summarise the realistic scenario for the user in 3-4 sentences. "
364
+ "Then offer: (1) reverse planning, (2) nudges, (3) change annuity split."
365
+ )
366
+ follow_up = fu_resp.text
367
+
368
+ return {
369
+ "success": True,
370
+ "text": display_text,
371
+ "tool_result": tool_result,
372
+ "follow_up": follow_up,
373
+ }
374
+
375
+ except Exception as e:
376
+ raise HTTPException(status_code=500, detail=str(e))
377
+
378
+ if __name__ == "__main__":
379
+ uvicorn.run("app:app", host="0.0.0.0", port=7860)
pension_engine.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PensionSight β€” Core Financial Calculation Engine
3
+ All pure math, no external dependencies.
4
+ """
5
+
6
+ import math
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+
10
+
11
+ # ─────────────────────────────────────────────
12
+ # NPS SCHEME CONSTANTS (2025 PFRDA norms)
13
+ # ─────────────────────────────────────────────
14
+
15
+ NPS_SCHEMES = {
16
+ "LC25": {"name": "Life Cycle 25 – Low", "max_equity": 0.25, "equity_exit_age": 55},
17
+ "LC50": {"name": "Life Cycle 50 – Moderate", "max_equity": 0.50, "equity_exit_age": 55},
18
+ "LC75": {"name": "Life Cycle 75 – High", "max_equity": 0.75, "equity_exit_age": 55},
19
+ "AGGRESSIVE": {"name": "Life Cycle Aggressive","max_equity": 0.35, "equity_exit_age": 55},
20
+ "ACTIVE": {"name": "Active Choice", "max_equity": 0.75, "equity_exit_age": None},
21
+ }
22
+
23
+ # Historical NPS fund CAGR approximations (2004-2025 verified ranges)
24
+ SCENARIO_RETURNS = {
25
+ "conservative": {
26
+ "equity": 0.10,
27
+ "corporate_bond": 0.07,
28
+ "govt_bond": 0.06,
29
+ "label": "Conservative (low market)"
30
+ },
31
+ "realistic": {
32
+ "equity": 0.13,
33
+ "corporate_bond": 0.08,
34
+ "govt_bond": 0.07,
35
+ "label": "Realistic (average market)"
36
+ },
37
+ "optimistic": {
38
+ "equity": 0.16,
39
+ "corporate_bond": 0.095,
40
+ "govt_bond": 0.08,
41
+ "label": "Optimistic (strong market)"
42
+ }
43
+ }
44
+
45
+ ANNUITY_RATE_DEFAULT = 0.06 # 6% annuity rate (ASP average 2025)
46
+ ANNUITY_MIN_PERCENT = 0.40 # 40% minimum annuity purchase (PFRDA 2025 norm)
47
+ INFLATION_RATE = 0.06 # 6% CPI (RBI average)
48
+ MIN_RETIREMENT_AGE = 60
49
+ MAX_DEFERRAL_AGE = 75
50
+ TAX_EXEMPT_LUMPSUM = 0.60 # 60% lumpsum is tax-free under NPS
51
+
52
+
53
+ # ─────────────────────────────────────────────
54
+ # DATA MODELS
55
+ # ─────────────────────────────────────────────
56
+
57
+ @dataclass
58
+ class SubscriberProfile:
59
+ current_age: int
60
+ retirement_age: int
61
+ monthly_contribution: float
62
+ existing_corpus: float = 0.0
63
+ annual_contribution_increase: float = 0.0 # % per year step-up
64
+ sector: str = "non_government" # government / non_government / vatsalya
65
+ scheme: str = "LC50"
66
+ annuity_percent: float = 0.40
67
+ annuity_rate: float = ANNUITY_RATE_DEFAULT
68
+ deferral_age: Optional[int] = None # defer exit beyond 60
69
+ employer_contribution: float = 0.0 # monthly employer top-up
70
+ desired_monthly_pension: Optional[float] = None
71
+
72
+ # Gig worker fields
73
+ is_gig_worker: bool = False
74
+ monthly_incomes: list = field(default_factory=list) # list of monthly income values
75
+
76
+ @property
77
+ def investment_years(self) -> int:
78
+ end_age = self.deferral_age if self.deferral_age else self.retirement_age
79
+ return max(1, end_age - self.current_age)
80
+
81
+ @property
82
+ def total_monthly_contribution(self) -> float:
83
+ return self.monthly_contribution + self.employer_contribution
84
+
85
+
86
+ @dataclass
87
+ class ScenarioResult:
88
+ scenario: str
89
+ label: str
90
+ blended_return: float
91
+ projected_corpus: float
92
+ real_corpus: float # inflation-adjusted
93
+ lumpsum_withdrawal: float
94
+ annuity_corpus: float
95
+ monthly_pension: float
96
+ real_monthly_pension: float
97
+ total_contributions: float
98
+ wealth_gained: float
99
+ investment_years: int
100
+
101
+
102
+ @dataclass
103
+ class ReverseplanResult:
104
+ desired_monthly_pension: float
105
+ required_corpus: float
106
+ required_monthly_sip: float
107
+ scenario: str
108
+ investment_years: int
109
+ current_age: int
110
+ retirement_age: int
111
+
112
+
113
+ @dataclass
114
+ class NudgeResult:
115
+ nudge_type: str
116
+ message: str
117
+ impact_rupees: float
118
+ current_value: float
119
+ improved_value: float
120
+
121
+
122
+ # ─────────────────────────────────────────────
123
+ # CORE CALCULATION ENGINE
124
+ # ─────────────────────────────────────────────
125
+
126
+ class PensionCalculationEngine:
127
+
128
+ def _blended_return(self, scheme_key: str, scenario: str) -> float:
129
+ """Calculate blended return based on scheme allocation and scenario."""
130
+ scheme = NPS_SCHEMES.get(scheme_key, NPS_SCHEMES["LC50"])
131
+ s = SCENARIO_RETURNS[scenario]
132
+
133
+ eq = scheme["max_equity"]
134
+ gb = (1 - eq) * 0.6 # 60% of debt in govt bonds
135
+ cb = (1 - eq) * 0.4 # 40% of debt in corporate bonds
136
+
137
+ return round(eq * s["equity"] + cb * s["corporate_bond"] + gb * s["govt_bond"], 4)
138
+
139
+ def _future_value_step_up_sip(
140
+ self,
141
+ monthly_sip: float,
142
+ annual_return: float,
143
+ years: int,
144
+ annual_step_up_pct: float = 0.0,
145
+ existing_corpus: float = 0.0
146
+ ) -> float:
147
+ """
148
+ Future value of a step-up SIP (SIP increases by annual_step_up_pct every year).
149
+ Also compounds any existing corpus.
150
+ """
151
+ monthly_rate = annual_return / 12
152
+ total_months = years * 12
153
+
154
+ # Compound existing corpus
155
+ fv_existing = existing_corpus * ((1 + monthly_rate) ** total_months)
156
+
157
+ # Step-up SIP: iterate year by year
158
+ fv_sip = 0.0
159
+ for year in range(years):
160
+ sip_this_year = monthly_sip * ((1 + annual_step_up_pct / 100) ** year)
161
+ months_remaining = (years - year) * 12
162
+ # FV of 12 equal monthly payments with months_remaining left to compound
163
+ for m in range(12):
164
+ months_to_end = months_remaining - m
165
+ fv_sip += sip_this_year * ((1 + monthly_rate) ** months_to_end)
166
+
167
+ return fv_existing + fv_sip
168
+
169
+ def _gig_worker_avg_monthly(self, monthly_incomes: list) -> float:
170
+ """Average monthly income for gig workers from a list of monthly values."""
171
+ if not monthly_incomes:
172
+ return 0.0
173
+ return sum(monthly_incomes) / len(monthly_incomes)
174
+
175
+ def _monthly_pension_from_corpus(self, annuity_corpus: float, annuity_rate: float) -> float:
176
+ """Monthly pension from annuity corpus at given annual annuity rate."""
177
+ return (annuity_corpus * annuity_rate) / 12
178
+
179
+ def _real_value(self, nominal: float, years: int) -> float:
180
+ """Deflate nominal value to today's purchasing power."""
181
+ return nominal / ((1 + INFLATION_RATE) ** years)
182
+
183
+ # ── Main projection ──────────────────────────────────────────────────────
184
+
185
+ def project_all_scenarios(self, profile: SubscriberProfile) -> dict:
186
+ """Run 3-scenario projection for a subscriber profile."""
187
+ results = {}
188
+
189
+ # Effective monthly contribution (handle gig worker)
190
+ if profile.is_gig_worker and profile.monthly_incomes:
191
+ avg_income = self._gig_worker_avg_monthly(profile.monthly_incomes)
192
+ # Use contribution as % of average income if provided, else use monthly_contribution
193
+ effective_monthly = profile.monthly_contribution if profile.monthly_contribution > 0 \
194
+ else avg_income * 0.10 # default 10% if not set
195
+ else:
196
+ effective_monthly = profile.total_monthly_contribution
197
+
198
+ years = profile.investment_years
199
+ total_contributions = effective_monthly * 12 * years # rough estimate
200
+
201
+ for scenario in ["conservative", "realistic", "optimistic"]:
202
+ r = self._blended_return(profile.scheme, scenario)
203
+ corpus = self._future_value_step_up_sip(
204
+ monthly_sip=effective_monthly,
205
+ annual_return=r,
206
+ years=years,
207
+ annual_step_up_pct=profile.annual_contribution_increase,
208
+ existing_corpus=profile.existing_corpus
209
+ )
210
+
211
+ annuity_corpus = corpus * max(profile.annuity_percent, ANNUITY_MIN_PERCENT)
212
+ lumpsum = corpus * (1 - max(profile.annuity_percent, ANNUITY_MIN_PERCENT))
213
+ monthly_pension = self._monthly_pension_from_corpus(annuity_corpus, profile.annuity_rate)
214
+ real_corpus = self._real_value(corpus, years)
215
+ real_monthly_pen = self._real_value(monthly_pension, years)
216
+
217
+ results[scenario] = ScenarioResult(
218
+ scenario=scenario,
219
+ label=SCENARIO_RETURNS[scenario]["label"],
220
+ blended_return=round(r * 100, 2),
221
+ projected_corpus=round(corpus),
222
+ real_corpus=round(real_corpus),
223
+ lumpsum_withdrawal=round(lumpsum),
224
+ annuity_corpus=round(annuity_corpus),
225
+ monthly_pension=round(monthly_pension),
226
+ real_monthly_pension=round(real_monthly_pen),
227
+ total_contributions=round(total_contributions),
228
+ wealth_gained=round(corpus - total_contributions),
229
+ investment_years=years
230
+ )
231
+
232
+ return results
233
+
234
+ # ── Reverse Planner ──────────────────────────────────────────────────────
235
+
236
+ def reverse_plan(
237
+ self,
238
+ desired_monthly_pension: float,
239
+ current_age: int,
240
+ retirement_age: int,
241
+ scheme: str = "LC50",
242
+ scenario: str = "realistic",
243
+ annuity_percent: float = 0.40,
244
+ annuity_rate: float = ANNUITY_RATE_DEFAULT,
245
+ existing_corpus: float = 0.0,
246
+ annual_step_up: float = 0.0
247
+ ) -> ReverseplanResult:
248
+ """
249
+ Given a desired monthly pension, calculate required monthly SIP today.
250
+ Works backwards: pension β†’ annuity corpus β†’ total corpus β†’ SIP.
251
+ """
252
+ years = max(1, retirement_age - current_age)
253
+ r = self._blended_return(scheme, scenario)
254
+ monthly_rate = r / 12
255
+ total_months = years * 12
256
+
257
+ # Step 1: Annuity corpus needed for desired pension
258
+ annual_pension = desired_monthly_pension * 12
259
+ required_annuity_c = annual_pension / annuity_rate
260
+
261
+ # Step 2: Total corpus needed (annuity is annuity_percent of corpus)
262
+ effective_annuity_pct = max(annuity_percent, ANNUITY_MIN_PERCENT)
263
+ required_corpus = required_annuity_c / effective_annuity_pct
264
+
265
+ # Step 3: Subtract compounded existing corpus
266
+ fv_existing = existing_corpus * ((1 + monthly_rate) ** total_months)
267
+ corpus_from_sip = max(0, required_corpus - fv_existing)
268
+
269
+ # Step 4: Required monthly SIP (standard FV of annuity formula)
270
+ if corpus_from_sip == 0:
271
+ required_sip = 0.0
272
+ elif annual_step_up == 0:
273
+ # Simple SIP formula: FV = SIP Γ— [((1+r)^n - 1)/r] Γ— (1+r)
274
+ fv_factor = ((1 + monthly_rate) ** total_months - 1) / monthly_rate * (1 + monthly_rate)
275
+ required_sip = corpus_from_sip / fv_factor
276
+ else:
277
+ # Step-up SIP: binary search (harder to invert analytically)
278
+ required_sip = self._binary_search_sip(
279
+ target_corpus=corpus_from_sip,
280
+ annual_return=r,
281
+ years=years,
282
+ annual_step_up=annual_step_up
283
+ )
284
+
285
+ return ReverseplanResult(
286
+ desired_monthly_pension=desired_monthly_pension,
287
+ required_corpus=round(required_corpus),
288
+ required_monthly_sip=round(required_sip),
289
+ scenario=scenario,
290
+ investment_years=years,
291
+ current_age=current_age,
292
+ retirement_age=retirement_age
293
+ )
294
+
295
+ def _binary_search_sip(
296
+ self,
297
+ target_corpus: float,
298
+ annual_return: float,
299
+ years: int,
300
+ annual_step_up: float,
301
+ tolerance: float = 100
302
+ ) -> float:
303
+ """Binary search for required SIP when step-up is involved."""
304
+ lo, hi = 100.0, target_corpus
305
+ for _ in range(60):
306
+ mid = (lo + hi) / 2
307
+ fv = self._future_value_step_up_sip(mid, annual_return, years, annual_step_up)
308
+ if abs(fv - target_corpus) < tolerance:
309
+ return mid
310
+ if fv < target_corpus:
311
+ lo = mid
312
+ else:
313
+ hi = mid
314
+ return (lo + hi) / 2
315
+
316
+ # ── AI Nudge Engine ───────────────────────────────────────────────────────
317
+
318
+ def generate_nudges(self, profile: SubscriberProfile) -> list:
319
+ """
320
+ Rule-based nudge engine: compares current vs improved scenarios.
321
+ Returns list of NudgeResult objects sorted by impact.
322
+ """
323
+ nudges = []
324
+ base_results = self.project_all_scenarios(profile)
325
+ base_corpus = base_results["realistic"].projected_corpus
326
+ base_pension = base_results["realistic"].monthly_pension
327
+ years = profile.investment_years
328
+
329
+ # Nudge 1: Increase SIP by β‚Ή500
330
+ if profile.monthly_contribution < 50000:
331
+ boost = 500
332
+ improved = self._sim_corpus_change(profile, sip_delta=boost)
333
+ gain = improved - base_corpus
334
+ if gain > 0:
335
+ nudges.append(NudgeResult(
336
+ nudge_type="sip_increase",
337
+ message=f"Increasing your monthly SIP by β‚Ή{boost:,} adds β‚Ή{gain:,.0f} to your corpus β€” that's β‚Ή{round(gain * ANNUITY_MIN_PERCENT * ANNUITY_RATE_DEFAULT / 12):,}/month extra pension.",
338
+ impact_rupees=gain,
339
+ current_value=base_corpus,
340
+ improved_value=improved
341
+ ))
342
+
343
+ # Nudge 2: Add annual step-up of 5%
344
+ if profile.annual_contribution_increase < 5:
345
+ improved = self._sim_corpus_change(profile, step_up_delta=5)
346
+ gain = improved - base_corpus
347
+ if gain > 0:
348
+ nudges.append(NudgeResult(
349
+ nudge_type="step_up",
350
+ message=f"Adding a 5% annual step-up to your SIP (raise contributions by 5% each year) grows your corpus by β‚Ή{gain:,.0f} β€” β‚Ή{round(gain * ANNUITY_MIN_PERCENT * ANNUITY_RATE_DEFAULT / 12):,}/month more pension.",
351
+ impact_rupees=gain,
352
+ current_value=base_corpus,
353
+ improved_value=improved
354
+ ))
355
+
356
+ # Nudge 3: Start 2 years earlier
357
+ if profile.current_age > 25:
358
+ improved = self._sim_corpus_change(profile, age_delta=-2)
359
+ gain = improved - base_corpus
360
+ if gain > 0:
361
+ nudges.append(NudgeResult(
362
+ nudge_type="early_start",
363
+ message=f"If you had started NPS 2 years earlier, your corpus would be β‚Ή{gain:,.0f} higher. Starting early is the single most powerful retirement lever.",
364
+ impact_rupees=gain,
365
+ current_value=base_corpus,
366
+ improved_value=improved
367
+ ))
368
+
369
+ # Nudge 4: Retire 2 years later (defer)
370
+ if profile.retirement_age <= 60:
371
+ improved = self._sim_corpus_change(profile, retire_later=2)
372
+ gain = improved - base_corpus
373
+ if gain > 0:
374
+ nudges.append(NudgeResult(
375
+ nudge_type="defer_retirement",
376
+ message=f"Deferring retirement by 2 years to age {profile.retirement_age + 2} adds β‚Ή{gain:,.0f} to your corpus β€” β‚Ή{round(gain * ANNUITY_MIN_PERCENT * ANNUITY_RATE_DEFAULT / 12):,}/month more pension.",
377
+ impact_rupees=gain,
378
+ current_value=base_corpus,
379
+ improved_value=improved
380
+ ))
381
+
382
+ # Nudge 5: Switch to higher growth scheme
383
+ if profile.scheme in ["LC25", "LC50"] and profile.current_age < 45:
384
+ improved = self._sim_corpus_change(profile, scheme_upgrade="LC75")
385
+ gain = improved - base_corpus
386
+ if gain > 0:
387
+ nudges.append(NudgeResult(
388
+ nudge_type="scheme_upgrade",
389
+ message=f"Switching from {NPS_SCHEMES[profile.scheme]['name']} to LC75 (higher equity at your young age) could add β‚Ή{gain:,.0f} to your corpus.",
390
+ impact_rupees=gain,
391
+ current_value=base_corpus,
392
+ improved_value=improved
393
+ ))
394
+
395
+ nudges.sort(key=lambda x: x.impact_rupees, reverse=True)
396
+ return nudges
397
+
398
+ def _sim_corpus_change(
399
+ self, profile, sip_delta=0, step_up_delta=0,
400
+ age_delta=0, retire_later=0, scheme_upgrade=None
401
+ ) -> float:
402
+ """Simulate corpus with a single parameter change."""
403
+ import copy
404
+ p = copy.copy(profile)
405
+ p.monthly_contribution += sip_delta
406
+ p.annual_contribution_increase = max(0, p.annual_contribution_increase + step_up_delta)
407
+ p.current_age = max(18, p.current_age + age_delta)
408
+ p.retirement_age += retire_later
409
+ if scheme_upgrade:
410
+ p.scheme = scheme_upgrade
411
+ return self.project_all_scenarios(p)["realistic"].projected_corpus
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pydantic
4
+ google-generativeai