From 49c91e5d85590a8780849be2d087a0b3afcd710c Mon Sep 17 00:00:00 2001 From: Oleg Sheynin Date: Tue, 28 Jul 2026 23:45:59 +0000 Subject: [PATCH] Release v1.0.1 --- CHANGELOG.md | 40 +- notebooks/spbt_day.ipynb | 397 +++++++++-------- requirements.txt | 2 + scripts/spbt_day.py | 926 +++++++++++++++++++++++++++++++++++++-- tests/test_spbt_day.py | 902 +++++++++++++++++++++++++++++++++++++- 5 files changed, 2037 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384aa32..c622469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,45 @@ All notable changes to this project are documented in this file. ## Unreleased -- No unreleased changes yet. +No unreleased changes yet. + +## 2026-07-28 v1.0.1 + +- Added the `spbt_day` notebook for interactive single-day backtest result + analysis, including SQLite result file selection from the local data + directory. +- Added selector-pair loading and dense ranking by `mr_score.final`, preserving + rows with invalid score JSON for inspection. +- Added theoretical return calculation for ranked pairs from + `trading_instructions`, including reusable helper functions and tests. +- Added a Plotly histogram for visual analysis of total theoretical return by + pair. +- Moved notebook support code into reusable `scripts/spbt_day.py` helpers. +- Adjusted notebook table outputs to show all relevant rows and reduce + redundant intermediate displays. +- Added an alphabetically sorted pair selector for individual pair analysis. +- Added selected-pair theoretical execution tables and aligned TheoRet + calculations with target-delta trade generation. +- Added per-asset `strength` values to selected-pair theoretical execution + tables. +- Corrected theoretical execution size to use + `10000 * strength / reference_price`. +- Removed `:USD` quote suffixes from displayed pair names in notebook tables, + chart hovers, and the pair selector dropdown while preserving full internal + pair keys for calculations. +- Added `num_trades` to pair TheoRet summaries, counting asset-level theoretical + trades from effective `TARGET` and `CLOSE` instructions. +- Added sortable interactive grids for the pair TheoRet and selected-pair + theoretical execution tables. +- Styled interactive dataframe grids with black text on white backgrounds for + readability across notebook themes. +- Added a selected-pair Plotly chart that overlays theoretical BUY/SELL + executions on relative 1-minute market close data for both instruments. +- Anchored the selected-pair market chart at trading-day midnight and normalized + relative prices to each instrument's close at that timestamp. +- Added a `min_pctg_change` threshold for ranked pair TheoRet calculations to + skip small target-strength changes after a position is acquired. +- Added a notebook input field for the minimum TARGET strength-change threshold. ## 2026-07-25 v0.0.9 diff --git a/notebooks/spbt_day.ipynb b/notebooks/spbt_day.ipynb index 4026182..daec8ec 100644 --- a/notebooks/spbt_day.ipynb +++ b/notebooks/spbt_day.ipynb @@ -23,39 +23,43 @@ "metadata": {}, "outputs": [], "source": [ - "from html import escape\n", "from pathlib import Path\n", - "import sqlite3\n", + "import importlib\n", "import sys\n", - "from urllib.parse import quote\n", "\n", "from IPython.display import display\n", "import ipywidgets as widgets\n", + "import pandas as pd\n", "\n", + "START_DIR = Path.cwd().resolve()\n", + "for candidate in (START_DIR, *START_DIR.parents):\n", + " if (candidate / \"scripts\" / \"spbt_day.py\").exists():\n", + " if str(candidate) not in sys.path:\n", + " sys.path.insert(0, str(candidate))\n", + " break\n", "\n", - "def find_repo_root(start: Path | None = None) -> Path:\n", - " \"\"\"Return the nearest parent containing repository-level files.\"\"\"\n", - " current = (start or Path.cwd()).resolve()\n", - " for candidate in (current, *current.parents):\n", - " if (candidate / \"requirements.txt\").exists() and (candidate / \"notebooks\").is_dir():\n", - " return candidate\n", - " return current\n", + "import scripts.spbt_day as spbt_day\n", "\n", + "spbt_day = importlib.reload(spbt_day)\n", + "\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_trades_market_plot = spbt_day.create_pair_trades_market_plot\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", + "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", + "show_interactive_dataframe = spbt_day.show_interactive_dataframe\n", "\n", "REPO_ROOT = find_repo_root()\n", - "if str(REPO_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(REPO_ROOT))\n", - "\n", - "from scripts.spbt_day import (\n", - " calculate_ranked_pairs_theo_ret,\n", - " load_selector_pair_rankings,\n", - " load_trading_instructions,\n", - ")\n", - "\n", "DEFAULT_DATA_DIR = REPO_ROOT / \"data\"\n", - "SQLITE_EXTENSIONS = {\".db\", \".sqlite\", \".sqlite3\"}\n", - "\n", - "selected_db_path: Path | None = None\n", "\n", "REPO_ROOT, DEFAULT_DATA_DIR" ] @@ -67,136 +71,12 @@ "metadata": {}, "outputs": [], "source": [ - "directory_input = widgets.Text(\n", - " value=str(DEFAULT_DATA_DIR),\n", - " description=\"Directory\",\n", - " continuous_update=False,\n", - " layout=widgets.Layout(width=\"100%\"),\n", - " style={\"description_width\": \"90px\"},\n", + "db_selector = create_database_file_selector(\n", + " default_data_dir=DEFAULT_DATA_DIR,\n", + " repo_root=REPO_ROOT,\n", ")\n", "\n", - "show_all_files = widgets.Checkbox(\n", - " value=False,\n", - " description=\"Show all files\",\n", - " indent=False,\n", - ")\n", - "\n", - "refresh_button = widgets.Button(\n", - " description=\"Refresh\",\n", - " icon=\"refresh\",\n", - " button_style=\"\",\n", - " tooltip=\"Rescan the selected directory\",\n", - ")\n", - "\n", - "file_select = widgets.Select(\n", - " options=[],\n", - " rows=12,\n", - " description=\"Files\",\n", - " layout=widgets.Layout(width=\"100%\"),\n", - " style={\"description_width\": \"90px\"},\n", - ")\n", - "\n", - "selected_path_display = widgets.HTML(value=\"Selected database: none\")\n", - "status_output = widgets.Output()\n", - "\n", - "\n", - "def normalize_directory(raw_path: str) -> Path:\n", - " path = Path(raw_path).expanduser()\n", - " if not path.is_absolute():\n", - " path = REPO_ROOT / path\n", - " return path.resolve()\n", - "\n", - "\n", - "def list_candidate_files(directory: Path, show_all: bool = False) -> list[Path]:\n", - " def result_file_sort_key(path: Path) -> tuple[int, str]:\n", - " name = path.name.lower()\n", - " if \".spbt_results.\" in name:\n", - " priority = 0\n", - " elif \"selector\" in name and \"results\" in name:\n", - " priority = 1\n", - " elif \"results\" in name:\n", - " priority = 2\n", - " else:\n", - " priority = 3\n", - " return priority, name\n", - "\n", - " if show_all:\n", - " return sorted(\n", - " (path for path in directory.iterdir() if path.is_file()),\n", - " key=result_file_sort_key,\n", - " )\n", - " return sorted(\n", - " (\n", - " path\n", - " for path in directory.iterdir()\n", - " if path.is_file() and path.suffix.lower() in SQLITE_EXTENSIONS\n", - " ),\n", - " key=result_file_sort_key,\n", - " )\n", - "\n", - "\n", - "def set_selected_database(path_value: str | None) -> None:\n", - " global selected_db_path\n", - " selected_db_path = Path(path_value).resolve() if path_value else None\n", - " label = str(selected_db_path) if selected_db_path else \"none\"\n", - " selected_path_display.value = f\"Selected database: {escape(label)}\"\n", - "\n", - "\n", - "def refresh_file_list(*_args) -> None:\n", - " directory = normalize_directory(directory_input.value)\n", - " with status_output:\n", - " status_output.clear_output()\n", - " if not directory.exists():\n", - " file_select.options = []\n", - " set_selected_database(None)\n", - " print(f\"Directory does not exist: {directory}\")\n", - " return\n", - " if not directory.is_dir():\n", - " file_select.options = []\n", - " set_selected_database(None)\n", - " print(f\"Path is not a directory: {directory}\")\n", - " return\n", - "\n", - " candidates = list_candidate_files(directory, show_all=show_all_files.value)\n", - " candidate_values = [str(path) for path in candidates]\n", - " previous_value = file_select.value\n", - " file_select.options = [(path.name, str(path)) for path in candidates]\n", - " if candidates:\n", - " file_select.value = previous_value if previous_value in candidate_values else candidate_values[0]\n", - " set_selected_database(file_select.value)\n", - " else:\n", - " set_selected_database(None)\n", - "\n", - " if candidates:\n", - " print(f\"Found {len(candidates)} file(s) in {directory}\")\n", - " else:\n", - " suffixes = \", \".join(sorted(SQLITE_EXTENSIONS))\n", - " print(f\"No SQLite files ({suffixes}) found in {directory}\")\n", - "\n", - "\n", - "def on_file_selected(change) -> None:\n", - " if change[\"name\"] == \"value\":\n", - " set_selected_database(change[\"new\"])\n", - "\n", - "\n", - "refresh_button.on_click(refresh_file_list)\n", - "show_all_files.observe(refresh_file_list, names=\"value\")\n", - "directory_input.observe(refresh_file_list, names=\"value\")\n", - "file_select.observe(on_file_selected, names=\"value\")\n", - "\n", - "display(\n", - " widgets.VBox(\n", - " [\n", - " widgets.HBox([directory_input, refresh_button]),\n", - " show_all_files,\n", - " file_select,\n", - " selected_path_display,\n", - " status_output,\n", - " ]\n", - " )\n", - ")\n", - "\n", - "refresh_file_list()" + "display(db_selector.widget)" ] }, { @@ -206,23 +86,8 @@ "metadata": {}, "outputs": [], "source": [ - "def selected_database_path() -> Path:\n", - " \"\"\"Return the interactively selected SQLite result path.\"\"\"\n", - " if selected_db_path is None:\n", - " raise ValueError(\"Choose a SQLite result file before continuing.\")\n", - " if not selected_db_path.exists():\n", - " raise FileNotFoundError(f\"Selected database does not exist: {selected_db_path}\")\n", - " if not selected_db_path.is_file():\n", - " raise ValueError(f\"Selected database path is not a file: {selected_db_path}\")\n", - " return selected_db_path\n", - "\n", - "\n", - "def connect_selected_database() -> sqlite3.Connection:\n", - " \"\"\"Open a read-only SQLite connection to the selected result database.\"\"\"\n", - " db_path = selected_database_path()\n", - " uri = f\"file:{quote(db_path.as_posix(), safe='/:')}?mode=ro\"\n", - " return sqlite3.connect(uri, uri=True)\n", - "\n", + "selected_database_path = db_selector.selected_database_path\n", + "connect_selected_database = db_selector.connect_selected_database\n", "\n", "# Later notebook sections can call selected_database_path() or connect_selected_database()." ] @@ -252,24 +117,11 @@ "finally:\n", " conn.close()\n", "\n", - "selector_pair_rankings" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "selector-pair-ranking-summary", - "metadata": {}, - "outputs": [], - "source": [ - "selector_pair_ranking_summary = (\n", - " selector_pair_rankings[\"mr_score_parse_status\"]\n", - " .value_counts(dropna=False)\n", - " .rename_axis(\"mr_score_parse_status\")\n", - " .reset_index(name=\"row_count\")\n", + "selector_pair_rankings_display = format_pair_names_for_display(\n", + " selector_pair_rankings[[\"pair_rank\", \"pair_name\", \"mr_score_final\"]]\n", ")\n", - "\n", - "selector_pair_ranking_summary" + "with pd.option_context(\"display.max_rows\", None):\n", + " display(selector_pair_rankings_display)" ] }, { @@ -279,9 +131,26 @@ "source": [ "## Theoretical Return by Pair\n", "\n", - "Load `trading_instructions` and calculate theoretical return for each ranked pair. Each pair starts from a fixed `$10,000` theoretical USD base. `TARGET` opens or replaces the current theoretical position using each asset's `strength` and `reference_price`; `CLOSE` liquidates the open position at the close row's `reference_price`; `HOLD` is ignored.\n", + "Load `trading_instructions` and calculate theoretical return for each ranked pair. Each pair starts from a fixed `$10,000` theoretical USD base. `TARGET` trades from the current theoretical position to the new target position, where target size is `10000 * strength / reference_price`; `CLOSE` liquidates the open position at the close row's `reference_price`; `HOLD` is ignored.\n", "\n", - "`realized_pnl` and `unrealized_pnl` are percentage returns relative to `$10,000`, sorted by ascending MR rank." + "`MIN_TARGET_STRENGTH_CHANGE_PCTG` can be raised above `0.0` to skip `TARGET` updates whose absolute percentage strength change is smaller than the threshold since the position was acquired. `num_trades` counts asset-level theoretical trades caused by effective `TARGET` and `CLOSE` rows. `realized_pnl` and `unrealized_pnl` are percentage returns relative to `$10,000`. The displayed dataframe is sorted by total return (`realized_pnl + unrealized_pnl`) ascending." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "target-change-threshold-input", + "metadata": {}, + "outputs": [], + "source": [ + "min_target_change_input = widgets.FloatText(\n", + " value=0.0,\n", + " description=\"Mininal TARGET change (%)\",\n", + " step=1.0,\n", + " layout=widgets.Layout(width=\"420px\"),\n", + " style={\"description_width\": \"190px\"},\n", + ")\n", + "display(min_target_change_input)" ] }, { @@ -297,7 +166,7 @@ "finally:\n", " conn.close()\n", "\n", - "trading_instructions" + "print(f\"Loaded {len(trading_instructions):,} trading instruction rows.\")" ] }, { @@ -307,12 +176,160 @@ "metadata": {}, "outputs": [], "source": [ - "pair_theo_ret = calculate_ranked_pairs_theo_ret(\n", - " selector_pair_rankings,\n", + "MIN_TARGET_STRENGTH_CHANGE_PCTG = float(min_target_change_input.value)\n", + "\n", + "pair_theo_ret = add_total_pnl(\n", + " calculate_ranked_pairs_theo_ret(\n", + " selector_pair_rankings,\n", + " trading_instructions,\n", + " min_pctg_change=MIN_TARGET_STRENGTH_CHANGE_PCTG,\n", + " )\n", + ").sort_values(\n", + " [\"total_pnl\", \"pair_name\"],\n", + " ascending=[True, True],\n", + " 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,\n", + " table_id=\"pair-theo-ret-grid\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "theoretical-return-histogram-context", + "metadata": {}, + "source": [ + "## Total Theoretical Return Distribution\n", + "\n", + "Plot the distribution of total theoretical return, calculated as `realized_pnl + unrealized_pnl`. Plotly chooses histogram bins automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "plot-total-theoretical-return-histogram", + "metadata": {}, + "outputs": [], + "source": [ + "total_pnl_histogram = create_total_pnl_histogram(pair_theo_ret)\n", + "\n", + "total_pnl_histogram" + ] + }, + { + "cell_type": "markdown", + "id": "individual-pair-analysis-context", + "metadata": {}, + "source": [ + "## Individual Pair Analysis\n", + "\n", + "Choose one pair for detailed follow-up analysis. Pair names are sorted alphabetically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "choose-individual-pair", + "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", ")\n", "\n", - "pair_theo_ret" + "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", + ")\n", + "show_interactive_dataframe(\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", + ")\n", + "\n", + "selected_pair_market_trades_plot" ] } ], diff --git a/requirements.txt b/requirements.txt index d19d9d7..fd4658e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,11 @@ # Interactive analysis ipykernel>=6.29,<7 ipywidgets>=8.1,<9 +itables>=2.2,<3 jupyter>=1.1,<2 nbformat>=5.10,<6 pandas>=2.2,<3 +plotly>=5.24,<7 # Verification nbmake>=1.5,<2 diff --git a/scripts/spbt_day.py b/scripts/spbt_day.py index bd5082e..d467d60 100644 --- a/scripts/spbt_day.py +++ b/scripts/spbt_day.py @@ -2,17 +2,266 @@ from __future__ import annotations +from html import escape import json import math +from pathlib import Path import sqlite3 from typing import Any +from urllib.parse import quote import pandas as pd SELECTOR_PAIRS_COLUMNS = ("pair_name", "mr_score") +SELECTOR_PAIR_INSTRUMENT_COLUMNS = ("pair_name", "instrument_a", "instrument_b") TRADING_INSTRUCTIONS_COLUMNS = ("time_ns", "tstamp", "data") +OHLCV_1MIN_COLUMNS = ("tstamp", "tstamp_ns", "exch_acct", "instrument_id", "close") INITIAL_THEO_CAPITAL_USD = 10_000.0 +SQLITE_EXTENSIONS = {".db", ".sqlite", ".sqlite3"} +PAIR_NAME_DISPLAY_SUFFIX = ":USD" +INTERACTIVE_TABLE_CSS = """ +table.dataTable, +table.dataTable th, +table.dataTable td { + background-color: #ffffff !important; + color: #000000 !important; +} + +table.dataTable.display tbody tr.odd, +table.dataTable.display tbody tr.even, +table.dataTable.display tbody tr:hover, +table.dataTable.hover tbody tr:hover { + background-color: #ffffff !important; + color: #000000 !important; +} + +div.dt-container, +div.dt-container label, +div.dt-container input, +div.dt-container select, +div.dt-container .dt-info, +div.dt-container .dt-paging, +div.dt-container .dt-paging .dt-paging-button { + background-color: #ffffff !important; + color: #000000 !important; +} +""" + + +def find_repo_root(start: Path | None = None) -> Path: + """Return the nearest parent containing repository-level files.""" + current = (start or Path.cwd()).resolve() + for candidate in (current, *current.parents): + has_requirements = (candidate / "requirements.txt").exists() + has_notebooks = (candidate / "notebooks").is_dir() + if has_requirements and has_notebooks: + return candidate + return current + + +def normalize_directory(raw_path: str, base_dir: Path) -> Path: + """Resolve a user-provided directory path relative to a base directory.""" + path = Path(raw_path).expanduser() + if not path.is_absolute(): + path = base_dir / path + return path.resolve() + + +def result_file_sort_key(path: Path) -> tuple[int, str]: + """Sort result-like SQLite files before other local database files.""" + name = path.name.lower() + if ".spbt_results." in name: + priority = 0 + elif "selector" in name and "results" in name: + priority = 1 + elif "results" in name: + priority = 2 + else: + priority = 3 + return priority, name + + +def list_candidate_files(directory: Path, show_all: bool = False) -> list[Path]: + """List selectable files, preferring SQLite result databases by default.""" + if show_all: + candidates = (path for path in directory.iterdir() if path.is_file()) + else: + candidates = ( + path + for path in directory.iterdir() + if path.is_file() and path.suffix.lower() in SQLITE_EXTENSIONS + ) + return sorted(candidates, key=result_file_sort_key) + + +def read_only_sqlite_uri(db_path: Path) -> str: + """Build a read-only SQLite URI for a local database path.""" + return f"file:{quote(db_path.resolve().as_posix(), safe='/:')}?mode=ro" + + +def connect_sqlite_read_only(db_path: Path) -> sqlite3.Connection: + """Open a read-only SQLite connection for a local database path.""" + return sqlite3.connect(read_only_sqlite_uri(db_path), uri=True) + + +class DatabaseFileSelector: + """Small ipywidgets controller for choosing a local SQLite result database.""" + + def __init__( + self, + *, + repo_root: Path, + directory_input: Any, + show_all_files: Any, + refresh_button: Any, + file_select: Any, + selected_path_display: Any, + status_output: Any, + widget: Any, + ) -> None: + self.repo_root = repo_root + self.directory_input = directory_input + self.show_all_files = show_all_files + self.refresh_button = refresh_button + self.file_select = file_select + self.selected_path_display = selected_path_display + self.status_output = status_output + self.widget = widget + self.selected_db_path: Path | None = None + + def set_selected_database(self, path_value: str | None) -> None: + """Set the selected database and update its display label.""" + self.selected_db_path = Path(path_value).resolve() if path_value else None + label = str(self.selected_db_path) if self.selected_db_path else "none" + self.selected_path_display.value = f"Selected database: {escape(label)}" + + def refresh_file_list(self, *_args: Any) -> None: + """Rescan the configured directory and refresh selectable files.""" + directory = normalize_directory(self.directory_input.value, self.repo_root) + with self.status_output: + self.status_output.clear_output() + if not directory.exists(): + self.file_select.options = [] + self.set_selected_database(None) + print(f"Directory does not exist: {directory}") + return + if not directory.is_dir(): + self.file_select.options = [] + self.set_selected_database(None) + print(f"Path is not a directory: {directory}") + return + + candidates = list_candidate_files( + directory, + show_all=self.show_all_files.value, + ) + candidate_values = [str(path) for path in candidates] + previous_value = self.file_select.value + self.file_select.options = [(path.name, str(path)) for path in candidates] + if candidates: + self.file_select.value = ( + previous_value + if previous_value in candidate_values + else candidate_values[0] + ) + self.set_selected_database(self.file_select.value) + else: + self.set_selected_database(None) + + if candidates: + print(f"Found {len(candidates)} file(s) in {directory}") + else: + suffixes = ", ".join(sorted(SQLITE_EXTENSIONS)) + print(f"No SQLite files ({suffixes}) found in {directory}") + + def on_file_selected(self, change: dict[str, Any]) -> None: + """Update selected path when the widget selection changes.""" + if change["name"] == "value": + self.set_selected_database(change["new"]) + + def selected_database_path(self) -> Path: + """Return the interactively selected SQLite result path.""" + if self.selected_db_path is None: + raise ValueError("Choose a SQLite result file before continuing.") + if not self.selected_db_path.exists(): + raise FileNotFoundError( + f"Selected database does not exist: {self.selected_db_path}" + ) + if not self.selected_db_path.is_file(): + raise ValueError( + f"Selected database path is not a file: {self.selected_db_path}" + ) + return self.selected_db_path + + def connect_selected_database(self) -> sqlite3.Connection: + """Open a read-only SQLite connection to the selected result database.""" + return connect_sqlite_read_only(self.selected_database_path()) + + +def create_database_file_selector( + default_data_dir: Path | None = None, + repo_root: Path | None = None, +) -> DatabaseFileSelector: + """Create an interactive database file selector for notebook use.""" + import ipywidgets as widgets + + resolved_repo_root = (repo_root or find_repo_root()).resolve() + resolved_data_dir = (default_data_dir or resolved_repo_root / "data").resolve() + + directory_input = widgets.Text( + value=str(resolved_data_dir), + description="Directory", + continuous_update=False, + layout=widgets.Layout(width="100%"), + style={"description_width": "90px"}, + ) + show_all_files = widgets.Checkbox( + value=False, + description="Show all files", + indent=False, + ) + refresh_button = widgets.Button( + description="Refresh", + icon="refresh", + button_style="", + tooltip="Rescan the selected directory", + ) + file_select = widgets.Select( + options=[], + rows=12, + description="Files", + layout=widgets.Layout(width="100%"), + style={"description_width": "90px"}, + ) + selected_path_display = widgets.HTML(value="Selected database: none") + status_output = widgets.Output() + widget = widgets.VBox( + [ + widgets.HBox([directory_input, refresh_button]), + show_all_files, + file_select, + selected_path_display, + status_output, + ] + ) + + selector = DatabaseFileSelector( + repo_root=resolved_repo_root, + directory_input=directory_input, + show_all_files=show_all_files, + refresh_button=refresh_button, + file_select=file_select, + selected_path_display=selected_path_display, + status_output=status_output, + widget=widget, + ) + refresh_button.on_click(selector.refresh_file_list) + show_all_files.observe(selector.refresh_file_list, names="value") + directory_input.observe(selector.refresh_file_list, names="value") + file_select.observe(selector.on_file_selected, names="value") + selector.refresh_file_list() + return selector def parse_mr_score_final(raw_score: Any) -> tuple[float | None, str]: @@ -59,6 +308,19 @@ def validate_selector_pairs_table(conn: sqlite3.Connection) -> None: raise ValueError(f"selector_pairs is missing required column(s): {missing}") +def validate_selector_pair_instrument_columns(conn: sqlite3.Connection) -> None: + """Raise if selector_pairs cannot map pair names to market instruments.""" + table_info = conn.execute("PRAGMA table_info(selector_pairs)").fetchall() + if not table_info: + raise ValueError("SQLite database is missing required table: selector_pairs") + + existing_columns = {row[1] for row in table_info} + missing_columns = set(SELECTOR_PAIR_INSTRUMENT_COLUMNS) - existing_columns + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError(f"selector_pairs is missing required column(s): {missing}") + + def rank_selector_pairs(selector_pairs: pd.DataFrame) -> pd.DataFrame: """Rank selector pairs by dense descending mr_score.final.""" missing_columns = set(SELECTOR_PAIRS_COLUMNS) - set(selector_pairs.columns) @@ -125,6 +387,32 @@ def load_trading_instructions(conn: sqlite3.Connection) -> pd.DataFrame: ) +def infer_trading_day_start_ns(trd_inst_df: pd.DataFrame) -> int: + """Infer the UTC midnight timestamp for the trading-instruction day.""" + if "time_ns" not in trd_inst_df.columns: + raise ValueError("trading instructions dataframe is missing column: time_ns") + + time_ns = pd.to_numeric(trd_inst_df["time_ns"], errors="coerce").dropna() + if time_ns.empty: + raise ValueError("trading instructions dataframe does not contain timestamps") + + first_timestamp = pd.to_datetime(int(time_ns.min()), unit="ns", utc=True) + return int(first_timestamp.floor("D").value) + + +def validate_ohlcv_1min_table(conn: sqlite3.Connection) -> None: + """Raise an actionable error if ohlcv_1min lacks required columns.""" + table_info = conn.execute("PRAGMA table_info(ohlcv_1min)").fetchall() + if not table_info: + raise ValueError("SQLite database is missing required table: ohlcv_1min") + + existing_columns = {row[1] for row in table_info} + missing_columns = set(OHLCV_1MIN_COLUMNS) - existing_columns + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError(f"ohlcv_1min is missing required column(s): {missing}") + + def pair_assets_and_quote(pair_name: str) -> tuple[tuple[str, ...], str]: """Parse a pair name like ADA:USD-BTC:USD into assets and quote asset.""" pair_legs = pair_name.split("-") @@ -209,6 +497,16 @@ def _sort_trading_instructions(trd_inst_df: pd.DataFrame) -> pd.DataFrame: def _matching_pair_instructions( pair_name: str, trd_inst_df: pd.DataFrame, +) -> list[dict[str, Any]]: + return [ + instruction_row["data"] + for instruction_row in _matching_pair_instruction_rows(pair_name, trd_inst_df) + ] + + +def _matching_pair_instruction_rows( + pair_name: str, + trd_inst_df: pd.DataFrame, ) -> list[dict[str, Any]]: pair_assets, quote_asset = pair_assets_and_quote(pair_name) pair_asset_set = set(pair_assets) @@ -217,7 +515,10 @@ def _matching_pair_instructions( raise ValueError("trading instructions dataframe is missing column: data") selected_instructions: list[dict[str, Any]] = [] - for raw_data in _sort_trading_instructions(trd_inst_df)["data"]: + for instruction_row in _sort_trading_instructions(trd_inst_df).itertuples( + index=False + ): + raw_data = getattr(instruction_row, "data") parsed = _parse_instruction_data(raw_data) if parsed is None or parsed.get("quote_asset") != quote_asset: continue @@ -226,77 +527,500 @@ def _matching_pair_instructions( if not isinstance(assets, dict) or set(assets) != pair_asset_set: continue - selected_instructions.append(parsed) + selected_instructions.append( + { + "time_ns": getattr(instruction_row, "time_ns", None), + "tstamp": getattr(instruction_row, "tstamp", None), + "data": parsed, + } + ) return selected_instructions -def calculate_pair_theo_ret( +def _execution_side(size: float) -> str: + return "BUY" if size > 0 else "SELL" + + +def _validate_min_pctg_change(min_pctg_change: float) -> float: + try: + numeric_min_pctg_change = float(min_pctg_change) + except (TypeError, ValueError) as exc: + raise ValueError("min_pctg_change must be numeric") from exc + + if not math.isfinite(numeric_min_pctg_change): + raise ValueError("min_pctg_change must be finite") + if numeric_min_pctg_change < 0: + raise ValueError("min_pctg_change must be non-negative") + return numeric_min_pctg_change + + +def _target_strength_change_pctg( + current_strength: float | None, + target_strength: float, +) -> float | None: + if current_strength is None: + return None + if current_strength == 0: + return math.inf if target_strength != 0 else 0.0 + return abs((target_strength - current_strength) / current_strength) * 100.0 + + +def calculate_pair_theo_executions( pair_name: str, trd_inst_df: pd.DataFrame, -) -> dict[str, float | str]: - """Calculate realized and unrealized TheoRet percentages for one pair. + min_pctg_change: float = 0, +) -> pd.DataFrame: + """Create asset-level theoretical executions for one selected pair. - Repeated TARGET actions replace the previous open theoretical position. - CLOSE actions liquidate the currently open position. HOLD and unknown - actions are ignored. Returned PnL values are percentages of the fixed - 10,000 USD theoretical capital base. + TARGET rows trade from the current theoretical position to the new target + position when the absolute percentage strength change since the last + executed TARGET reaches min_pctg_change. CLOSE rows flatten the current + theoretical position. Positive size is a BUY; negative size is a SELL. USD + value is the opposite signed cash movement, so buys are negative and sells + are positive. """ + min_pctg_change = _validate_min_pctg_change(min_pctg_change) pair_assets, _quote_asset = pair_assets_and_quote(pair_name) - realized_pnl_usd = 0.0 - open_position: dict[str, dict[str, float]] | None = None + current_sizes = {asset: 0.0 for asset in pair_assets} + current_strengths: dict[str, float | None] = {asset: None for asset in pair_assets} + records: list[dict[str, Any]] = [] + execution_order = 0 - for instruction in _matching_pair_instructions(pair_name, trd_inst_df): - action = instruction.get("action") - assets_data = instruction["assets"] + for instruction in _matching_pair_instruction_rows(pair_name, trd_inst_df): + action = instruction["data"].get("action") + assets_data = instruction["data"]["assets"] if action == "TARGET": - open_position = {} for asset in pair_assets: asset_data = assets_data[asset] - quantity = INITIAL_THEO_CAPITAL_USD * _strength( + target_strength = _strength( asset_data, asset, pair_name, ) - entry_price = _reference_price(asset_data, asset, pair_name) - open_position[asset] = { - "quantity": quantity, - "entry_value": quantity * entry_price, - "latest_value": quantity * entry_price, - } + price = _reference_price(asset_data, asset, pair_name) + target_size = INITIAL_THEO_CAPITAL_USD * target_strength / price + strength_change_pctg = _target_strength_change_pctg( + current_strengths[asset], + target_strength, + ) + if ( + strength_change_pctg is not None + and strength_change_pctg < min_pctg_change + and not math.isclose(strength_change_pctg, min_pctg_change) + ): + continue + + trade_size = target_size - current_sizes[asset] + if trade_size == 0: + continue + + records.append( + { + "time": instruction["tstamp"] or instruction["time_ns"], + "time_ns": instruction["time_ns"], + "pair_name": pair_name, + "asset": asset, + "action": action, + "side": _execution_side(trade_size), + "strength": target_strength, + "size": trade_size, + "price": price, + "usd_value": -trade_size * price, + "_execution_order": execution_order, + } + ) + execution_order += 1 + current_sizes[asset] = target_size + current_strengths[asset] = target_strength elif action == "CLOSE": - if open_position is None: + if not any(current_sizes.values()): continue for asset in pair_assets: - asset_data = assets_data[asset] - close_value = ( - open_position[asset]["quantity"] - * _reference_price(asset_data, asset, pair_name) - ) - realized_pnl_usd += close_value - open_position[asset]["entry_value"] - open_position = None + current_size = current_sizes[asset] + if current_size == 0: + continue - unrealized_pnl_usd = 0.0 - if open_position is not None: - unrealized_pnl_usd = sum( - position["latest_value"] - position["entry_value"] - for position in open_position.values() + asset_data = assets_data[asset] + trade_size = -current_size + price = _reference_price(asset_data, asset, pair_name) + records.append( + { + "time": instruction["tstamp"] or instruction["time_ns"], + "time_ns": instruction["time_ns"], + "pair_name": pair_name, + "asset": asset, + "action": action, + "side": _execution_side(trade_size), + "strength": None, + "size": trade_size, + "price": price, + "usd_value": -trade_size * price, + "_execution_order": execution_order, + } + ) + execution_order += 1 + current_sizes[asset] = 0.0 + current_strengths[asset] = None + + columns = [ + "time", + "time_ns", + "pair_name", + "asset", + "action", + "side", + "strength", + "size", + "price", + "usd_value", + "_execution_order", + ] + return ( + pd.DataFrame.from_records(records, columns=columns) + .sort_values( + ["time_ns", "_execution_order"], + kind="mergesort", + na_position="last", ) + .drop(columns="_execution_order") + .reset_index(drop=True) + ) + + +def calculate_pair_theo_ret_from_executions( + pair_name: str, + trd_inst_df: pd.DataFrame, + min_pctg_change: float = 0, +) -> dict[str, float | int | str]: + """Calculate pair TheoRet from the generated theoretical executions.""" + executions = calculate_pair_theo_executions( + pair_name, + trd_inst_df, + min_pctg_change=min_pctg_change, + ) + if executions.empty: + return { + "pair_name": pair_name, + "num_trades": 0, + "realized_pnl": 0.0, + "unrealized_pnl": 0.0, + } + + pair_assets, _quote_asset = pair_assets_and_quote(pair_name) + current_sizes = {asset: 0.0 for asset in pair_assets} + latest_prices = {asset: 0.0 for asset in pair_assets} + open_cash_flow_usd = 0.0 + realized_pnl_usd = 0.0 + + for execution in executions.itertuples(index=False): + current_sizes[execution.asset] += execution.size + latest_prices[execution.asset] = execution.price + open_cash_flow_usd += execution.usd_value + if execution.action == "CLOSE" and not any(current_sizes.values()): + realized_pnl_usd += open_cash_flow_usd + open_cash_flow_usd = 0.0 + + unrealized_pnl_usd = open_cash_flow_usd + sum( + current_sizes[asset] * latest_prices[asset] for asset in pair_assets + ) return { "pair_name": pair_name, + "num_trades": len(executions), "realized_pnl": realized_pnl_usd / INITIAL_THEO_CAPITAL_USD * 100.0, "unrealized_pnl": unrealized_pnl_usd / INITIAL_THEO_CAPITAL_USD * 100.0, } +def calculate_pair_theo_ret( + pair_name: str, + trd_inst_df: pd.DataFrame, + min_pctg_change: float = 0, +) -> dict[str, float | int | str]: + """Calculate realized and unrealized TheoRet percentages for one pair. + + TARGET actions trade the delta between current and target theoretical + positions when the target strength change reaches min_pctg_change. CLOSE + actions liquidate the currently open position. HOLD and unknown actions are + ignored. Returned PnL values are percentages of the fixed 10,000 USD + theoretical capital base. + """ + return calculate_pair_theo_ret_from_executions( + pair_name, + trd_inst_df, + min_pctg_change=min_pctg_change, + ) + + +def parse_selector_instrument(raw_instrument: Any) -> tuple[str, str]: + """Parse selector_pairs instrument value into exch_acct and instrument_id.""" + if not isinstance(raw_instrument, str) or ":" not in raw_instrument: + raise ValueError(f"selector instrument must use EXCH_ACCT:INSTRUMENT form") + + exch_acct, instrument_id = raw_instrument.split(":", 1) + if not exch_acct or not instrument_id: + raise ValueError(f"selector instrument must use EXCH_ACCT:INSTRUMENT form") + return exch_acct, instrument_id + + +def load_pair_market_data( + conn: sqlite3.Connection, + pair_name: str, + *, + trading_day_start_ns: int, +) -> pd.DataFrame: + """Load 1-minute close data from trading-day start for selected instruments.""" + validate_selector_pair_instrument_columns(conn) + validate_ohlcv_1min_table(conn) + pair_assets, _quote_asset = pair_assets_and_quote(pair_name) + + selector_pair = pd.read_sql_query( + """ + SELECT instrument_a, instrument_b + FROM selector_pairs + WHERE pair_name = ? + ORDER BY rowid + LIMIT 1 + """, + conn, + params=(pair_name,), + ) + if selector_pair.empty: + raise ValueError(f"selector_pairs does not contain pair_name: {pair_name}") + + instrument_values = [ + selector_pair["instrument_a"].iloc[0], + selector_pair["instrument_b"].iloc[0], + ] + + market_frames = [] + missing_market_assets = [] + for asset, raw_instrument in zip(pair_assets, instrument_values, strict=True): + exch_acct, instrument_id = parse_selector_instrument(raw_instrument) + instrument_market_data = pd.read_sql_query( + """ + SELECT + tstamp AS time, + tstamp_ns AS time_ns, + exch_acct, + instrument_id, + close + FROM ohlcv_1min + WHERE exch_acct = ? AND instrument_id = ? AND tstamp_ns >= ? + ORDER BY tstamp_ns, rowid + """, + conn, + params=(exch_acct, instrument_id, trading_day_start_ns), + ) + if instrument_market_data.empty: + missing_market_assets.append(asset) + continue + + instrument_market_data["pair_name"] = pair_name + instrument_market_data["asset"] = asset + market_frames.append(instrument_market_data) + + if missing_market_assets: + missing_assets = ", ".join(sorted(missing_market_assets)) + raise ValueError( + f"ohlcv_1min does not contain market data for asset(s): {missing_assets}" + ) + + market_data = pd.concat(market_frames, ignore_index=True) + market_data["close"] = pd.to_numeric(market_data["close"], errors="coerce") + missing_start_price_assets = sorted( + set(pair_assets) + - set(market_data.loc[market_data["time_ns"] == trading_day_start_ns, "asset"]) + ) + if missing_start_price_assets: + missing_assets = ", ".join(missing_start_price_assets) + raise ValueError( + "ohlcv_1min does not contain trading-day start close for " + f"asset(s): {missing_assets}" + ) + + initial_close_by_asset = market_data.drop_duplicates("asset").set_index("asset")[ + "close" + ] + market_data["initial_close"] = market_data["asset"].map( + initial_close_by_asset + ) + invalid_initial_close = ( + market_data["initial_close"].isna() | (market_data["initial_close"] <= 0) + ) + if invalid_initial_close.any(): + missing_assets = ", ".join( + sorted(market_data.loc[invalid_initial_close, "asset"].unique()) + ) + raise ValueError( + f"ohlcv_1min initial close must be positive for asset(s): {missing_assets}" + ) + + market_data["relative_close"] = ( + market_data["close"] - market_data["initial_close"] + ) / market_data["initial_close"] + return market_data.loc[ + :, + [ + "pair_name", + "asset", + "time", + "time_ns", + "exch_acct", + "instrument_id", + "close", + "initial_close", + "relative_close", + ], + ] + + +def _normalize_trade_prices_to_initial_close( + theo_executions: pd.DataFrame, + market_data: pd.DataFrame, +) -> pd.DataFrame: + required_execution_columns = {"asset", "side", "price", "time", "time_ns"} + missing_execution_columns = required_execution_columns - set( + theo_executions.columns + ) + if missing_execution_columns: + missing = ", ".join(sorted(missing_execution_columns)) + raise ValueError(f"theo executions dataframe missing column(s): {missing}") + + required_market_columns = {"asset", "initial_close"} + missing_market_columns = required_market_columns - set(market_data.columns) + if missing_market_columns: + missing = ", ".join(sorted(missing_market_columns)) + raise ValueError(f"market data dataframe missing column(s): {missing}") + + if theo_executions.empty: + return theo_executions.assign(relative_price=pd.Series(dtype="float64")) + + initial_close_by_asset = ( + market_data.dropna(subset=["initial_close"]) + .drop_duplicates("asset") + .set_index("asset")["initial_close"] + ) + trades = theo_executions.copy() + trades["initial_close"] = trades["asset"].map(initial_close_by_asset) + missing_initial_close = trades["initial_close"].isna() | ( + trades["initial_close"] <= 0 + ) + if missing_initial_close.any(): + missing_assets = ", ".join( + sorted(trades.loc[missing_initial_close, "asset"].dropna().unique()) + ) + raise ValueError( + f"market data initial close is required for trade asset(s): {missing_assets}" + ) + + trades["price"] = pd.to_numeric(trades["price"], errors="coerce") + trades["relative_price"] = ( + trades["price"] - trades["initial_close"] + ) / trades["initial_close"] + return trades + + +def create_pair_trades_market_plot( + pair_name: str, + market_data: pd.DataFrame, + theo_executions: pd.DataFrame, +) -> Any: + """Plot relative market closes and selected-pair theoretical trades.""" + import plotly.graph_objects as go + + required_market_columns = {"asset", "time", "close", "relative_close"} + missing_market_columns = required_market_columns - set(market_data.columns) + if missing_market_columns: + missing = ", ".join(sorted(missing_market_columns)) + raise ValueError(f"market data dataframe missing column(s): {missing}") + + pair_assets, _quote_asset = pair_assets_and_quote(pair_name) + trades = _normalize_trade_prices_to_initial_close(theo_executions, market_data) + figure = go.Figure() + + for asset in pair_assets: + asset_market_data = market_data.loc[market_data["asset"] == asset].sort_values( + ["time_ns", "time"], + kind="mergesort", + na_position="last", + ) + figure.add_trace( + go.Scatter( + x=asset_market_data["time"], + y=asset_market_data["relative_close"], + mode="lines", + name=f"{asset} close", + hovertemplate=( + "Asset=%{customdata[0]}
" + "Time=%{x}
" + "Close=%{customdata[1]:.8g}
" + "Relative=%{y:.4%}" + ), + customdata=asset_market_data[["asset", "close"]], + ) + ) + + for side, color, symbol in ( + ("BUY", "darkgreen", "triangle-up"), + ("SELL", "darkred", "triangle-down"), + ): + asset_side_trades = trades.loc[ + (trades["asset"] == asset) & (trades["side"] == side) + ].sort_values(["time_ns", "time"], kind="mergesort", na_position="last") + if asset_side_trades.empty: + continue + + figure.add_trace( + go.Scatter( + x=asset_side_trades["time"], + y=asset_side_trades["relative_price"], + mode="markers", + name=f"{asset} {side}", + marker={ + "symbol": symbol, + "color": color, + "size": 11, + "line": {"color": "white", "width": 1}, + }, + hovertemplate=( + "Asset=%{customdata[0]}
" + "Side=%{customdata[1]}
" + "Action=%{customdata[2]}
" + "Time=%{x}
" + "Price=%{customdata[3]:.8g}
" + "Relative=%{y:.4%}
" + "Size=%{customdata[4]:.8g}" + ), + customdata=asset_side_trades[ + ["asset", "side", "action", "price", "size"] + ], + ) + ) + + figure.update_layout( + title=f"{format_pair_name_for_display(pair_name)} Trades on Market Data", + xaxis_title="Time", + yaxis_title="Relative price", + hovermode="x unified", + legend_title="Series", + ) + if not market_data.empty: + figure.update_xaxes(range=[market_data["time"].min(), market_data["time"].max()]) + figure.update_yaxes(tickformat=".2%") + return figure + + def calculate_ranked_pairs_theo_ret( selector_pair_rankings: pd.DataFrame, trd_inst_df: pd.DataFrame, + min_pctg_change: float = 0, ) -> pd.DataFrame: """Calculate TheoRet percentages for every ranked selector pair.""" + min_pctg_change = _validate_min_pctg_change(min_pctg_change) required_columns = {"pair_name", "pair_rank"} missing_columns = required_columns - set(selector_pair_rankings.columns) if missing_columns: @@ -305,11 +1029,16 @@ def calculate_ranked_pairs_theo_ret( records = [] for row in selector_pair_rankings.itertuples(index=False): - pair_result = calculate_pair_theo_ret(row.pair_name, trd_inst_df) + pair_result = calculate_pair_theo_ret( + row.pair_name, + trd_inst_df, + min_pctg_change=min_pctg_change, + ) records.append( { "pair_name": pair_result["pair_name"], "mr_ranking": row.pair_rank, + "num_trades": pair_result["num_trades"], "realized_pnl": pair_result["realized_pnl"], "unrealized_pnl": pair_result["unrealized_pnl"], } @@ -318,8 +1047,131 @@ def calculate_ranked_pairs_theo_ret( return ( pd.DataFrame.from_records( records, - columns=["pair_name", "mr_ranking", "realized_pnl", "unrealized_pnl"], + columns=[ + "pair_name", + "mr_ranking", + "num_trades", + "realized_pnl", + "unrealized_pnl", + ], ) .sort_values(["mr_ranking", "pair_name"], na_position="last", kind="mergesort") .reset_index(drop=True) ) + + +def format_pair_name_for_display(pair_name: Any) -> Any: + """Return a human-facing pair label without the USD quote suffix.""" + if pd.isna(pair_name): + return pair_name + return "-".join( + leg.removesuffix(PAIR_NAME_DISPLAY_SUFFIX) for leg in str(pair_name).split("-") + ) + + +def format_pair_names_for_display( + dataframe: pd.DataFrame, + column: str = "pair_name", +) -> pd.DataFrame: + """Return a copy with pair-name labels formatted for display.""" + if column not in dataframe.columns: + raise ValueError(f"dataframe missing column: {column}") + formatted = dataframe.copy() + formatted[column] = formatted[column].map(format_pair_name_for_display) + return formatted + + +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"} + missing_columns = required_columns - set(pair_theo_ret.columns) + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError(f"pair TheoRet dataframe missing column(s): {missing}") + return pair_theo_ret.assign( + total_pnl=pair_theo_ret["realized_pnl"] + pair_theo_ret["unrealized_pnl"] + ) + + +def create_total_pnl_histogram(pair_theo_ret: pd.DataFrame) -> Any: + """Create a Plotly histogram of total theoretical return with automatic bins.""" + import plotly.express as px + + pair_theo_ret_for_plot = format_pair_names_for_display(add_total_pnl(pair_theo_ret)) + hover_columns = [ + column + for column in ("pair_name", "mr_ranking", "realized_pnl", "unrealized_pnl") + if column in pair_theo_ret_for_plot.columns + ] + total_pnl_histogram = px.histogram( + pair_theo_ret_for_plot, + x="total_pnl", + labels={ + "total_pnl": "Total TheoRet (%)", + "count": "Pair count", + }, + title="Total TheoRet Distribution by Pair", + hover_data=hover_columns, + ) + total_pnl_histogram.update_layout( + bargap=0.05, + yaxis_title="Pair count", + ) + return total_pnl_histogram + + +def show_interactive_dataframe( + dataframe: pd.DataFrame, + *, + table_id: str | None = None, + **kwargs: Any, +) -> None: + """Render a dataframe as an interactive sortable notebook grid.""" + from itables import show + + options = { + "paging": True, + "pageLength": 25, + "scrollX": True, + "ordering": True, + "showIndex": False, + "maxBytes": "8MB", + "classes": "display compact stripe hover", + "css": INTERACTIVE_TABLE_CSS, + } + if table_id is not None: + options["table_id"] = table_id + options.update(kwargs) + show(dataframe, **options) + + +def sorted_pair_names(selector_pair_rankings: pd.DataFrame) -> list[str]: + """Return unique pair names sorted alphabetically for pair-level analysis.""" + if "pair_name" not in selector_pair_rankings.columns: + raise ValueError("selector pair rankings missing column: pair_name") + return sorted( + { + str(pair_name) + for pair_name in selector_pair_rankings["pair_name"].dropna() + if str(pair_name) + } + ) + + +def create_pair_name_dropdown(selector_pair_rankings: pd.DataFrame) -> Any: + """Create a dropdown for choosing one pair name from ranked pairs.""" + import ipywidgets as widgets + + pair_names = sorted_pair_names(selector_pair_rankings) + if not pair_names: + raise ValueError("selector pair rankings do not contain any pair names") + options = [ + (format_pair_name_for_display(pair_name), pair_name) for pair_name in pair_names + ] + return widgets.Dropdown( + options=options, + value=pair_names[0], + description="Pair", + layout=widgets.Layout(width="100%"), + style={"description_width": "90px"}, + ) diff --git a/tests/test_spbt_day.py b/tests/test_spbt_day.py index 84a28c0..1a6cf26 100644 --- a/tests/test_spbt_day.py +++ b/tests/test_spbt_day.py @@ -1,16 +1,34 @@ import sqlite3 +from pathlib import Path import pandas as pd import pytest from scripts.spbt_day import ( + add_total_pnl, + calculate_pair_theo_executions, calculate_pair_theo_ret, calculate_ranked_pairs_theo_ret, + create_pair_name_dropdown, + create_pair_trades_market_plot, + connect_sqlite_read_only, + create_total_pnl_histogram, + find_repo_root, + format_pair_name_for_display, + format_pair_names_for_display, + infer_trading_day_start_ns, + list_candidate_files, + load_pair_market_data, load_selector_pair_rankings, load_trading_instructions, + normalize_directory, pair_assets_and_quote, + parse_selector_instrument, parse_mr_score_final, rank_selector_pairs, + read_only_sqlite_uri, + show_interactive_dataframe, + sorted_pair_names, ) @@ -89,6 +107,318 @@ def test_pair_assets_and_quote_parses_two_leg_pair(): assert pair_assets_and_quote("ADA:USD-BTC:USD") == (("ADA", "BTC"), "USD") +def test_infer_trading_day_start_ns_uses_utc_midnight(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [ + pd.Timestamp("2026-06-17T02:25:00Z").value, + pd.Timestamp("2026-06-17T00:01:00Z").value, + ] + } + ) + + assert infer_trading_day_start_ns(trd_inst_df) == pd.Timestamp( + "2026-06-17T00:00:00Z" + ).value + + +def test_parse_selector_instrument_splits_exchange_account_and_instrument_id(): + assert parse_selector_instrument("COINBASE_AT:PAIR-ADA-USD") == ( + "COINBASE_AT", + "PAIR-ADA-USD", + ) + + +def test_load_pair_market_data_maps_selector_instruments_and_relative_close(): + trading_day_start_ns = 10 + conn = sqlite3.connect(":memory:") + conn.execute( + """ + CREATE TABLE selector_pairs ( + pair_name TEXT, + instrument_a TEXT, + instrument_b TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE ohlcv_1min ( + tstamp TEXT, + tstamp_ns INTEGER, + exch_acct TEXT, + instrument_id TEXT, + close REAL + ) + """ + ) + conn.execute( + "INSERT INTO selector_pairs VALUES (?, ?, ?)", + ( + "AAA:USD-BBB:USD", + "EXCH_A:PAIR-AAA-USD", + "EXCH_B:PAIR-BBB-USD", + ), + ) + conn.executemany( + "INSERT INTO ohlcv_1min VALUES (?, ?, ?, ?, ?)", + [ + ("pre", 9, "EXCH_A", "PAIR-AAA-USD", 90.0), + ("t0", 10, "EXCH_A", "PAIR-AAA-USD", 100.0), + ("t1", 11, "EXCH_A", "PAIR-AAA-USD", 110.0), + ("pre", 9, "EXCH_B", "PAIR-BBB-USD", 55.0), + ("t0", 10, "EXCH_B", "PAIR-BBB-USD", 50.0), + ("t1", 11, "EXCH_B", "PAIR-BBB-USD", 45.0), + ("t0", 10, "OTHER", "PAIR-AAA-USD", 999.0), + ], + ) + + market_data = load_pair_market_data( + conn, + "AAA:USD-BBB:USD", + trading_day_start_ns=trading_day_start_ns, + ) + + assert market_data[ + ["asset", "exch_acct", "instrument_id", "close", "initial_close"] + ].to_dict("records") == [ + { + "asset": "AAA", + "exch_acct": "EXCH_A", + "instrument_id": "PAIR-AAA-USD", + "close": 100.0, + "initial_close": 100.0, + }, + { + "asset": "AAA", + "exch_acct": "EXCH_A", + "instrument_id": "PAIR-AAA-USD", + "close": 110.0, + "initial_close": 100.0, + }, + { + "asset": "BBB", + "exch_acct": "EXCH_B", + "instrument_id": "PAIR-BBB-USD", + "close": 50.0, + "initial_close": 50.0, + }, + { + "asset": "BBB", + "exch_acct": "EXCH_B", + "instrument_id": "PAIR-BBB-USD", + "close": 45.0, + "initial_close": 50.0, + }, + ] + assert market_data["time_ns"].tolist() == [10, 11, 10, 11] + assert market_data["relative_close"].tolist() == [ + 0.0, + pytest.approx(0.1), + 0.0, + pytest.approx(-0.1), + ] + + +def test_load_pair_market_data_requires_market_rows_for_both_assets(): + trading_day_start_ns = 1 + conn = sqlite3.connect(":memory:") + conn.execute( + """ + CREATE TABLE selector_pairs ( + pair_name TEXT, + instrument_a TEXT, + instrument_b TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE ohlcv_1min ( + tstamp TEXT, + tstamp_ns INTEGER, + exch_acct TEXT, + instrument_id TEXT, + close REAL + ) + """ + ) + conn.execute( + "INSERT INTO selector_pairs VALUES (?, ?, ?)", + ( + "AAA:USD-BBB:USD", + "EXCH_A:PAIR-AAA-USD", + "EXCH_B:PAIR-BBB-USD", + ), + ) + conn.execute( + "INSERT INTO ohlcv_1min VALUES (?, ?, ?, ?, ?)", + ("t1", 1, "EXCH_A", "PAIR-AAA-USD", 100.0), + ) + + with pytest.raises( + ValueError, + match=r"ohlcv_1min does not contain market data for asset\(s\): BBB", + ): + load_pair_market_data( + conn, + "AAA:USD-BBB:USD", + trading_day_start_ns=trading_day_start_ns, + ) + + +def test_load_pair_market_data_requires_time_zero_close_for_each_asset(): + trading_day_start_ns = 1 + conn = sqlite3.connect(":memory:") + conn.execute( + """ + CREATE TABLE selector_pairs ( + pair_name TEXT, + instrument_a TEXT, + instrument_b TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE ohlcv_1min ( + tstamp TEXT, + tstamp_ns INTEGER, + exch_acct TEXT, + instrument_id TEXT, + close REAL + ) + """ + ) + conn.execute( + "INSERT INTO selector_pairs VALUES (?, ?, ?)", + ( + "AAA:USD-BBB:USD", + "EXCH_A:PAIR-AAA-USD", + "EXCH_B:PAIR-BBB-USD", + ), + ) + conn.executemany( + "INSERT INTO ohlcv_1min VALUES (?, ?, ?, ?, ?)", + [ + ("t1", 1, "EXCH_A", "PAIR-AAA-USD", None), + ("t2", 2, "EXCH_A", "PAIR-AAA-USD", 110.0), + ("t1", 1, "EXCH_B", "PAIR-BBB-USD", 50.0), + ], + ) + + with pytest.raises( + ValueError, + match=r"ohlcv_1min initial close must be positive for asset\(s\): AAA", + ): + load_pair_market_data( + conn, + "AAA:USD-BBB:USD", + trading_day_start_ns=trading_day_start_ns, + ) + + +def test_load_pair_market_data_requires_exact_trading_day_start_row(): + trading_day_start_ns = 10 + conn = sqlite3.connect(":memory:") + conn.execute( + """ + CREATE TABLE selector_pairs ( + pair_name TEXT, + instrument_a TEXT, + instrument_b TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE ohlcv_1min ( + tstamp TEXT, + tstamp_ns INTEGER, + exch_acct TEXT, + instrument_id TEXT, + close REAL + ) + """ + ) + conn.execute( + "INSERT INTO selector_pairs VALUES (?, ?, ?)", + ( + "AAA:USD-BBB:USD", + "EXCH_A:PAIR-AAA-USD", + "EXCH_B:PAIR-BBB-USD", + ), + ) + conn.executemany( + "INSERT INTO ohlcv_1min VALUES (?, ?, ?, ?, ?)", + [ + ("t1", 11, "EXCH_A", "PAIR-AAA-USD", 110.0), + ("t0", 10, "EXCH_B", "PAIR-BBB-USD", 50.0), + ], + ) + + with pytest.raises( + ValueError, + match=( + "ohlcv_1min does not contain trading-day start close for " + r"asset\(s\): AAA" + ), + ): + load_pair_market_data( + conn, + "AAA:USD-BBB:USD", + trading_day_start_ns=trading_day_start_ns, + ) + + +def test_create_pair_trades_market_plot_adds_relative_lines_and_trade_markers(): + market_data = pd.DataFrame( + { + "pair_name": ["AAA:USD-BBB:USD"] * 4, + "asset": ["AAA", "AAA", "BBB", "BBB"], + "time": ["t1", "t2", "t1", "t2"], + "time_ns": [1, 2, 1, 2], + "close": [100.0, 110.0, 50.0, 45.0], + "initial_close": [100.0, 100.0, 50.0, 50.0], + "relative_close": [0.0, 0.1, 0.0, -0.1], + } + ) + theo_executions = pd.DataFrame( + { + "time": ["t1.5", "t2.5"], + "time_ns": [15, 25], + "asset": ["AAA", "BBB"], + "action": ["TARGET", "CLOSE"], + "side": ["BUY", "SELL"], + "size": [2.0, -3.0], + "price": [105.0, 40.0], + } + ) + + figure = create_pair_trades_market_plot( + "AAA:USD-BBB:USD", + market_data, + theo_executions, + ) + + assert [trace.name for trace in figure.data] == [ + "AAA close", + "AAA BUY", + "BBB close", + "BBB SELL", + ] + assert figure.data[0].y.tolist() == [0.0, 0.1] + assert figure.data[1].marker.symbol == "triangle-up" + assert figure.data[1].marker.color == "darkgreen" + assert figure.data[1].x.tolist() == ["t1.5"] + assert figure.data[1].y.tolist() == [pytest.approx(0.05)] + assert figure.data[3].marker.symbol == "triangle-down" + assert figure.data[3].marker.color == "darkred" + assert figure.data[3].x.tolist() == ["t2.5"] + assert figure.data[3].y.tolist() == [pytest.approx(-0.2)] + assert figure.layout.xaxis.range == ("t1", "t2") + + def test_calculate_pair_theo_ret_replaces_targets_and_closes_open_position(): trd_inst_df = pd.DataFrame( { @@ -118,7 +448,297 @@ def test_calculate_pair_theo_ret_replaces_targets_and_closes_open_position(): assert theo_ret == { "pair_name": "AAA:USD-BBB:USD", - "realized_pnl": pytest.approx(24.0), + "num_trades": 6, + "realized_pnl": pytest.approx(0.1), + "unrealized_pnl": 0.0, + } + + +def test_calculate_pair_theo_executions_uses_target_deltas_and_signed_cash(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2, 3, 4], + "tstamp": ["t1", "t2", "t3", "t4"], + "data": [ + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"95"},' + '"BBB":{"reference_price":"55"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.01"},' + '"BBB":{"reference_price":"50","strength":"-0.02"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"110","strength":"0.015"},' + '"BBB":{"reference_price":"45","strength":"-0.01"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"120"},' + '"BBB":{"reference_price":"40"}}}' + ), + ], + } + ) + + executions = calculate_pair_theo_executions("AAA:USD-BBB:USD", trd_inst_df) + + display_columns = [ + "time", + "asset", + "action", + "side", + "strength", + "size", + "price", + "usd_value", + ] + execution_rows = executions[display_columns].to_dict("records") + + assert execution_rows[:4] == [ + { + "time": "t2", + "asset": "AAA", + "action": "TARGET", + "side": "BUY", + "strength": 0.01, + "size": 1.0, + "price": 100.0, + "usd_value": -100.0, + }, + { + "time": "t2", + "asset": "BBB", + "action": "TARGET", + "side": "SELL", + "strength": -0.02, + "size": -4.0, + "price": 50.0, + "usd_value": 200.0, + }, + { + "time": "t3", + "asset": "AAA", + "action": "TARGET", + "side": "BUY", + "strength": 0.015, + "size": pytest.approx(0.36363636363636365), + "price": 110.0, + "usd_value": pytest.approx(-40.0), + }, + { + "time": "t3", + "asset": "BBB", + "action": "TARGET", + "side": "BUY", + "strength": -0.01, + "size": pytest.approx(1.7777777777777777), + "price": 45.0, + "usd_value": pytest.approx(-80.0), + }, + ] + assert execution_rows[4] | {"strength": None} == { + "time": "t4", + "asset": "AAA", + "action": "CLOSE", + "side": "SELL", + "strength": None, + "size": pytest.approx(-1.3636363636363638), + "price": 120.0, + "usd_value": pytest.approx(163.63636363636365), + } + assert execution_rows[5] | {"strength": None} == { + "time": "t4", + "asset": "BBB", + "action": "CLOSE", + "side": "BUY", + "strength": None, + "size": pytest.approx(2.2222222222222223), + "price": 40.0, + "usd_value": pytest.approx(-88.88888888888889), + } + assert executions["strength"].iloc[:4].tolist() == [0.01, -0.02, 0.015, -0.01] + assert executions["strength"].iloc[4:].isna().all() + + +def test_calculate_pair_theo_executions_skips_small_target_strength_changes(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2, 3, 4], + "tstamp": ["t1", "t2", "t3", "t4"], + "data": [ + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.5"},' + '"BBB":{"reference_price":"50","strength":"-0.5"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.53"},' + '"BBB":{"reference_price":"50","strength":"-0.47"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.7"},' + '"BBB":{"reference_price":"50","strength":"-0.7"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100"},' + '"BBB":{"reference_price":"50"}}}' + ), + ], + } + ) + + executions = calculate_pair_theo_executions( + "AAA:USD-BBB:USD", + trd_inst_df, + min_pctg_change=25, + ) + + execution_rows = executions[["time", "asset", "action", "strength", "size"]] + + assert execution_rows.iloc[:4].to_dict("records") == [ + { + "time": "t1", + "asset": "AAA", + "action": "TARGET", + "strength": 0.5, + "size": 50.0, + }, + { + "time": "t1", + "asset": "BBB", + "action": "TARGET", + "strength": -0.5, + "size": -100.0, + }, + { + "time": "t3", + "asset": "AAA", + "action": "TARGET", + "strength": 0.7, + "size": 20.0, + }, + { + "time": "t3", + "asset": "BBB", + "action": "TARGET", + "strength": -0.7, + "size": -40.0, + }, + ] + assert execution_rows.iloc[4].to_dict() | {"strength": None} == { + "time": "t4", + "asset": "AAA", + "action": "CLOSE", + "strength": None, + "size": -70.0, + } + assert execution_rows.iloc[5].to_dict() | {"strength": None} == { + "time": "t4", + "asset": "BBB", + "action": "CLOSE", + "strength": None, + "size": 140.0, + } + + +def test_calculate_pair_theo_executions_trades_threshold_boundary_and_zero_crossing(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2, 3, 4], + "tstamp": ["t1", "t2", "t3", "t4"], + "data": [ + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.4"},' + '"BBB":{"reference_price":"50","strength":"0"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.5"},' + '"BBB":{"reference_price":"50","strength":"0"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.51"},' + '"BBB":{"reference_price":"50","strength":"0.1"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100"},' + '"BBB":{"reference_price":"50"}}}' + ), + ], + } + ) + + executions = calculate_pair_theo_executions( + "AAA:USD-BBB:USD", + trd_inst_df, + min_pctg_change=25, + ) + + assert executions[["time", "asset", "action", "strength", "size"]].iloc[ + :3 + ].to_dict("records") == [ + { + "time": "t1", + "asset": "AAA", + "action": "TARGET", + "strength": 0.4, + "size": 40.0, + }, + { + "time": "t2", + "asset": "AAA", + "action": "TARGET", + "strength": 0.5, + "size": 10.0, + }, + { + "time": "t3", + "asset": "BBB", + "action": "TARGET", + "strength": 0.1, + "size": 20.0, + }, + ] + + +def test_calculate_pair_theo_ret_uses_execution_cash_flows(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2, 3], + "data": [ + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.01"},' + '"BBB":{"reference_price":"50","strength":"-0.02"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"110","strength":"0.015"},' + '"BBB":{"reference_price":"45","strength":"-0.01"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"120"},' + '"BBB":{"reference_price":"40"}}}' + ), + ], + } + ) + + assert calculate_pair_theo_ret("AAA:USD-BBB:USD", trd_inst_df) == { + "pair_name": "AAA:USD-BBB:USD", + "num_trades": 6, + "realized_pnl": pytest.approx(0.5474747474747474), "unrealized_pnl": 0.0, } @@ -144,6 +764,7 @@ def test_calculate_pair_theo_ret_ignores_unmatched_quote_and_close_without_targe assert calculate_pair_theo_ret("AAA:USD-BBB:USD", trd_inst_df) == { "pair_name": "AAA:USD-BBB:USD", + "num_trades": 0, "realized_pnl": 0.0, "unrealized_pnl": 0.0, } @@ -180,20 +801,297 @@ def test_calculate_ranked_pairs_theo_ret_preserves_pairs_without_instructions(): { "pair_name": "AAA:USD-BBB:USD", "mr_ranking": 1, - "realized_pnl": 20.0, + "num_trades": 4, + "realized_pnl": 0.3, "unrealized_pnl": 0.0, }, { "pair_name": "CCC:USD-DDD:USD", "mr_ranking": 2, + "num_trades": 0, "realized_pnl": 0.0, "unrealized_pnl": 0.0, }, ] +def test_calculate_ranked_pairs_theo_ret_applies_min_pctg_change(): + rankings = pd.DataFrame( + { + "pair_name": ["AAA:USD-BBB:USD"], + "pair_rank": pd.Series([1], dtype="Int64"), + } + ) + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2, 3], + "data": [ + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.5"},' + '"BBB":{"reference_price":"50","strength":"-0.5"}}}' + ), + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.53"},' + '"BBB":{"reference_price":"50","strength":"-0.47"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100"},' + '"BBB":{"reference_price":"50"}}}' + ), + ], + } + ) + + result = calculate_ranked_pairs_theo_ret( + rankings, + trd_inst_df, + min_pctg_change=25, + ) + + assert result.to_dict("records") == [ + { + "pair_name": "AAA:USD-BBB:USD", + "mr_ranking": 1, + "num_trades": 4, + "realized_pnl": 0.0, + "unrealized_pnl": 0.0, + } + ] + + +def test_calculate_ranked_pairs_theo_ret_validates_min_pctg_change(): + rankings = pd.DataFrame( + { + "pair_name": ["AAA:USD-BBB:USD"], + "pair_rank": pd.Series([1], dtype="Int64"), + } + ) + trd_inst_df = pd.DataFrame({"time_ns": [], "data": []}) + + with pytest.raises(ValueError, match="min_pctg_change must be non-negative"): + calculate_ranked_pairs_theo_ret( + rankings, + trd_inst_df, + min_pctg_change=-1, + ) + + def test_load_trading_instructions_validates_required_table(): conn = sqlite3.connect(":memory:") with pytest.raises(ValueError, match="missing required table: trading_instructions"): load_trading_instructions(conn) + + +def test_find_repo_root_and_normalize_directory(): + repo_root = find_repo_root(Path("notebooks").resolve()) + + assert repo_root.name == "stat_pairs_backtest" + assert normalize_directory("data", repo_root) == (repo_root / "data").resolve() + + +def test_list_candidate_files_prefers_result_databases(tmp_path): + names = [ + "20260617.spbt_md.db", + "20260617.spbt_results.db", + "20260617.spbt_selector_results.db", + "notes.txt", + ] + for name in names: + (tmp_path / name).write_text("", encoding="utf-8") + + assert [path.name for path in list_candidate_files(tmp_path)] == [ + "20260617.spbt_results.db", + "20260617.spbt_selector_results.db", + "20260617.spbt_md.db", + ] + assert [path.name for path in list_candidate_files(tmp_path, show_all=True)] == [ + "20260617.spbt_results.db", + "20260617.spbt_selector_results.db", + "20260617.spbt_md.db", + "notes.txt", + ] + + +def test_connect_sqlite_read_only_uses_read_only_uri(tmp_path): + db_path = tmp_path / "example name.sqlite" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE sample (value INTEGER)") + conn.execute("INSERT INTO sample (value) VALUES (1)") + conn.commit() + conn.close() + + assert read_only_sqlite_uri(db_path).endswith("?mode=ro") + + read_only_conn = connect_sqlite_read_only(db_path) + try: + assert read_only_conn.execute("SELECT value FROM sample").fetchone() == (1,) + with pytest.raises(sqlite3.OperationalError, match="readonly"): + read_only_conn.execute("INSERT INTO sample (value) VALUES (2)") + finally: + read_only_conn.close() + + +def test_add_total_pnl_and_histogram_builder(): + pair_theo_ret = pd.DataFrame( + { + "pair_name": ["AAA:USD-BBB:USD", "PAIR_B"], + "mr_ranking": [1, 2], + "realized_pnl": [1.5, -0.5], + "unrealized_pnl": [0.25, 0.0], + } + ) + + with_total = add_total_pnl(pair_theo_ret) + histogram = create_total_pnl_histogram(pair_theo_ret) + + assert with_total["total_pnl"].tolist() == [1.75, -0.5] + assert histogram.data[0].type == "histogram" + assert histogram.data[0].x.tolist() == [1.75, -0.5] + assert histogram.data[0].xbins.start is None + assert histogram.data[0].xbins.size is None + + +def test_format_pair_names_for_display_removes_usd_suffix_without_mutating_source(): + pair_theo_ret = pd.DataFrame( + { + "pair_name": [ + "AAA:USD-BBB:USD", + "CCC:EUR-DDD:EUR", + "AAA:USDT-BBB:USDT", + None, + ], + "realized_pnl": [1.0, 2.0, 3.0, 4.0], + } + ) + + formatted = format_pair_names_for_display(pair_theo_ret) + + assert format_pair_name_for_display("AAA:USD-BBB:USD") == "AAA-BBB" + assert formatted["pair_name"].tolist() == [ + "AAA-BBB", + "CCC:EUR-DDD:EUR", + "AAA:USDT-BBB:USDT", + None, + ] + assert pair_theo_ret["pair_name"].tolist() == [ + "AAA:USD-BBB:USD", + "CCC:EUR-DDD:EUR", + "AAA:USDT-BBB:USDT", + None, + ] + + +def test_show_interactive_dataframe_uses_sortable_grid_defaults(monkeypatch): + calls = [] + + def fake_show(dataframe, **kwargs): + calls.append((dataframe, kwargs)) + + import itables + + monkeypatch.setattr(itables, "show", fake_show) + dataframe = pd.DataFrame({"pair_name": ["AAA-BBB"], "num_trades": [2]}) + + show_interactive_dataframe( + dataframe, + table_id="pair-theo-ret-grid", + pageLength=50, + ) + + assert len(calls) == 1 + assert calls[0][0] is dataframe + assert calls[0][1] == { + "paging": True, + "pageLength": 50, + "scrollX": True, + "ordering": True, + "showIndex": False, + "maxBytes": "8MB", + "classes": "display compact stripe hover", + "css": calls[0][1]["css"], + "table_id": "pair-theo-ret-grid", + } + assert "background-color: #ffffff" in calls[0][1]["css"] + assert "color: #000000" in calls[0][1]["css"] + + +def test_sorted_pair_names_and_dropdown_use_alphabetical_unique_pairs(): + selector_pair_rankings = pd.DataFrame( + { + "pair_name": [ + "BTC:USD-ETH:USD", + "ADA:USD-BTC:USD", + "BTC:USD-ETH:USD", + ] + } + ) + + assert sorted_pair_names(selector_pair_rankings) == [ + "ADA:USD-BTC:USD", + "BTC:USD-ETH:USD", + ] + + dropdown = create_pair_name_dropdown(selector_pair_rankings) + + assert dropdown.options == ( + ("ADA-BTC", "ADA:USD-BTC:USD"), + ("BTC-ETH", "BTC:USD-ETH:USD"), + ) + assert dropdown.value == "ADA:USD-BTC:USD" + + +def test_example_database_pair_theo_executions_include_strength(): + db_path = Path("data/20260617.spbt_results.db") + if not db_path.exists(): + pytest.skip(f"example database not available: {db_path}") + + conn = connect_sqlite_read_only(db_path) + try: + rankings = load_selector_pair_rankings(conn) + trading_instructions = load_trading_instructions(conn) + finally: + conn.close() + + empty_pair_executions = calculate_pair_theo_executions( + "ADA:USD-BNB:USD", + trading_instructions, + ) + non_empty_pair_executions = calculate_pair_theo_executions( + "ADA:USD-BTC:USD", + trading_instructions, + ) + + assert "strength" in empty_pair_executions.columns + assert "strength" in non_empty_pair_executions.columns + assert len(rankings) == 78 + assert len(non_empty_pair_executions) > 0 + assert ( + non_empty_pair_executions.loc[ + non_empty_pair_executions["action"] == "TARGET", + "strength", + ] + .notna() + .all() + ) + assert ( + non_empty_pair_executions.loc[ + non_empty_pair_executions["action"] == "CLOSE", + "strength", + ] + .isna() + .all() + ) + + first_target_execution = non_empty_pair_executions[ + non_empty_pair_executions["action"] == "TARGET" + ].iloc[0] + assert first_target_execution["size"] == pytest.approx( + 10_000 * first_target_execution["strength"] / first_target_execution["price"] + ) + assert first_target_execution["usd_value"] == pytest.approx( + -first_target_execution["size"] * first_target_execution["price"] + )