Skip to content

Commit 84e6a6f

Browse files
Improve solver correctness: return 400 NO_SOLUTION when no sign change for bond yield/XIRR
1 parent 7d4f648 commit 84e6a6f

1 file changed

Lines changed: 87 additions & 15 deletions

File tree

app/main.py

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -680,18 +680,46 @@ def bond_present_value(yield_rate):
680680
pv_face = face_value / ((1 + yield_rate / payments_per_year) ** total_payments)
681681
return pv_coupons + pv_face - current_price
682682

683+
# Common bracket for yield search
684+
low, high = 0.0, 1.0
685+
686+
# Check for sign change before attempting a root find.
687+
pv_low = bond_present_value(low)
688+
pv_high = bond_present_value(high)
689+
690+
if abs(pv_low) < 1e-9:
691+
return low
692+
if abs(pv_high) < 1e-9:
693+
return high
694+
695+
# If there is no sign change, there is no solution in [low, high].
696+
if pv_low * pv_high > 0:
697+
raise HTTPException(
698+
status_code=status.HTTP_400_BAD_REQUEST,
699+
detail={
700+
"ok": False,
701+
"error": {
702+
"code": "NO_SOLUTION",
703+
"message": "No bond yield solution in [0, 1] for the given inputs.",
704+
"details": [
705+
f"pv(low=0.0)={pv_low}",
706+
f"pv(high=1.0)={pv_high}",
707+
],
708+
},
709+
},
710+
)
711+
683712
# Use scipy if available (more accurate)
684713
if SCIPY_AVAILABLE:
685714
try:
686-
result = root_scalar(bond_present_value, bracket=[0.0, 1.0], method='brentq')
715+
result = root_scalar(bond_present_value, bracket=[low, high], method="brentq")
687716
return result.root
688717
except ValueError:
689718
# Fallback to fsolve
690719
result = fsolve(bond_present_value, 0.05)
691720
return float(result[0])
692721
else:
693722
# Fallback: Simple bisection method (no scipy required)
694-
low, high = 0.0, 1.0
695723
tolerance = 1e-6
696724
max_iterations = 100
697725

@@ -771,6 +799,9 @@ def calculate_bond_yield(payload: BondYieldRequest):
771799
}
772800
}
773801
)
802+
except HTTPException:
803+
# Let HTTP exceptions bubble up so the unified handler can format them.
804+
raise
774805
except Exception as e:
775806
raise HTTPException(
776807
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -799,39 +830,77 @@ def npv(rate):
799830
total += cf.amount / ((1 + rate) ** years)
800831
return total
801832

833+
# Default bracket for XIRR search
834+
low, high = -0.99, 10.0
835+
836+
# Adjust bounds if initial guess suggests different range
837+
if 0 < initial_guess < 1:
838+
low, high = -0.5, 2.0
839+
840+
# Check for sign change before attempting a root find.
841+
pv_low = npv(low)
842+
pv_high = npv(high)
843+
844+
if abs(pv_low) < 1e-9:
845+
return low
846+
if abs(pv_high) < 1e-9:
847+
return high
848+
849+
if pv_low * pv_high > 0:
850+
# No sign change within bracket → no solution in this range.
851+
raise HTTPException(
852+
status_code=status.HTTP_400_BAD_REQUEST,
853+
detail={
854+
"ok": False,
855+
"error": {
856+
"code": "NO_SOLUTION",
857+
"message": "No XIRR solution in the searched range for the given cashflows.",
858+
"details": [
859+
f"npv(low={low})={pv_low}",
860+
f"npv(high={high})={pv_high}",
861+
],
862+
},
863+
},
864+
)
865+
802866
# Use scipy if available (more accurate)
803867
if SCIPY_AVAILABLE:
804868
try:
805-
result = root_scalar(npv, bracket=[-0.99, 10.0], method='brentq', x0=initial_guess)
869+
result = root_scalar(npv, bracket=[low, high], method="brentq", x0=initial_guess)
806870
return result.root
807871
except ValueError:
808872
# Fallback to fsolve
809873
result = fsolve(npv, initial_guess)
810874
return float(result[0])
811875
else:
812876
# Fallback: Simple bisection method (no scipy required)
813-
low, high = -0.99, 10.0
814877
tolerance = 1e-6
815878
max_iterations = 100
816-
817-
# Adjust bounds if initial guess suggests different range
818-
if initial_guess > 0 and initial_guess < 1:
819-
low, high = -0.5, 2.0
820-
879+
821880
for _ in range(max_iterations):
822881
mid = (low + high) / 2
823882
npv_value = npv(mid)
824-
883+
825884
if abs(npv_value) < tolerance:
826885
return mid
827-
886+
828887
if npv_value > 0:
829888
low = mid
830889
else:
831890
high = mid
832-
833-
# Return best guess if convergence not reached
834-
return (low + high) / 2
891+
892+
# No convergence within max_iterations: treat as no solution
893+
raise HTTPException(
894+
status_code=status.HTTP_400_BAD_REQUEST,
895+
detail={
896+
"ok": False,
897+
"error": {
898+
"code": "NO_SOLUTION",
899+
"message": "No XIRR solution found within iteration limit.",
900+
"details": [],
901+
},
902+
},
903+
)
835904

836905
@app.post("/v1/xirr",
837906
response_model=XIRRResponse,
@@ -895,7 +964,7 @@ def calculate_xirr(payload: XIRRRequest):
895964
payload.initial_guess
896965
)
897966
xirr_result = future.result(timeout=SOLVER_TIMEOUT_SECONDS)
898-
967+
899968
return {"ok": True, "xirr": round(xirr_result, 6)}
900969
except FutureTimeoutError:
901970
raise HTTPException(
@@ -909,6 +978,9 @@ def calculate_xirr(payload: XIRRRequest):
909978
}
910979
}
911980
)
981+
except HTTPException:
982+
# Let HTTP exceptions bubble up so the unified handler can format them.
983+
raise
912984
except Exception as e:
913985
raise HTTPException(
914986
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

0 commit comments

Comments
 (0)