Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- app.py +379 -0
- pension_engine.py +411 -0
- 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
|