225 lines
7.4 KiB
Python
225 lines
7.4 KiB
Python
"""Executable research contract for K-ArtSell Aegis sell/reentry policies.
|
|
|
|
This module is intentionally dependency-free. It is not a production trading engine.
|
|
It exists to make policy ordering, units, invariants, and golden vectors reproducible.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from decimal import Decimal, ROUND_HALF_EVEN
|
|
from enum import Enum
|
|
from typing import Iterable, Optional, Protocol
|
|
|
|
ZERO = Decimal("0")
|
|
ONE = Decimal("1")
|
|
|
|
|
|
class SellAction(str, Enum):
|
|
HOLD = "HOLD"
|
|
PARTIAL_SELL = "PARTIAL_SELL"
|
|
FULL_SELL = "FULL_SELL"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SellInput:
|
|
position_lot_id: str
|
|
cycle_id: str
|
|
evidence_id: str
|
|
dataset_id: str
|
|
model_version: str
|
|
config_version: str
|
|
code_sha: str
|
|
as_of: datetime
|
|
published_at_cutoff: datetime
|
|
current_portfolio_weight: Decimal
|
|
strategic_core_floor_weight: Decimal
|
|
hard_impairment_approved: bool = False
|
|
capital_floor_breached: bool = False
|
|
survival_sell_ratio_of_lot: Decimal = ZERO
|
|
gap_below_floor_atr: Decimal = ZERO
|
|
consecutive_close_breaches: int = 0
|
|
cooldown_satisfied: bool = True
|
|
concentration_sell_ratio_of_lot: Decimal = ZERO
|
|
opportunity_edge_lower_bound: Decimal = ZERO
|
|
opportunity_sell_ratio_of_lot: Decimal = ZERO
|
|
|
|
def validate(self) -> None:
|
|
if self.published_at_cutoff > self.as_of:
|
|
raise ValueError("published_at_cutoff cannot be later than as_of")
|
|
for name, value in (
|
|
("current_portfolio_weight", self.current_portfolio_weight),
|
|
("strategic_core_floor_weight", self.strategic_core_floor_weight),
|
|
("survival_sell_ratio_of_lot", self.survival_sell_ratio_of_lot),
|
|
("concentration_sell_ratio_of_lot", self.concentration_sell_ratio_of_lot),
|
|
("opportunity_sell_ratio_of_lot", self.opportunity_sell_ratio_of_lot),
|
|
):
|
|
if not ZERO <= value <= ONE:
|
|
raise ValueError(f"{name} must be between 0 and 1")
|
|
if self.strategic_core_floor_weight > self.current_portfolio_weight:
|
|
raise ValueError("strategic core cannot exceed current portfolio weight")
|
|
if self.gap_below_floor_atr < ZERO:
|
|
raise ValueError("gap_below_floor_atr cannot be negative")
|
|
if self.consecutive_close_breaches < 0:
|
|
raise ValueError("consecutive_close_breaches cannot be negative")
|
|
|
|
def max_sell_ratio_preserving_core(self) -> Decimal:
|
|
if self.current_portfolio_weight <= ZERO:
|
|
return ZERO
|
|
sellable_weight = max(ZERO, self.current_portfolio_weight - self.strategic_core_floor_weight)
|
|
return min(ONE, sellable_weight / self.current_portfolio_weight)
|
|
|
|
def target_weight_after(self, ratio: Decimal) -> Decimal:
|
|
bounded = min(ONE, max(ZERO, ratio))
|
|
return (self.current_portfolio_weight * (ONE - bounded)).quantize(
|
|
Decimal("0.00000001"), rounding=ROUND_HALF_EVEN
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SellDecision:
|
|
action: SellAction
|
|
sell_ratio_of_lot: Decimal
|
|
target_portfolio_weight_after: Decimal
|
|
policy_id: str
|
|
priority: int
|
|
reason_code: str
|
|
reentry_eligible: bool
|
|
|
|
|
|
class SellPolicy(Protocol):
|
|
priority: int
|
|
policy_id: str
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]: ...
|
|
|
|
|
|
class HardImpairmentPolicy:
|
|
priority = 1000
|
|
policy_id = "ALG-SELL-001"
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]:
|
|
if not value.hard_impairment_approved:
|
|
return None
|
|
return _decision(value, ONE, self.policy_id, self.priority, "HARD_IMPAIRMENT", False)
|
|
|
|
|
|
class PortfolioSurvivalPolicy:
|
|
priority = 900
|
|
policy_id = "ALG-SELL-PORT-001"
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]:
|
|
if not value.capital_floor_breached or not value.cooldown_satisfied:
|
|
return None
|
|
ratio = min(ONE, max(ZERO, value.survival_sell_ratio_of_lot))
|
|
return None if ratio == ZERO else _decision(
|
|
value, ratio, self.policy_id, self.priority, "PORTFOLIO_SURVIVAL", True
|
|
)
|
|
|
|
|
|
class GapFloorPolicy:
|
|
priority = 800
|
|
policy_id = "ALG-SELL-002"
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]:
|
|
if not value.cooldown_satisfied or value.gap_below_floor_atr < Decimal("1.5"):
|
|
return None
|
|
ratio = min(Decimal("0.40"), value.max_sell_ratio_preserving_core())
|
|
return None if ratio == ZERO else _decision(
|
|
value, ratio, self.policy_id, self.priority, "GAP_FLOOR_BREACH", True
|
|
)
|
|
|
|
|
|
class TwoClosePolicy:
|
|
priority = 700
|
|
policy_id = "ALG-SELL-003"
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]:
|
|
if not value.cooldown_satisfied or value.consecutive_close_breaches < 2:
|
|
return None
|
|
ratio = min(Decimal("0.20"), value.max_sell_ratio_preserving_core())
|
|
return None if ratio == ZERO else _decision(
|
|
value, ratio, self.policy_id, self.priority, "TWO_CLOSE_FLOOR_BREACH", True
|
|
)
|
|
|
|
|
|
class ConcentrationPolicy:
|
|
priority = 600
|
|
policy_id = "ALG-SELL-004"
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]:
|
|
if not value.cooldown_satisfied:
|
|
return None
|
|
ratio = min(value.concentration_sell_ratio_of_lot, value.max_sell_ratio_preserving_core())
|
|
return None if ratio <= ZERO else _decision(
|
|
value, ratio, self.policy_id, self.priority, "CONCENTRATION_OR_LIQUIDITY", True
|
|
)
|
|
|
|
|
|
class OpportunityCostPolicy:
|
|
priority = 500
|
|
policy_id = "ALG-SELL-005"
|
|
|
|
def evaluate(self, value: SellInput) -> Optional[SellDecision]:
|
|
if not value.cooldown_satisfied or value.opportunity_edge_lower_bound <= ZERO:
|
|
return None
|
|
requested = min(Decimal("0.25"), max(Decimal("0.10"), value.opportunity_sell_ratio_of_lot))
|
|
ratio = min(requested, value.max_sell_ratio_preserving_core())
|
|
return None if ratio <= ZERO else _decision(
|
|
value, ratio, self.policy_id, self.priority, "OPPORTUNITY_REPLACEMENT", True
|
|
)
|
|
|
|
|
|
class SellPolicyChain:
|
|
def __init__(self, policies: Iterable[SellPolicy]):
|
|
self._policies = tuple(sorted(policies, key=lambda p: (-p.priority, p.policy_id)))
|
|
|
|
def evaluate(self, value: SellInput) -> SellDecision:
|
|
value.validate()
|
|
for policy in self._policies:
|
|
decision = policy.evaluate(value)
|
|
if decision is not None:
|
|
return decision
|
|
return SellDecision(
|
|
SellAction.HOLD,
|
|
ZERO,
|
|
value.current_portfolio_weight,
|
|
"ALG-HOLD-001",
|
|
0,
|
|
"NO_SELL_CONDITION",
|
|
False,
|
|
)
|
|
|
|
|
|
def default_chain() -> SellPolicyChain:
|
|
return SellPolicyChain(
|
|
[
|
|
OpportunityCostPolicy(),
|
|
ConcentrationPolicy(),
|
|
TwoClosePolicy(),
|
|
GapFloorPolicy(),
|
|
PortfolioSurvivalPolicy(),
|
|
HardImpairmentPolicy(),
|
|
]
|
|
)
|
|
|
|
|
|
def _decision(
|
|
value: SellInput,
|
|
ratio: Decimal,
|
|
policy_id: str,
|
|
priority: int,
|
|
reason: str,
|
|
reentry_eligible: bool,
|
|
) -> SellDecision:
|
|
action = SellAction.FULL_SELL if ratio == ONE else SellAction.PARTIAL_SELL
|
|
return SellDecision(
|
|
action,
|
|
ratio,
|
|
value.target_weight_after(ratio),
|
|
policy_id,
|
|
priority,
|
|
reason,
|
|
reentry_eligible,
|
|
)
|