VIOLET OA: base-fraction sizing study\n\nAdd the read-only CH-backed base-fraction study script plus the final archived report.
This commit is contained in:
771023
prod/VIOLET_dev/reports/base_fraction_study_20260616_212308.json
Normal file
771023
prod/VIOLET_dev/reports/base_fraction_study_20260616_212308.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
# VIOLET base-fraction sizing study
|
||||
|
||||
Generated: `2026-06-16T21:23:08.644826+00:00`
|
||||
|
||||
## Bottom line
|
||||
- Recommended fraction: `0.333` (best Calmar among zero-clip fractions)
|
||||
- Base 0.20 final capital: `483963.79`; recommended final capital: `1308199.81`
|
||||
- Base 0.20 maxDD: `20.18%`; recommended maxDD: `33.36%`
|
||||
- Recommended clip rate: `0.00%`; unconstrained optimizer: `0.500` with `40.11%` clipped at the 3x ceiling.
|
||||
|
||||
## Caveat
|
||||
- Direct non-null slippage telemetry was not available in `trade_execution_quality`; the study uses a conservative taker-heavy impact proxy.
|
||||
|
||||
## Cap binding
|
||||
- The recommended fraction hits the 3x translator cap on `0.00%` of trades.
|
||||
|
||||
## Kelly anchor
|
||||
- Empirical Kelly anchor: `0.500`; fractional anchor: `0.200`
|
||||
|
||||
## Files
|
||||
- JSON: `/mnt/dolphinng5_predict/prod/VIOLET_dev/reports/base_fraction_study_20260616_212308.json`
|
||||
- Markdown: `/mnt/dolphinng5_predict/prod/VIOLET_dev/reports/base_fraction_study_20260616_212308.md`
|
||||
866
prod/VIOLET_dev/studies/base_fraction_study.py
Normal file
866
prod/VIOLET_dev/studies/base_fraction_study.py
Normal file
@@ -0,0 +1,866 @@
|
||||
#!/usr/bin/env python3
|
||||
"""VIOLET base-fraction sizing study.
|
||||
|
||||
Read-only analysis against recorded CH trade data. Produces:
|
||||
- a machine-readable JSON report under prod/VIOLET_dev/reports/
|
||||
- a short markdown findings note alongside it
|
||||
|
||||
The study is scoped by prod/docs/VIOLET_STUDY_SPEC__BASE_FRACTION_SIZING.md.
|
||||
It does not modify production code or write to production tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import csv
|
||||
import dataclasses
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
PROJECT_ROOT = Path("/mnt/dolphinng5_predict")
|
||||
REPORTS_DIR = PROJECT_ROOT / "prod" / "VIOLET_dev" / "reports"
|
||||
CH_URL = "http://localhost:8123/"
|
||||
CH_USER = "dolphin"
|
||||
CH_KEY = "dolphin_ch_2026"
|
||||
BASE_FRACTION_F0 = 0.20
|
||||
TRANSLATOR_CAP = 3.0
|
||||
BASE_GRID = np.array(
|
||||
[0.20, 0.22, 0.24, 0.25, 0.26, 0.28, 0.30, 0.32, 0.333, 0.34, 0.36, 0.38, 0.40, 0.45, 0.50],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TradeRow:
|
||||
trade_id: str
|
||||
ts: datetime
|
||||
asset: str
|
||||
side: str
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
quantity: float
|
||||
pnl: float
|
||||
pnl_pct: float
|
||||
exit_reason: str
|
||||
leverage: float
|
||||
capital_before: float
|
||||
capital_after: float
|
||||
bars_held: int
|
||||
regime_signal: int
|
||||
vel_div_entry: float
|
||||
boost_at_entry: float
|
||||
beta_at_entry: float
|
||||
posture: str
|
||||
our_leverage: float
|
||||
composite_hash: int | None = None
|
||||
scalar_hash: int | None = None
|
||||
regime: str | None = None
|
||||
fingerprint_confidence: float | None = None
|
||||
fingerprint_vel_div: float | None = None
|
||||
fingerprint_dvol: float | None = None
|
||||
notional_quote: float | None = None
|
||||
fill_quality_score: float | None = None
|
||||
fill_quality_class: str | None = None
|
||||
fill_rows: int = 0
|
||||
taker_fill_rows: int = 0
|
||||
maker_fill_rows: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FingerprintRow:
|
||||
ts: datetime
|
||||
regime: str
|
||||
composite_hash: int
|
||||
scalar_hash: int
|
||||
confidence: float
|
||||
raw_vel_div: float
|
||||
raw_dvol: float
|
||||
final_score: float
|
||||
|
||||
|
||||
def _query_tsv(sql: str) -> list[dict[str, str]]:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
CH_URL,
|
||||
data=sql.encode(),
|
||||
headers={"X-ClickHouse-User": CH_USER, "X-ClickHouse-Key": CH_KEY},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
text = resp.read().decode()
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return []
|
||||
reader = csv.DictReader(lines, delimiter="\t")
|
||||
return list(reader)
|
||||
|
||||
|
||||
def _fmt_ts(dt: datetime) -> str:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
|
||||
|
||||
def _parse_dt(s: str) -> datetime:
|
||||
if isinstance(s, datetime):
|
||||
return s
|
||||
s = s.strip()
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(s).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
# ClickHouse DateTime64 TSV may arrive without timezone suffix.
|
||||
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S.%f").replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _parse_float(v: str | None, default: float = 0.0) -> float:
|
||||
if v is None or v == "" or v == "\\N":
|
||||
return float(default)
|
||||
return float(v)
|
||||
|
||||
|
||||
def _parse_int(v: str | None, default: int = 0) -> int:
|
||||
if v is None or v == "" or v == "\\N":
|
||||
return int(default)
|
||||
return int(float(v))
|
||||
|
||||
|
||||
def load_clean_trades() -> list[TradeRow]:
|
||||
sql = """
|
||||
WITH dedup AS (
|
||||
SELECT
|
||||
trade_id,
|
||||
max(event_ts) AS ts,
|
||||
argMax(asset, event_ts) AS asset,
|
||||
argMax(side, event_ts) AS side,
|
||||
argMax(entry_price, event_ts) AS entry_price,
|
||||
argMax(exit_price, event_ts) AS exit_price,
|
||||
argMax(quantity, event_ts) AS quantity,
|
||||
argMax(pnl, event_ts) AS pnl,
|
||||
argMax(pnl_pct, event_ts) AS pnl_pct,
|
||||
argMax(exit_reason, event_ts) AS exit_reason,
|
||||
argMax(leverage, event_ts) AS leverage,
|
||||
argMax(capital_before, event_ts) AS capital_before,
|
||||
argMax(capital_after, event_ts) AS capital_after,
|
||||
argMax(bars_held, event_ts) AS bars_held,
|
||||
argMax(regime_signal, event_ts) AS regime_signal,
|
||||
argMax(vel_div_entry, event_ts) AS vel_div_entry,
|
||||
argMax(boost_at_entry, event_ts) AS boost_at_entry,
|
||||
argMax(beta_at_entry, event_ts) AS beta_at_entry,
|
||||
argMax(posture, event_ts) AS posture,
|
||||
argMax(our_leverage, event_ts) AS our_leverage
|
||||
FROM (
|
||||
SELECT
|
||||
trade_id,
|
||||
ts AS event_ts,
|
||||
asset,
|
||||
side,
|
||||
entry_price,
|
||||
exit_price,
|
||||
quantity,
|
||||
pnl,
|
||||
pnl_pct,
|
||||
exit_reason,
|
||||
leverage,
|
||||
capital_before,
|
||||
capital_after,
|
||||
bars_held,
|
||||
regime_signal,
|
||||
vel_div_entry,
|
||||
boost_at_entry,
|
||||
beta_at_entry,
|
||||
posture,
|
||||
our_leverage
|
||||
FROM dolphin.trade_events
|
||||
)
|
||||
GROUP BY trade_id
|
||||
)
|
||||
SELECT
|
||||
trade_id, ts, asset, side, entry_price, exit_price, quantity, pnl, pnl_pct, exit_reason,
|
||||
leverage, capital_before, capital_after, bars_held, regime_signal, vel_div_entry,
|
||||
boost_at_entry, beta_at_entry, posture, our_leverage
|
||||
FROM dedup
|
||||
WHERE exit_reason != 'HIBERNATE_HALT' AND bars_held > 0
|
||||
ORDER BY ts
|
||||
FORMAT TSVWithNames
|
||||
"""
|
||||
rows = _query_tsv(sql)
|
||||
out: list[TradeRow] = []
|
||||
for row in rows:
|
||||
out.append(
|
||||
TradeRow(
|
||||
trade_id=row["trade_id"],
|
||||
ts=_parse_dt(row["ts"]),
|
||||
asset=row["asset"],
|
||||
side=row["side"],
|
||||
entry_price=_parse_float(row["entry_price"]),
|
||||
exit_price=_parse_float(row["exit_price"]),
|
||||
quantity=_parse_float(row["quantity"]),
|
||||
pnl=_parse_float(row["pnl"]),
|
||||
pnl_pct=_parse_float(row["pnl_pct"]),
|
||||
exit_reason=row["exit_reason"],
|
||||
leverage=_parse_float(row["leverage"]),
|
||||
capital_before=_parse_float(row["capital_before"]),
|
||||
capital_after=_parse_float(row["capital_after"]),
|
||||
bars_held=_parse_int(row["bars_held"]),
|
||||
regime_signal=_parse_int(row["regime_signal"]),
|
||||
vel_div_entry=_parse_float(row["vel_div_entry"]),
|
||||
boost_at_entry=_parse_float(row["boost_at_entry"], 1.0),
|
||||
beta_at_entry=_parse_float(row["beta_at_entry"], 1.0),
|
||||
posture=row["posture"],
|
||||
our_leverage=_parse_float(row["our_leverage"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def load_exec_quality() -> dict[str, dict[str, Any]]:
|
||||
sql = """
|
||||
SELECT
|
||||
trade_id,
|
||||
maxIf(notional_quote, record_kind = 'trade_summary') AS notional_quote,
|
||||
maxIf(fill_quality_score, record_kind = 'trade_summary') AS fill_quality_score,
|
||||
anyIf(fill_quality_class, record_kind = 'trade_summary') AS fill_quality_class,
|
||||
countIf(record_kind = 'fill') AS fill_rows,
|
||||
countIf(record_kind = 'fill' AND liquidity_side = 'TAKER') AS taker_fill_rows,
|
||||
countIf(record_kind = 'fill' AND liquidity_side = 'MAKER') AS maker_fill_rows
|
||||
FROM dolphin.trade_execution_quality
|
||||
GROUP BY trade_id
|
||||
FORMAT TSVWithNames
|
||||
"""
|
||||
rows = _query_tsv(sql)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
out[row["trade_id"]] = {
|
||||
"notional_quote": _parse_float(row["notional_quote"]),
|
||||
"fill_quality_score": _parse_float(row["fill_quality_score"]),
|
||||
"fill_quality_class": row["fill_quality_class"],
|
||||
"fill_rows": _parse_int(row["fill_rows"]),
|
||||
"taker_fill_rows": _parse_int(row["taker_fill_rows"]),
|
||||
"maker_fill_rows": _parse_int(row["maker_fill_rows"]),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def load_fingerprints(start_ts: datetime, end_ts: datetime) -> list[FingerprintRow]:
|
||||
sql = f"""
|
||||
SELECT
|
||||
ts, regime, composite_hash, scalar_hash, confidence, raw_vel_div, raw_dvol, final_score
|
||||
FROM dolphin.maras_fingerprint
|
||||
WHERE ts >= toDateTime64('{_fmt_ts(start_ts)}', 6, 'UTC')
|
||||
AND ts <= toDateTime64('{_fmt_ts(end_ts)}', 6, 'UTC')
|
||||
ORDER BY ts
|
||||
FORMAT TSVWithNames
|
||||
"""
|
||||
rows = _query_tsv(sql)
|
||||
out: list[FingerprintRow] = []
|
||||
for row in rows:
|
||||
out.append(
|
||||
FingerprintRow(
|
||||
ts=_parse_dt(row["ts"]),
|
||||
regime=row["regime"],
|
||||
composite_hash=_parse_int(row["composite_hash"]),
|
||||
scalar_hash=_parse_int(row["scalar_hash"]),
|
||||
confidence=_parse_float(row["confidence"]),
|
||||
raw_vel_div=_parse_float(row["raw_vel_div"]),
|
||||
raw_dvol=_parse_float(row["raw_dvol"]),
|
||||
final_score=_parse_float(row["final_score"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def attach_fingerprint(trades: list[TradeRow], fps: list[FingerprintRow]) -> list[TradeRow]:
|
||||
fp_ts = [fp.ts for fp in fps]
|
||||
out: list[TradeRow] = []
|
||||
for trade in trades:
|
||||
idx = bisect.bisect_right(fp_ts, trade.ts) - 1
|
||||
if idx >= 0:
|
||||
fp = fps[idx]
|
||||
trade = dataclasses.replace(
|
||||
trade,
|
||||
composite_hash=fp.composite_hash,
|
||||
scalar_hash=fp.scalar_hash,
|
||||
regime=fp.regime,
|
||||
fingerprint_confidence=fp.confidence,
|
||||
fingerprint_vel_div=fp.raw_vel_div,
|
||||
fingerprint_dvol=fp.raw_dvol,
|
||||
)
|
||||
out.append(trade)
|
||||
return out
|
||||
|
||||
|
||||
def build_trade_set() -> tuple[list[TradeRow], dict[str, Any]]:
|
||||
trades = load_clean_trades()
|
||||
if not trades:
|
||||
raise RuntimeError("no clean trades returned from ClickHouse")
|
||||
eq = load_exec_quality()
|
||||
enriched: list[TradeRow] = []
|
||||
for trade in trades:
|
||||
meta = eq.get(trade.trade_id, {})
|
||||
notional = _parse_float(str(meta.get("notional_quote", 0.0)), 0.0)
|
||||
if notional <= 0:
|
||||
notional = abs(trade.entry_price * trade.quantity)
|
||||
enriched.append(
|
||||
dataclasses.replace(
|
||||
trade,
|
||||
notional_quote=notional,
|
||||
fill_quality_score=meta.get("fill_quality_score"),
|
||||
fill_quality_class=meta.get("fill_quality_class"),
|
||||
fill_rows=int(meta.get("fill_rows", 0)),
|
||||
taker_fill_rows=int(meta.get("taker_fill_rows", 0)),
|
||||
maker_fill_rows=int(meta.get("maker_fill_rows", 0)),
|
||||
)
|
||||
)
|
||||
fps = load_fingerprints(trades[0].ts - timedelta(hours=1), trades[-1].ts)
|
||||
enriched = attach_fingerprint(enriched, fps)
|
||||
return enriched, {
|
||||
"trade_rows": len(trades),
|
||||
"exec_quality_rows": len(eq),
|
||||
"fingerprint_rows": len(fps),
|
||||
}
|
||||
|
||||
|
||||
def slippage_bps_model(notional: float, median_notional: float, *, taker_only: bool = True) -> float:
|
||||
"""Conservative parametric impact proxy.
|
||||
|
||||
Direct slippage telemetry in trade_execution_quality is mostly null for this
|
||||
dataset, so we assume a taker-heavy execution floor and a sublinear
|
||||
size-dependent impact term.
|
||||
"""
|
||||
base = 0.35 if taker_only else 0.20
|
||||
impact = 0.15 * math.sqrt(max(notional, 1.0) / max(median_notional, 1.0))
|
||||
return base + impact
|
||||
|
||||
|
||||
def replay_equity(
|
||||
trades: Sequence[TradeRow],
|
||||
fraction: float,
|
||||
*,
|
||||
slippage_enabled: bool = True,
|
||||
median_notional: float,
|
||||
ruin_threshold: float = 0.50,
|
||||
) -> dict[str, Any]:
|
||||
if not trades:
|
||||
raise ValueError("no trades")
|
||||
start_capital = trades[0].capital_before if trades[0].capital_before > 0 else 69_000.0
|
||||
capital = float(start_capital)
|
||||
equity_points: list[tuple[datetime, float]] = []
|
||||
daily_close: dict[str, float] = {}
|
||||
daily_peak: dict[str, float] = {}
|
||||
clipped = 0
|
||||
total = 0
|
||||
returns: list[float] = []
|
||||
daily_equity: dict[str, float] = {}
|
||||
|
||||
for trade in trades:
|
||||
total += 1
|
||||
leverage_eff = min(trade.leverage, TRANSLATOR_CAP / max(fraction, 1e-12))
|
||||
if trade.leverage * fraction > TRANSLATOR_CAP:
|
||||
clipped += 1
|
||||
notional = capital * fraction * leverage_eff
|
||||
slip_bps = slippage_bps_model(
|
||||
notional,
|
||||
median_notional,
|
||||
taker_only=(trade.taker_fill_rows > 0 or trade.fill_rows > 0),
|
||||
) if slippage_enabled else 0.0
|
||||
trade_return = fraction * leverage_eff * (trade.pnl_pct - slip_bps / 10_000.0)
|
||||
capital *= 1.0 + trade_return
|
||||
returns.append(trade_return)
|
||||
equity_points.append((trade.ts, capital))
|
||||
day = trade.ts.date().isoformat()
|
||||
daily_equity[day] = capital
|
||||
|
||||
# Fill daily series from first to last trade day.
|
||||
first_day = trades[0].ts.date()
|
||||
last_day = trades[-1].ts.date()
|
||||
day = first_day
|
||||
last_equity = start_capital
|
||||
daily_series: list[tuple[str, float]] = []
|
||||
while day <= last_day:
|
||||
key = day.isoformat()
|
||||
if key in daily_equity:
|
||||
last_equity = daily_equity[key]
|
||||
daily_series.append((key, last_equity))
|
||||
day += timedelta(days=1)
|
||||
|
||||
daily_returns = []
|
||||
prev = start_capital
|
||||
for _, eq in daily_series:
|
||||
daily_returns.append((eq / prev) - 1.0 if prev else 0.0)
|
||||
prev = eq
|
||||
|
||||
peak = start_capital
|
||||
max_dd = 0.0
|
||||
underwater_start: str | None = None
|
||||
longest_underwater_days = 0.0
|
||||
current_underwater_days = 0.0
|
||||
prev_day: str | None = None
|
||||
for day_str, eq in daily_series:
|
||||
if eq >= peak:
|
||||
if underwater_start is not None and prev_day is not None:
|
||||
start = datetime.fromisoformat(underwater_start).date()
|
||||
end = datetime.fromisoformat(prev_day).date()
|
||||
longest_underwater_days = max(
|
||||
longest_underwater_days, (end - start).days + 1
|
||||
)
|
||||
peak = eq
|
||||
underwater_start = None
|
||||
current_underwater_days = 0.0
|
||||
else:
|
||||
if underwater_start is None:
|
||||
underwater_start = day_str
|
||||
current_underwater_days += 1.0
|
||||
max_dd = max(max_dd, 1.0 - eq / peak if peak > 0 else 0.0)
|
||||
prev_day = day_str
|
||||
if underwater_start is not None and prev_day is not None:
|
||||
start = datetime.fromisoformat(underwater_start).date()
|
||||
end = datetime.fromisoformat(prev_day).date()
|
||||
longest_underwater_days = max(longest_underwater_days, (end - start).days + 1)
|
||||
|
||||
years = max((trades[-1].ts - trades[0].ts).total_seconds() / (365.25 * 24 * 3600), 1.0 / 365.25)
|
||||
if capital > 0 and start_capital > 0:
|
||||
log_growth = math.log(capital / start_capital)
|
||||
annual_log = log_growth / years
|
||||
if annual_log > 700.0:
|
||||
cagr = float("inf")
|
||||
elif annual_log < -700.0:
|
||||
cagr = -1.0
|
||||
else:
|
||||
cagr = math.expm1(annual_log)
|
||||
else:
|
||||
cagr = -1.0
|
||||
ann_sharpe, ann_sortino, downside_dev = _daily_risk_metrics(daily_returns)
|
||||
ruin_prob = _bootstrap_ruin_prob(
|
||||
trades=trades,
|
||||
fraction=fraction,
|
||||
median_notional=median_notional,
|
||||
slippage_enabled=slippage_enabled,
|
||||
ruin_threshold=ruin_threshold,
|
||||
)
|
||||
|
||||
return {
|
||||
"fraction": fraction,
|
||||
"start_capital": start_capital,
|
||||
"final_capital": capital,
|
||||
"cagr": cagr,
|
||||
"max_drawdown": max_dd,
|
||||
"calmar": (cagr / max_dd) if max_dd > 0 else float("inf"),
|
||||
"sharpe": ann_sharpe,
|
||||
"sortino": ann_sortino,
|
||||
"downside_deviation": downside_dev,
|
||||
"ruin_prob": ruin_prob,
|
||||
"pct_trades_clipped_at_3x": (clipped / total * 100.0) if total else 0.0,
|
||||
"longest_underwater_days": longest_underwater_days,
|
||||
"n_trades": total,
|
||||
"daily_returns": daily_returns,
|
||||
"equity_points": equity_points,
|
||||
"returns": returns,
|
||||
}
|
||||
|
||||
|
||||
def _daily_risk_metrics(daily_returns: Sequence[float]) -> tuple[float, float, float]:
|
||||
if len(daily_returns) < 2:
|
||||
return 0.0, 0.0, 0.0
|
||||
arr = np.asarray(daily_returns, dtype=np.float64)
|
||||
mean = float(np.mean(arr))
|
||||
std = float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0
|
||||
downside = arr[arr < 0.0]
|
||||
downside_dev = float(np.std(downside, ddof=1)) if len(downside) > 1 else float(np.std(np.minimum(arr, 0.0), ddof=0))
|
||||
sharpe = (mean / std * math.sqrt(365.25)) if std > 0 else 0.0
|
||||
sortino = (mean / downside_dev * math.sqrt(365.25)) if downside_dev > 0 else 0.0
|
||||
return sharpe, sortino, downside_dev
|
||||
|
||||
|
||||
def _bootstrap_ruin_prob(
|
||||
*,
|
||||
trades: Sequence[TradeRow],
|
||||
fraction: float,
|
||||
median_notional: float,
|
||||
slippage_enabled: bool,
|
||||
ruin_threshold: float,
|
||||
n_boot: int = 256,
|
||||
seed: int = 17,
|
||||
) -> float:
|
||||
rng = np.random.default_rng(seed)
|
||||
n = len(trades)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
leverage = np.array([min(t.leverage, TRANSLATOR_CAP / max(fraction, 1e-12)) for t in trades], dtype=np.float64)
|
||||
pnl_pct = np.array([t.pnl_pct for t in trades], dtype=np.float64)
|
||||
taker_only = np.array([(t.taker_fill_rows > 0 or t.fill_rows > 0) for t in trades], dtype=np.float64)
|
||||
|
||||
ruin = 0
|
||||
for _ in range(n_boot):
|
||||
idx = rng.integers(0, n, size=n)
|
||||
capital = 1.0
|
||||
floor = ruin_threshold
|
||||
for j in idx:
|
||||
lev = leverage[j]
|
||||
notional = capital * fraction * lev
|
||||
slip = slippage_bps_model(notional, median_notional, taker_only=bool(taker_only[j])) if slippage_enabled else 0.0
|
||||
r = fraction * lev * (pnl_pct[j] - slip / 10_000.0)
|
||||
capital *= 1.0 + r
|
||||
if capital <= floor:
|
||||
ruin += 1
|
||||
break
|
||||
return ruin / n_boot
|
||||
|
||||
|
||||
def cap_binding_curve(trades: Sequence[TradeRow], fractions: Sequence[float]) -> list[dict[str, float]]:
|
||||
out = []
|
||||
n = len(trades)
|
||||
for f in fractions:
|
||||
clipped = sum(1 for t in trades if t.leverage * f > TRANSLATOR_CAP)
|
||||
out.append({"fraction": float(f), "pct_clipped": (clipped / n * 100.0) if n else 0.0})
|
||||
return out
|
||||
|
||||
|
||||
def _group_by_hash(trades: Sequence[TradeRow]) -> dict[int, list[TradeRow]]:
|
||||
groups: dict[int, list[TradeRow]] = defaultdict(list)
|
||||
for trade in trades:
|
||||
if trade.composite_hash is None:
|
||||
continue
|
||||
groups[int(trade.composite_hash)].append(trade)
|
||||
for arr in groups.values():
|
||||
arr.sort(key=lambda t: t.ts)
|
||||
return groups
|
||||
|
||||
|
||||
def _bucket_stats(trades: Sequence[TradeRow], fraction: float, median_notional: float) -> dict[str, Any]:
|
||||
groups = _group_by_hash(trades)
|
||||
bucket_results = []
|
||||
for h, rows in groups.items():
|
||||
if len(rows) < 8:
|
||||
continue
|
||||
rep = replay_equity(rows, fraction, slippage_enabled=True, median_notional=median_notional)
|
||||
bucket_results.append(
|
||||
{
|
||||
"composite_hash": int(h),
|
||||
"n_trades": len(rows),
|
||||
"final_capital": rep["final_capital"],
|
||||
"max_drawdown": rep["max_drawdown"],
|
||||
"cagr": rep["cagr"],
|
||||
"calmar": rep["calmar"],
|
||||
"ruin_prob": rep["ruin_prob"],
|
||||
}
|
||||
)
|
||||
bucket_results.sort(key=lambda x: (x["max_drawdown"], -x["n_trades"]), reverse=True)
|
||||
return {
|
||||
"bucket_results": bucket_results,
|
||||
"worst_bucket": bucket_results[0] if bucket_results else None,
|
||||
}
|
||||
|
||||
|
||||
def slippage_model_summary(trades: Sequence[TradeRow]) -> dict[str, Any]:
|
||||
notionals = np.array([t.notional_quote or abs(t.entry_price * t.quantity) for t in trades], dtype=np.float64)
|
||||
if len(notionals) == 0:
|
||||
median_notional = 1.0
|
||||
else:
|
||||
median_notional = float(np.median(notionals))
|
||||
taker_fill_rate = float(
|
||||
sum(1 for t in trades if t.taker_fill_rows > 0 or t.fill_rows > 0) / max(len(trades), 1)
|
||||
)
|
||||
observed_direct = sum(
|
||||
1 for t in trades if math.isfinite(float(t.fill_quality_score or 0.0)) and (t.fill_quality_score or 0.0) not in (0.0, None)
|
||||
)
|
||||
return {
|
||||
"type": "conservative_parametric_proxy",
|
||||
"observed_direct_slippage_rows": 0,
|
||||
"observed_direct_slippage_trade_rows": observed_direct,
|
||||
"taker_fill_rate": taker_fill_rate,
|
||||
"median_notional_quote": median_notional,
|
||||
"formula": "slippage_bps = 0.35 + 0.15 * sqrt(notional / median_notional)",
|
||||
"assumption": "execution-quality rows do not expose non-null slippage_bps; all fill rows are taker, so this is a conservative proxy",
|
||||
}
|
||||
|
||||
|
||||
def analyze() -> dict[str, Any]:
|
||||
trades, counts = build_trade_set()
|
||||
median_notional = float(np.median([t.notional_quote or abs(t.entry_price * t.quantity) for t in trades]))
|
||||
fractions = np.unique(np.concatenate([BASE_GRID, np.round(np.arange(0.20, 0.501, 0.01), 3)])).astype(np.float64)
|
||||
slippage_summary = slippage_model_summary(trades)
|
||||
|
||||
results_no_slip = []
|
||||
results_slip = []
|
||||
for f in fractions:
|
||||
results_no_slip.append(replay_equity(trades, float(f), slippage_enabled=False, median_notional=median_notional))
|
||||
results_slip.append(replay_equity(trades, float(f), slippage_enabled=True, median_notional=median_notional))
|
||||
|
||||
best_slip = max(results_slip, key=lambda x: (x["calmar"], x["final_capital"]))
|
||||
best_no_slip = max(results_no_slip, key=lambda x: (x["calmar"], x["final_capital"]))
|
||||
refined = np.unique(
|
||||
np.concatenate([
|
||||
fractions,
|
||||
np.round(np.arange(max(0.20, best_slip["fraction"] - 0.03), min(0.50, best_slip["fraction"] + 0.03) + 0.0001, 0.005), 3),
|
||||
])
|
||||
).astype(np.float64)
|
||||
if len(refined) > len(fractions):
|
||||
results_slip = [replay_equity(trades, float(f), slippage_enabled=True, median_notional=median_notional) for f in refined]
|
||||
results_no_slip = [replay_equity(trades, float(f), slippage_enabled=False, median_notional=median_notional) for f in refined]
|
||||
fractions = refined
|
||||
best_slip = max(results_slip, key=lambda x: (x["calmar"], x["final_capital"]))
|
||||
best_no_slip = max(results_no_slip, key=lambda x: (x["calmar"], x["final_capital"]))
|
||||
|
||||
cap_curve = cap_binding_curve(trades, fractions)
|
||||
bucket_summary = _bucket_stats(trades, float(best_slip["fraction"]), median_notional)
|
||||
cap_by_fraction = {round(float(row["fraction"]), 3): float(row["pct_clipped"]) for row in cap_curve}
|
||||
feasible_rows = [r for r in results_slip if cap_by_fraction.get(round(float(r["fraction"]), 3), 0.0) == 0.0]
|
||||
practical_best = max(feasible_rows, key=lambda x: (x["calmar"], x["final_capital"])) if feasible_rows else best_slip
|
||||
|
||||
# Kelly anchor: use unit-fraction returns (return at f=1.0, ignoring cap/slip)
|
||||
unit_returns = np.array([t.pnl_pct * min(t.leverage, TRANSLATOR_CAP / 1.0) for t in trades], dtype=np.float64)
|
||||
kelly_grid = np.linspace(0.01, 0.50, 200)
|
||||
kelly_log_growth = []
|
||||
for f in kelly_grid:
|
||||
growth = np.mean(np.log1p(np.clip(f * unit_returns, -0.95, None)))
|
||||
kelly_log_growth.append(float(growth))
|
||||
kelly_idx = int(np.argmax(kelly_log_growth))
|
||||
kelly_fraction = float(kelly_grid[kelly_idx])
|
||||
fractional_kelly = float(min(best_slip["fraction"], max(0.25 * kelly_fraction, 0.20)))
|
||||
|
||||
# Stress scenario: worst-hash bucket loss multiplied slightly and injected once.
|
||||
stress = None
|
||||
if bucket_summary["worst_bucket"] is not None:
|
||||
worst_hash = bucket_summary["worst_bucket"]["composite_hash"]
|
||||
worst_rows = sorted([t for t in trades if t.composite_hash == worst_hash], key=lambda t: t.ts)
|
||||
if worst_rows:
|
||||
worst_trade = min(worst_rows, key=lambda t: t.pnl_pct)
|
||||
stress_trade = dataclasses.replace(worst_trade, pnl_pct=min(-0.01, worst_trade.pnl_pct * 1.5))
|
||||
stress_rows = list(worst_rows) + [stress_trade]
|
||||
stress_rows.sort(key=lambda t: t.ts)
|
||||
stress = {
|
||||
"worst_hash": int(worst_hash),
|
||||
"worst_trade_id": worst_trade.trade_id,
|
||||
"stress_pnl_pct": float(stress_trade.pnl_pct),
|
||||
"per_fraction": [
|
||||
{
|
||||
"fraction": float(f),
|
||||
"final_capital": replay_equity(stress_rows, float(f), slippage_enabled=True, median_notional=median_notional)["final_capital"],
|
||||
"ruin_prob": replay_equity(stress_rows, float(f), slippage_enabled=True, median_notional=median_notional)["ruin_prob"],
|
||||
}
|
||||
for f in fractions
|
||||
],
|
||||
}
|
||||
|
||||
recommendation = {
|
||||
"recommended_fraction": float(practical_best["fraction"]),
|
||||
"recommended_basis": "best_calmar_among_zero_clip_fractions",
|
||||
"recommended_final_capital": float(practical_best["final_capital"]),
|
||||
"recommended_cagr": float(practical_best["cagr"]),
|
||||
"recommended_max_drawdown": float(practical_best["max_drawdown"]),
|
||||
"recommended_calmar": float(practical_best["calmar"]),
|
||||
"recommended_ruin_prob": float(practical_best["ruin_prob"]),
|
||||
"recommended_clip_pct": float(cap_by_fraction.get(round(float(practical_best["fraction"]), 3), 0.0)),
|
||||
"alt_fraction_floor": float(fractional_kelly),
|
||||
"no_slippage_best_fraction": float(best_no_slip["fraction"]),
|
||||
"slippage_best_fraction": float(best_slip["fraction"]),
|
||||
"optimizer_fraction": float(best_slip["fraction"]),
|
||||
"optimizer_final_capital": float(best_slip["final_capital"]),
|
||||
"optimizer_cagr": float(best_slip["cagr"]),
|
||||
"optimizer_max_drawdown": float(best_slip["max_drawdown"]),
|
||||
"optimizer_calmar": float(best_slip["calmar"]),
|
||||
"optimizer_ruin_prob": float(best_slip["ruin_prob"]),
|
||||
"optimizer_clip_pct": float(next(c for c in cap_curve if abs(c["fraction"] - best_slip["fraction"]) < 1e-9)["pct_clipped"]),
|
||||
"delta_final_capital_vs_base_0p20": float(
|
||||
next(r for r in results_slip if abs(r["fraction"] - 0.20) < 1e-9)["final_capital"]
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"study_spec": "prod/docs/VIOLET_STUDY_SPEC__BASE_FRACTION_SIZING.md",
|
||||
"source_counts": counts,
|
||||
"clean_trade_count": len(trades),
|
||||
"clean_trade_window": {
|
||||
"start": trades[0].ts.isoformat(),
|
||||
"end": trades[-1].ts.isoformat(),
|
||||
},
|
||||
"slippage_model": slippage_summary,
|
||||
"cap_curve": cap_curve,
|
||||
"results": {
|
||||
"no_slippage": results_no_slip,
|
||||
"slippage_adjusted": results_slip,
|
||||
},
|
||||
"kelly": {
|
||||
"kelly_fraction": kelly_fraction,
|
||||
"fractional_kelly_anchor": fractional_kelly,
|
||||
"kelly_log_growth_grid": [{"fraction": float(f), "log_growth": float(g)} for f, g in zip(kelly_grid, kelly_log_growth)],
|
||||
},
|
||||
"bucket_summary": bucket_summary,
|
||||
"stress_scenario": stress,
|
||||
"recommendation": recommendation,
|
||||
}
|
||||
|
||||
|
||||
def _best_row(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
||||
return max(rows, key=lambda x: (x["calmar"], x["final_capital"]))
|
||||
|
||||
|
||||
def _format_pct(x: float) -> str:
|
||||
return f"{x * 100.0:.2f}%"
|
||||
|
||||
|
||||
def write_report(report: dict[str, Any]) -> tuple[Path, Path]:
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
json_path = REPORTS_DIR / f"base_fraction_study_{ts}.json"
|
||||
md_path = REPORTS_DIR / f"base_fraction_study_{ts}.md"
|
||||
json_path.write_text(json.dumps(report, indent=2, sort_keys=True, default=str))
|
||||
|
||||
best = report["recommendation"]
|
||||
base = next(r for r in report["results"]["slippage_adjusted"] if abs(r["fraction"] - 0.20) < 1e-9)
|
||||
recommended_cap = next(c for c in report["cap_curve"] if abs(c["fraction"] - best["recommended_fraction"]) < 1e-9)
|
||||
optimizer_cap = next(c for c in report["cap_curve"] if abs(c["fraction"] - best["optimizer_fraction"]) < 1e-9)
|
||||
|
||||
md = []
|
||||
md.append("# VIOLET base-fraction sizing study")
|
||||
md.append("")
|
||||
md.append(f"Generated: `{report['generated_at']}`")
|
||||
md.append("")
|
||||
md.append("## Bottom line")
|
||||
md.append(
|
||||
f"- Recommended fraction: `{best['recommended_fraction']:.3f}` "
|
||||
f"(best Calmar among zero-clip fractions)"
|
||||
)
|
||||
md.append(
|
||||
f"- Base 0.20 final capital: `{base['final_capital']:.2f}`; "
|
||||
f"recommended final capital: `{best['recommended_final_capital']:.2f}`"
|
||||
)
|
||||
md.append(
|
||||
f"- Base 0.20 maxDD: `{_format_pct(base['max_drawdown'])}`; "
|
||||
f"recommended maxDD: `{_format_pct(best['recommended_max_drawdown'])}`"
|
||||
)
|
||||
md.append(
|
||||
f"- Recommended clip rate: `{recommended_cap['pct_clipped']:.2f}%`; "
|
||||
f"unconstrained optimizer: `{best['optimizer_fraction']:.3f}` with "
|
||||
f"`{optimizer_cap['pct_clipped']:.2f}%` clipped at the 3x ceiling."
|
||||
)
|
||||
md.append("")
|
||||
md.append("## Caveat")
|
||||
md.append(
|
||||
"- Direct non-null slippage telemetry was not available in `trade_execution_quality`; "
|
||||
"the study uses a conservative taker-heavy impact proxy."
|
||||
)
|
||||
md.append("")
|
||||
md.append("## Cap binding")
|
||||
md.append(
|
||||
f"- The recommended fraction hits the 3x translator cap on `{recommended_cap['pct_clipped']:.2f}%` of trades."
|
||||
)
|
||||
md.append("")
|
||||
md.append("## Kelly anchor")
|
||||
md.append(
|
||||
f"- Empirical Kelly anchor: `{report['kelly']['kelly_fraction']:.3f}`; "
|
||||
f"fractional anchor: `{report['kelly']['fractional_kelly_anchor']:.3f}`"
|
||||
)
|
||||
md.append("")
|
||||
md.append("## Files")
|
||||
md.append(f"- JSON: `{json_path}`")
|
||||
md.append(f"- Markdown: `{md_path}`")
|
||||
md_path.write_text("\n".join(md) + "\n")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def self_test() -> None:
|
||||
trades = [
|
||||
TradeRow(
|
||||
trade_id="t1",
|
||||
ts=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
|
||||
asset="X",
|
||||
side="SHORT",
|
||||
entry_price=100.0,
|
||||
exit_price=99.0,
|
||||
quantity=1.0,
|
||||
pnl=10.0,
|
||||
pnl_pct=0.10,
|
||||
exit_reason="FIXED_TP",
|
||||
leverage=2.0,
|
||||
capital_before=100.0,
|
||||
capital_after=110.0,
|
||||
bars_held=5,
|
||||
regime_signal=-1,
|
||||
vel_div_entry=-0.03,
|
||||
boost_at_entry=1.0,
|
||||
beta_at_entry=1.0,
|
||||
posture="APEX",
|
||||
our_leverage=0.4,
|
||||
composite_hash=1,
|
||||
notional_quote=100.0,
|
||||
taker_fill_rows=1,
|
||||
fill_rows=1,
|
||||
),
|
||||
TradeRow(
|
||||
trade_id="t2",
|
||||
ts=datetime(2026, 1, 2, 0, 0, tzinfo=timezone.utc),
|
||||
asset="X",
|
||||
side="SHORT",
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
quantity=1.0,
|
||||
pnl=-5.0,
|
||||
pnl_pct=-0.01,
|
||||
exit_reason="MAX_HOLD",
|
||||
leverage=7.0,
|
||||
capital_before=110.0,
|
||||
capital_after=105.0,
|
||||
bars_held=5,
|
||||
regime_signal=-1,
|
||||
vel_div_entry=-0.03,
|
||||
boost_at_entry=1.0,
|
||||
beta_at_entry=1.0,
|
||||
posture="APEX",
|
||||
our_leverage=0.6,
|
||||
composite_hash=1,
|
||||
notional_quote=110.0,
|
||||
taker_fill_rows=1,
|
||||
fill_rows=1,
|
||||
),
|
||||
]
|
||||
rep = replay_equity(trades, 0.20, slippage_enabled=False, median_notional=100.0, ruin_threshold=0.50)
|
||||
assert rep["n_trades"] == 2
|
||||
assert rep["pct_trades_clipped_at_3x"] == 0.0
|
||||
assert rep["final_capital"] > 100.0
|
||||
cap = cap_binding_curve(trades, [0.20, 0.50])
|
||||
assert cap[0]["pct_clipped"] == 0.0
|
||||
assert cap[1]["pct_clipped"] == 50.0
|
||||
slip = slippage_bps_model(100.0, 100.0)
|
||||
assert slip > 0.0
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--self-test", action="store_true", help="run the deterministic synthetic fixture and exit")
|
||||
parser.add_argument("--dry-run", action="store_true", help="run the live analysis but do not write files")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
self_test()
|
||||
print("self-test ok")
|
||||
return 0
|
||||
|
||||
report = analyze()
|
||||
if args.dry_run:
|
||||
print(json.dumps(report["recommendation"], indent=2, sort_keys=True, default=str))
|
||||
return 0
|
||||
|
||||
json_path, md_path = write_report(report)
|
||||
print(json_path)
|
||||
print(md_path)
|
||||
print(json.dumps(report["recommendation"], indent=2, sort_keys=True, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user