"""Helpers for single-day SPBT result analysis notebooks."""
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"
PAIR_NAME_VALUE_COLUMN = "_pair_name_value"
ANALYZE_BUTTON_COLUMN = "analyze"
ANALYZE_BUTTON_HTML = (
''
)
SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS = [
"time",
"asset",
"action",
"side",
"strength",
"size",
"price",
"usd_value",
]
INTERACTIVE_TABLE_CSS = """
table.dataTable,
table.dataTable th,
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]:
"""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 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)
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 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("-")
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]]:
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)
if "data" not in trd_inst_df.columns:
raise ValueError("trading instructions dataframe is missing column: data")
selected_instructions: list[dict[str, Any]] = []
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
assets = parsed.get("assets")
if not isinstance(assets, dict) or set(assets) != pair_asset_set:
continue
selected_instructions.append(
{
"time_ns": getattr(instruction_row, "time_ns", None),
"tstamp": getattr(instruction_row, "tstamp", None),
"data": parsed,
}
)
return selected_instructions
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,
min_pctg_change: float = 0,
) -> pd.DataFrame:
"""Create asset-level theoretical executions for one selected pair.
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)
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_instruction_rows(pair_name, trd_inst_df):
action = instruction["data"].get("action")
assets_data = instruction["data"]["assets"]
if action == "TARGET":
for asset in pair_assets:
asset_data = assets_data[asset]
target_strength = _strength(
asset_data,
asset,
pair_name,
)
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 not any(current_sizes.values()):
continue
for asset in pair_assets:
current_size = current_sizes[asset]
if current_size == 0:
continue
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:
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,
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"],
}
)
return (
pd.DataFrame.from_records(
records,
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 format_pair_theo_ret_for_analyze_grid(pair_theo_ret: pd.DataFrame) -> pd.DataFrame:
"""Format pair TheoRet rows for an Analyze-button grid.
The visible pair name is display-cleaned, while the original pair name is
retained in a hidden column for callbacks that need the calculation key.
"""
if "pair_name" not in pair_theo_ret.columns:
raise ValueError("pair TheoRet dataframe missing column: pair_name")
formatted = format_pair_names_for_display(pair_theo_ret)
formatted[PAIR_NAME_VALUE_COLUMN] = pair_theo_ret["pair_name"].to_numpy()
return formatted
def pair_name_from_analyze_event(table: Any, event: Any) -> str:
"""Resolve the full pair name from a Panel Tabulator click event."""
if PAIR_NAME_VALUE_COLUMN not in table.value.columns:
raise ValueError(f"Analyze table missing column: {PAIR_NAME_VALUE_COLUMN}")
pair_name = table.value[PAIR_NAME_VALUE_COLUMN].iloc[event.row]
if pd.isna(pair_name) or not str(pair_name):
raise ValueError(f"Analyze row {event.row} does not contain a pair name")
return str(pair_name)
def create_pair_theo_ret_analyze_grid(
pair_theo_ret_display: pd.DataFrame,
*,
height: int = 420,
) -> Any:
"""Create a sortable Panel grid with a compact Analyze button column."""
import panel as pn
return pn.widgets.Tabulator(
pair_theo_ret_display,
buttons={ANALYZE_BUTTON_COLUMN: ANALYZE_BUTTON_HTML},
hidden_columns=[PAIR_NAME_VALUE_COLUMN],
show_index=False,
pagination=None,
layout="fit_data_table",
height=height,
selectable=False,
)
def create_selected_pair_executions_grid(
dataframe: pd.DataFrame | None = None,
*,
height: int = 320,
) -> Any:
"""Create a sortable Panel grid for selected-pair theoretical executions."""
import panel as pn
source = (
dataframe
if dataframe is not None
else pd.DataFrame(columns=SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS)
)
return pn.widgets.Tabulator(
source.reindex(columns=SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS),
show_index=False,
pagination=None,
layout="fit_data_table",
height=height,
selectable=False,
)
def add_total_pnl(pair_theo_ret: pd.DataFrame) -> pd.DataFrame:
"""Return a copy of pair TheoRet rows with total realized plus unrealized PnL."""
required_columns = {"realized_pnl", "unrealized_pnl"}
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"},
)