@@ -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