From 400bd41e56f1ecb186c8a277bea9b79b03e7737e Mon Sep 17 00:00:00 2001 From: Oleg Sheynin Date: Tue, 28 Jul 2026 00:50:32 +0000 Subject: [PATCH] notbebooks initial --- .gitignore | 2 + data/.gitkeep | 1 - notebooks/spbt_day.ipynb | 340 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + scripts/spbt_day.py | 325 +++++++++++++++++++++++++++++++++++++ tests/test_spbt_day.py | 199 +++++++++++++++++++++++ 6 files changed, 867 insertions(+), 1 deletion(-) delete mode 100644 data/.gitkeep create mode 100644 notebooks/spbt_day.ipynb create mode 100644 scripts/spbt_day.py create mode 100644 tests/test_spbt_day.py diff --git a/.gitignore b/.gitignore index 72099d0..0187857 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,7 @@ data/* results/* !results/.gitkeep +data + cvttpy tmp/ diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/data/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/notebooks/spbt_day.ipynb b/notebooks/spbt_day.ipynb new file mode 100644 index 0000000..4026182 --- /dev/null +++ b/notebooks/spbt_day.ipynb @@ -0,0 +1,340 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "single-day-title", + "metadata": {}, + "source": [ + "# Single-Day Backtest Result Analysis\n", + "\n", + "This notebook analyzes the result of one single-day backtest stored in a SQLite database. Development is staged; Step 1 only selects the database file that later sections will read.\n", + "\n", + "Input assumptions for Step 1:\n", + "\n", + "- The default data directory is `data/` at the repository root.\n", + "- SQLite result files usually use `.db`, `.sqlite`, or `.sqlite3` extensions.\n", + "- The directory can be changed interactively if the result file lives elsewhere." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports-and-paths", + "metadata": {}, + "outputs": [], + "source": [ + "from html import escape\n", + "from pathlib import Path\n", + "import sqlite3\n", + "import sys\n", + "from urllib.parse import quote\n", + "\n", + "from IPython.display import display\n", + "import ipywidgets as widgets\n", + "\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", + "\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" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "database-file-selector", + "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", + ")\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()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "selected-database-helpers", + "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", + "\n", + "# Later notebook sections can call selected_database_path() or connect_selected_database()." + ] + }, + { + "cell_type": "markdown", + "id": "selector-pair-rankings-context", + "metadata": {}, + "source": [ + "## Selector Pair Rankings\n", + "\n", + "Load `selector_pairs.pair_name` and `selector_pairs.mr_score` from the selected SQLite database. The JSON field `mr_score.final` is parsed as a numeric score and ranked descending with dense ranks, so tied scores share the same rank and the next distinct score gets the next rank.\n", + "\n", + "Rows with missing, malformed, non-numeric, or non-finite `mr_score.final` values are preserved, sorted after ranked rows, and marked in `mr_score_parse_status`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-selector-pair-rankings", + "metadata": {}, + "outputs": [], + "source": [ + "conn = connect_selected_database()\n", + "try:\n", + " selector_pair_rankings = load_selector_pair_rankings(conn)\n", + "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", + ")\n", + "\n", + "selector_pair_ranking_summary" + ] + }, + { + "cell_type": "markdown", + "id": "theoretical-return-context", + "metadata": {}, + "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", + "\n", + "`realized_pnl` and `unrealized_pnl` are percentage returns relative to `$10,000`, sorted by ascending MR rank." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-trading-instructions", + "metadata": {}, + "outputs": [], + "source": [ + "conn = connect_selected_database()\n", + "try:\n", + " trading_instructions = load_trading_instructions(conn)\n", + "finally:\n", + " conn.close()\n", + "\n", + "trading_instructions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "calculate-pair-theoretical-returns", + "metadata": {}, + "outputs": [], + "source": [ + "pair_theo_ret = calculate_ranked_pairs_theo_ret(\n", + " selector_pair_rankings,\n", + " trading_instructions,\n", + ")\n", + "\n", + "pair_theo_ret" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "python3.12-venv (3.12.13.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/requirements.txt b/requirements.txt index 1870b2c..d19d9d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # Interactive analysis ipykernel>=6.29,<7 +ipywidgets>=8.1,<9 jupyter>=1.1,<2 nbformat>=5.10,<6 pandas>=2.2,<3 diff --git a/scripts/spbt_day.py b/scripts/spbt_day.py new file mode 100644 index 0000000..bd5082e --- /dev/null +++ b/scripts/spbt_day.py @@ -0,0 +1,325 @@ +"""Helpers for single-day SPBT result analysis notebooks.""" + +from __future__ import annotations + +import json +import math +import sqlite3 +from typing import Any + +import pandas as pd + + +SELECTOR_PAIRS_COLUMNS = ("pair_name", "mr_score") +TRADING_INSTRUCTIONS_COLUMNS = ("time_ns", "tstamp", "data") +INITIAL_THEO_CAPITAL_USD = 10_000.0 + + +def parse_mr_score_final(raw_score: Any) -> tuple[float | None, str]: + """Parse the JSON mr_score.final value, preserving parse status.""" + if raw_score is None: + return None, "missing_mr_score" + + try: + parsed = json.loads(raw_score) + except (TypeError, json.JSONDecodeError): + return None, "malformed_json" + + if not isinstance(parsed, dict): + return None, "unexpected_json_type" + + if "final" not in parsed or parsed["final"] in (None, ""): + return None, "missing_final" + + final_value = parsed["final"] + if isinstance(final_value, bool): + return None, "non_numeric_final" + + try: + numeric_final = float(final_value) + except (TypeError, ValueError): + return None, "non_numeric_final" + + if not math.isfinite(numeric_final): + return None, "non_finite_final" + + return numeric_final, "ok" + + +def validate_selector_pairs_table(conn: sqlite3.Connection) -> None: + """Raise an actionable error if selector_pairs lacks required columns.""" + 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_PAIRS_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) + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError(f"selector_pairs dataframe is missing column(s): {missing}") + + ranked = selector_pairs.loc[:, list(SELECTOR_PAIRS_COLUMNS)].copy() + parsed_scores = ranked["mr_score"].map(parse_mr_score_final) + ranked["mr_score_final"] = [score for score, _status in parsed_scores] + ranked["mr_score_parse_status"] = [status for _score, status in parsed_scores] + + ranked["pair_rank"] = ( + ranked["mr_score_final"].rank(method="dense", ascending=False).astype("Int64") + ) + + return ( + ranked.loc[ + :, + [ + "pair_rank", + "pair_name", + "mr_score_final", + "mr_score_parse_status", + "mr_score", + ], + ] + .sort_values(["pair_rank", "pair_name"], na_position="last", kind="mergesort") + .reset_index(drop=True) + ) + + +def load_selector_pair_rankings(conn: sqlite3.Connection) -> pd.DataFrame: + """Load selector_pairs from SQLite and return dense-ranked pair rows.""" + validate_selector_pairs_table(conn) + selector_pairs = pd.read_sql_query( + "SELECT pair_name, mr_score FROM selector_pairs", + conn, + ) + return rank_selector_pairs(selector_pairs) + + +def validate_trading_instructions_table(conn: sqlite3.Connection) -> None: + """Raise an actionable error if trading_instructions lacks required columns.""" + table_info = conn.execute("PRAGMA table_info(trading_instructions)").fetchall() + if not table_info: + raise ValueError("SQLite database is missing required table: trading_instructions") + + existing_columns = {row[1] for row in table_info} + missing_columns = set(TRADING_INSTRUCTIONS_COLUMNS) - existing_columns + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError( + f"trading_instructions is missing required column(s): {missing}" + ) + + +def load_trading_instructions(conn: sqlite3.Connection) -> pd.DataFrame: + """Load the full trading_instructions table ordered by timestamp.""" + validate_trading_instructions_table(conn) + return pd.read_sql_query( + "SELECT * FROM trading_instructions ORDER BY time_ns, rowid", + conn, + ) + + +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("-") + if len(pair_legs) != 2: + raise ValueError(f"Pair name must contain exactly two legs: {pair_name}") + + assets: list[str] = [] + quote_assets: list[str] = [] + for leg in pair_legs: + parts = leg.split(":") + if len(parts) != 2 or not all(parts): + raise ValueError(f"Pair leg must use ASSET:QUOTE form: {leg}") + assets.append(parts[0]) + quote_assets.append(parts[1]) + + distinct_quote_assets = set(quote_assets) + if len(distinct_quote_assets) != 1: + raise ValueError(f"Pair legs must use the same quote asset: {pair_name}") + + return tuple(assets), quote_assets[0] + + +def _parse_instruction_data(raw_data: Any) -> dict[str, Any] | None: + if raw_data is None: + return None + + try: + parsed = json.loads(raw_data) + except (TypeError, json.JSONDecodeError): + return None + + return parsed if isinstance(parsed, dict) else None + + +def _finite_float(value: Any, field_name: str, pair_name: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{field_name} for {pair_name} must be numeric, got bool") + + try: + numeric_value = float(value) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{field_name} for {pair_name} must be numeric, got {value!r}" + ) from exc + + if not math.isfinite(numeric_value): + raise ValueError(f"{field_name} for {pair_name} must be finite") + + return numeric_value + + +def _reference_price(asset_data: Any, asset: str, pair_name: str) -> float: + if not isinstance(asset_data, dict): + raise ValueError(f"Asset data for {asset} in {pair_name} must be a JSON object") + + reference_price = _finite_float( + asset_data.get("reference_price"), + f"reference_price[{asset}]", + pair_name, + ) + if reference_price <= 0: + raise ValueError(f"reference_price[{asset}] for {pair_name} must be positive") + return reference_price + + +def _strength(asset_data: Any, asset: str, pair_name: str) -> float: + if not isinstance(asset_data, dict): + raise ValueError(f"Asset data for {asset} in {pair_name} must be a JSON object") + return _finite_float(asset_data.get("strength"), f"strength[{asset}]", pair_name) + + +def _sort_trading_instructions(trd_inst_df: pd.DataFrame) -> pd.DataFrame: + sort_columns = [column for column in ("time_ns", "tstamp") if column in trd_inst_df] + ordered = trd_inst_df.copy() + ordered["_input_order"] = range(len(ordered)) + return ordered.sort_values( + [*sort_columns, "_input_order"], + kind="mergesort", + ).drop(columns="_input_order") + + +def _matching_pair_instructions( + 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) + + if "data" not in trd_inst_df.columns: + 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"]: + parsed = _parse_instruction_data(raw_data) + if parsed is None or parsed.get("quote_asset") != quote_asset: + continue + + assets = parsed.get("assets") + if not isinstance(assets, dict) or set(assets) != pair_asset_set: + continue + + selected_instructions.append(parsed) + + return selected_instructions + + +def calculate_pair_theo_ret( + pair_name: str, + trd_inst_df: pd.DataFrame, +) -> dict[str, float | str]: + """Calculate realized and unrealized TheoRet percentages for one 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. + """ + pair_assets, _quote_asset = pair_assets_and_quote(pair_name) + realized_pnl_usd = 0.0 + open_position: dict[str, dict[str, float]] | None = None + + for instruction in _matching_pair_instructions(pair_name, trd_inst_df): + action = instruction.get("action") + assets_data = instruction["assets"] + + if action == "TARGET": + open_position = {} + for asset in pair_assets: + asset_data = assets_data[asset] + quantity = INITIAL_THEO_CAPITAL_USD * _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, + } + elif action == "CLOSE": + if open_position is None: + 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 + + 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() + ) + + return { + "pair_name": pair_name, + "realized_pnl": realized_pnl_usd / INITIAL_THEO_CAPITAL_USD * 100.0, + "unrealized_pnl": unrealized_pnl_usd / INITIAL_THEO_CAPITAL_USD * 100.0, + } + + +def calculate_ranked_pairs_theo_ret( + selector_pair_rankings: pd.DataFrame, + trd_inst_df: pd.DataFrame, +) -> pd.DataFrame: + """Calculate TheoRet percentages for every ranked selector pair.""" + required_columns = {"pair_name", "pair_rank"} + missing_columns = required_columns - set(selector_pair_rankings.columns) + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError(f"selector pair rankings missing column(s): {missing}") + + records = [] + for row in selector_pair_rankings.itertuples(index=False): + pair_result = calculate_pair_theo_ret(row.pair_name, trd_inst_df) + records.append( + { + "pair_name": pair_result["pair_name"], + "mr_ranking": row.pair_rank, + "realized_pnl": pair_result["realized_pnl"], + "unrealized_pnl": pair_result["unrealized_pnl"], + } + ) + + return ( + pd.DataFrame.from_records( + records, + columns=["pair_name", "mr_ranking", "realized_pnl", "unrealized_pnl"], + ) + .sort_values(["mr_ranking", "pair_name"], na_position="last", kind="mergesort") + .reset_index(drop=True) + ) diff --git a/tests/test_spbt_day.py b/tests/test_spbt_day.py new file mode 100644 index 0000000..84a28c0 --- /dev/null +++ b/tests/test_spbt_day.py @@ -0,0 +1,199 @@ +import sqlite3 + +import pandas as pd +import pytest + +from scripts.spbt_day import ( + calculate_pair_theo_ret, + calculate_ranked_pairs_theo_ret, + load_selector_pair_rankings, + load_trading_instructions, + pair_assets_and_quote, + parse_mr_score_final, + rank_selector_pairs, +) + + +@pytest.mark.parametrize( + ("raw_score", "expected_score", "expected_status"), + [ + ('{"final":"0.75"}', 0.75, "ok"), + ('{"final":0.5}', 0.5, "ok"), + (None, None, "missing_mr_score"), + ("not-json", None, "malformed_json"), + ("[]", None, "unexpected_json_type"), + ('{"other": "0.1"}', None, "missing_final"), + ('{"final": ""}', None, "missing_final"), + ('{"final": true}', None, "non_numeric_final"), + ('{"final": "abc"}', None, "non_numeric_final"), + ('{"final": "NaN"}', None, "non_finite_final"), + ], +) +def test_parse_mr_score_final(raw_score, expected_score, expected_status): + assert parse_mr_score_final(raw_score) == (expected_score, expected_status) + + +def test_rank_selector_pairs_uses_dense_descending_rank_and_preserves_bad_rows(): + selector_pairs = pd.DataFrame( + { + "pair_name": ["PAIR_C", "PAIR_A", "PAIR_B", "PAIR_BAD"], + "mr_score": [ + '{"final":"0.7"}', + '{"final":"0.9"}', + '{"final":"0.7"}', + '{"final":"bad"}', + ], + } + ) + + ranked = rank_selector_pairs(selector_pairs) + + assert ranked["pair_name"].tolist() == ["PAIR_A", "PAIR_B", "PAIR_C", "PAIR_BAD"] + assert ranked["pair_rank"].iloc[:3].tolist() == [1, 2, 2] + assert pd.isna(ranked["pair_rank"].iloc[3]) + assert ranked["mr_score_parse_status"].tolist() == [ + "ok", + "ok", + "ok", + "non_numeric_final", + ] + + +def test_load_selector_pair_rankings_validates_required_table(): + conn = sqlite3.connect(":memory:") + + with pytest.raises(ValueError, match="missing required table: selector_pairs"): + load_selector_pair_rankings(conn) + + +def test_load_selector_pair_rankings_reads_sqlite_table(): + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE selector_pairs (pair_name TEXT, mr_score TEXT)") + conn.executemany( + "INSERT INTO selector_pairs (pair_name, mr_score) VALUES (?, ?)", + [ + ("PAIR_A", '{"final":"0.1"}'), + ("PAIR_B", '{"final":"0.2"}'), + ], + ) + + ranked = load_selector_pair_rankings(conn) + + assert ranked[["pair_rank", "pair_name", "mr_score_final"]].to_dict("records") == [ + {"pair_rank": 1, "pair_name": "PAIR_B", "mr_score_final": 0.2}, + {"pair_rank": 2, "pair_name": "PAIR_A", "mr_score_final": 0.1}, + ] + + +def test_pair_assets_and_quote_parses_two_leg_pair(): + assert pair_assets_and_quote("ADA:USD-BTC:USD") == (("ADA", "BTC"), "USD") + + +def test_calculate_pair_theo_ret_replaces_targets_and_closes_open_position(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2, 3], + "tstamp": ["t1", "t2", "t3"], + "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":"120","strength":"0.01"},' + '"BBB":{"reference_price":"60","strength":"-0.02"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"132"},' + '"BBB":{"reference_price":"54"}}}' + ), + ], + } + ) + + theo_ret = calculate_pair_theo_ret("AAA:USD-BBB:USD", trd_inst_df) + + assert theo_ret == { + "pair_name": "AAA:USD-BBB:USD", + "realized_pnl": pytest.approx(24.0), + "unrealized_pnl": 0.0, + } + + +def test_calculate_pair_theo_ret_ignores_unmatched_quote_and_close_without_target(): + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2], + "data": [ + ( + '{"action":"TARGET","quote_asset":"EUR","assets":' + '{"AAA":{"reference_price":"100","strength":"0.01"},' + '"BBB":{"reference_price":"50","strength":"-0.02"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"110"},' + '"BBB":{"reference_price":"45"}}}' + ), + ], + } + ) + + assert calculate_pair_theo_ret("AAA:USD-BBB:USD", trd_inst_df) == { + "pair_name": "AAA:USD-BBB:USD", + "realized_pnl": 0.0, + "unrealized_pnl": 0.0, + } + + +def test_calculate_ranked_pairs_theo_ret_preserves_pairs_without_instructions(): + rankings = pd.DataFrame( + { + "pair_name": ["AAA:USD-BBB:USD", "CCC:USD-DDD:USD"], + "pair_rank": pd.Series([1, 2], dtype="Int64"), + } + ) + trd_inst_df = pd.DataFrame( + { + "time_ns": [1, 2], + "data": [ + ( + '{"action":"TARGET","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"100","strength":"0.01"},' + '"BBB":{"reference_price":"50","strength":"-0.02"}}}' + ), + ( + '{"action":"CLOSE","quote_asset":"USD","assets":' + '{"AAA":{"reference_price":"110"},' + '"BBB":{"reference_price":"45"}}}' + ), + ], + } + ) + + result = calculate_ranked_pairs_theo_ret(rankings, trd_inst_df) + + assert result.to_dict("records") == [ + { + "pair_name": "AAA:USD-BBB:USD", + "mr_ranking": 1, + "realized_pnl": 20.0, + "unrealized_pnl": 0.0, + }, + { + "pair_name": "CCC:USD-DDD:USD", + "mr_ranking": 2, + "realized_pnl": 0.0, + "unrealized_pnl": 0.0, + }, + ] + + +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)