notbebooks initial

This commit is contained in:
Oleg Sheynin
2026-07-28 00:50:32 +00:00
parent 1d1ebd385e
commit 400bd41e56
6 changed files with 867 additions and 1 deletions
+2
View File
@@ -18,5 +18,7 @@ data/*
results/* results/*
!results/.gitkeep !results/.gitkeep
data
cvttpy cvttpy
tmp/ tmp/
-1
View File
@@ -1 +0,0 @@
+340
View File
@@ -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
View File
@@ -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
+325
View File
@@ -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)
)
+199
View File
@@ -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)