Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion auramaur/db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,35 @@ async def _run_migrations(self, from_version: int) -> None:
await self._migrate_v47_to_v48()
if from_version < 49:
await self._migrate_v48_to_v49()
if from_version < 50:
await self._migrate_v49_to_v50()

async def _migrate_v49_to_v50(self) -> None:
"""Add auditable exit-policy observations for holdout calibration."""
await self._db.executescript("""
CREATE TABLE IF NOT EXISTS exit_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observed_at TEXT NOT NULL DEFAULT (datetime('now')),
market_id TEXT NOT NULL, exchange TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT 'YES',
is_paper INTEGER NOT NULL DEFAULT 1,
policy_action TEXT NOT NULL DEFAULT 'HOLD',
gross_pnl_pct REAL NOT NULL, net_pnl_pct REAL NOT NULL,
peak_pnl_pct REAL NOT NULL, target_pct REAL,
estimated_fees REAL NOT NULL DEFAULT 0,
current_price REAL NOT NULL, entry_price REAL NOT NULL,
size REAL NOT NULL);
CREATE INDEX IF NOT EXISTS idx_exit_decisions_position_time
ON exit_decisions(market_id, token, is_paper, observed_at);
-- observed_at is the composite's FOURTH column and the retention
-- delete constrains nothing else, so that index offers no seekable
-- prefix. Without this one the per-cycle prune full-scans.
CREATE INDEX IF NOT EXISTS idx_exit_decisions_observed_at
ON exit_decisions(observed_at);
UPDATE schema_version SET version = 50;
""")
await self._db.commit()
log.info("database.migrated", from_version=49, to_version=50)

async def _migrate_v48_to_v49(self) -> None:
"""Record the deterministic pair post-check on entailment verdicts (#405).
Expand Down Expand Up @@ -1563,4 +1592,4 @@ async def rollback(self) -> None:
log.debug(
"database.legacy_rollback_skipped_mid_transaction",
active_owner=self._txn_owner or "unknown",
)
)
27 changes: 26 additions & 1 deletion auramaur/db/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""SQLite table schemas as SQL strings."""

SCHEMA_VERSION = 49
SCHEMA_VERSION = 50

TABLES = """
CREATE TABLE IF NOT EXISTS schema_version (
Expand Down Expand Up @@ -793,6 +793,31 @@
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS exit_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observed_at TEXT NOT NULL DEFAULT (datetime('now')),
market_id TEXT NOT NULL,
exchange TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT 'YES',
is_paper INTEGER NOT NULL DEFAULT 1,
policy_action TEXT NOT NULL DEFAULT 'HOLD',
gross_pnl_pct REAL NOT NULL,
net_pnl_pct REAL NOT NULL,
peak_pnl_pct REAL NOT NULL,
target_pct REAL,
estimated_fees REAL NOT NULL DEFAULT 0,
current_price REAL NOT NULL,
entry_price REAL NOT NULL,
size REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_exit_decisions_position_time
ON exit_decisions(market_id, token, is_paper, observed_at);
-- The composite above cannot serve the per-cycle retention delete: its
-- predicate constrains observed_at alone, and the three leading columns are
-- unconstrained, so SQLite has no seekable prefix and full-scans the table.
CREATE INDEX IF NOT EXISTS idx_exit_decisions_observed_at
ON exit_decisions(observed_at);

CREATE TABLE IF NOT EXISTS rebalance_blocks (
event_key TEXT PRIMARY KEY,
blocked_until TEXT NOT NULL,
Expand Down
1 change: 1 addition & 0 deletions auramaur/exchange/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ def depth_within(self, price_cap: float, is_buy: bool) -> float:
class ExitReason(str, Enum):
STOP_LOSS = "STOP_LOSS"
PROFIT_TARGET = "PROFIT_TARGET"
TRAILING_STOP = "TRAILING_STOP"
EDGE_EROSION = "EDGE_EROSION"
TIME_DECAY = "TIME_DECAY"
DUST_CLEANUP = "DUST_CLEANUP"
Expand Down
80 changes: 80 additions & 0 deletions auramaur/risk/exit_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Pure exit-policy economics shared by runtime and calibration code."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ExitEconomics:
gross_pnl_pct: float
net_pnl_pct: float
estimated_fees: float
# Reported, never charged — see binary_exit_economics. Kept so calibration
# can measure the entry leg without the exit gate paying for it.
entry_fee: float = 0.0


def binary_exit_economics(
*, entry_price: float, exit_price: float, size: float, fee_coefficient: float,
is_long: bool = True,
) -> ExitEconomics:
"""Return executable SELL economics for a long binary-token position.

Only the EXIT fee is charged, because only the exit fee is ever realized.
``broker/pnl.py`` books ``pnl = (fill.price - old_avg_cost) * size -
fill.fee`` — the exit leg alone — and builds the ``pnl_ledger`` event only
on the SELL branch, so the entry fee never reaches the ledger at all. The
BUY branch stores ``total_cost = price * size``, making ``avg_cost``
fee-EXCLUSIVE; nothing downstream ever recovers the entry fee.

Charging both legs here therefore made the gate demand a cost the
accounting does not record, holding every winner past its target by the
entry leg's drag. The entry fee is still computed and reported, so the
diagnostic survives without binding the decision.
"""
cost = entry_price * size
if cost <= 0:
return ExitEconomics(0.0, 0.0, 0.0, 0.0)
direction = 1.0 if is_long else -1.0
gross = direction * (exit_price - entry_price) * size
coefficient = max(0.0, fee_coefficient)
# Conservative when maker/taker ancestry is unavailable: reserve the
# executable side at the taker schedule. Calibration sees the same
# economics.
entry_fee = coefficient * entry_price * (1.0 - entry_price) * size
exit_fee = coefficient * exit_price * (1.0 - exit_price) * size
return ExitEconomics(gross / cost * 100.0, (gross - exit_fee) / cost * 100.0,
exit_fee, entry_fee)


def lifecycle_profit_target(
*, base_pct: float, early_pct: float, late_pct: float,
fraction_remaining: float | None, early_fraction: float, late_fraction: float,
) -> float:
if fraction_remaining is None:
return base_pct
if fraction_remaining > early_fraction:
return early_pct
if fraction_remaining < late_fraction:
return late_pct
return base_pct


def trailing_stop_triggered(
*, peak_pct: float, current_pct: float, activation_pct: float,
giveback_fraction: float,
) -> bool:
"""Trailing stop, where zero in EITHER knob means DISABLED.

Zero used to mean the opposite of off. ``activation_pct = 0`` arms the
stop against every non-negative peak, and ``giveback_fraction = 0``
reduces the second test to ``peak > current`` — together they sell every
position on its first adverse tick. ``Field(gt=0)`` only converted that
footgun into a startup crash, which is stricter than the neighbouring
``stop_loss_pct`` / ``profit_target_pct`` and left an operator no way to
turn the tier off. Honour the intent instead: 0 disables.
"""
if activation_pct <= 0 or giveback_fraction <= 0:
return False
return peak_pct >= activation_pct and peak_pct - current_pct > peak_pct * giveback_fraction
Loading