feat: implement phase1 state machine, composite exits, and kill-switch orchestration (#275)
Some checks are pending
CI / test (pull_request) Waiting to run

This commit is contained in:
agentson
2026-02-26 23:22:58 +09:00
parent 8faf974522
commit 4987b6393a
7 changed files with 424 additions and 2 deletions

55
tests/test_kill_switch.py Normal file
View File

@@ -0,0 +1,55 @@
import pytest
from src.core.kill_switch import KillSwitchOrchestrator
@pytest.mark.asyncio
async def test_kill_switch_executes_steps_in_order() -> None:
ks = KillSwitchOrchestrator()
calls: list[str] = []
async def _cancel() -> None:
calls.append("cancel")
def _refresh() -> None:
calls.append("refresh")
def _reduce() -> None:
calls.append("reduce")
def _snapshot() -> None:
calls.append("snapshot")
def _notify() -> None:
calls.append("notify")
report = await ks.trigger(
reason="test",
cancel_pending_orders=_cancel,
refresh_order_state=_refresh,
reduce_risk=_reduce,
snapshot_state=_snapshot,
notify=_notify,
)
assert report.steps == [
"block_new_orders",
"cancel_pending_orders",
"refresh_order_state",
"reduce_risk",
"snapshot_state",
"notify",
]
assert calls == ["cancel", "refresh", "reduce", "snapshot", "notify"]
assert report.errors == []
@pytest.mark.asyncio
async def test_kill_switch_collects_step_errors() -> None:
ks = KillSwitchOrchestrator()
def _boom() -> None:
raise RuntimeError("boom")
report = await ks.trigger(reason="test", cancel_pending_orders=_boom)
assert any(err.startswith("cancel_pending_orders:") for err in report.errors)

View File

@@ -0,0 +1,38 @@
from src.strategy.exit_rules import ExitRuleConfig, ExitRuleInput, evaluate_exit
from src.strategy.position_state_machine import PositionState
def test_hard_stop_exit() -> None:
out = evaluate_exit(
current_state=PositionState.HOLDING,
config=ExitRuleConfig(hard_stop_pct=-2.0, arm_pct=3.0),
inp=ExitRuleInput(current_price=97.0, entry_price=100.0, peak_price=100.0),
)
assert out.should_exit is True
assert out.reason == "hard_stop"
def test_take_profit_exit_for_backward_compatibility() -> None:
out = evaluate_exit(
current_state=PositionState.HOLDING,
config=ExitRuleConfig(hard_stop_pct=-2.0, arm_pct=3.0),
inp=ExitRuleInput(current_price=104.0, entry_price=100.0, peak_price=104.0),
)
assert out.should_exit is True
assert out.reason == "arm_take_profit"
def test_model_assist_exit_signal() -> None:
out = evaluate_exit(
current_state=PositionState.ARMED,
config=ExitRuleConfig(model_prob_threshold=0.62, arm_pct=10.0),
inp=ExitRuleInput(
current_price=101.0,
entry_price=100.0,
peak_price=105.0,
pred_down_prob=0.8,
liquidity_weak=True,
),
)
assert out.should_exit is True
assert out.reason == "model_liquidity_exit"

View File

@@ -0,0 +1,30 @@
from src.strategy.position_state_machine import (
PositionState,
StateTransitionInput,
promote_state,
)
def test_gap_jump_promotes_to_armed_directly() -> None:
state = promote_state(
PositionState.HOLDING,
StateTransitionInput(
unrealized_pnl_pct=4.0,
be_arm_pct=1.2,
arm_pct=2.8,
),
)
assert state == PositionState.ARMED
def test_exited_has_priority_over_promotion() -> None:
state = promote_state(
PositionState.HOLDING,
StateTransitionInput(
unrealized_pnl_pct=5.0,
be_arm_pct=1.2,
arm_pct=2.8,
hard_stop_hit=True,
),
)
assert state == PositionState.EXITED