diff --git a/CHANGELOG.md b/CHANGELOG.md index c622469..072adca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project are documented in this file. No unreleased changes yet. +## 2026-07-29 v1.0.2 + +- Added a Panel application for single-day SPBT result analysis with result-file + selection, minimum TARGET-change input, pair TheoRet table, pair selector, + selected-pair execution table, and market/trade chart. +- Added a launcher script for the Panel application. +- Changed notebook and Panel pair analysis to use per-row Analyze actions from + the Pair TheoRet grid, deferring selected-pair calculations until clicked. +- Adjusted Panel sizing so key controls use compact widths and Pair TheoRet uses + content width with vertical scrolling instead of full-width paginated layout. +- Added a FastListTemplate shell to the Panel application for sidebar controls + and configurable app color accents. +- Made Plotly chart panes use all available horizontal space. + ## 2026-07-28 v1.0.1 - Added the `spbt_day` notebook for interactive single-day backtest result diff --git a/notebooks/spbt_day.ipynb b/notebooks/spbt_day.ipynb index daec8ec..e1f16d2 100644 --- a/notebooks/spbt_day.ipynb +++ b/notebooks/spbt_day.ipynb @@ -30,6 +30,7 @@ "from IPython.display import display\n", "import ipywidgets as widgets\n", "import pandas as pd\n", + "import panel as pn\n", "\n", "START_DIR = Path.cwd().resolve()\n", "for candidate in (START_DIR, *START_DIR.parents):\n", @@ -41,21 +42,27 @@ "import scripts.spbt_day as spbt_day\n", "\n", "spbt_day = importlib.reload(spbt_day)\n", + "pn.extension(\"tabulator\", \"plotly\")\n", "\n", + "ANALYZE_BUTTON_COLUMN = spbt_day.ANALYZE_BUTTON_COLUMN\n", + "SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS = spbt_day.SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS\n", "add_total_pnl = spbt_day.add_total_pnl\n", "calculate_pair_theo_executions = spbt_day.calculate_pair_theo_executions\n", "calculate_ranked_pairs_theo_ret = spbt_day.calculate_ranked_pairs_theo_ret\n", "create_database_file_selector = spbt_day.create_database_file_selector\n", - "create_pair_name_dropdown = spbt_day.create_pair_name_dropdown\n", + "create_pair_theo_ret_analyze_grid = spbt_day.create_pair_theo_ret_analyze_grid\n", "create_pair_trades_market_plot = spbt_day.create_pair_trades_market_plot\n", + "create_selected_pair_executions_grid = spbt_day.create_selected_pair_executions_grid\n", "create_total_pnl_histogram = spbt_day.create_total_pnl_histogram\n", "find_repo_root = spbt_day.find_repo_root\n", "format_pair_name_for_display = spbt_day.format_pair_name_for_display\n", "format_pair_names_for_display = spbt_day.format_pair_names_for_display\n", + "format_pair_theo_ret_for_analyze_grid = spbt_day.format_pair_theo_ret_for_analyze_grid\n", "infer_trading_day_start_ns = spbt_day.infer_trading_day_start_ns\n", "load_selector_pair_rankings = spbt_day.load_selector_pair_rankings\n", "load_pair_market_data = spbt_day.load_pair_market_data\n", "load_trading_instructions = spbt_day.load_trading_instructions\n", + "pair_name_from_analyze_event = spbt_day.pair_name_from_analyze_event\n", "show_interactive_dataframe = spbt_day.show_interactive_dataframe\n", "\n", "REPO_ROOT = find_repo_root()\n", @@ -190,12 +197,12 @@ " kind=\"mergesort\",\n", ").drop(columns=\"total_pnl\").reset_index(drop=True)\n", "\n", - "pair_theo_ret_display = format_pair_names_for_display(pair_theo_ret)\n", - "\n", - "show_interactive_dataframe(\n", + "pair_theo_ret_display = format_pair_theo_ret_for_analyze_grid(pair_theo_ret)\n", + "pair_theo_ret_grid = create_pair_theo_ret_analyze_grid(\n", " pair_theo_ret_display,\n", - " table_id=\"pair-theo-ret-grid\",\n", - ")" + " height=520,\n", + ")\n", + "display(pair_theo_ret_grid)" ] }, { @@ -216,8 +223,13 @@ "outputs": [], "source": [ "total_pnl_histogram = create_total_pnl_histogram(pair_theo_ret)\n", + "total_pnl_histogram_pane = pn.pane.Plotly(\n", + " total_pnl_histogram,\n", + " height=360,\n", + " sizing_mode=\"stretch_width\",\n", + ")\n", "\n", - "total_pnl_histogram" + "display(total_pnl_histogram_pane)" ] }, { @@ -227,109 +239,94 @@ "source": [ "## Individual Pair Analysis\n", "\n", - "Choose one pair for detailed follow-up analysis. Pair names are sorted alphabetically." + "Click the Analyze button in the Pair TheoRet grid to load detailed follow-up analysis for that row. The selected-pair execution table and market/trade chart are not calculated until an Analyze button is clicked." ] }, { "cell_type": "code", "execution_count": null, - "id": "choose-individual-pair", + "id": "individual-pair-analysis", "metadata": {}, "outputs": [], "source": [ - "pair_name_dropdown = create_pair_name_dropdown(selector_pair_rankings)\n", - "display(pair_name_dropdown)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "selected-individual-pair", - "metadata": {}, - "outputs": [], - "source": [ - "selected_pair_name = pair_name_dropdown.value\n", - "format_pair_name_for_display(selected_pair_name)" - ] - }, - { - "cell_type": "markdown", - "id": "selected-pair-theo-executions-context", - "metadata": {}, - "source": [ - "### Selected Pair Theoretical Executions\n", - "\n", - "Create the theoretical asset-level executions used by the PnL calculation for the selected pair. `TARGET` rows trade the position difference from the current theoretical position to the new target position, where target size is `10000 * strength / reference_price`; `CLOSE` rows flatten the current theoretical position. Positive size is `BUY`; negative size is `SELL`; USD value is signed as the opposite cash movement." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "selected-pair-theo-executions", - "metadata": {}, - "outputs": [], - "source": [ - "selected_pair_theo_executions = calculate_pair_theo_executions(\n", - " selected_pair_name,\n", - " trading_instructions,\n", - " min_pctg_change=MIN_TARGET_STRENGTH_CHANGE_PCTG,\n", + "selected_pair_name = None\n", + "selected_pair_theo_executions = pd.DataFrame()\n", + "selected_pair_theo_executions_display = pd.DataFrame(\n", + " columns=SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS\n", ")\n", + "selected_pair_market_data = pd.DataFrame()\n", + "selected_pair_market_trades_plot = None\n", "\n", - "selected_pair_theo_execution_columns = [\n", - " \"time\",\n", - " \"asset\",\n", - " \"action\",\n", - " \"side\",\n", - " \"strength\",\n", - " \"size\",\n", - " \"price\",\n", - " \"usd_value\",\n", - "]\n", - "selected_pair_theo_executions_display = selected_pair_theo_executions.reindex(\n", - " columns=selected_pair_theo_execution_columns\n", + "selected_pair_message = pn.pane.Markdown(\n", + " \"Click Analyze in the Pair TheoRet grid to load individual-pair details.\"\n", ")\n", - "show_interactive_dataframe(\n", + "selected_pair_theo_executions_grid = create_selected_pair_executions_grid(\n", " selected_pair_theo_executions_display,\n", - " table_id=\"selected-pair-theo-executions-grid\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "selected-pair-market-trades-context", - "metadata": {}, - "source": [ - "### Selected Pair Trades on Market Data\n", - "\n", - "Load full available 1-minute market data for the selected pair's instruments from `ohlcv_1min`, starting at midnight UTC of the trading day inferred from `trading_instructions`. Close prices are shown as relative prices from each instrument's close at that midnight. Theoretical executions are overlaid at their execution `reference_price`, normalized by the same midnight close. Execution markers use execution timestamps directly and do not require a matching OHLCV row." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "selected-pair-market-trades-plot", - "metadata": {}, - "outputs": [], - "source": [ - "trading_day_start_ns = infer_trading_day_start_ns(trading_instructions)\n", - "\n", - "conn = connect_selected_database()\n", - "try:\n", - " selected_pair_market_data = load_pair_market_data(\n", - " conn,\n", - " selected_pair_name,\n", - " trading_day_start_ns=trading_day_start_ns,\n", - " )\n", - "finally:\n", - " conn.close()\n", - "\n", - "selected_pair_market_trades_plot = create_pair_trades_market_plot(\n", - " selected_pair_name,\n", - " selected_pair_market_data,\n", - " selected_pair_theo_executions,\n", + " height=360,\n", + ")\n", + "selected_pair_market_trades_plot_pane = pn.pane.Plotly(\n", + " None,\n", + " height=520,\n", + " sizing_mode=\"stretch_width\",\n", ")\n", "\n", - "selected_pair_market_trades_plot" + "\n", + "def analyze_pair_click(event):\n", + " global selected_pair_name\n", + " global selected_pair_theo_executions\n", + " global selected_pair_theo_executions_display\n", + " global selected_pair_market_data\n", + " global selected_pair_market_trades_plot\n", + "\n", + " try:\n", + " selected_pair_name = pair_name_from_analyze_event(pair_theo_ret_grid, event)\n", + " selected_pair_message.object = (\n", + " f\"Selected pair: **{format_pair_name_for_display(selected_pair_name)}**\"\n", + " )\n", + "\n", + " selected_pair_theo_executions = calculate_pair_theo_executions(\n", + " selected_pair_name,\n", + " trading_instructions,\n", + " min_pctg_change=MIN_TARGET_STRENGTH_CHANGE_PCTG,\n", + " )\n", + " selected_pair_theo_executions_display = selected_pair_theo_executions.reindex(\n", + " columns=SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS\n", + " )\n", + " selected_pair_theo_executions_grid.value = selected_pair_theo_executions_display\n", + "\n", + " trading_day_start_ns = infer_trading_day_start_ns(trading_instructions)\n", + " conn = connect_selected_database()\n", + " try:\n", + " selected_pair_market_data = load_pair_market_data(\n", + " conn,\n", + " selected_pair_name,\n", + " trading_day_start_ns=trading_day_start_ns,\n", + " )\n", + " finally:\n", + " conn.close()\n", + "\n", + " selected_pair_market_trades_plot = create_pair_trades_market_plot(\n", + " selected_pair_name,\n", + " selected_pair_market_data,\n", + " selected_pair_theo_executions,\n", + " )\n", + " selected_pair_market_trades_plot_pane.object = selected_pair_market_trades_plot\n", + " except Exception as exc:\n", + " selected_pair_message.object = f\"**Error:** {exc}\"\n", + " selected_pair_market_trades_plot_pane.object = None\n", + "\n", + "\n", + "pair_theo_ret_grid.on_click(analyze_pair_click, column=ANALYZE_BUTTON_COLUMN)\n", + "\n", + "display(\n", + " pn.Column(\n", + " selected_pair_message,\n", + " \"### Theoretical Executions\",\n", + " selected_pair_theo_executions_grid,\n", + " \"### Trades on Market Data\",\n", + " selected_pair_market_trades_plot_pane,\n", + " )\n", + ")" ] } ], diff --git a/panel/spbt_day_panel.py b/panel/spbt_day_panel.py new file mode 100644 index 0000000..aaeac06 --- /dev/null +++ b/panel/spbt_day_panel.py @@ -0,0 +1,295 @@ +"""Panel application for single-day SPBT result analysis.""" + +from __future__ import annotations + +from pathlib import Path +import sys +from typing import Any + +import pandas as pd +import panel as pn + + +APP_DIR = Path(__file__).resolve().parent +REPO_ROOT = APP_DIR.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from scripts import spbt_day + + +pn.extension("tabulator", "plotly") + + +PAIR_THEO_RET_SORT_COLUMNS = ["total_pnl", "pair_name"] +PAIR_THEO_RET_DISPLAY_DROP_COLUMNS = ["total_pnl"] +APP_TITLE = "SPBT Day Analysis" +APP_ACCENT_COLOR = "#226c67" +APP_HEADER_COLOR = "#184c47" + + +class SpbtDayPanelApp: + """Stateful Panel UI for single-day SPBT analysis.""" + + def __init__(self, repo_root: Path | None = None) -> None: + self.repo_root = (repo_root or spbt_day.find_repo_root(REPO_ROOT)).resolve() + self.selector_pair_rankings = pd.DataFrame() + self.trading_instructions = pd.DataFrame() + self.pair_theo_ret = pd.DataFrame() + self.selected_pair_theo_executions = pd.DataFrame() + self.selected_pair_name: str | None = None + self.min_pctg_change = 0.0 + + self.directory_input = pn.widgets.TextInput( + label="Directory", + value=str(self.repo_root / "data"), + ) + self.show_all_files = pn.widgets.Checkbox(label="Show all files", value=False) + self.refresh_button = pn.widgets.Button(label="Refresh") + self.file_select = pn.widgets.Select( + label="SQLite result file", + options={}, + width=360, + ) + self.min_pctg_change_input = pn.widgets.FloatInput( + label="Mininal TARGET change (%)", + value=0.0, + step=1.0, + width=220, + ) + self.calculate_button = pn.widgets.Button( + label="Calculate", + color="primary", + width=110, + ) + + self.status = pn.pane.Markdown("") + self.pair_theo_ret_table = spbt_day.create_pair_theo_ret_analyze_grid( + pd.DataFrame(), + height=420, + ) + self.total_pnl_histogram = pn.pane.Plotly( + None, + height=360, + sizing_mode="stretch_width", + ) + self.selected_pair_message = pn.pane.Markdown( + "Click Analyze in the Pair TheoRet grid to load individual-pair details." + ) + self.selected_pair_executions_table = spbt_day.create_selected_pair_executions_grid( + height=320, + ) + self.selected_pair_market_plot = pn.pane.Plotly( + None, + height=520, + sizing_mode="stretch_width", + ) + + self.refresh_button.on_click(self.refresh_files) + self.calculate_button.on_click(self.calculate) + self.directory_input.param.watch(self.refresh_files, "value") + self.show_all_files.param.watch(self.refresh_files, "value") + self.pair_theo_ret_table.on_click( + self.analyze_pair_click, + column=spbt_day.ANALYZE_BUTTON_COLUMN, + ) + self.refresh_files() + + def set_status(self, message: str, *, error: bool = False) -> None: + """Update visible status text.""" + prefix = "**Error:** " if error else "" + self.status.object = f"{prefix}{message}" if message else "" + + def selected_database_path(self) -> Path: + """Return the selected result database path.""" + if not self.file_select.value: + raise ValueError("Select a SQLite result file before calculating.") + db_path = Path(str(self.file_select.value)).resolve() + if not db_path.exists(): + raise FileNotFoundError(f"Selected database does not exist: {db_path}") + if not db_path.is_file(): + raise ValueError(f"Selected database path is not a file: {db_path}") + return db_path + + def refresh_files(self, *_events: Any) -> None: + """Refresh selectable SQLite files from the configured directory.""" + try: + directory = spbt_day.normalize_directory( + self.directory_input.value, + self.repo_root, + ) + candidates = spbt_day.list_candidate_files( + directory, + show_all=self.show_all_files.value, + ) + except Exception as exc: + self.file_select.options = {} + self.file_select.value = None + self.set_status(str(exc), error=True) + return + + options = {path.name: str(path) for path in candidates} + previous_value = self.file_select.value + self.file_select.options = options + if previous_value in options.values(): + self.file_select.value = previous_value + elif options: + self.file_select.value = next(iter(options.values())) + else: + self.file_select.value = None + + if options: + self.set_status(f"Found {len(options):,} file(s) in {directory}.") + else: + self.set_status(f"No selectable files found in {directory}.") + + def calculate(self, *_events: Any) -> None: + """Load selected data and calculate all-pair TheoRet.""" + self.calculate_button.loading = True + try: + db_path = self.selected_database_path() + self.min_pctg_change = float(self.min_pctg_change_input.value) + + conn = spbt_day.connect_sqlite_read_only(db_path) + try: + self.selector_pair_rankings = spbt_day.load_selector_pair_rankings(conn) + self.trading_instructions = spbt_day.load_trading_instructions(conn) + finally: + conn.close() + + self.pair_theo_ret = ( + spbt_day.add_total_pnl( + spbt_day.calculate_ranked_pairs_theo_ret( + self.selector_pair_rankings, + self.trading_instructions, + min_pctg_change=self.min_pctg_change, + ) + ) + .sort_values( + PAIR_THEO_RET_SORT_COLUMNS, + ascending=[True, True], + kind="mergesort", + ) + .drop(columns=PAIR_THEO_RET_DISPLAY_DROP_COLUMNS) + .reset_index(drop=True) + ) + self.pair_theo_ret_table.value = spbt_day.format_pair_theo_ret_for_analyze_grid( + self.pair_theo_ret + ) + self.total_pnl_histogram.object = spbt_day.create_total_pnl_histogram( + self.pair_theo_ret + ) + self.clear_selected_pair_analysis() + + self.set_status( + f"Calculated {len(self.pair_theo_ret):,} pair row(s) from {db_path.name}." + ) + except Exception as exc: + self.set_status(str(exc), error=True) + finally: + self.calculate_button.loading = False + + def clear_selected_pair_analysis(self) -> None: + """Clear individual-pair outputs until a row Analyze button is clicked.""" + self.selected_pair_name = None + self.selected_pair_theo_executions = pd.DataFrame() + self.selected_pair_message.object = ( + "Click Analyze in the Pair TheoRet grid to load individual-pair details." + ) + self.selected_pair_executions_table.value = pd.DataFrame( + columns=spbt_day.SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS + ) + self.selected_pair_market_plot.object = None + + def analyze_pair_click(self, event: Any) -> None: + """Run selected-pair analysis from a Pair TheoRet Analyze button click.""" + self.update_selected_pair( + spbt_day.pair_name_from_analyze_event(self.pair_theo_ret_table, event) + ) + + def analyze_pair_row(self, row: int) -> None: + """Run selected-pair analysis for a Pair TheoRet table row.""" + event = type("AnalyzeEvent", (), {"row": row})() + self.analyze_pair_click(event) + + def update_selected_pair(self, pair_name: str) -> None: + """Calculate selected-pair executions and market plot.""" + if self.trading_instructions.empty: + self.clear_selected_pair_analysis() + return + + self.selected_pair_name = pair_name + self.selected_pair_message.object = ( + f"Selected pair: **{spbt_day.format_pair_name_for_display(pair_name)}**" + ) + self.selected_pair_theo_executions = spbt_day.calculate_pair_theo_executions( + pair_name, + self.trading_instructions, + min_pctg_change=self.min_pctg_change, + ) + self.selected_pair_executions_table.value = ( + self.selected_pair_theo_executions.reindex( + columns=spbt_day.SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS + ) + ) + + try: + trading_day_start_ns = spbt_day.infer_trading_day_start_ns( + self.trading_instructions + ) + conn = spbt_day.connect_sqlite_read_only(self.selected_database_path()) + try: + selected_pair_market_data = spbt_day.load_pair_market_data( + conn, + pair_name, + trading_day_start_ns=trading_day_start_ns, + ) + finally: + conn.close() + self.selected_pair_market_plot.object = spbt_day.create_pair_trades_market_plot( + pair_name, + selected_pair_market_data, + self.selected_pair_theo_executions, + ) + except Exception as exc: + self.selected_pair_market_plot.object = None + self.set_status(str(exc), error=True) + + @property + def view(self) -> pn.template.FastListTemplate: + """Return the app layout.""" + controls = pn.Column( + "## Inputs", + pn.Row(self.directory_input, self.refresh_button), + self.show_all_files, + self.file_select, + self.min_pctg_change_input, + self.calculate_button, + self.status, + width=400, + ) + main = pn.Column( + "## Pair TheoRet", + self.pair_theo_ret_table, + self.total_pnl_histogram, + "## Individual Pair", + self.selected_pair_message, + "### Theoretical Executions", + self.selected_pair_executions_table, + "### Trades on Market Data", + self.selected_pair_market_plot, + ) + return pn.template.FastListTemplate( + title=APP_TITLE, + sidebar=[controls], + main=[main], + sidebar_width=430, + accent_base_color=APP_ACCENT_COLOR, + header_background=APP_HEADER_COLOR, + main_layout=None, + ) + + +app_controller = SpbtDayPanelApp() +app = app_controller.view +app.servable(title=APP_TITLE) diff --git a/requirements.txt b/requirements.txt index fd4658e..268635f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ itables>=2.2,<3 jupyter>=1.1,<2 nbformat>=5.10,<6 pandas>=2.2,<3 +panel>=1.5,<2 plotly>=5.24,<7 # Verification diff --git a/scripts/run_spbt_day_panel.sh b/scripts/run_spbt_day_panel.sh new file mode 100755 index 0000000..3afcda8 --- /dev/null +++ b/scripts/run_spbt_day_panel.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +panel serve panel/spbt_day_panel.py --show "$@" diff --git a/scripts/spbt_day.py b/scripts/spbt_day.py index d467d60..ba76ea1 100644 --- a/scripts/spbt_day.py +++ b/scripts/spbt_day.py @@ -20,6 +20,22 @@ OHLCV_1MIN_COLUMNS = ("tstamp", "tstamp_ns", "exch_acct", "instrument_id", "clos INITIAL_THEO_CAPITAL_USD = 10_000.0 SQLITE_EXTENSIONS = {".db", ".sqlite", ".sqlite3"} PAIR_NAME_DISPLAY_SUFFIX = ":USD" +PAIR_NAME_VALUE_COLUMN = "_pair_name_value" +ANALYZE_BUTTON_COLUMN = "analyze" +ANALYZE_BUTTON_HTML = ( + '' +) +SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS = [ + "time", + "asset", + "action", + "side", + "strength", + "size", + "price", + "usd_value", +] INTERACTIVE_TABLE_CSS = """ table.dataTable, table.dataTable th, @@ -1081,6 +1097,75 @@ def format_pair_names_for_display( return formatted +def format_pair_theo_ret_for_analyze_grid(pair_theo_ret: pd.DataFrame) -> pd.DataFrame: + """Format pair TheoRet rows for an Analyze-button grid. + + The visible pair name is display-cleaned, while the original pair name is + retained in a hidden column for callbacks that need the calculation key. + """ + if "pair_name" not in pair_theo_ret.columns: + raise ValueError("pair TheoRet dataframe missing column: pair_name") + + formatted = format_pair_names_for_display(pair_theo_ret) + formatted[PAIR_NAME_VALUE_COLUMN] = pair_theo_ret["pair_name"].to_numpy() + return formatted + + +def pair_name_from_analyze_event(table: Any, event: Any) -> str: + """Resolve the full pair name from a Panel Tabulator click event.""" + if PAIR_NAME_VALUE_COLUMN not in table.value.columns: + raise ValueError(f"Analyze table missing column: {PAIR_NAME_VALUE_COLUMN}") + pair_name = table.value[PAIR_NAME_VALUE_COLUMN].iloc[event.row] + if pd.isna(pair_name) or not str(pair_name): + raise ValueError(f"Analyze row {event.row} does not contain a pair name") + return str(pair_name) + + +def create_pair_theo_ret_analyze_grid( + pair_theo_ret_display: pd.DataFrame, + *, + height: int = 420, +) -> Any: + """Create a sortable Panel grid with a compact Analyze button column.""" + import panel as pn + + return pn.widgets.Tabulator( + pair_theo_ret_display, + buttons={ANALYZE_BUTTON_COLUMN: ANALYZE_BUTTON_HTML}, + hidden_columns=[PAIR_NAME_VALUE_COLUMN], + show_index=False, + pagination=None, + layout="fit_data_table", + height=height, + sizing_mode="fixed", + selectable=False, + ) + + +def create_selected_pair_executions_grid( + dataframe: pd.DataFrame | None = None, + *, + height: int = 320, +) -> Any: + """Create a sortable Panel grid for selected-pair theoretical executions.""" + import panel as pn + + source = ( + dataframe + if dataframe is not None + else pd.DataFrame(columns=SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS) + ) + return pn.widgets.Tabulator( + source.reindex(columns=SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS), + show_index=False, + pagination=None, + layout="fit_data_table", + height=height, + sizing_mode="fixed", + selectable=False, + ) + + def add_total_pnl(pair_theo_ret: pd.DataFrame) -> pd.DataFrame: """Return a copy of pair TheoRet rows with total realized plus unrealized PnL.""" required_columns = {"realized_pnl", "unrealized_pnl"} diff --git a/tests/test_spbt_day.py b/tests/test_spbt_day.py index 1a6cf26..eb5736a 100644 --- a/tests/test_spbt_day.py +++ b/tests/test_spbt_day.py @@ -5,17 +5,22 @@ import pandas as pd import pytest from scripts.spbt_day import ( + ANALYZE_BUTTON_COLUMN, + PAIR_NAME_VALUE_COLUMN, add_total_pnl, calculate_pair_theo_executions, calculate_pair_theo_ret, calculate_ranked_pairs_theo_ret, create_pair_name_dropdown, + create_pair_theo_ret_analyze_grid, create_pair_trades_market_plot, + create_selected_pair_executions_grid, connect_sqlite_read_only, create_total_pnl_histogram, find_repo_root, format_pair_name_for_display, format_pair_names_for_display, + format_pair_theo_ret_for_analyze_grid, infer_trading_day_start_ns, list_candidate_files, load_pair_market_data, @@ -985,6 +990,52 @@ def test_format_pair_names_for_display_removes_usd_suffix_without_mutating_sourc ] +def test_format_pair_theo_ret_for_analyze_grid_preserves_full_pair_name(): + pair_theo_ret = pd.DataFrame( + { + "pair_name": ["AAA:USD-BBB:USD"], + "mr_ranking": [1], + "realized_pnl": [1.0], + "unrealized_pnl": [0.0], + } + ) + + formatted = format_pair_theo_ret_for_analyze_grid(pair_theo_ret) + + assert formatted["pair_name"].tolist() == ["AAA-BBB"] + assert formatted[PAIR_NAME_VALUE_COLUMN].tolist() == ["AAA:USD-BBB:USD"] + assert pair_theo_ret["pair_name"].tolist() == ["AAA:USD-BBB:USD"] + + +def test_create_panel_grids_use_analyze_button_and_hidden_pair_column(): + pair_grid = create_pair_theo_ret_analyze_grid( + pd.DataFrame( + { + "pair_name": ["AAA-BBB"], + PAIR_NAME_VALUE_COLUMN: ["AAA:USD-BBB:USD"], + } + ) + ) + executions_grid = create_selected_pair_executions_grid() + + assert pair_grid.buttons.keys() == {ANALYZE_BUTTON_COLUMN} + assert pair_grid.hidden_columns == [PAIR_NAME_VALUE_COLUMN] + assert pair_grid.disabled is False + assert pair_grid.pagination is None + assert pair_grid.layout == "fit_data_table" + assert pair_grid.sizing_mode == "fixed" + assert executions_grid.value.columns.tolist() == [ + "time", + "asset", + "action", + "side", + "strength", + "size", + "price", + "usd_value", + ] + + def test_show_interactive_dataframe_uses_sortable_grid_defaults(monkeypatch): calls = [] diff --git a/tests/test_spbt_day_panel.py b/tests/test_spbt_day_panel.py new file mode 100644 index 0000000..2655966 --- /dev/null +++ b/tests/test_spbt_day_panel.py @@ -0,0 +1,218 @@ +import importlib.util +import sqlite3 +from pathlib import Path + +import pandas as pd + + +def load_panel_app_module(): + module_path = Path("panel/spbt_day_panel.py").resolve() + spec = importlib.util.spec_from_file_location("spbt_day_panel_app", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def create_panel_fixture_db(db_path: Path) -> None: + trading_day_start_ns = pd.Timestamp("2026-06-17T00:00:00Z").value + conn = sqlite3.connect(db_path) + try: + conn.execute( + """ + CREATE TABLE selector_pairs ( + time_ns INTEGER, + tstamp TEXT, + pair_name TEXT, + instrument_a TEXT, + instrument_b TEXT, + mr_score TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE trading_instructions ( + time_ns INTEGER, + tstamp TEXT, + book_id TEXT, + strategy_id TEXT, + type TEXT, + data TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE ohlcv_1min ( + tstamp TEXT, + tstamp_ns INTEGER, + exch_acct TEXT, + exchange_id TEXT, + instrument_id TEXT, + interval_sec INTEGER, + open REAL, + high REAL, + low REAL, + close REAL, + volume REAL, + vwap REAL, + num_trades INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO selector_pairs VALUES (?, ?, ?, ?, ?, ?)", + ( + 10, + "2026-06-17T00:00:00Z", + "AAA:USD-BBB:USD", + "EXCH:PAIR-AAA-USD", + "EXCH:PAIR-BBB-USD", + '{"final":"0.5"}', + ), + ) + conn.executemany( + "INSERT INTO trading_instructions VALUES (?, ?, ?, ?, ?, ?)", + [ + ( + trading_day_start_ns, + "2026-06-17T00:00:00Z", + "book", + "strategy-AAA:USD-BBB:USD", + "TARGET_POSITION", + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.5"},' + '"BBB":{"reference_price":"50","strength":"-0.5"}}}' + ), + ), + ( + trading_day_start_ns + 60_000_000_000, + "2026-06-17T00:01:00Z", + "book", + "strategy-AAA:USD-BBB:USD", + "CLOSE_POSITION", + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"110"},' + '"BBB":{"reference_price":"45"}}}' + ), + ), + ], + ) + conn.executemany( + "INSERT INTO ohlcv_1min VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + ( + "2026-06-17T00:00:00Z", + trading_day_start_ns, + "EXCH", + "EXCH", + "PAIR-AAA-USD", + 60, + 100.0, + 100.0, + 100.0, + 100.0, + 1.0, + 100.0, + 1, + ), + ( + "2026-06-17T00:00:00Z", + trading_day_start_ns, + "EXCH", + "EXCH", + "PAIR-BBB-USD", + 60, + 50.0, + 50.0, + 50.0, + 50.0, + 1.0, + 50.0, + 1, + ), + ], + ) + conn.commit() + finally: + conn.close() + + +def test_pair_analyze_grid_keeps_clean_labels_and_full_pair_values(): + module = load_panel_app_module() + pair_theo_ret = pd.DataFrame( + { + "pair_name": ["BTC:USD-ETH:USD", "ADA:USD-BTC:USD"], + "mr_ranking": [2, 1], + "realized_pnl": [0.0, 0.0], + "unrealized_pnl": [0.0, 0.0], + } + ) + + formatted = module.spbt_day.format_pair_theo_ret_for_analyze_grid(pair_theo_ret) + + assert formatted["pair_name"].tolist() == ["BTC-ETH", "ADA-BTC"] + assert formatted[module.spbt_day.PAIR_NAME_VALUE_COLUMN].tolist() == [ + "BTC:USD-ETH:USD", + "ADA:USD-BTC:USD", + ] + + +def test_panel_app_uses_fast_list_template(tmp_path): + module = load_panel_app_module() + app = module.SpbtDayPanelApp(repo_root=tmp_path) + view = app.view + + assert isinstance(view, module.pn.template.FastListTemplate) + assert view.title == module.APP_TITLE + assert view.sidebar_width == 430 + assert view.accent_base_color == module.APP_ACCENT_COLOR + assert view.header_background == module.APP_HEADER_COLOR + assert len(view.sidebar) == 1 + assert len(view.main) == 1 + + +def test_panel_app_calculates_pairs_and_selected_pair_outputs(tmp_path): + module = load_panel_app_module() + data_dir = tmp_path / "data" + data_dir.mkdir() + db_path = data_dir / "20260617.spbt_results.db" + create_panel_fixture_db(db_path) + + app = module.SpbtDayPanelApp(repo_root=tmp_path) + app.directory_input.value = str(data_dir) + app.refresh_files() + app.min_pctg_change_input.value = 0.0 + + app.calculate() + + assert app.file_select.value == str(db_path) + assert app.file_select.width == 360 + assert app.min_pctg_change_input.width == 220 + assert app.calculate_button.width == 110 + assert app.total_pnl_histogram.sizing_mode == "stretch_width" + assert app.selected_pair_market_plot.sizing_mode == "stretch_width" + assert app.pair_theo_ret_table.pagination is None + assert app.pair_theo_ret_table.layout == "fit_data_table" + assert app.pair_theo_ret_table.value["pair_name"].tolist() == ["AAA-BBB"] + assert ( + app.pair_theo_ret_table.value[module.spbt_day.PAIR_NAME_VALUE_COLUMN].tolist() + == ["AAA:USD-BBB:USD"] + ) + assert app.selected_pair_name is None + assert app.selected_pair_executions_table.value.empty + assert app.selected_pair_market_plot.object is None + + app.analyze_pair_row(0) + + assert app.selected_pair_name == "AAA:USD-BBB:USD" + assert app.selected_pair_executions_table.value["action"].tolist() == [ + "TARGET", + "TARGET", + "CLOSE", + "CLOSE", + ] + assert app.selected_pair_market_plot.object is not None