-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcalc_engine.py
More file actions
248 lines (214 loc) · 9.46 KB
/
calc_engine.py
File metadata and controls
248 lines (214 loc) · 9.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
"""
Deterministic calculation engine for question types that are too complex for LLM math.
Pure Python computation — no LLM needed, 100% accurate, instant.
"""
import re
def compute_buy_vs_rent_pv(params: dict[str, float]) -> float:
"""
Compute PV benefit of buying vs. renting over a time horizon.
This replicates the Excel FLEXIBILITY worksheet logic:
- Buyer: mortgage payments + running costs, builds equity via home appreciation
- Renter: pays rent, invests savings at alternative return rate
- PV benefit = (Equity − Savings) / (1 + discount_rate)^N
Returns the PV benefit value (positive = buying is better).
"""
purchase_price = params["purchase_price"]
downpayment = params["downpayment"]
interest_rate = params["interest_rate"]
n_years = int(params["principal_amortization_years"])
transfer_duty = params["transfer_duty"]
property_taxes = params["property_taxes"]
annual_maintenance = params["annual_maintenance"]
water = params["water"]
strata_fees = params["strata_fees"]
general_inflation = params["general_inflation"]
rent_yield = params["annual_rent_yield_gross"]
home_appreciation = params["home_value_annual_appreciation"]
alt_return = params["return_on_alternative_investments"]
cash_rate = params["cash_rate"]
loan = purchase_price - downpayment
# Annual mortgage payment (PMT formula)
if interest_rate == 0:
mortgage_payment = loan / n_years
else:
mortgage_payment = loan * interest_rate / (1 - (1 + interest_rate) ** (-n_years))
# Year-by-year simulation — replicates FLEXIBILITY worksheet exactly:
# Home Value(t) = Home Value(t-1) × (1 + appreciation)
# Property taxes(t) = Home Value(t) × tax_rate (uses CURRENT year)
# Rent(t) = Home Value(t-1) × rent_yield (uses PRIOR year)
# Savings(t) = Buying_costs(t) - Rent(t) + Savings(t-1) × (1 + alt_return)
# PV benefit = (Equity(N) - Savings(N)) / (1 + Cash_Rate)^N
home_value_prior = purchase_price # year 0 home value
debt_balance = loan
renter_savings = downpayment + transfer_duty # initial savings pool
for year in range(1, n_years + 1):
# Home value appreciates FIRST (matches Excel column layout)
home_value_current = home_value_prior * (1 + home_appreciation)
# Debt amortization
interest_paid = debt_balance * interest_rate
principal_paid = mortgage_payment - interest_paid
debt_balance = debt_balance - principal_paid
# Buyer's annual costs
inflation_factor = (1 + general_inflation) ** year
buying_costs = (
mortgage_payment
+ home_value_current * property_taxes # current year home value
+ strata_fees * inflation_factor
+ annual_maintenance * inflation_factor
+ water * inflation_factor
)
# Rent uses PRIOR year's home value (Excel: E45=-D28*$Q$12)
rent = home_value_prior * rent_yield
# Net cash saved by renter vs buyer
cash_saved = buying_costs - rent
# Savings accumulation (Excel: E46=E44+E45+D46*(1+$Q$14))
renter_savings = renter_savings * (1 + alt_return) + cash_saved
# Advance for next year
home_value_prior = home_value_current
# End of horizon
equity = home_value_current - max(debt_balance, 0)
# PV discount uses Cash Rate, NOT Return on Alternative Investments
pv_benefit = (equity - renter_savings) / (1 + cash_rate) ** n_years
return round(pv_benefit)
# ── Parameter extraction ─────────────────────────────────────────────
# Map question text labels to parameter keys
_PARAM_MAP = {
# Purchase price variants
"purchase price": "purchase_price",
"home price": "purchase_price",
"property price": "purchase_price",
"house price": "purchase_price",
# Down payment variants
"downpayment": "downpayment",
"down payment": "downpayment",
"deposit": "downpayment",
# Interest rate
"interest rate": "interest_rate",
"mortgage rate": "interest_rate",
"loan rate": "interest_rate",
# Amortization
"principal amortization (years)": "principal_amortization_years",
"principal amortization": "principal_amortization_years",
"amortization (years)": "principal_amortization_years",
"loan term (years)": "principal_amortization_years",
"time horizon": "principal_amortization_years",
# Transfer duty
"transfer duty": "transfer_duty",
"stamp duty": "transfer_duty",
# Property taxes
"property taxes": "property_taxes",
"property tax": "property_taxes",
"council rates": "property_taxes",
# Running costs
"annual maintenance": "annual_maintenance",
"maintenance": "annual_maintenance",
"water": "water",
"water rates": "water",
"strata fees": "strata_fees",
"strata": "strata_fees",
"body corporate": "strata_fees",
# Macro factors
"cash rate": "cash_rate",
"discount rate": "cash_rate",
"general inflation": "general_inflation",
"inflation": "general_inflation",
"inflation rate": "general_inflation",
"annual rent yield (gross)": "annual_rent_yield_gross",
"annual rent yield": "annual_rent_yield_gross",
"rent yield": "annual_rent_yield_gross",
"gross rent yield": "annual_rent_yield_gross",
"home value annual appreciation": "home_value_annual_appreciation",
"home appreciation": "home_value_annual_appreciation",
"appreciation rate": "home_value_annual_appreciation",
"annual appreciation": "home_value_annual_appreciation",
"return on alternative investments": "return_on_alternative_investments",
"alternative investment return": "return_on_alternative_investments",
"alt return": "return_on_alternative_investments",
"investment return": "return_on_alternative_investments",
}
_REQUIRED_PARAMS = {
"purchase_price", "downpayment", "interest_rate",
"principal_amortization_years", "transfer_duty", "property_taxes",
"annual_maintenance", "water", "strata_fees", "cash_rate",
"general_inflation", "annual_rent_yield_gross",
"home_value_annual_appreciation", "return_on_alternative_investments",
}
def extract_buy_rent_params(question_text: str, data_context: str) -> dict[str, float] | None:
"""
Extract Buy vs Rent parameters from question text and data context.
Returns dict of param_key → float, or None if not a Buy vs Rent question.
"""
text_lower = question_text.lower()
# Flexible detection: any combination of buy/rent keywords + PV or flexibility
has_buy_rent = any(kw in text_lower for kw in [
"buying vs", "buy vs", "buying versus", "buy versus",
"renting", "rent vs", "flexibility",
])
has_pv = any(kw in text_lower for kw in [
"pv benefit", "pv of buying", "present value benefit",
"pv advantage", "benefit of buying",
])
# Also detect by parameter presence: if the data has mortgage-related params
has_mortgage_params = data_context and any(
kw in data_context.lower() for kw in ["purchase price", "downpayment", "mortgage", "strata fees"]
)
if not (has_buy_rent and (has_pv or has_mortgage_params)):
return None
params = {}
# Parse from data_context (tab-separated key-value pairs)
for line in data_context.split("\n"):
line = line.strip()
if not line or line.startswith("["):
continue
parts = line.split("\t", 1)
if len(parts) != 2:
continue
label, value_str = parts[0].strip(), parts[1].strip()
key = _PARAM_MAP.get(label.lower())
if key:
# Parse value: handle $, %, commas
clean = value_str.replace("$", "").replace(",", "").replace("%", "").strip()
try:
val = float(clean)
# If the original had % sign or value < 1 and key is a rate, it's already decimal
# If value > 1 and key is a rate type, might need /100
params[key] = val
except ValueError:
continue
# Also try inline "key = value" format from merged question text
for line in question_text.split("\n"):
m = re.match(r"^(.+?)\s*=\s*(.+)$", line.strip())
if not m:
continue
label, value_str = m.group(1).strip(), m.group(2).strip()
key = _PARAM_MAP.get(label.lower())
if key and key not in params:
clean = value_str.replace("$", "").replace(",", "").replace("%", "").strip()
try:
params[key] = float(clean)
except ValueError:
continue
# Check we have all required params
missing = _REQUIRED_PARAMS - set(params.keys())
if missing:
return None
return params
def match_choice(computed_value: float, choices: tuple[str, ...]) -> str | None:
"""Match computed value to the closest choice."""
best_match = None
best_diff = float("inf")
for choice in choices:
# Extract number from choice text
clean = choice.replace("$", "").replace(",", "").strip()
try:
choice_val = float(clean)
except ValueError:
continue
diff = abs(computed_value - choice_val)
if diff < best_diff:
best_diff = diff
best_match = choice
# Only match if within 5% or $500 tolerance
if best_match and best_diff < max(abs(computed_value) * 0.05, 500):
return best_match
return None