From 59c3a6a6c14dd130883f77601fbc8b524a3eeae7 Mon Sep 17 00:00:00 2001 From: Herklos Date: Thu, 19 Feb 2026 16:32:09 +0100 Subject: [PATCH 1/2] Refactor report Signed-off-by: Herklos --- .gitignore | 3 + MANIFEST.in | 2 + biome.json | 42 + components.json | 23 + octobot_script/internal/octobot_mocks.py | 7 + octobot_script/model/backtest_plot.py | 167 +- .../model/backtest_report_server.py | 246 + octobot_script/resources/__init__.py | 2 +- octobot_script/resources/report/.gitignore | 1 + octobot_script/resources/report/index.html | 17 + octobot_script/resources/report/src/App.tsx | 121 + .../report/src/components/AppSidebar.tsx | 122 + .../src/components/ChartAreaInteractive.tsx | 100 + .../report/src/components/DataTable.tsx | 162 + .../report/src/components/MetricCards.tsx | 212 + .../report/src/components/SectionCards.tsx | 81 + .../report/src/components/SiteHeader.tsx | 31 + .../report/src/components/TradesTable.tsx | 275 + .../report/src/components/TradingChart.tsx | 462 + .../report/src/components/ui/avatar.tsx | 109 + .../report/src/components/ui/badge.tsx | 48 + .../report/src/components/ui/breadcrumb.tsx | 109 + .../report/src/components/ui/button.tsx | 64 + .../report/src/components/ui/card.tsx | 92 + .../report/src/components/ui/chart.tsx | 355 + .../report/src/components/ui/checkbox.tsx | 30 + .../report/src/components/ui/drawer.tsx | 133 + .../src/components/ui/dropdown-menu.tsx | 257 + .../report/src/components/ui/input.tsx | 21 + .../report/src/components/ui/label.tsx | 24 + .../report/src/components/ui/select.tsx | 188 + .../report/src/components/ui/separator.tsx | 26 + .../report/src/components/ui/sheet.tsx | 143 + .../report/src/components/ui/sidebar.tsx | 726 ++ .../report/src/components/ui/skeleton.tsx | 13 + .../report/src/components/ui/sonner.tsx | 38 + .../report/src/components/ui/table.tsx | 114 + .../report/src/components/ui/tabs.tsx | 91 + .../report/src/components/ui/toggle-group.tsx | 83 + .../report/src/components/ui/toggle.tsx | 47 + .../report/src/components/ui/tooltip.tsx | 55 + .../resources/report/src/hooks/use-mobile.ts | 19 + octobot_script/resources/report/src/index.css | 202 + .../resources/report/src/lib/utils.ts | 23 + octobot_script/resources/report/src/main.tsx | 147 + octobot_script/resources/report/src/types.ts | 97 + .../resources/reports/css/style.css | 44 - .../resources/reports/css/w2ui_template.css | 37 - .../reports/default_report_template.html | 59 - octobot_script/resources/reports/header.html | 36 - octobot_script/resources/reports/js/common.js | 3 - octobot_script/resources/reports/js/data.js | 1 - octobot_script/resources/reports/js/graphs.js | 197 - octobot_script/resources/reports/js/tables.js | 140 - octobot_script/resources/reports/js/texts.js | 42 - octobot_script/resources/reports/scripts.html | 15 - package-lock.json | 10685 ++++++++++++++++ package.json | 51 + setup.py | 51 +- tsconfig.build.json | 7 + tsconfig.json | 21 + vite.config.ts | 22 + 62 files changed, 16122 insertions(+), 619 deletions(-) create mode 100644 biome.json create mode 100644 components.json create mode 100644 octobot_script/model/backtest_report_server.py create mode 100644 octobot_script/resources/report/.gitignore create mode 100644 octobot_script/resources/report/index.html create mode 100644 octobot_script/resources/report/src/App.tsx create mode 100644 octobot_script/resources/report/src/components/AppSidebar.tsx create mode 100644 octobot_script/resources/report/src/components/ChartAreaInteractive.tsx create mode 100644 octobot_script/resources/report/src/components/DataTable.tsx create mode 100644 octobot_script/resources/report/src/components/MetricCards.tsx create mode 100644 octobot_script/resources/report/src/components/SectionCards.tsx create mode 100644 octobot_script/resources/report/src/components/SiteHeader.tsx create mode 100644 octobot_script/resources/report/src/components/TradesTable.tsx create mode 100644 octobot_script/resources/report/src/components/TradingChart.tsx create mode 100644 octobot_script/resources/report/src/components/ui/avatar.tsx create mode 100644 octobot_script/resources/report/src/components/ui/badge.tsx create mode 100644 octobot_script/resources/report/src/components/ui/breadcrumb.tsx create mode 100644 octobot_script/resources/report/src/components/ui/button.tsx create mode 100644 octobot_script/resources/report/src/components/ui/card.tsx create mode 100644 octobot_script/resources/report/src/components/ui/chart.tsx create mode 100644 octobot_script/resources/report/src/components/ui/checkbox.tsx create mode 100644 octobot_script/resources/report/src/components/ui/drawer.tsx create mode 100644 octobot_script/resources/report/src/components/ui/dropdown-menu.tsx create mode 100644 octobot_script/resources/report/src/components/ui/input.tsx create mode 100644 octobot_script/resources/report/src/components/ui/label.tsx create mode 100644 octobot_script/resources/report/src/components/ui/select.tsx create mode 100644 octobot_script/resources/report/src/components/ui/separator.tsx create mode 100644 octobot_script/resources/report/src/components/ui/sheet.tsx create mode 100644 octobot_script/resources/report/src/components/ui/sidebar.tsx create mode 100644 octobot_script/resources/report/src/components/ui/skeleton.tsx create mode 100644 octobot_script/resources/report/src/components/ui/sonner.tsx create mode 100644 octobot_script/resources/report/src/components/ui/table.tsx create mode 100644 octobot_script/resources/report/src/components/ui/tabs.tsx create mode 100644 octobot_script/resources/report/src/components/ui/toggle-group.tsx create mode 100644 octobot_script/resources/report/src/components/ui/toggle.tsx create mode 100644 octobot_script/resources/report/src/components/ui/tooltip.tsx create mode 100644 octobot_script/resources/report/src/hooks/use-mobile.ts create mode 100644 octobot_script/resources/report/src/index.css create mode 100644 octobot_script/resources/report/src/lib/utils.ts create mode 100644 octobot_script/resources/report/src/main.tsx create mode 100644 octobot_script/resources/report/src/types.ts delete mode 100644 octobot_script/resources/reports/css/style.css delete mode 100644 octobot_script/resources/reports/css/w2ui_template.css delete mode 100644 octobot_script/resources/reports/default_report_template.html delete mode 100644 octobot_script/resources/reports/header.html delete mode 100644 octobot_script/resources/reports/js/common.js delete mode 100644 octobot_script/resources/reports/js/data.js delete mode 100644 octobot_script/resources/reports/js/graphs.js delete mode 100644 octobot_script/resources/reports/js/tables.js delete mode 100644 octobot_script/resources/reports/js/texts.js delete mode 100644 octobot_script/resources/reports/scripts.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index ebf3b5f..34363ef 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ wheels/ .installed.cfg *.egg +node_modules + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. @@ -136,6 +138,7 @@ cython_debug/ .env *.zip +*.db # tensorboard tensorboard_logs diff --git a/MANIFEST.in b/MANIFEST.in index 07a4615..b161687 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,7 @@ recursive-include octobot_script/config *.json *.ini recursive-include octobot_script/resources *.js *.css *.html +recursive-exclude octobot_script/resources/report/node_modules * +recursive-exclude octobot_script/resources/report/src * include README.md include LICENSE diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b52c800 --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "files": { + "includes": [ + "**", + "!**/dist/**/*", + "!**/node_modules/**/*", + "!**/src/components/ui/**/*" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noArrayIndexKey": "off" + }, + "style": { + "noNonNullAssertion": "off", + "noParameterAssign": "error", + "useSelfClosingElements": "error", + "noUselessElse": "error" + } + } + }, + "formatter": { + "indentStyle": "space" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "asNeeded" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..5a0eeda --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "octobot_script/resources/report/src/index.css", + "baseColor": "gray", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/octobot_script/internal/octobot_mocks.py b/octobot_script/internal/octobot_mocks.py index d8ff03e..eea0641 100644 --- a/octobot_script/internal/octobot_mocks.py +++ b/octobot_script/internal/octobot_mocks.py @@ -35,6 +35,13 @@ def get_tentacles_config(): commons_constants.CONFIG_TENTACLES_FILE ) tentacles_setup_config = octobot_tentacles_manager_api.get_tentacles_setup_config(ref_tentacles_config_path) + if not tentacles_setup_config.is_successfully_loaded: + # reference config not available (tentacles not yet installed via CLI), + # populate from currently imported tentacles + octobot_tentacles_manager_api.fill_with_installed_tentacles( + tentacles_setup_config, + tentacles_folder=get_imported_tentacles_path() + ) # activate OctoBot-Script required tentacles _force_tentacles_config_activation(tentacles_setup_config) return tentacles_setup_config diff --git a/octobot_script/model/backtest_plot.py b/octobot_script/model/backtest_plot.py index b0f8cd0..4d754f7 100644 --- a/octobot_script/model/backtest_plot.py +++ b/octobot_script/model/backtest_plot.py @@ -13,12 +13,12 @@ # # You should have received a copy of the GNU General Public # License along with OctoBot-Script. If not, see . +import html +import os +import re +import shutil import time -import webbrowser -import jinja2 import json -import http.server -import socketserver import octobot_commons.constants as commons_constants import octobot_commons.display as display @@ -27,17 +27,22 @@ import octobot.api as octobot_api import octobot_script.resources as resources import octobot_script.internal.backtester_trading_mode as backtester_trading_mode +from octobot_script.model.backtest_report_server import BacktestReportServer class BacktestPlot: DEFAULT_REPORT_NAME = "report.html" - DEFAULT_TEMPLATE = "default_report_template.html" - JINJA_ENVIRONMENT = jinja2.Environment(loader=jinja2.FileSystemLoader( - resources.get_report_resource_path(None) - )) + ADVANCED_TEMPLATE = os.path.join("dist", "index.html") + DEFAULT_TEMPLATE = ADVANCED_TEMPLATE + REPORT_DATA_FILENAME = "report_data.json" + REPORT_META_FILENAME = "report_meta.json" + REPORT_BUNDLE_FILENAME = "report.json" + HISTORY_DIR = "backtesting" + HISTORY_TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S" GENERATED_TIME_FORMAT = "%Y-%m-%d at %H:%M:%S" SERVER_PORT = 5555 SERVER_HOST = "localhost" + SERVE_TIMEOUT = 300 # seconds — keep server alive for history browsing def __init__(self, backtest_result, run_db_identifier, report_file=None): self.backtest_result = backtest_result @@ -46,49 +51,127 @@ def __init__(self, backtest_result, run_db_identifier, report_file=None): self.backtesting_analysis_settings = self.default_backtesting_analysis_settings() async def fill(self, template_file=None): - template = self.JINJA_ENVIRONMENT.get_template(template_file or self.DEFAULT_TEMPLATE) + template_name = template_file or self.DEFAULT_TEMPLATE template_data = await self._get_template_data() - with open(self.report_file, "w") as report: - report.write(template.render(template_data)) + report_dir = os.path.dirname(os.path.abspath(self.report_file)) + shutil.copy2(resources.get_report_resource_path(template_name), self.report_file) + meta = template_data["meta"] + with open(os.path.join(report_dir, self.REPORT_DATA_FILENAME), "w", encoding="utf-8") as f: + f.write(template_data["full_data"]) + with open(os.path.join(report_dir, self.REPORT_META_FILENAME), "w", encoding="utf-8") as f: + json.dump(meta, f) + bundle = {"meta": meta, "data": json.loads(template_data["full_data"])} + with open(os.path.join(report_dir, self.REPORT_BUNDLE_FILENAME), "w", encoding="utf-8") as f: + json.dump(bundle, f) + self._save_history_entry(report_dir, template_data["full_data"], meta, bundle) + + def _save_history_entry(self, report_dir, data_str, meta, bundle): + ts = time.strftime(self.HISTORY_TIMESTAMP_FORMAT) + run_dir = os.path.join(report_dir, self.HISTORY_DIR, ts) + os.makedirs(run_dir, exist_ok=True) + with open(os.path.join(run_dir, self.REPORT_DATA_FILENAME), "w", encoding="utf-8") as f: + f.write(data_str) + with open(os.path.join(run_dir, self.REPORT_META_FILENAME), "w", encoding="utf-8") as f: + json.dump(meta, f) + with open(os.path.join(run_dir, self.REPORT_BUNDLE_FILENAME), "w", encoding="utf-8") as f: + json.dump(bundle, f) def show(self): - backtest_plot_instance = self - print(f"Report in {self.report_file}") + report_dir = os.path.dirname(os.path.abspath(self.report_file)) + report_name = os.path.basename(self.report_file) + runs_root_dir = os.path.dirname(report_dir) + print(f"Report: {self.report_file}") + server = BacktestReportServer( + report_file=self.report_file, + report_dir=report_dir, + report_name=report_name, + runs_root_dir=runs_root_dir, + server_host=self.SERVER_HOST, + server_port=self.SERVER_PORT, + serve_timeout=self.SERVE_TIMEOUT, + history_dir=self.HISTORY_DIR, + data_filename=self.REPORT_DATA_FILENAME, + meta_filename=self.REPORT_META_FILENAME, + bundle_filename=self.REPORT_BUNDLE_FILENAME, + ) + server.serve() - class ReportRequestHandler(http.server.SimpleHTTPRequestHandler): - def log_request(self, *_, **__): - # do not log requests - pass + async def _get_template_data(self): + full_data, symbols, time_frames, exchanges = await self._get_full_data() + summary = self._extract_summary_metrics(full_data) + return { + "full_data": full_data, + "meta": { + "title": f"{', '.join(symbols)}", + "creation_time": timestamp_util.convert_timestamp_to_datetime( + time.time(), self.GENERATED_TIME_FORMAT, local_timezone=True + ), + "strategy_config": self.backtest_result.strategy_config, + "symbols": symbols, + "time_frames": time_frames, + "exchanges": exchanges, + "summary": summary, + }, + } - def do_GET(self): - self.send_response(http.HTTPStatus.OK) - self.send_header("Content-type", "text/html") - self.end_headers() + @staticmethod + def _extract_summary_metrics(full_data): + try: + parsed = json.loads(full_data) + sub_elements = parsed.get("data", {}).get("sub_elements", []) + details = next( + ( + element for element in sub_elements + if element.get("name") == "backtesting-details" and element.get("type") == "value" + ), + None + ) + values = details.get("data", {}).get("elements", []) if details else [] + metrics = {} + for element in values: + title = element.get("title") + value = element.get("value") + if title and value is not None: + metrics[str(title)] = str(value) + metric_html = element.get("html") + if metric_html: + metrics.update(BacktestPlot._extract_metrics_from_html(metric_html)) - with open(backtest_plot_instance.report_file, "rb") as report: - self.wfile.write(report.read()) + def _find_metric(candidates): + for key, value in metrics.items(): + lowered = key.lower() + if any(candidate in lowered for candidate in candidates): + return value + return None - try: - with socketserver.TCPServer(("", self.SERVER_PORT), ReportRequestHandler) as httpd: - webbrowser.open(f"http://{self.SERVER_HOST}:{self.SERVER_PORT}") - httpd.handle_request() + return { + "profitability": _find_metric(("usdt gains", "profitability", "profit", "roi", "return")), + "portfolio": _find_metric(("end portfolio usdt value", "end portfolio", "portfolio")), + "metrics": metrics, + } except Exception: - webbrowser.open(self.report_file) + return {"profitability": None, "portfolio": None, "metrics": {}} - async def _get_template_data(self): - full_data, symbols, time_frames, exchanges = await self._get_full_data() - return { - "FULL_DATA": full_data, - "title": f"{', '.join(symbols)}", - "top_title": f"{', '.join(symbols)} on {', '.join(time_frames)} from " - f"{', '.join([e.capitalize() for e in exchanges])}", - "creation_time": timestamp_util.convert_timestamp_to_datetime( - time.time(), self.GENERATED_TIME_FORMAT, local_timezone=True - ), - "middle_title": "Portfolio value", - "bottom_title": "Details", - "strategy_config": self.backtest_result.strategy_config - } + @staticmethod + def _extract_metrics_from_html(metric_html): + metrics = {} + matches = re.findall( + r'backtesting-run-container-values-label[^>]*>\s*(.*?)\s*\s*' + r']*backtesting-run-container-values-value[^>]*>\s*(.*?)\s*', + metric_html, + flags=re.IGNORECASE | re.DOTALL + ) + for raw_label, raw_value in matches: + label = BacktestPlot._html_to_text(raw_label) + value = BacktestPlot._html_to_text(raw_value) + if label and value: + metrics[label] = value + return metrics + + @staticmethod + def _html_to_text(content): + no_tags = re.sub(r"<[^>]+>", " ", content or "") + return re.sub(r"\s+", " ", html.unescape(no_tags)).strip() async def _get_full_data(self): # tentacles not available during first install diff --git a/octobot_script/model/backtest_report_server.py b/octobot_script/model/backtest_report_server.py new file mode 100644 index 0000000..2718a19 --- /dev/null +++ b/octobot_script/model/backtest_report_server.py @@ -0,0 +1,246 @@ +# This file is part of OctoBot-Script (https://github.com/Drakkar-Software/OctoBot-Script) +# Copyright (c) 2023 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot-Script. If not, see . +import base64 +import functools +import json +import os +import shutil +import threading +import webbrowser +import http.server +import socketserver + + +class BacktestReportServer: + def __init__( + self, + report_file, + report_dir, + report_name, + runs_root_dir, + server_host, + server_port, + serve_timeout, + history_dir, + data_filename, + meta_filename, + bundle_filename, + ): + self.report_file = report_file + self.report_dir = report_dir + self.report_name = report_name + self.runs_root_dir = runs_root_dir + self.server_host = server_host + self.server_port = server_port + self.serve_timeout = serve_timeout + self.history_dir = history_dir + self.data_filename = data_filename + self.meta_filename = meta_filename + self.bundle_filename = bundle_filename + + @staticmethod + def _encode_path(path): + return base64.urlsafe_b64encode(path.encode()).decode().rstrip("=") + + def _collect_run_dirs(self): + run_dirs = [] + try: + for entry in os.scandir(self.runs_root_dir): + if entry.is_dir() and entry.name.startswith("backtesting_"): + run_dirs.append(entry.path) + except Exception: + pass + if self.report_dir not in run_dirs: + run_dirs.append(self.report_dir) + return run_dirs + + def _load_meta_from_dir(self, root_path): + meta_path = os.path.join(root_path, self.meta_filename) + bundle_path = os.path.join(root_path, self.bundle_filename) + if os.path.isfile(meta_path): + with open(meta_path, encoding="utf-8") as f: + return json.load(f) + with open(bundle_path, encoding="utf-8") as f: + return json.load(f).get("meta", {}) + + def _has_report_data(self, root_path): + data_path = os.path.join(root_path, self.data_filename) + meta_path = os.path.join(root_path, self.meta_filename) + bundle_path = os.path.join(root_path, self.bundle_filename) + return os.path.isfile(bundle_path) or (os.path.isfile(meta_path) and os.path.isfile(data_path)) + + def _collect_history_entries(self): + entries = [] + for run_dir in self._collect_run_dirs(): + run_name = os.path.basename(run_dir) + try: + if self._has_report_data(run_dir): + try: + meta = self._load_meta_from_dir(run_dir) + entries.append({ + "id": self._encode_path(run_dir), + "meta": meta, + "path": run_dir, + "timestamp": meta.get("creation_time", ""), + "run_name": run_name, + }) + except Exception: + pass + except Exception: + pass + + history_root = os.path.join(run_dir, self.history_dir) + try: + for history_entry in os.scandir(history_root): + if not history_entry.is_dir(): + continue + if not self._has_report_data(history_entry.path): + continue + try: + meta = self._load_meta_from_dir(history_entry.path) + entries.append({ + "id": self._encode_path(history_entry.path), + "meta": meta, + "path": history_entry.path, + "timestamp": history_entry.name, + "run_name": run_name, + }) + except Exception: + pass + except Exception: + pass + + return sorted(entries, key=lambda item: str(item["timestamp"]), reverse=True) + + def _clear_histories(self): + cleared_history_dirs = 0 + cleared_run_reports = 0 + try: + for entry in os.scandir(self.runs_root_dir): + if not (entry.is_dir() and entry.name.startswith("backtesting_")): + continue + is_current_run = os.path.abspath(entry.path) == os.path.abspath(self.report_dir) + history_root = os.path.join(entry.path, self.history_dir) + if os.path.isdir(history_root): + shutil.rmtree(history_root, ignore_errors=True) + cleared_history_dirs += 1 + if not is_current_run: + removed_any = False + for filename in (self.bundle_filename, self.data_filename, self.meta_filename): + file_path = os.path.join(entry.path, filename) + if os.path.isfile(file_path): + try: + os.remove(file_path) + removed_any = True + except Exception: + pass + if removed_any: + cleared_run_reports += 1 + except Exception: + pass + return cleared_history_dirs, cleared_run_reports + + def _create_handler(self): + server = self + + class HistoryHandler(http.server.SimpleHTTPRequestHandler): + def _send_json(self, payload, status=200): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/history.json": + history = [ + {"id": entry["id"], "run_name": entry["run_name"], **entry["meta"]} + for entry in server._collect_history_entries() + ] + self._send_json(history) + return + if path.startswith("/history/"): + self._serve_history_file(path) + return + super().do_GET() + + def _serve_history_file(self, path): + parts = path.strip("/").split("/") + if len(parts) != 3 or parts[0] != "history": + self.send_error(404) + return + run_id, filename = parts[1], parts[2] + if filename not in {server.data_filename, server.meta_filename, server.bundle_filename}: + self.send_error(404) + return + history_entry = next( + (entry for entry in server._collect_history_entries() if entry["id"] == run_id), + None + ) + if history_entry is None: + self.send_error(404) + return + file_path = os.path.join(history_entry["path"], filename) + if not os.path.isfile(file_path): + self.send_error(404) + return + with open(file_path, "rb") as f: + body = f.read() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + path = self.path.split("?")[0] + if path != "/history-clear": + self.send_error(404) + return + cleared_history_dirs, cleared_run_reports = server._clear_histories() + history = [ + {"id": entry["id"], "run_name": entry["run_name"], **entry["meta"]} + for entry in server._collect_history_entries() + ] + self._send_json({ + "cleared": cleared_history_dirs, + "cleared_history_dirs": cleared_history_dirs, + "cleared_run_reports": cleared_run_reports, + "history": history, + }) + + def log_request(self, *_): + pass + + def log_error(self, *_): + pass + + return HistoryHandler + + def serve(self): + handler = functools.partial(self._create_handler(), directory=self.report_dir) + try: + with socketserver.TCPServer(("", self.server_port), handler) as httpd: + url = f"http://{self.server_host}:{self.server_port}/{self.report_name}" + webbrowser.open(url) + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + server_thread.start() + server_thread.join(timeout=self.serve_timeout) + httpd.shutdown() + except Exception: + webbrowser.open(self.report_file) diff --git a/octobot_script/resources/__init__.py b/octobot_script/resources/__init__.py index 222e0b0..3197ceb 100644 --- a/octobot_script/resources/__init__.py +++ b/octobot_script/resources/__init__.py @@ -18,7 +18,7 @@ def get_report_resource_path(resource_name): - base_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "reports") + base_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "report") if resource_name: return os.path.join(base_path, resource_name) return base_path diff --git a/octobot_script/resources/report/.gitignore b/octobot_script/resources/report/.gitignore new file mode 100644 index 0000000..2e6bd7b --- /dev/null +++ b/octobot_script/resources/report/.gitignore @@ -0,0 +1 @@ +!src/lib/ diff --git a/octobot_script/resources/report/index.html b/octobot_script/resources/report/index.html new file mode 100644 index 0000000..10f0e94 --- /dev/null +++ b/octobot_script/resources/report/index.html @@ -0,0 +1,17 @@ + + + + + + OctoBot Script Report + + + +
+ + + diff --git a/octobot_script/resources/report/src/App.tsx b/octobot_script/resources/report/src/App.tsx new file mode 100644 index 0000000..a551e60 --- /dev/null +++ b/octobot_script/resources/report/src/App.tsx @@ -0,0 +1,121 @@ +import type { ChartElement, HistoryRun, ReportData, ReportMeta, SubElement, TableElement } from "@/types" +import { AppSidebar } from "@/components/AppSidebar" +import { ChartAreaInteractive } from "@/components/ChartAreaInteractive" +import { DataTable } from "@/components/DataTable" +import { SectionCards } from "@/components/SectionCards" +import { SiteHeader } from "@/components/SiteHeader" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" + +// Named element IDs from the backtesting pipeline +const MAIN_CHART_ID = "main-chart" +const SUB_CHART_ID = "sub-chart" +const PORTFOLIO_ID = "backtesting-run-overview" +const TRADES_ID = "list-of-trades-part" + +function findByName(elements: SubElement[], name: string): SubElement | undefined { + return elements.find((el) => el.name === name) +} + +function getChartElements(el: SubElement | undefined): ChartElement[] { + if (!el || el.type !== "chart") return [] + return el.data.elements as ChartElement[] +} + +function getTableElements(el: SubElement | undefined): TableElement[] { + if (!el || el.type !== "table") return [] + return el.data.elements as TableElement[] +} + +interface AppProps { + reportData: ReportData + meta: ReportMeta + history?: HistoryRun[] + currentRunId?: string | null + onRunChange?: (runId: string | null) => void + isSwitching?: boolean + onClearHistories?: () => Promise | void + isClearingHistory?: boolean +} + +export function App({ + reportData, + meta, + history, + currentRunId, + onRunChange, + isSwitching, + onClearHistories, + isClearingHistory, +}: AppProps) { + const subElements = reportData.data.sub_elements + + const mainChartEl = findByName(subElements, MAIN_CHART_ID) + const subChartEl = findByName(subElements, SUB_CHART_ID) + const portfolioEl = findByName(subElements, PORTFOLIO_ID) + const tradesEl = findByName(subElements, TRADES_ID) + + const mainChartElements = getChartElements(mainChartEl) + const subChartElements = getChartElements(subChartEl) + const portfolioElements = getChartElements(portfolioEl) + const tradeTableElements = getTableElements(tradesEl) + + const hasMainChart = mainChartElements.some((el) => el.x !== null) + const hasSubChart = subChartElements.some((el) => el.x !== null) + const hasPortfolio = portfolioElements.some((el) => el.x !== null) + const hasHistory = (history?.length ?? 0) > 0 + const selectedRunId = currentRunId ?? history?.[0]?.id ?? null + const chartElements: ChartElement[] = [ + ...(hasMainChart ? mainChartElements : []), + ...(hasSubChart ? subChartElements : []), + ...(hasPortfolio ? portfolioElements : []), + ] + const chartGroups = { + all: chartElements, + tradesOrders: hasMainChart ? mainChartElements : [], + indicators: hasSubChart ? subChartElements : [], + portfolioHistory: hasPortfolio ? portfolioElements : [], + } + + return ( + + {hasHistory && ( + + )} + + +
+
+
+
+ +
+ {chartElements.length > 0 && ( +
+ +
+ )} + {tradeTableElements.length > 0 && ( + + )} +
+ Generated {meta.creation_time} +
+
+
+
+
+
+ ) +} diff --git a/octobot_script/resources/report/src/components/AppSidebar.tsx b/octobot_script/resources/report/src/components/AppSidebar.tsx new file mode 100644 index 0000000..2345f38 --- /dev/null +++ b/octobot_script/resources/report/src/components/AppSidebar.tsx @@ -0,0 +1,122 @@ +import type * as React from "react" +import { History, Loader2, Trash2, TrendingUp } from "lucide-react" +import type { HistoryRun } from "@/types" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" + +interface AppSidebarProps extends React.ComponentProps { + runs: HistoryRun[] + selectedRunId?: string | null + onRunChange?: (runId: string | null) => void + onClearHistories?: () => Promise | void + isClearingHistory?: boolean + isSwitching?: boolean +} + +export function AppSidebar({ + runs, + selectedRunId, + onRunChange, + onClearHistories, + isClearingHistory, + isSwitching, + ...props +}: AppSidebarProps) { + const onClearAll = async () => { + if (!onClearHistories) return + const confirmed = window.confirm( + "Delete all saved backtesting history snapshots from every backtesting run?\n\nThis cannot be undone." + ) + if (!confirmed) return + await onClearHistories() + } + + return ( + + + + + + +
+ OctoBot Script + Report Workspace +
+
+
+
+ +
+ + + + + Backtesting History + + + + {runs.map((run) => ( + + onRunChange?.(run.id)} + className="h-auto items-start py-2 transition-all" + disabled={isSwitching || isClearingHistory} + > +
+
{run.creation_time}
+
{run.run_name ?? run.title}
+ {run.summary?.profitability && ( +
+ {run.summary.profitability} +
+ )} +
+
+
+ ))} +
+
+
+
+ + + + + + {runs.length} runs + Saved + + + + + + + +
+ ) +} diff --git a/octobot_script/resources/report/src/components/ChartAreaInteractive.tsx b/octobot_script/resources/report/src/components/ChartAreaInteractive.tsx new file mode 100644 index 0000000..ef5b1ba --- /dev/null +++ b/octobot_script/resources/report/src/components/ChartAreaInteractive.tsx @@ -0,0 +1,100 @@ +"use client" + +import * as React from "react" +import type { ChartElement } from "@/types" +import { TradingChart } from "@/components/TradingChart" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group" + +interface ChartGroups { + all: ChartElement[] + tradesOrders: ChartElement[] + indicators: ChartElement[] + portfolioHistory: ChartElement[] +} + +function getTabElements(groups: ChartGroups, tab: string): ChartElement[] { + if (tab === "trades-orders") return groups.tradesOrders + if (tab === "indicators") return groups.indicators + if (tab === "portfolio-history") return groups.portfolioHistory + return groups.all +} + +export function ChartAreaInteractive({ + title, + description, + groups, + height = 320, +}: { + title: string + description?: string + groups: ChartGroups + height?: number +}) { + const [activeTab, setActiveTab] = React.useState("all") + const filteredElements = React.useMemo(() => getTabElements(groups, activeTab), [groups, activeTab]) + + return ( + + + {title} + {description && {description}} + + v && setActiveTab(v)} + variant="outline" + className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex" + > + All + Trades & Orders + Indicators + Portfolio history + + + + + + {filteredElements.length > 0 ? ( + + ) : ( +
+ No chart available for this tab. +
+ )} +
+
+ ) +} diff --git a/octobot_script/resources/report/src/components/DataTable.tsx b/octobot_script/resources/report/src/components/DataTable.tsx new file mode 100644 index 0000000..4c8ea84 --- /dev/null +++ b/octobot_script/resources/report/src/components/DataTable.tsx @@ -0,0 +1,162 @@ +"use client" + +import * as React from "react" +import type { ReportMeta, TableElement } from "@/types" +import { TradesTable } from "@/components/TradesTable" +import { Badge } from "@/components/ui/badge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group" + +function parseDistribution(value?: string) { + if (!value) return [] + const matches = Array.from(value.matchAll(/([A-Za-z0-9]+):\s*([^\s]+)/g)) + return matches.map((match) => ({ asset: match[1], amount: match[2] })) +} + +function mergePortfolioRows( + startDistribution: Array<{ asset: string; amount: string }>, + endDistribution: Array<{ asset: string; amount: string }> +) { + const byAsset = new Map() + for (const entry of startDistribution) { + byAsset.set(entry.asset, { asset: entry.asset, start: entry.amount, end: "0" }) + } + for (const entry of endDistribution) { + const current = byAsset.get(entry.asset) + if (current) current.end = entry.amount + else byAsset.set(entry.asset, { asset: entry.asset, start: "0", end: entry.amount }) + } + return Array.from(byAsset.values()) +} + +function splitTables(tables: TableElement[]) { + const trades = tables.filter((table) => { + const title = table.title.toLowerCase() + return title.includes("trade") + }) + const orders = tables.filter((table) => { + const title = table.title.toLowerCase() + return title.includes("order") + }) + return { trades, orders } +} + +function countRows(tables: TableElement[]) { + return tables.reduce((total, table) => total + table.rows.length, 0) +} + +function PortfolioDetails({ meta }: { meta: ReportMeta }) { + const metrics = meta.summary?.metrics ?? {} + const startDistribution = parseDistribution(metrics["start portfolio"]) + const endDistribution = parseDistribution(metrics["end portfolio"]) + const rows = mergePortfolioRows(startDistribution, endDistribution) + + if (rows.length === 0) { + return
No portfolio history data.
+ } + + return ( +
+ + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
AssetStartEnd
{row.asset}{row.start}{row.end}
+
+ ) +} + +export function DataTable({ tables, meta }: { tables: TableElement[]; meta: ReportMeta }) { + const { trades, orders } = splitTables(tables) + const tradesCount = countRows(trades) + const ordersCount = countRows(orders) + const metrics = meta.summary?.metrics ?? {} + const portfolioCount = mergePortfolioRows( + parseDistribution(metrics["start portfolio"]), + parseDistribution(metrics["end portfolio"]) + ).length + const defaultTab = trades.length > 0 ? "trades" : orders.length > 0 ? "orders" : "portfolio" + const [activeTab, setActiveTab] = React.useState(defaultTab) + React.useEffect(() => { + setActiveTab(defaultTab) + }, [defaultTab]) + + return ( + + +
+ Backtesting Details + Trades and orders from the selected run +
+ + v && setActiveTab(v)} + variant="outline" + className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex" + > + + Trades {tradesCount} + + + Orders {ordersCount} + + + Portfolio {portfolioCount} + + + + +
+ + {activeTab === "trades" && } + {activeTab === "orders" && } + {activeTab === "portfolio" && } + +
+ ) +} diff --git a/octobot_script/resources/report/src/components/MetricCards.tsx b/octobot_script/resources/report/src/components/MetricCards.tsx new file mode 100644 index 0000000..9508ce0 --- /dev/null +++ b/octobot_script/resources/report/src/components/MetricCards.tsx @@ -0,0 +1,212 @@ +import type { ValueElement } from "@/types" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { isPositive } from "@/lib/utils" +import { + Activity, + AlertTriangle, + BarChart2, + DollarSign, + Percent, + TrendingDown, + TrendingUp, +} from "lucide-react" +import { cn } from "@/lib/utils" + +const METRIC_ICONS: Record = { + roi: , + gain: , + gains: , + return: , + "win rate": , + wins: , + losses: , + trades: , + drawdown: , + portfolio: , + profit: , +} + +function getIcon(title: string): React.ReactNode { + const key = title.toLowerCase() + for (const [match, icon] of Object.entries(METRIC_ICONS)) { + if (key.includes(match)) return icon + } + return +} + +function getValueColor(title: string, value: string | null): string { + const titleLower = title.toLowerCase() + const pos = isPositive(value) + if (pos === null) return "text-foreground" + const negativeGoodMetrics = ["drawdown", "loss", "losses"] + const isNegativeGood = negativeGoodMetrics.some((m) => titleLower.includes(m)) + if (isNegativeGood) return pos ? "text-destructive" : "text-foreground" + return pos ? "text-foreground" : "text-destructive" +} + +interface MetricCardProps { + title: string + value: string | null +} + +function MetricCard({ title, value }: MetricCardProps) { + const icon = getIcon(title) + const valueColor = getValueColor(title, value) + + return ( + + +
+
+

+ {title} +

+

+ {value ?? "—"} +

+
+
+ {icon} +
+
+
+ +

{title}

+
+
+ ) +} + +interface MetricCardsProps { + elements: ValueElement[] +} + +interface GroupedMetric { + baseTitle: string + start?: string | null + end?: string | null +} + +function normalizeTitle(title: string): string { + return title.trim().replace(/\s+/g, " ") +} + +function groupMetrics(elements: Array<{ title: string; value: string | null }>) { + const grouped = new Map() + const singles: Array<{ title: string; value: string | null }> = [] + + for (const element of elements) { + const normalizedTitle = normalizeTitle(element.title) + const lower = normalizedTitle.toLowerCase() + if (lower.startsWith("start ")) { + const baseTitle = normalizedTitle.slice(6) + const key = baseTitle.toLowerCase() + const current = grouped.get(key) ?? { baseTitle } + current.start = element.value + grouped.set(key, current) + continue + } + if (lower.startsWith("end ")) { + const baseTitle = normalizedTitle.slice(4) + const key = baseTitle.toLowerCase() + const current = grouped.get(key) ?? { baseTitle } + current.end = element.value + grouped.set(key, current) + continue + } + singles.push(element) + } + + return { + groups: Array.from(grouped.values()), + singles, + } +} + +function GroupMetricCard({ baseTitle, start, end }: GroupedMetric) { + const title = normalizeTitle(baseTitle) + const valueColor = getValueColor(title, end ?? start ?? null) + return ( +
+

+ {title} +

+
+
+

Start

+

{start ?? "—"}

+
+
+

End

+

{end ?? "—"}

+
+
+
+ ) +} + +export function MetricCards({ elements }: MetricCardsProps) { + const displayable = elements.filter((el) => el.html === null && el.title !== null && el.value !== null) + if (displayable.length === 0) return null + + const parsed = displayable.map((element) => ({ title: element.title!, value: element.value })) + const { groups, singles } = groupMetrics(parsed) + const cardCount = groups.length + singles.length + + return ( +
+ {groups.map((group) => ( + + ))} + {singles.map((el) => ( + + ))} +
+ ) +} + +interface HtmlMetricProps { + elements: ValueElement[] +} + +export function HtmlMetrics({ elements }: HtmlMetricProps) { + const htmlElements = elements.filter((el) => el.html !== null) + if (htmlElements.length === 0) return null + + return ( +
+ {htmlElements.map((el, i) => ( +
+ {el.title && ( +
+

+ {el.title} +

+
+ )} + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: report HTML from trusted Python backend */} +
+
+ ))} +
+ ) +} diff --git a/octobot_script/resources/report/src/components/SectionCards.tsx b/octobot_script/resources/report/src/components/SectionCards.tsx new file mode 100644 index 0000000..0259884 --- /dev/null +++ b/octobot_script/resources/report/src/components/SectionCards.tsx @@ -0,0 +1,81 @@ +import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" +import type { ReportMeta } from "@/types" +import { isPositive } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +function toCards(meta: ReportMeta, limit = 4) { + const metrics = meta.summary?.metrics ?? {} + const entries = Object.entries(metrics) + const preferred = [ + "USDT gains", + "end portfolio USDT value", + "markets profitability", + "trades (entries and exits)", + ] + const selected = preferred + .map((key) => [key, metrics[key]] as const) + .filter(([, value]) => value !== undefined) + const excluded = new Set([ + ...preferred, + "start portfolio", + "end portfolio", + "start portfolio USDT value", + "end portfolio USDT value", + ]) + const fallback = entries.filter(([key]) => !excluded.has(key)).slice(0, Math.max(0, limit - selected.length)) + return [...selected, ...fallback].slice(0, limit) +} + +function getCardLabel(title: string) { + const labels: Record = { + "USDT gains": { title: "Net PnL", detail: "Realized gains in USDT" }, + "end portfolio USDT value": { title: "Final Portfolio Value", detail: "Portfolio total in USDT" }, + "markets profitability": { title: "Market Benchmark", detail: "Underlying market performance" }, + "trades (entries and exits)": { title: "Executed Trades", detail: "Total entries + exits" }, + } + return labels[title] ?? { title, detail: "Backtesting metric" } +} + +export function SectionCards({ meta }: { meta: ReportMeta }) { + const cards = toCards(meta, 4) + if (cards.length === 0) return null + + return ( +
+ {cards.map(([title, value]) => { + const positive = isPositive(value) + const label = getCardLabel(title) + return ( + + + {label.detail} + + {value} + + + {positive !== null && ( + + {positive ? : } + + )} + + + +
+ {label.title} +
+
+
+ ) + })} +
+ ) +} diff --git a/octobot_script/resources/report/src/components/SiteHeader.tsx b/octobot_script/resources/report/src/components/SiteHeader.tsx new file mode 100644 index 0000000..f688404 --- /dev/null +++ b/octobot_script/resources/report/src/components/SiteHeader.tsx @@ -0,0 +1,31 @@ +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" +import { Loader2 } from "lucide-react" + +interface SiteHeaderProps { + isSwitching?: boolean +} + +export function SiteHeader({ isSwitching }: SiteHeaderProps) { + return ( +
+
+ + +

Backtesting Dashboard

+
+ {isSwitching && ( + + + Loading + + )} + +
+
+
+ ) +} diff --git a/octobot_script/resources/report/src/components/TradesTable.tsx b/octobot_script/resources/report/src/components/TradesTable.tsx new file mode 100644 index 0000000..c30f85b --- /dev/null +++ b/octobot_script/resources/report/src/components/TradesTable.tsx @@ -0,0 +1,275 @@ +import type { TableColumn, TableElement } from "@/types" +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type SortingState, + useReactTable, +} from "@tanstack/react-table" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { ArrowUpDown, ChevronLeft, ChevronRight, Download } from "lucide-react" +import { cn } from "@/lib/utils" + +function downloadCsv(name: string, columns: { field: string; label: string }[], rows: Record[]) { + const header = columns.map((c) => c.label).join(",") + const body = rows + .map((row) => + columns + .map((c) => { + const v = row[c.field] + return typeof v === "string" ? v.replaceAll(",", " ") : String(v ?? "") + }) + .join(",") + ) + .join("\n") + const blob = new Blob([`${header}\n${body}`], { type: "text/csv" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${name}.csv` + a.click() + URL.revokeObjectURL(url) +} + +/** Detects if a column is a time/date column (should not be colorized by value sign). */ +function isTimeColumn(col: TableColumn): boolean { + const name = `${col.field} ${col.label}`.toLowerCase() + return name.includes("time") || name.includes("date") || name.includes("timestamp") +} + +/** Detects if a column represents trade side (buy/sell). */ +function isSideColumn(col: TableColumn): boolean { + const name = `${col.field} ${col.label}`.toLowerCase() + return name.includes("side") || name.includes("trade_type") || name.includes("order_type") +} + +/** Returns "buy" | "sell" | null for a side column value. */ +function parseSide(value: unknown): "buy" | "sell" | null { + const str = String(value ?? "").toLowerCase() + if (str.includes("buy") || str === "long") return "buy" + if (str.includes("sell") || str === "short") return "sell" + return null +} + +/** Determines a row's trade side from its data. */ +function getRowSide( + row: Record, + sideColField: string | null +): "buy" | "sell" | null { + if (!sideColField) return null + return parseSide(row[sideColField]) +} + +/** Regular value cell — foreground for positive, red for negative. */ +function ValueCell({ value }: { value: unknown }) { + const str = String(value ?? "—") + const isPos = typeof value === "number" ? value > 0 : str.startsWith("+") + const isNeg = typeof value === "number" ? value < 0 : str.startsWith("-") + return ( + + {str} + + ) +} + +/** Side badge cell — buy in foreground colors, sell in red. */ +function SideCell({ value }: { value: unknown }) { + const side = parseSide(value) + const label = String(value ?? "—") + return ( + + {label} + + ) +} + +interface SingleTableProps { + table: TableElement +} + +function SingleTable({ table }: SingleTableProps) { + const [sorting, setSorting] = useState([]) + const [globalFilter, setGlobalFilter] = useState("") + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 }) + + const sideCol = table.columns.find(isSideColumn) + const sideField = sideCol?.field ?? null + const timeFields = new Set(table.columns.filter(isTimeColumn).map((c) => c.field)) + const sideFields = new Set(table.columns.filter(isSideColumn).map((c) => c.field)) + + const columns: ColumnDef>[] = table.columns.map((col) => ({ + accessorKey: col.field, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const value = getValue() + if (sideFields.has(col.field)) return + if (timeFields.has(col.field)) return {String(value ?? "—")} + return + }, + })) + + const reactTable = useReactTable({ + data: table.rows, + columns, + state: { sorting, globalFilter, pagination }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const totalPages = reactTable.getPageCount() + const currentPage = reactTable.getState().pagination.pageIndex + + return ( +
+
+

+ {table.title.replaceAll("_", " ")} +

+
+ setGlobalFilter(e.target.value)} + className="h-8 px-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring w-40" + /> + +
+
+ + + + {reactTable.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {reactTable.getRowModel().rows.length > 0 ? ( + reactTable.getRowModel().rows.map((row) => { + const side = getRowSide(row.original, sideField) + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + }) + ) : ( + + + No data + + + )} + +
+ + {totalPages > 1 && ( +
+ + {reactTable.getFilteredRowModel().rows.length} row + {reactTable.getFilteredRowModel().rows.length !== 1 ? "s" : ""} + +
+ + + {currentPage + 1} / {totalPages} + + +
+
+ )} +
+ ) +} + +interface TradesTableProps { + elements: TableElement[] +} + +export function TradesTable({ elements }: TradesTableProps) { + if (elements.length === 0) return null + + return ( +
+ {elements.map((table, i) => ( + + ))} +
+ ) +} diff --git a/octobot_script/resources/report/src/components/TradingChart.tsx b/octobot_script/resources/report/src/components/TradingChart.tsx new file mode 100644 index 0000000..8dd79fd --- /dev/null +++ b/octobot_script/resources/report/src/components/TradingChart.tsx @@ -0,0 +1,462 @@ +"use client" + +import type { ChartElement } from "@/types" +import { useEffect, useRef } from "react" +import { cn } from "@/lib/utils" +import { + ColorType, + createChart, + LineStyle, + PriceScaleMode, + type UTCTimestamp, +} from "lightweight-charts" + +const THEME = { + bg: "#071633", + grid: "#2a2e39", + text: "#b2b5be", +} + +const CANDLE_COLORS = { + up: "#18b07a", + down: "#f6465d", +} + +interface LegendItem { + key: string + label: string + color: string +} + +interface MarkerPoint { + time: UTCTimestamp + position: "aboveBar" | "belowBar" + color: string + shape: "arrowUp" | "arrowDown" | "circle" + text: string +} + +interface AppliedElement { + series: any | null + markers: MarkerPoint[] + isCandles: boolean + timeSet: Set +} + +function toTime(raw: number | string): UTCTimestamp | null { + if (typeof raw === "number") { + if (raw > 10_000_000_000) return Math.floor(raw / 1000) as UTCTimestamp + return Math.floor(raw) as UTCTimestamp + } + const parsed = Date.parse(raw) + if (Number.isNaN(parsed)) return null + return Math.floor(parsed / 1000) as UTCTimestamp +} + +function isIndicatorElement(el: ChartElement): boolean { + return /\b(rsi|signals?|macd|stoch|indicator)\b/i.test(el.title) +} + +function inferIsLogScale(elements: ChartElement[]): boolean { + if (!elements.some((el) => el.y_type === "log")) return false + for (const el of elements) { + const series = [el.y, el.open, el.high, el.low, el.close] + for (const arr of series) { + if (!Array.isArray(arr)) continue + for (const v of arr) { + if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return false + } + } + } + return true +} + +function getColorAt(el: ChartElement, index = 0, fallback = "#7dd3fc"): string { + if (typeof el.color === "string") return el.color + if (Array.isArray(el.color) && typeof el.color[index] === "string") return el.color[index] as string + return fallback +} + +function getSeriesLineColor(el: ChartElement, fallback = "#a78bfa"): string { + return getColorAt(el, 0, fallback) +} + +function isValidNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) +} + +function toLineData(el: ChartElement) { + if (!el.x || !el.y) return [] + const size = Math.min(el.x.length, el.y.length) + const data: Array<{ time: UTCTimestamp; value: number }> = [] + for (let i = 0; i < size; i += 1) { + const time = toTime(el.x[i] as number | string) + const value = el.y[i] + if (time === null || !isValidNumber(value)) continue + data.push({ time, value }) + } + data.sort((a, b) => a.time - b.time) + const deduped: Array<{ time: UTCTimestamp; value: number }> = [] + for (const point of data) { + if (deduped.length > 0 && deduped[deduped.length - 1].time === point.time) { + deduped[deduped.length - 1] = point + } else { + deduped.push(point) + } + } + return deduped +} + +function toCandleData(el: ChartElement) { + if (!el.x || !el.open || !el.high || !el.low || !el.close) return [] + const size = Math.min(el.x.length, el.open.length, el.high.length, el.low.length, el.close.length) + const data: Array<{ time: UTCTimestamp; open: number; high: number; low: number; close: number }> = [] + for (let i = 0; i < size; i += 1) { + const time = toTime(el.x[i] as number | string) + const open = el.open[i] + const high = el.high[i] + const low = el.low[i] + const close = el.close[i] + if ( + time === null || + !isValidNumber(open) || + !isValidNumber(high) || + !isValidNumber(low) || + !isValidNumber(close) + ) continue + data.push({ time, open, high, low, close }) + } + data.sort((a, b) => a.time - b.time) + const deduped: Array<{ time: UTCTimestamp; open: number; high: number; low: number; close: number }> = [] + for (const point of data) { + if (deduped.length > 0 && deduped[deduped.length - 1].time === point.time) { + deduped[deduped.length - 1] = point + } else { + deduped.push(point) + } + } + return deduped +} + +function buildMarkers(el: ChartElement) { + if (!el.x) return [] + const markers: MarkerPoint[] = [] + for (let i = 0; i < el.x.length; i += 1) { + const time = toTime(el.x[i] as number | string) + if (time === null) continue + const text = Array.isArray(el.text) ? String(el.text[i] ?? "") : "" + const lower = text.toLowerCase() + const isBuy = lower.includes("buy") || lower.includes("long") + const isStopLoss = lower.includes("stop_loss") || lower.includes("stop loss") + const isLimitSell = lower.includes("limit") && lower.includes("sell") + const isSell = lower.includes("sell") || lower.includes("short") || isStopLoss + const color = isBuy + ? "#22c55e" + : isStopLoss + ? "#ef4444" + : isLimitSell + ? "#f59e0b" + : isSell + ? "#f43f5e" + : getColorAt(el, i, "#38bdf8") + const shape = isBuy ? "arrowUp" : isSell ? "arrowDown" : "circle" + const position = isBuy ? "belowBar" : "aboveBar" + const label = isBuy + ? "BUY" + : isStopLoss + ? "SL" + : isLimitSell + ? "TP" + : isSell + ? "SELL" + : "" + markers.push({ + time, + position, + color, + shape, + text: label, + }) + } + return markers +} + +function filterMarkersByTimes(markers: MarkerPoint[], times: Set) { + if (times.size === 0) return [] + return markers.filter((m) => times.has(m.time)) +} + +function isMarkerElement(el: ChartElement): boolean { + return (el.mode ?? "").includes("markers") +} + +function getLegendItems(elements: ChartElement[]): LegendItem[] { + return elements + .filter((el) => !el.is_hidden && el.x !== null && el.title !== "candles_source") + .map((el) => ({ + key: el.title, + label: el.title.replaceAll("_", " ").toLowerCase(), + color: el.kind === "candlestick" ? CANDLE_COLORS.up : getSeriesLineColor(el), + })) +} + +function buildChart(container: HTMLDivElement, height: number, isLogScale: boolean) { + return createChart(container, { + height, + layout: { + background: { type: ColorType.Solid, color: THEME.bg }, + textColor: THEME.text, + }, + grid: { + vertLines: { color: THEME.grid, style: LineStyle.Solid }, + horzLines: { color: THEME.grid, style: LineStyle.Solid }, + }, + crosshair: { + mode: 1, + }, + rightPriceScale: { + mode: isLogScale ? PriceScaleMode.Logarithmic : PriceScaleMode.Normal, + borderColor: THEME.grid, + }, + leftPriceScale: { + visible: false, + borderColor: THEME.grid, + }, + timeScale: { + borderColor: THEME.grid, + timeVisible: true, + rightOffset: 2, + secondsVisible: false, + }, + }) +} + +function applyElement(chart: ReturnType, el: ChartElement): AppliedElement | null { + if (!el.x) return null + if (el.title === "candles_source") return null + + if (isMarkerElement(el)) { + return { + series: null, + markers: buildMarkers(el), + isCandles: false, + timeSet: new Set(), + } + } + + if (el.kind === "candlestick" || (el.open && el.high && el.low && el.close)) { + const series = chart.addCandlestickSeries({ + upColor: CANDLE_COLORS.up, + downColor: CANDLE_COLORS.down, + borderVisible: false, + wickUpColor: CANDLE_COLORS.up, + wickDownColor: CANDLE_COLORS.down, + priceLineVisible: false, + }) + const candles = toCandleData(el) + const timeSet = new Set() + if (candles.length > 0) { + for (const point of candles) timeSet.add(point.time) + series.setData(candles) + } + return { series, markers: [], isCandles: true, timeSet } + } + + const lineData = toLineData(el) + if (lineData.length === 0) return { series: null, markers: [], isCandles: false, timeSet: new Set() } + + const series = chart.addLineSeries({ + color: getSeriesLineColor(el), + lineWidth: 2, + lineVisible: true, + priceLineVisible: false, + lastValueVisible: false, + }) + series.setData(lineData) + const timeSet = new Set() + for (const point of lineData) timeSet.add(point.time) + return { series, markers: [], isCandles: false, timeSet } +} + +interface TradingChartProps { + elements: ChartElement[] + height?: number + className?: string +} + +export function TradingChart({ elements, height = 350, className }: TradingChartProps) { + const rootRef = useRef(null) + const legendItems = getLegendItems(elements) + + useEffect(() => { + if (!rootRef.current) return + const root = rootRef.current + root.innerHTML = "" + + const visibleElements = elements.filter((el) => !el.is_hidden && el.x !== null) + if (visibleElements.length === 0) return + + const indicatorElements = visibleElements.filter(isIndicatorElement) + const mainElements = visibleElements.filter((el) => !isIndicatorElement(el)) + + const showIndicatorPane = indicatorElements.length > 0 && mainElements.length > 0 + const paneGap = 8 + const usableHeight = showIndicatorPane ? Math.max(height - paneGap, 120) : height + + const mainContainer = document.createElement("div") + mainContainer.style.width = "100%" + root.appendChild(mainContainer) + + const indicatorContainer = document.createElement("div") + if (showIndicatorPane) { + indicatorContainer.style.width = "100%" + indicatorContainer.style.marginTop = "8px" + root.appendChild(indicatorContainer) + } + + const mainHeight = showIndicatorPane ? Math.round(usableHeight * 0.72) : usableHeight + const indicatorHeight = showIndicatorPane ? usableHeight - mainHeight : 0 + + const mainChart = buildChart(mainContainer, mainHeight, inferIsLogScale(mainElements)) + const indicatorChart = showIndicatorPane + ? buildChart(indicatorContainer, indicatorHeight, inferIsLogScale(indicatorElements)) + : null + + const targetMain = mainElements.length > 0 ? mainElements : visibleElements + const markerBuffersMain: ReturnType = [] + let markerTargetMain: any = null + let markerTargetMainTimes = new Set() + for (const el of targetMain) { + const applied = applyElement(mainChart, el) + if (!applied) continue + if (applied.series && !markerTargetMain) markerTargetMain = applied.series + if (applied.series && !markerTargetMainTimes.size) markerTargetMainTimes = applied.timeSet + if (applied.isCandles && applied.series) { + markerTargetMain = applied.series + markerTargetMainTimes = applied.timeSet + } + if (applied.markers && applied.markers.length > 0) { + if (applied.series) { + applied.series.setMarkers(applied.markers.sort((a, b) => a.time - b.time) as any) + } else { + markerBuffersMain.push(...applied.markers) + } + } + } + if (markerBuffersMain.length > 0) { + if (!markerTargetMain) { + const markerData = markerBuffersMain.map((m) => ({ time: m.time, value: 1 })) + markerData.sort((a, b) => a.time - b.time) + markerTargetMain = mainChart.addLineSeries({ + color: "transparent", + lineVisible: false, + priceLineVisible: false, + lastValueVisible: false, + }) + markerTargetMain.setData(markerData as any) + markerTargetMainTimes = new Set(markerData.map((p) => p.time)) + } + const filteredMarkers = filterMarkersByTimes(markerBuffersMain, markerTargetMainTimes) + if (filteredMarkers.length > 0) { + markerTargetMain.setMarkers(filteredMarkers.sort((a, b) => a.time - b.time) as any) + } + } + + if (indicatorChart) { + const markerBuffersIndicator: ReturnType = [] + let markerTargetIndicator: any = null + let markerTargetIndicatorTimes = new Set() + for (const el of indicatorElements) { + const applied = applyElement(indicatorChart, el) + if (!applied) continue + if (applied.series && !markerTargetIndicator) markerTargetIndicator = applied.series + if (applied.series && !markerTargetIndicatorTimes.size) markerTargetIndicatorTimes = applied.timeSet + if (applied.markers && applied.markers.length > 0) { + if (applied.series) { + applied.series.setMarkers(applied.markers.sort((a, b) => a.time - b.time) as any) + } else { + markerBuffersIndicator.push(...applied.markers) + } + } + } + if (markerBuffersIndicator.length > 0) { + if (!markerTargetIndicator) { + const markerData = markerBuffersIndicator.map((m) => ({ time: m.time, value: 1 })) + markerData.sort((a, b) => a.time - b.time) + markerTargetIndicator = indicatorChart.addLineSeries({ + color: "transparent", + lineVisible: false, + priceLineVisible: false, + lastValueVisible: false, + }) + markerTargetIndicator.setData(markerData as any) + markerTargetIndicatorTimes = new Set(markerData.map((p) => p.time)) + } + const filteredMarkers = filterMarkersByTimes(markerBuffersIndicator, markerTargetIndicatorTimes) + if (filteredMarkers.length > 0) { + markerTargetIndicator.setMarkers(filteredMarkers.sort((a, b) => a.time - b.time) as any) + } + } + } + + mainChart.timeScale().fitContent() + if (indicatorChart) indicatorChart.timeScale().fitContent() + + if (indicatorChart) { + let syncing = false + const syncTo = (from: ReturnType, to: ReturnType) => { + from.timeScale().subscribeVisibleTimeRangeChange((range) => { + if (!range || syncing) return + syncing = true + to.timeScale().setVisibleRange(range) + syncing = false + }) + } + syncTo(mainChart, indicatorChart) + syncTo(indicatorChart, mainChart) + } + + const resize = () => { + const width = root.clientWidth + mainChart.applyOptions({ width, height: mainHeight }) + if (indicatorChart) indicatorChart.applyOptions({ width, height: indicatorHeight }) + } + resize() + + const observer = new ResizeObserver(resize) + observer.observe(root) + + return () => { + observer.disconnect() + mainChart.remove() + if (indicatorChart) indicatorChart.remove() + root.innerHTML = "" + } + }, [elements, height]) + + if (elements.length === 0 || elements.every((el) => el.x === null)) { + return ( +
+ No chart data available +
+ ) + } + + return ( +
+
+ {legendItems.length > 0 && ( +
+
+ {legendItems.map((item) => ( +
+ + - {item.label} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/octobot_script/resources/report/src/components/ui/avatar.tsx b/octobot_script/resources/report/src/components/ui/avatar.tsx new file mode 100644 index 0000000..1ac1570 --- /dev/null +++ b/octobot_script/resources/report/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/octobot_script/resources/report/src/components/ui/badge.tsx b/octobot_script/resources/report/src/components/ui/badge.tsx new file mode 100644 index 0000000..beb56ed --- /dev/null +++ b/octobot_script/resources/report/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/octobot_script/resources/report/src/components/ui/breadcrumb.tsx b/octobot_script/resources/report/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..542e762 --- /dev/null +++ b/octobot_script/resources/report/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return