strategy: align model exit signal policy with v2 spec (#369) #388
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Doc-ID: DOC-REQ-001
|
Doc-ID: DOC-REQ-001
|
||||||
Version: 1.0.9
|
Version: 1.0.10
|
||||||
Status: active
|
Status: active
|
||||||
Owner: strategy
|
Owner: strategy
|
||||||
Updated: 2026-03-02
|
Updated: 2026-03-02
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Updated: 2026-03-02
|
|||||||
| REQ-V2-001 | 4-상태 매도 상태기계 (HOLDING→BE_LOCK→ARMED→EXITED) | `src/strategy/position_state_machine.py` | ✅ 완료 |
|
| REQ-V2-001 | 4-상태 매도 상태기계 (HOLDING→BE_LOCK→ARMED→EXITED) | `src/strategy/position_state_machine.py` | ✅ 완료 |
|
||||||
| REQ-V2-002 | 즉시 최상위 상태 승격 (갭 대응) | `position_state_machine.py:51-70` | ✅ 완료 |
|
| REQ-V2-002 | 즉시 최상위 상태 승격 (갭 대응) | `position_state_machine.py:51-70` | ✅ 완료 |
|
||||||
| REQ-V2-003 | EXITED 우선 평가 | `position_state_machine.py:38-48` | ✅ 완료 |
|
| REQ-V2-003 | EXITED 우선 평가 | `position_state_machine.py:38-48` | ✅ 완료 |
|
||||||
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model) | `src/strategy/exit_rules.py` | ✅ 완료 |
|
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model assist-only, 직접 EXIT 미트리거) | `src/strategy/exit_rules.py` | ✅ 완료 |
|
||||||
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
|
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
|
||||||
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
|
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
|
||||||
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py`, `src/analysis/backtest_pipeline.py` | ✅ 완료 |
|
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py`, `src/analysis/backtest_pipeline.py` | ✅ 완료 |
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ def evaluate_exit(
|
|||||||
reason = "atr_trailing_stop"
|
reason = "atr_trailing_stop"
|
||||||
elif be_lock_threat:
|
elif be_lock_threat:
|
||||||
reason = "be_lock_threat"
|
reason = "be_lock_threat"
|
||||||
|
elif model_exit_signal and next_state == PositionState.BE_LOCK:
|
||||||
|
reason = "model_assist_be_lock"
|
||||||
elif take_profit_hit:
|
elif take_profit_hit:
|
||||||
# Backward-compatible immediate profit-taking path.
|
# Backward-compatible immediate profit-taking path.
|
||||||
reason = "arm_take_profit"
|
reason = "arm_take_profit"
|
||||||
|
|||||||
@@ -62,5 +62,8 @@ def promote_state(current: PositionState, inp: StateTransitionInput) -> Position
|
|||||||
target = PositionState.ARMED
|
target = PositionState.ARMED
|
||||||
elif inp.unrealized_pnl_pct >= inp.be_arm_pct:
|
elif inp.unrealized_pnl_pct >= inp.be_arm_pct:
|
||||||
target = PositionState.BE_LOCK
|
target = PositionState.BE_LOCK
|
||||||
|
elif inp.model_exit_signal:
|
||||||
|
# Model signal assists risk posture by tightening to BE_LOCK.
|
||||||
|
target = PositionState.BE_LOCK
|
||||||
|
|
||||||
return target if _STATE_RANK[target] > _STATE_RANK[current] else current
|
return target if _STATE_RANK[target] > _STATE_RANK[current] else current
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ def test_take_profit_exit_for_backward_compatibility() -> None:
|
|||||||
assert out.reason == "arm_take_profit"
|
assert out.reason == "arm_take_profit"
|
||||||
|
|
||||||
|
|
||||||
def test_model_assist_signal_does_not_exit_directly() -> None:
|
def test_model_assist_signal_promotes_be_lock_without_direct_exit() -> None:
|
||||||
out = evaluate_exit(
|
out = evaluate_exit(
|
||||||
current_state=PositionState.ARMED,
|
current_state=PositionState.HOLDING,
|
||||||
config=ExitRuleConfig(model_prob_threshold=0.62, arm_pct=10.0),
|
config=ExitRuleConfig(model_prob_threshold=0.62, be_arm_pct=1.2, arm_pct=10.0),
|
||||||
inp=ExitRuleInput(
|
inp=ExitRuleInput(
|
||||||
current_price=101.0,
|
current_price=100.5,
|
||||||
entry_price=100.0,
|
entry_price=100.0,
|
||||||
peak_price=105.0,
|
peak_price=105.0,
|
||||||
pred_down_prob=0.8,
|
pred_down_prob=0.8,
|
||||||
@@ -35,4 +35,5 @@ def test_model_assist_signal_does_not_exit_directly() -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert out.should_exit is False
|
assert out.should_exit is False
|
||||||
assert out.reason == "hold"
|
assert out.state == PositionState.BE_LOCK
|
||||||
|
assert out.reason == "model_assist_be_lock"
|
||||||
|
|||||||
@@ -30,7 +30,20 @@ def test_exited_has_priority_over_promotion() -> None:
|
|||||||
assert state == PositionState.EXITED
|
assert state == PositionState.EXITED
|
||||||
|
|
||||||
|
|
||||||
def test_model_signal_is_assist_only_not_direct_exit() -> None:
|
def test_model_signal_promotes_be_lock_as_assist() -> None:
|
||||||
|
state = promote_state(
|
||||||
|
PositionState.HOLDING,
|
||||||
|
StateTransitionInput(
|
||||||
|
unrealized_pnl_pct=0.5,
|
||||||
|
be_arm_pct=1.2,
|
||||||
|
arm_pct=2.8,
|
||||||
|
model_exit_signal=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert state == PositionState.BE_LOCK
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_signal_does_not_force_exit_directly() -> None:
|
||||||
state = promote_state(
|
state = promote_state(
|
||||||
PositionState.ARMED,
|
PositionState.ARMED,
|
||||||
StateTransitionInput(
|
StateTransitionInput(
|
||||||
|
|||||||
Reference in New Issue
Block a user