88import math
99import random
1010from typing import Any
11+ from uuid import uuid4
1112
1213from flask import g , jsonify , request
1314
1920 parse_rate ,
2021)
2122from app .services .backtest_limits import BacktestRangeLimitError
23+ from app .services .billing_service import get_billing_service
2224from app .services .script_source import get_script_source_service
2325from app .services .strategy_v2 import (
2426 FactorResearchRepository ,
@@ -80,7 +82,7 @@ def _source(payload: dict[str, Any], user_id: int) -> tuple[str, int | None, int
8082 return code , source_id , strategy_id , strategy_name
8183
8284
83- def _run (payload : dict [str , Any ], user_id : int , * , persist : bool ) -> tuple [ int | None , dict [str , Any ] ]:
85+ def _prepare_run (payload : dict [str , Any ], user_id : int ) -> dict [str , Any ]:
8486 code , source_id , strategy_id , strategy_name = _source (payload , user_id )
8587 start_raw = str (payload .get ("startDate" ) or "" ).strip ()
8688 end_raw = str (payload .get ("endDate" ) or "" ).strip ()
@@ -89,36 +91,131 @@ def _run(payload: dict[str, Any], user_id: int, *, persist: bool) -> tuple[int |
8991 start_date = datetime .strptime (start_raw , "%Y-%m-%d" )
9092 end_date = datetime .strptime (end_raw , "%Y-%m-%d" ).replace (hour = 23 , minute = 59 , second = 59 )
9193 leverage_enabled = bool (payload .get ("leverageEnabled" , False ))
92- return get_strategy_backtest_service ().run (
94+ return {
95+ "user_id" : user_id ,
96+ "code" : code ,
97+ "start_date" : start_date ,
98+ "end_date" : end_date ,
99+ "initial_capital" : float (payload .get ("initialCapital" ) or 10_000 ),
100+ "leverage_enabled" : leverage_enabled ,
101+ "leverage" : float (payload .get ("leverage" ) or 1 ),
102+ "commission" : parse_rate (payload .get ("commission" ), default = default_commission_if_missing (None )),
103+ "slippage" : parse_rate (payload .get ("slippage" ), default = default_slippage_if_missing (None )),
104+ "params" : dict (payload .get ("params" ) or {}),
105+ "strategy_id" : strategy_id ,
106+ "source_id" : source_id ,
107+ "strategy_name" : strategy_name ,
108+ }
109+
110+
111+ def _run_prepared (prepared : dict [str , Any ], * , persist : bool ) -> tuple [int | None , dict [str , Any ]]:
112+ return get_strategy_backtest_service ().run (** prepared , persist = persist )
113+
114+
115+ def _run (payload : dict [str , Any ], user_id : int , * , persist : bool ) -> tuple [int | None , dict [str , Any ]]:
116+ return _run_prepared (_prepare_run (payload , user_id ), persist = persist )
117+
118+
119+ def _consume_backtest_credits (user_id : int ) -> tuple [Any , dict [str , Any ]]:
120+ """Charge one backtest run and return a response-safe billing snapshot."""
121+ billing = get_billing_service ()
122+ enabled = bool (billing .is_billing_enabled ())
123+ cost = max (0 , int (billing .get_feature_cost ("backtest" ) or 0 ))
124+ reference_id = f"backtest:{ uuid4 ().hex } "
125+ charge = {
126+ "enabled" : enabled ,
127+ "cost" : cost ,
128+ "charged" : 0 ,
129+ "remaining" : float (billing .get_user_credits (user_id )),
130+ "referenceId" : reference_id ,
131+ }
132+ if not enabled or cost <= 0 :
133+ return billing , charge
134+
135+ success , message = billing .check_and_consume (
136+ user_id = user_id ,
137+ feature = "backtest" ,
138+ reference_id = reference_id ,
139+ )
140+ if not success :
141+ current = float (billing .get_user_credits (user_id ))
142+ if str (message ).startswith ("insufficient_credits:" ):
143+ return billing , {
144+ ** charge ,
145+ "error" : "insufficient_credits" ,
146+ "current" : current ,
147+ "required" : cost ,
148+ "shortage" : max (0 , cost - current ),
149+ }
150+ return billing , {** charge , "error" : "billing_error" , "message" : str (message )}
151+
152+ charge ["charged" ] = cost
153+ charge ["remaining" ] = float (billing .get_user_credits (user_id ))
154+ return billing , charge
155+
156+
157+ def _refund_backtest_credits (billing : Any , user_id : int , charge : dict [str , Any ]) -> None :
158+ cost = int (charge .get ("charged" ) or 0 )
159+ if not billing or cost <= 0 :
160+ return
161+ refunded , message = billing .add_credits (
93162 user_id = user_id ,
94- code = code ,
95- start_date = start_date ,
96- end_date = end_date ,
97- initial_capital = float (payload .get ("initialCapital" ) or 10_000 ),
98- leverage_enabled = leverage_enabled ,
99- leverage = float (payload .get ("leverage" ) or 1 ),
100- commission = parse_rate (payload .get ("commission" ), default = default_commission_if_missing (None )),
101- slippage = parse_rate (payload .get ("slippage" ), default = default_slippage_if_missing (None )),
102- params = dict (payload .get ("params" ) or {}),
103- persist = persist ,
104- strategy_id = strategy_id ,
105- source_id = source_id ,
106- strategy_name = strategy_name ,
163+ amount = cost ,
164+ action = "refund" ,
165+ remark = "Automatic refund: backtest execution failed" ,
166+ reference_id = str (charge .get ("referenceId" ) or "" ),
107167 )
168+ if not refunded :
169+ logger .error ("Backtest credit refund failed for user %s: %s" , user_id , message )
108170
109171
110172@backtest_center_blp .route ("/run" , methods = ["POST" ])
111173@login_required
112174def run_strategy_backtest ():
175+ billing = None
176+ charge : dict [str , Any ] = {}
177+ user_id = int (g .user_id )
113178 try :
114179 payload = request .get_json (silent = True ) or {}
115- run_id , result = _run (payload , int (g .user_id ), persist = bool (payload .get ("persist" , True )))
116- return jsonify ({"code" : 1 , "msg" : "success" , "data" : {** result , "runId" : run_id }})
180+ prepared = _prepare_run (payload , user_id )
181+ billing , charge = _consume_backtest_credits (user_id )
182+ if charge .get ("error" ) == "insufficient_credits" :
183+ return jsonify ({
184+ "code" : 0 ,
185+ "msg" : "insufficient_credits" ,
186+ "data" : {
187+ "error_type" : "INSUFFICIENT_CREDITS" ,
188+ "feature" : "backtest" ,
189+ "current" : charge ["current" ],
190+ "required" : charge ["required" ],
191+ "shortage" : charge ["shortage" ],
192+ },
193+ }), 402
194+ if charge .get ("error" ):
195+ return jsonify ({
196+ "code" : 0 ,
197+ "msg" : charge .get ("message" ) or "Failed to deduct credits" ,
198+ "data" : {"error_type" : "BILLING_ERROR" , "feature" : "backtest" },
199+ }), 500
200+
201+ run_id , result = _run_prepared (prepared , persist = bool (payload .get ("persist" , True )))
202+ billing_data = {
203+ key : charge .get (key )
204+ for key in ("enabled" , "cost" , "charged" , "remaining" )
205+ }
206+ return jsonify ({
207+ "code" : 1 ,
208+ "msg" : "success" ,
209+ "data" : {** result , "runId" : run_id , "billing" : billing_data },
210+ })
117211 except BacktestRangeLimitError as exc :
212+ _refund_backtest_credits (billing , user_id , charge )
118213 return jsonify ({"code" : 0 , "msg" : str (exc ), "data" : exc .details }), 400
119214 except ValueError as exc :
215+ _refund_backtest_credits (billing , user_id , charge )
120216 return jsonify ({"code" : 0 , "msg" : str (exc ), "data" : None }), 400
121217 except Exception as exc :
218+ _refund_backtest_credits (billing , user_id , charge )
122219 logger .exception ("Strategy backtest failed" )
123220 return jsonify ({"code" : 0 , "msg" : str (exc ), "data" : None }), 500
124221
@@ -343,9 +440,14 @@ def _candidates(space: dict[str, list[Any]], *, method: str, limit: int) -> list
343440
344441def _metrics (result : dict [str , Any ]) -> dict [str , float ]:
345442 raw = result .get ("metrics" ) if isinstance (result .get ("metrics" ), dict ) else result
443+ annual_return = raw .get ("annualReturn" )
444+ if annual_return is None :
445+ annual_return = raw .get ("annualizedReturn" )
446+ if annual_return is None :
447+ annual_return = raw .get ("annual_return" )
346448 return {
347449 "totalReturn" : _number (raw .get ("totalReturn" , raw .get ("total_return" ))),
348- "annualReturn" : _number (raw . get ( "annualReturn" , raw . get ( " annual_return" )) ),
450+ "annualReturn" : _number (annual_return ),
349451 "maxDrawdown" : _number (raw .get ("maxDrawdown" , raw .get ("max_drawdown" ))),
350452 "sharpeRatio" : _number (raw .get ("sharpeRatio" , raw .get ("sharpe_ratio" ))),
351453 "winRate" : _number (raw .get ("winRate" , raw .get ("win_rate" ))),
0 commit comments