1313#
1414# You should have received a copy of the GNU General Public
1515# License along with OctoBot-Script. If not, see <https://www.gnu.org/licenses/>.
16+ import html
17+ import os
18+ import re
19+ import shutil
1620import time
17- import webbrowser
18- import jinja2
1921import json
20- import http .server
21- import socketserver
2222
2323import octobot_commons .constants as commons_constants
2424import octobot_commons .display as display
2727import octobot .api as octobot_api
2828import octobot_script .resources as resources
2929import octobot_script .internal .backtester_trading_mode as backtester_trading_mode
30+ from octobot_script .model .backtest_report_server import BacktestReportServer
3031
3132
3233class BacktestPlot :
3334 DEFAULT_REPORT_NAME = "report.html"
34- DEFAULT_TEMPLATE = "default_report_template.html"
35- JINJA_ENVIRONMENT = jinja2 .Environment (loader = jinja2 .FileSystemLoader (
36- resources .get_report_resource_path (None )
37- ))
35+ ADVANCED_TEMPLATE = os .path .join ("dist" , "index.html" )
36+ DEFAULT_TEMPLATE = ADVANCED_TEMPLATE
37+ REPORT_DATA_FILENAME = "report_data.json"
38+ REPORT_META_FILENAME = "report_meta.json"
39+ REPORT_BUNDLE_FILENAME = "report.json"
40+ HISTORY_DIR = "backtesting"
41+ HISTORY_TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S"
3842 GENERATED_TIME_FORMAT = "%Y-%m-%d at %H:%M:%S"
3943 SERVER_PORT = 5555
4044 SERVER_HOST = "localhost"
45+ SERVE_TIMEOUT = 300 # seconds — keep server alive for history browsing
4146
4247 def __init__ (self , backtest_result , run_db_identifier , report_file = None ):
4348 self .backtest_result = backtest_result
@@ -46,49 +51,127 @@ def __init__(self, backtest_result, run_db_identifier, report_file=None):
4651 self .backtesting_analysis_settings = self .default_backtesting_analysis_settings ()
4752
4853 async def fill (self , template_file = None ):
49- template = self . JINJA_ENVIRONMENT . get_template ( template_file or self .DEFAULT_TEMPLATE )
54+ template_name = template_file or self .DEFAULT_TEMPLATE
5055 template_data = await self ._get_template_data ()
51- with open (self .report_file , "w" ) as report :
52- report .write (template .render (template_data ))
56+ report_dir = os .path .dirname (os .path .abspath (self .report_file ))
57+ shutil .copy2 (resources .get_report_resource_path (template_name ), self .report_file )
58+ meta = template_data ["meta" ]
59+ with open (os .path .join (report_dir , self .REPORT_DATA_FILENAME ), "w" , encoding = "utf-8" ) as f :
60+ f .write (template_data ["full_data" ])
61+ with open (os .path .join (report_dir , self .REPORT_META_FILENAME ), "w" , encoding = "utf-8" ) as f :
62+ json .dump (meta , f )
63+ bundle = {"meta" : meta , "data" : json .loads (template_data ["full_data" ])}
64+ with open (os .path .join (report_dir , self .REPORT_BUNDLE_FILENAME ), "w" , encoding = "utf-8" ) as f :
65+ json .dump (bundle , f )
66+ self ._save_history_entry (report_dir , template_data ["full_data" ], meta , bundle )
67+
68+ def _save_history_entry (self , report_dir , data_str , meta , bundle ):
69+ ts = time .strftime (self .HISTORY_TIMESTAMP_FORMAT )
70+ run_dir = os .path .join (report_dir , self .HISTORY_DIR , ts )
71+ os .makedirs (run_dir , exist_ok = True )
72+ with open (os .path .join (run_dir , self .REPORT_DATA_FILENAME ), "w" , encoding = "utf-8" ) as f :
73+ f .write (data_str )
74+ with open (os .path .join (run_dir , self .REPORT_META_FILENAME ), "w" , encoding = "utf-8" ) as f :
75+ json .dump (meta , f )
76+ with open (os .path .join (run_dir , self .REPORT_BUNDLE_FILENAME ), "w" , encoding = "utf-8" ) as f :
77+ json .dump (bundle , f )
5378
5479 def show (self ):
55- backtest_plot_instance = self
56- print (f"Report in { self .report_file } " )
80+ report_dir = os .path .dirname (os .path .abspath (self .report_file ))
81+ report_name = os .path .basename (self .report_file )
82+ runs_root_dir = os .path .dirname (report_dir )
83+ print (f"Report: { self .report_file } " )
84+ server = BacktestReportServer (
85+ report_file = self .report_file ,
86+ report_dir = report_dir ,
87+ report_name = report_name ,
88+ runs_root_dir = runs_root_dir ,
89+ server_host = self .SERVER_HOST ,
90+ server_port = self .SERVER_PORT ,
91+ serve_timeout = self .SERVE_TIMEOUT ,
92+ history_dir = self .HISTORY_DIR ,
93+ data_filename = self .REPORT_DATA_FILENAME ,
94+ meta_filename = self .REPORT_META_FILENAME ,
95+ bundle_filename = self .REPORT_BUNDLE_FILENAME ,
96+ )
97+ server .serve ()
5798
58- class ReportRequestHandler (http .server .SimpleHTTPRequestHandler ):
59- def log_request (self , * _ , ** __ ):
60- # do not log requests
61- pass
99+ async def _get_template_data (self ):
100+ full_data , symbols , time_frames , exchanges = await self ._get_full_data ()
101+ summary = self ._extract_summary_metrics (full_data )
102+ return {
103+ "full_data" : full_data ,
104+ "meta" : {
105+ "title" : f"{ ', ' .join (symbols )} " ,
106+ "creation_time" : timestamp_util .convert_timestamp_to_datetime (
107+ time .time (), self .GENERATED_TIME_FORMAT , local_timezone = True
108+ ),
109+ "strategy_config" : self .backtest_result .strategy_config ,
110+ "symbols" : symbols ,
111+ "time_frames" : time_frames ,
112+ "exchanges" : exchanges ,
113+ "summary" : summary ,
114+ },
115+ }
62116
63- def do_GET (self ):
64- self .send_response (http .HTTPStatus .OK )
65- self .send_header ("Content-type" , "text/html" )
66- self .end_headers ()
117+ @staticmethod
118+ def _extract_summary_metrics (full_data ):
119+ try :
120+ parsed = json .loads (full_data )
121+ sub_elements = parsed .get ("data" , {}).get ("sub_elements" , [])
122+ details = next (
123+ (
124+ element for element in sub_elements
125+ if element .get ("name" ) == "backtesting-details" and element .get ("type" ) == "value"
126+ ),
127+ None
128+ )
129+ values = details .get ("data" , {}).get ("elements" , []) if details else []
130+ metrics = {}
131+ for element in values :
132+ title = element .get ("title" )
133+ value = element .get ("value" )
134+ if title and value is not None :
135+ metrics [str (title )] = str (value )
136+ metric_html = element .get ("html" )
137+ if metric_html :
138+ metrics .update (BacktestPlot ._extract_metrics_from_html (metric_html ))
67139
68- with open (backtest_plot_instance .report_file , "rb" ) as report :
69- self .wfile .write (report .read ())
140+ def _find_metric (candidates ):
141+ for key , value in metrics .items ():
142+ lowered = key .lower ()
143+ if any (candidate in lowered for candidate in candidates ):
144+ return value
145+ return None
70146
71- try :
72- with socketserver .TCPServer (("" , self .SERVER_PORT ), ReportRequestHandler ) as httpd :
73- webbrowser .open (f"http://{ self .SERVER_HOST } :{ self .SERVER_PORT } " )
74- httpd .handle_request ()
147+ return {
148+ "profitability" : _find_metric (("usdt gains" , "profitability" , "profit" , "roi" , "return" )),
149+ "portfolio" : _find_metric (("end portfolio usdt value" , "end portfolio" , "portfolio" )),
150+ "metrics" : metrics ,
151+ }
75152 except Exception :
76- webbrowser . open ( self . report_file )
153+ return { "profitability" : None , "portfolio" : None , "metrics" : {}}
77154
78- async def _get_template_data (self ):
79- full_data , symbols , time_frames , exchanges = await self ._get_full_data ()
80- return {
81- "FULL_DATA" : full_data ,
82- "title" : f"{ ', ' .join (symbols )} " ,
83- "top_title" : f"{ ', ' .join (symbols )} on { ', ' .join (time_frames )} from "
84- f"{ ', ' .join ([e .capitalize () for e in exchanges ])} " ,
85- "creation_time" : timestamp_util .convert_timestamp_to_datetime (
86- time .time (), self .GENERATED_TIME_FORMAT , local_timezone = True
87- ),
88- "middle_title" : "Portfolio value" ,
89- "bottom_title" : "Details" ,
90- "strategy_config" : self .backtest_result .strategy_config
91- }
155+ @staticmethod
156+ def _extract_metrics_from_html (metric_html ):
157+ metrics = {}
158+ matches = re .findall (
159+ r'backtesting-run-container-values-label[^>]*>\s*(.*?)\s*</div>\s*'
160+ r'<div[^>]*backtesting-run-container-values-value[^>]*>\s*(.*?)\s*</div>' ,
161+ metric_html ,
162+ flags = re .IGNORECASE | re .DOTALL
163+ )
164+ for raw_label , raw_value in matches :
165+ label = BacktestPlot ._html_to_text (raw_label )
166+ value = BacktestPlot ._html_to_text (raw_value )
167+ if label and value :
168+ metrics [label ] = value
169+ return metrics
170+
171+ @staticmethod
172+ def _html_to_text (content ):
173+ no_tags = re .sub (r"<[^>]+>" , " " , content or "" )
174+ return re .sub (r"\s+" , " " , html .unescape (no_tags )).strip ()
92175
93176 async def _get_full_data (self ):
94177 # tentacles not available during first install
0 commit comments