notbebooks initial
This commit is contained in:
@@ -18,5 +18,7 @@ data/*
|
|||||||
results/*
|
results/*
|
||||||
!results/.gitkeep
|
!results/.gitkeep
|
||||||
|
|
||||||
|
data
|
||||||
|
|
||||||
cvttpy
|
cvttpy
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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=\"<b>Selected database:</b> 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\"<b>Selected database:</b> {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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Interactive analysis
|
# Interactive analysis
|
||||||
ipykernel>=6.29,<7
|
ipykernel>=6.29,<7
|
||||||
|
ipywidgets>=8.1,<9
|
||||||
jupyter>=1.1,<2
|
jupyter>=1.1,<2
|
||||||
nbformat>=5.10,<6
|
nbformat>=5.10,<6
|
||||||
pandas>=2.2,<3
|
pandas>=2.2,<3
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user