Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# 연구 알고리즘 계약 하드닝
|
||||
|
||||
`kartsell_policy_contract.py`는 첨부 연구 번들의 결론을 생산코드와 독립적으로 검증하기 위한 dependency-free Golden Contract다.
|
||||
|
||||
- `SellRatioOfLot`와 `TargetPortfolioWeightAfter`를 분리한다.
|
||||
- Hard impairment → Portfolio survival → Profit floor → Concentration/Liquidity → Opportunity cost 순서를 고정한다.
|
||||
- Point-in-Time 공개시각이 평가시각을 넘으면 실패한다.
|
||||
- 전략적 코어는 gap/two-close/concentration/opportunity 정책에서 보존되지만, 자본바닥 정책은 생존을 위해 코어 아래로 축소할 수 있다.
|
||||
- 이 코드는 주문·추천·생산 모델이 아니다.
|
||||
|
||||
실행:
|
||||
|
||||
```bash
|
||||
cd research/hardening
|
||||
python -m unittest -v
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
test_future_published_at_is_rejected (test_policy_contract.PolicyContractTests.test_future_published_at_is_rejected) ... ok
|
||||
test_gap_preserves_absolute_core_weight (test_policy_contract.PolicyContractTests.test_gap_preserves_absolute_core_weight) ... ok
|
||||
test_hard_impairment_wins (test_policy_contract.PolicyContractTests.test_hard_impairment_wins) ... ok
|
||||
test_survival_can_cross_core (test_policy_contract.PolicyContractTests.test_survival_can_cross_core) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 4 tests in 0.000s
|
||||
|
||||
OK
|
||||
@@ -0,0 +1,9 @@
|
||||
test_future_published_at_is_rejected (test_policy_contract.PolicyContractTests.test_future_published_at_is_rejected) ... ok
|
||||
test_gap_preserves_absolute_core_weight (test_policy_contract.PolicyContractTests.test_gap_preserves_absolute_core_weight) ... ok
|
||||
test_hard_impairment_wins (test_policy_contract.PolicyContractTests.test_hard_impairment_wins) ... ok
|
||||
test_survival_can_cross_core (test_policy_contract.PolicyContractTests.test_survival_can_cross_core) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 4 tests in 0.000s
|
||||
|
||||
OK
|
||||
@@ -0,0 +1,224 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
|
||||
from kartsell_policy_contract import SellAction, SellInput, default_chain
|
||||
|
||||
|
||||
def base_input(**changes):
|
||||
values = dict(
|
||||
position_lot_id="lot-1",
|
||||
cycle_id="cycle-1",
|
||||
evidence_id="evidence-1",
|
||||
dataset_id="dataset-1",
|
||||
model_version="model-1",
|
||||
config_version="config-1",
|
||||
code_sha="sha-1",
|
||||
as_of=datetime(2026, 8, 1, 7, tzinfo=timezone.utc),
|
||||
published_at_cutoff=datetime(2026, 8, 1, 6, tzinfo=timezone.utc),
|
||||
current_portfolio_weight=Decimal("0.60"),
|
||||
strategic_core_floor_weight=Decimal("0.30"),
|
||||
)
|
||||
values.update(changes)
|
||||
return SellInput(**values)
|
||||
|
||||
|
||||
class PolicyContractTests(unittest.TestCase):
|
||||
def test_hard_impairment_wins(self):
|
||||
result = default_chain().evaluate(base_input(
|
||||
hard_impairment_approved=True,
|
||||
capital_floor_breached=True,
|
||||
survival_sell_ratio_of_lot=Decimal("0.5"),
|
||||
gap_below_floor_atr=Decimal("2"),
|
||||
))
|
||||
self.assertEqual("ALG-SELL-001", result.policy_id)
|
||||
self.assertEqual(SellAction.FULL_SELL, result.action)
|
||||
self.assertFalse(result.reentry_eligible)
|
||||
|
||||
def test_gap_preserves_absolute_core_weight(self):
|
||||
result = default_chain().evaluate(base_input(
|
||||
strategic_core_floor_weight=Decimal("0.50"),
|
||||
gap_below_floor_atr=Decimal("2"),
|
||||
))
|
||||
self.assertEqual("ALG-SELL-002", result.policy_id)
|
||||
self.assertEqual(Decimal("0.50000000"), result.target_portfolio_weight_after)
|
||||
|
||||
def test_survival_can_cross_core(self):
|
||||
result = default_chain().evaluate(base_input(
|
||||
capital_floor_breached=True,
|
||||
survival_sell_ratio_of_lot=Decimal("0.50"),
|
||||
))
|
||||
self.assertEqual("ALG-SELL-PORT-001", result.policy_id)
|
||||
self.assertEqual(Decimal("0.30000000"), result.target_portfolio_weight_after)
|
||||
|
||||
def test_future_published_at_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
default_chain().evaluate(base_input(
|
||||
published_at_cutoff=datetime(2026, 8, 1, 8, tzinfo=timezone.utc)
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user