Skip to content

Commit 59c3a6a

Browse files
committed
Refactor report
Signed-off-by: Herklos <herklos@drakkar.software>
1 parent b5f292f commit 59c3a6a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+16122
-619
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ wheels/
2727
.installed.cfg
2828
*.egg
2929

30+
node_modules
31+
3032
# PyInstaller
3133
# Usually these files are written by a python script from a template
3234
# before PyInstaller builds the exe, so as to inject date/other infos into it.
@@ -136,6 +138,7 @@ cython_debug/
136138
.env
137139

138140
*.zip
141+
*.db
139142

140143
# tensorboard
141144
tensorboard_logs

MANIFEST.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
recursive-include octobot_script/config *.json *.ini
22
recursive-include octobot_script/resources *.js *.css *.html
3+
recursive-exclude octobot_script/resources/report/node_modules *
4+
recursive-exclude octobot_script/resources/report/src *
35

46
include README.md
57
include LICENSE

biome.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
3+
"assist": { "actions": { "source": { "organizeImports": "on" } } },
4+
"files": {
5+
"includes": [
6+
"**",
7+
"!**/dist/**/*",
8+
"!**/node_modules/**/*",
9+
"!**/src/components/ui/**/*"
10+
]
11+
},
12+
"linter": {
13+
"enabled": true,
14+
"rules": {
15+
"recommended": true,
16+
"suspicious": {
17+
"noExplicitAny": "off",
18+
"noArrayIndexKey": "off"
19+
},
20+
"style": {
21+
"noNonNullAssertion": "off",
22+
"noParameterAssign": "error",
23+
"useSelfClosingElements": "error",
24+
"noUselessElse": "error"
25+
}
26+
}
27+
},
28+
"formatter": {
29+
"indentStyle": "space"
30+
},
31+
"javascript": {
32+
"formatter": {
33+
"quoteStyle": "double",
34+
"semicolons": "asNeeded"
35+
}
36+
},
37+
"css": {
38+
"parser": {
39+
"tailwindDirectives": true
40+
}
41+
}
42+
}

components.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema.json",
3+
"style": "new-york",
4+
"rsc": false,
5+
"tsx": true,
6+
"tailwind": {
7+
"config": "",
8+
"css": "octobot_script/resources/report/src/index.css",
9+
"baseColor": "gray",
10+
"cssVariables": true,
11+
"prefix": ""
12+
},
13+
"iconLibrary": "lucide",
14+
"rtl": false,
15+
"aliases": {
16+
"components": "@/components",
17+
"utils": "@/lib/utils",
18+
"ui": "@/components/ui",
19+
"lib": "@/lib",
20+
"hooks": "@/hooks"
21+
},
22+
"registries": {}
23+
}

octobot_script/internal/octobot_mocks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ def get_tentacles_config():
3535
commons_constants.CONFIG_TENTACLES_FILE
3636
)
3737
tentacles_setup_config = octobot_tentacles_manager_api.get_tentacles_setup_config(ref_tentacles_config_path)
38+
if not tentacles_setup_config.is_successfully_loaded:
39+
# reference config not available (tentacles not yet installed via CLI),
40+
# populate from currently imported tentacles
41+
octobot_tentacles_manager_api.fill_with_installed_tentacles(
42+
tentacles_setup_config,
43+
tentacles_folder=get_imported_tentacles_path()
44+
)
3845
# activate OctoBot-Script required tentacles
3946
_force_tentacles_config_activation(tentacles_setup_config)
4047
return tentacles_setup_config

octobot_script/model/backtest_plot.py

Lines changed: 125 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
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
1620
import time
17-
import webbrowser
18-
import jinja2
1921
import json
20-
import http.server
21-
import socketserver
2222

2323
import octobot_commons.constants as commons_constants
2424
import octobot_commons.display as display
@@ -27,17 +27,22 @@
2727
import octobot.api as octobot_api
2828
import octobot_script.resources as resources
2929
import octobot_script.internal.backtester_trading_mode as backtester_trading_mode
30+
from octobot_script.model.backtest_report_server import BacktestReportServer
3031

3132

3233
class 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

Comments
 (0)