Files
siloqy/prod/tests/test_pink_sizing_guards.py
Codex 567ce61e00 PINK DITAv2: source-level sizing guards (bounded size, close-always)
Located the source of the cutover non-finite: target_size = capital × fraction ×
leverage / price. notional (capital×fraction×leverage) is self-limiting (no division,
bounded by capital), so a non-finite size can only come from a corrupt raw input —
non-finite capital, or a price below the industry floor that overflows the division.

Guards in the PINK algo runner (pink_direct), per design review:
- _MIN_SANE_PRICE = 1e-8 industry-smallest-price floor.
- ENTER: _unsafe_entry_reason() rejects the OPEN (logs provenance, no trade) when
  capital/leverage/size are non-finite/non-positive or price < floor. A corrupt sizing
  input is an untrustworthy signal — don't open (nothing to strand).
- EXIT: _exit_intent_from_slot() sizes the close from the kernel's authoritative
  slot.size (cap to remaining; full remaining if policy size malformed) — a bad-math
  exit can never strand or overshoot a position. Falls back to policy size only when the
  kernel reports no/unknown remaining size.

size semantics confirmed: base-asset QUANTITY; notional = size×price; margin = notional/
leverage ≈ 0.2×capital (already margin-bounded by construction — no extra clamp needed).

Tests: test_pink_sizing_guards.py (4) green; full offline suite 25 green (no regression).
Complements the kernel INVALID_INTENT guard (9168cf0): source refuses to produce bad
sizes; kernel rejects any that slip through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:18:14 +02:00

104 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Source-level sizing guards in the PINK algo runner (pink_direct).
notional = capital × fraction × leverage is self-limiting (no division); the
only non-finite ingress is a corrupt raw input feeding size = notional / price.
So:
- ENTER: a non-finite capital or a price below the industry-smallest-price floor
is an untrustworthy signal -> suppress the OPEN (never trade on bad math).
- EXIT: size the close from the kernel's authoritative slot accounting, so a
malformed policy size can neither strand a position nor overshoot it.
"""
from __future__ import annotations
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
from prod.clean_arch.dita_v2 import (
ExecutionKernel, InMemoryControlPlane, KernelCommandType, KernelControlSnapshot,
KernelMode, KernelVerbosity, MemoryKernelJournal, MockVenueAdapter, MockVenueScenario,
TradeSide,
)
from prod.clean_arch.dita_v2.contracts import KernelIntent
from prod.clean_arch.dita import DecisionAction, DecisionConfig, DecisionEngine, IntentEngine
from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime, _MIN_SANE_PRICE
from prod.clean_arch.ports.data_feed import DataFeedPort, MarketSnapshot
class _StubFeed(DataFeedPort):
async def connect(self): return True
async def disconnect(self): pass
async def get_latest_snapshot(self, symbol): return None
async def subscribe_snapshots(self, callback): pass
async def get_acb_update(self): return None
def get_latency_ms(self): return 0.0
def health_check(self): return True
def _runtime(capital: float):
kernel = ExecutionKernel(
control_plane=InMemoryControlPlane(
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
),
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)),
journal=MemoryKernelJournal(),
)
kernel.account.snapshot.capital = capital
kernel.account.snapshot.peak_capital = capital if capital == capital else 25000.0
kernel.account.snapshot.equity = capital
cfg = DecisionConfig() # capital_fraction=0.20, allow_short=True, max_leverage=5
runtime = PinkDirectRuntime(
data_feed=_StubFeed(), kernel=kernel,
decision_engine=DecisionEngine(cfg), intent_engine=IntentEngine(cfg),
persistence=None, market_state_runtime=None,
)
return runtime, kernel
def _enter_snap(price: float) -> MarketSnapshot:
return MarketSnapshot(
timestamp=datetime.now(timezone.utc), symbol="BTCUSDT", price=price,
bid=price * 0.999, ask=price * 1.001, eigenvalues=[1.0],
velocity_divergence=-0.05, irp_alignment=0.5, scan_number=1, source="test",
)
def test_enter_suppressed_on_nonfinite_capital():
runtime, kernel = _runtime(float("inf"))
decision = asyncio.run(runtime.step(_enter_snap(100.0)))
assert decision.action == DecisionAction.ENTER # policy decided to enter
assert kernel.slot(0).is_free(), "ENTER must be suppressed on non-finite capital"
def test_enter_suppressed_on_subfloor_price():
runtime, kernel = _runtime(25_000.0)
decision = asyncio.run(runtime.step(_enter_snap(_MIN_SANE_PRICE / 100.0)))
assert kernel.slot(0).is_free(), "ENTER must be suppressed on a sub-floor price"
def test_enter_proceeds_on_sane_inputs():
runtime, kernel = _runtime(25_000.0)
decision = asyncio.run(runtime.step(_enter_snap(100.0)))
assert decision.action == DecisionAction.ENTER
assert not kernel.slot(0).is_free(), "sane ENTER must open a position"
def test_exit_sizes_from_kernel_slot_accounting():
runtime, kernel = _runtime(25_000.0)
asyncio.run(runtime.step(_enter_snap(100.0))) # open a position
slot_size = float(kernel.slot(0).size)
assert slot_size > 0.0
base = KernelIntent(
timestamp=datetime.now(timezone.utc), intent_id="x", trade_id=kernel.slot(0).trade_id,
slot_id=0, asset="BTCUSDT", side=TradeSide.SHORT, action=KernelCommandType.EXIT,
reference_price=100.0, target_size=999.0, leverage=1.0, exit_leg_ratios=(1.0,), reason="X",
)
# Oversized policy size -> capped to the real remaining size.
assert abs(runtime._exit_intent_from_slot(base).target_size - slot_size) < 1e-9
# Non-finite policy size -> full remaining size (never strands).
assert abs(runtime._exit_intent_from_slot(replace(base, target_size=float("inf"))).target_size - slot_size) < 1e-9
# Valid partial -> respected.
assert abs(runtime._exit_intent_from_slot(replace(base, target_size=slot_size * 0.5)).target_size - slot_size * 0.5) < 1e-9