Compare commits
10 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ffad58d57 | ||
|
|
df6baee7f1 | ||
|
|
c31a6a569d | ||
| 990f9696ab | |||
|
|
9bf72c63ec | ||
|
|
1399fa4d09 | ||
| f63fb53289 | |||
|
|
5050a4cf84 | ||
|
|
4987b6393a | ||
| 8faf974522 |
@@ -43,6 +43,11 @@ Updated: 2026-02-26
|
||||
- 기존 `tests/` 스위트 전량 실행
|
||||
- 신규 기능 플래그 ON/OFF 비교
|
||||
|
||||
4. 구동/모니터링 검증 (필수)
|
||||
- 개발 완료 후 시스템을 실제 구동해 핵심 경로를 관찰
|
||||
- 필수 관찰 항목: 주문 차단 정책, Kill Switch 동작, 경보/예외 로그, 세션 전환 로그
|
||||
- Runtime Verifier 코멘트로 증적(실행 명령/요약 로그) 첨부
|
||||
|
||||
## 실행 명령
|
||||
|
||||
```bash
|
||||
@@ -55,3 +60,4 @@ python3 scripts/validate_ouroboros_docs.py
|
||||
- 문서 검증 실패 시 구현 PR 병합 금지
|
||||
- `REQ-*` 변경 후 테스트 매핑 누락 시 병합 금지
|
||||
- 회귀 실패 시 원인 모듈 분리 후 재검증
|
||||
- 구동/모니터링 증적 누락 시 검증 승인 금지
|
||||
|
||||
@@ -17,11 +17,16 @@ Updated: 2026-02-26
|
||||
|
||||
- Main Agent: 최종 취합/우선순위/승인 게이트 오너
|
||||
- PM Agent: 시나리오/요구사항/티켓 관리
|
||||
- TPM Agent: PM-Dev-검증 간 구현 가능성/달성률 통제
|
||||
- TPM Agent: PM-Dev-검증 간 구현 가능성/달성률 통제, 티켓 등록 및 구현 우선순위 지정 오너
|
||||
- Dev Agent: 구현 수행, 블로커 발생 시 재계획 요청
|
||||
- Verifier Agent: 문서/코드/테스트 산출물 검증
|
||||
- Runtime Verifier Agent: 실제 동작 모니터링, 이상 징후 이슈 발행, 수정 후 이슈 클로즈 판정
|
||||
|
||||
Main Agent 아이디에이션 책임:
|
||||
- 진행 중 신규 구현 아이디어를 별도 문서에 누적 기록한다.
|
||||
- 기록 위치: [70_main_agent_ideation.md](./70_main_agent_ideation.md)
|
||||
- 각 항목은 `IDEA-*` 식별자, 배경, 기대효과, 리스크, 후속 티켓 후보를 포함해야 한다.
|
||||
|
||||
## Main Decision Checkpoints (Mandatory)
|
||||
|
||||
- DCP-01 범위 확정: Phase 0 종료 전 Main Agent 승인 필수
|
||||
@@ -140,6 +145,15 @@ Control checks:
|
||||
- Main Agent 승인 없는 재계획은 실행 금지
|
||||
- 재계획 반영 시 문서(`REQ/TASK/TEST`) 동시 갱신 필수
|
||||
|
||||
TPM 티켓 운영 규칙:
|
||||
- TPM은 합의된 변경을 이슈로 등록하고 우선순위(`P0/P1/P2`)를 지정한다.
|
||||
- PR 본문에는 TPM이 지정한 우선순위와 범위가 그대로 반영되어야 한다.
|
||||
- 우선순위 변경은 TPM 제안 + Main Agent 승인으로만 가능하다.
|
||||
|
||||
브랜치 운영 규칙:
|
||||
- TPM은 각 티켓에 대해 `ticket temp branch -> program feature branch` PR 경로를 지정한다.
|
||||
- 티켓 머지 대상은 항상 program feature branch이며, `main`은 최종 통합 단계에서만 사용한다.
|
||||
|
||||
## Runtime Verification Protocol
|
||||
|
||||
- Runtime Verifier는 테스트 통과 이후 실제 동작(스테이징/실운영)을 모니터링한다.
|
||||
@@ -149,6 +163,16 @@ Control checks:
|
||||
- 이슈 클로즈 규칙:
|
||||
- Dev 수정 완료 + Verifier 재검증 통과 + Runtime Verifier 재관측 정상
|
||||
- 최종 클로즈 승인자는 Main Agent
|
||||
- 개발 완료 필수 절차:
|
||||
- 시스템 실제 구동(스테이징/로컬 실운영 모드) 실행
|
||||
- 모니터링 체크리스트(핵심 경보/주문 경로/예외 로그) 수행
|
||||
- 결과를 티켓/PR 코멘트에 증적으로 첨부하지 않으면 완료로 간주하지 않음
|
||||
|
||||
## Server Reflection Rule
|
||||
|
||||
- `ticket temp branch -> program feature branch` 머지는 검증 승인 후 자동/수동 진행 가능하다.
|
||||
- `program feature branch -> main` 머지는 사용자 명시 승인 시에만 허용한다.
|
||||
- Main 병합 시 Main Agent가 승인 근거를 PR 코멘트에 기록한다.
|
||||
|
||||
## Acceptance Matrix (PM Scenario -> Dev Tasks -> Verifier Checks)
|
||||
|
||||
|
||||
@@ -50,10 +50,12 @@ Updated: 2026-02-26
|
||||
- PR 본문에 `REQ-*`, `TASK-*`, `TEST-*` 매핑 표 존재
|
||||
- `src/core/risk_manager.py` 변경 없음
|
||||
- 주요 의사결정 체크포인트(DCP-01~04) 중 해당 단계 Main Agent 확인 기록 존재
|
||||
- 티켓 PR의 base가 `main`이 아닌 program feature branch인지 확인
|
||||
|
||||
자동 점검:
|
||||
- 문서 검증 스크립트 통과
|
||||
- 테스트 통과
|
||||
- 개발 완료 시 시스템 구동/모니터링 증적 코멘트 존재
|
||||
|
||||
## 5) 감사 추적
|
||||
|
||||
@@ -86,3 +88,15 @@ Updated: 2026-02-26
|
||||
- Dev가 `REPLAN-REQUEST` 발행 시 TPM 심사 없이는 스코프/일정 변경 금지
|
||||
- `REPLAN-REQUEST`는 Main Agent 승인 전 \"제안\" 상태로 유지
|
||||
- 승인된 재계획은 `REQ/TASK/TEST` 문서를 동시 갱신해야 유효
|
||||
|
||||
## 9) 서버 반영 규칙
|
||||
|
||||
- 티켓 PR(`feature/issue-* -> feature/{stream}`)은 검증 승인 후 머지 가능하다.
|
||||
- 최종 통합 PR(`feature/{stream} -> main`)은 사용자 명시 승인 전 `tea pulls merge` 실행 금지.
|
||||
- Main 병합 시 승인 근거 코멘트 필수.
|
||||
|
||||
## 10) 최종 main 병합 조건
|
||||
|
||||
- 모든 티켓이 program feature branch로 병합 완료
|
||||
- Runtime Verifier의 구동/모니터링 검증 완료
|
||||
- 사용자 최종 승인 코멘트 확인 후에만 `feature -> main` PR 머지 허용
|
||||
|
||||
48
docs/ouroboros/70_main_agent_ideation.md
Normal file
48
docs/ouroboros/70_main_agent_ideation.md
Normal file
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
Doc-ID: DOC-IDEA-001
|
||||
Version: 1.0.0
|
||||
Status: active
|
||||
Owner: main-agent
|
||||
Updated: 2026-02-26
|
||||
-->
|
||||
|
||||
# 메인 에이전트 아이디에이션 백로그
|
||||
|
||||
목적:
|
||||
- 구현 진행 중 떠오른 신규 구현 아이디어를 계획 반영 전 임시 저장한다.
|
||||
- 본 문서는 사용자 검토 후 다음 계획 포함 여부를 결정하기 위한 검토 큐다.
|
||||
|
||||
운영 규칙:
|
||||
- 각 아이디어는 `IDEA-*` 식별자를 사용한다.
|
||||
- 필수 필드: 배경, 기대효과, 리스크, 후속 티켓 후보.
|
||||
- 상태는 `proposed`, `under-review`, `accepted`, `rejected` 중 하나를 사용한다.
|
||||
|
||||
## 아이디어 목록
|
||||
|
||||
- `IDEA-001` (status: proposed)
|
||||
- 제목: Kill-Switch 전역 상태를 프로세스 단일 전역에서 시장/세션 단위 상태로 분리
|
||||
- 배경: 현재는 전역 block 플래그 기반이라 시장별 분리 제어가 제한될 수 있음
|
||||
- 기대효과: KR/US 병행 운용 시 한 시장 장애가 다른 시장 주문을 불필요하게 막는 리스크 축소
|
||||
- 리스크: 상태 동기화 복잡도 증가, 테스트 케이스 확장 필요
|
||||
- 후속 티켓 후보: `TKT-P1-KS-SCOPE-SPLIT`
|
||||
|
||||
- `IDEA-002` (status: proposed)
|
||||
- 제목: Exit Engine 입력 계약(ATR/peak/model_prob/liquidity) 표준 DTO를 데이터 파이프라인에 고정
|
||||
- 배경: 현재 ATR/모델확률 일부가 fallback 기반이라 운영 일관성이 약함
|
||||
- 기대효과: 백테스트-실거래 입력 동형성 강화, 회귀 분석 용이
|
||||
- 리스크: 기존 스캐너/시나리오 엔진 연동 작업량 증가
|
||||
- 후속 티켓 후보: `TKT-P1-EXIT-CONTRACT`
|
||||
|
||||
- `IDEA-003` (status: proposed)
|
||||
- 제목: Runtime Verifier 자동 이슈 생성기(로그 패턴 -> 이슈 템플릿 자동화)
|
||||
- 배경: 런타임 이상 리포트가 수동 작성 중심이라 누락 가능성 존재
|
||||
- 기대효과: 이상 탐지 후 이슈 등록 리드타임 단축, 증적 표준화
|
||||
- 리스크: 오탐 이슈 폭증 가능성, 필터링 룰 필요
|
||||
- 후속 티켓 후보: `TKT-P1-RUNTIME-AUTO-ISSUE`
|
||||
|
||||
- `IDEA-004` (status: proposed)
|
||||
- 제목: PR 코멘트 워크플로우 자동 점검(리뷰어->개발논의->검증승인 누락 차단)
|
||||
- 배경: 현재 절차는 강력하지만 수행 확인이 수동
|
||||
- 기대효과: 절차 누락 방지, 감사 추적 자동화
|
||||
- 리스크: CLI/API 연동 유지보수 비용
|
||||
- 후속 티켓 후보: `TKT-P0-WORKFLOW-GUARD`
|
||||
@@ -21,6 +21,7 @@ Updated: 2026-02-26
|
||||
7. PM 시나리오/이슈 분류: [50_scenario_matrix_and_issue_taxonomy.md](./50_scenario_matrix_and_issue_taxonomy.md)
|
||||
8. TPM 제어 프로토콜/수용 매트릭스: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md)
|
||||
9. 저장소 강제 설정 체크리스트: [60_repo_enforcement_checklist.md](./60_repo_enforcement_checklist.md)
|
||||
10. 메인 에이전트 아이디에이션 백로그: [70_main_agent_ideation.md](./70_main_agent_ideation.md)
|
||||
|
||||
## 운영 규칙
|
||||
|
||||
|
||||
@@ -5,14 +5,24 @@
|
||||
**CRITICAL: All code changes MUST follow this workflow. Direct pushes to `main` are ABSOLUTELY PROHIBITED.**
|
||||
|
||||
1. **Create Gitea Issue First** — All features, bug fixes, and policy changes require a Gitea issue before any code is written
|
||||
2. **Create Feature Branch** — Branch from `main` using format `feature/issue-{N}-{short-description}`
|
||||
- After creating the branch, run `git pull origin main` and rebase to ensure the branch is up to date
|
||||
3. **Implement Changes** — Write code, tests, and documentation on the feature branch
|
||||
4. **Create Pull Request** — Submit PR to `main` branch referencing the issue number
|
||||
5. **Review & Merge** — After approval, merge via PR (squash or merge commit)
|
||||
2. **Create Program Feature Branch** — Branch from `main` for the whole development stream
|
||||
- Format: `feature/{epic-or-stream-name}`
|
||||
3. **Create Ticket Temp Branch** — Branch from the program feature branch per ticket
|
||||
- Format: `feature/issue-{N}-{short-description}`
|
||||
4. **Implement Per Ticket** — Write code, tests, and documentation on the ticket temp branch
|
||||
5. **Create Pull Request to Program Feature Branch** — `feature/issue-N-* -> feature/{stream}`
|
||||
6. **Review/Verify and Merge into Program Feature Branch** — user approval not required
|
||||
7. **Final Integration PR to main** — Only after all ticket stages complete and explicit user approval
|
||||
|
||||
**Never commit directly to `main`.** This policy applies to all changes, no exceptions.
|
||||
|
||||
## Branch Strategy (Mandatory)
|
||||
|
||||
- Team operation default branch is the **program feature branch**, not `main`.
|
||||
- Ticket-level development happens only on **ticket temp branches** cut from the program feature branch.
|
||||
- Ticket PR merges into program feature branch are allowed after verifier approval.
|
||||
- Until final user sign-off, `main` merge is prohibited.
|
||||
|
||||
## Gitea CLI Formatting Troubleshooting
|
||||
|
||||
Issue/PR 본문 작성 시 줄바꿈(`\n`)이 문자열 그대로 저장되는 문제가 반복될 수 있다. 원인은 `-d "...\n..."` 형태에서 쉘/CLI가 이스케이프를 실제 개행으로 해석하지 않기 때문이다.
|
||||
|
||||
71
src/core/kill_switch.py
Normal file
71
src/core/kill_switch.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Kill switch orchestration for emergency risk actions.
|
||||
|
||||
Order is fixed:
|
||||
1) block new orders
|
||||
2) cancel pending orders
|
||||
3) refresh order state
|
||||
4) reduce risk
|
||||
5) snapshot and notify
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
StepCallable = Callable[[], Any | Awaitable[Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class KillSwitchReport:
|
||||
reason: str
|
||||
steps: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class KillSwitchOrchestrator:
|
||||
def __init__(self) -> None:
|
||||
self.new_orders_blocked = False
|
||||
|
||||
async def _run_step(
|
||||
self,
|
||||
report: KillSwitchReport,
|
||||
name: str,
|
||||
fn: StepCallable | None,
|
||||
) -> None:
|
||||
report.steps.append(name)
|
||||
if fn is None:
|
||||
return
|
||||
try:
|
||||
result = fn()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except Exception as exc: # pragma: no cover - intentionally resilient
|
||||
report.errors.append(f"{name}: {exc}")
|
||||
|
||||
async def trigger(
|
||||
self,
|
||||
*,
|
||||
reason: str,
|
||||
cancel_pending_orders: StepCallable | None = None,
|
||||
refresh_order_state: StepCallable | None = None,
|
||||
reduce_risk: StepCallable | None = None,
|
||||
snapshot_state: StepCallable | None = None,
|
||||
notify: StepCallable | None = None,
|
||||
) -> KillSwitchReport:
|
||||
report = KillSwitchReport(reason=reason)
|
||||
|
||||
self.new_orders_blocked = True
|
||||
report.steps.append("block_new_orders")
|
||||
|
||||
await self._run_step(report, "cancel_pending_orders", cancel_pending_orders)
|
||||
await self._run_step(report, "refresh_order_state", refresh_order_state)
|
||||
await self._run_step(report, "reduce_risk", reduce_risk)
|
||||
await self._run_step(report, "snapshot_state", snapshot_state)
|
||||
await self._run_step(report, "notify", notify)
|
||||
|
||||
return report
|
||||
|
||||
def clear_block(self) -> None:
|
||||
self.new_orders_blocked = False
|
||||
93
src/core/order_policy.py
Normal file
93
src/core/order_policy.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Session-aware order policy guards.
|
||||
|
||||
Default policy:
|
||||
- Low-liquidity sessions must reject market orders (price <= 0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.markets.schedule import MarketInfo
|
||||
|
||||
_LOW_LIQUIDITY_SESSIONS = {"NXT_AFTER", "US_PRE", "US_DAY", "US_AFTER"}
|
||||
|
||||
|
||||
class OrderPolicyRejected(Exception):
|
||||
"""Raised when an order violates session policy."""
|
||||
|
||||
def __init__(self, message: str, *, session_id: str, market_code: str) -> None:
|
||||
super().__init__(message)
|
||||
self.session_id = session_id
|
||||
self.market_code = market_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionInfo:
|
||||
session_id: str
|
||||
is_low_liquidity: bool
|
||||
|
||||
|
||||
def classify_session_id(market: MarketInfo, now: datetime | None = None) -> str:
|
||||
"""Classify current session by KST schedule used in v3 docs."""
|
||||
now = now or datetime.now(UTC)
|
||||
# v3 session tables are explicitly defined in KST perspective.
|
||||
kst_time = now.astimezone(ZoneInfo("Asia/Seoul")).timetz().replace(tzinfo=None)
|
||||
|
||||
if market.code == "KR":
|
||||
if time(8, 0) <= kst_time < time(8, 50):
|
||||
return "NXT_PRE"
|
||||
if time(9, 0) <= kst_time < time(15, 30):
|
||||
return "KRX_REG"
|
||||
if time(15, 30) <= kst_time < time(20, 0):
|
||||
return "NXT_AFTER"
|
||||
return "KR_OFF"
|
||||
|
||||
if market.code.startswith("US"):
|
||||
if time(10, 0) <= kst_time < time(18, 0):
|
||||
return "US_DAY"
|
||||
if time(18, 0) <= kst_time < time(23, 30):
|
||||
return "US_PRE"
|
||||
if time(23, 30) <= kst_time or kst_time < time(6, 0):
|
||||
return "US_REG"
|
||||
if time(6, 0) <= kst_time < time(7, 0):
|
||||
return "US_AFTER"
|
||||
return "US_OFF"
|
||||
|
||||
return "GENERIC_REG"
|
||||
|
||||
|
||||
def get_session_info(market: MarketInfo, now: datetime | None = None) -> SessionInfo:
|
||||
session_id = classify_session_id(market, now)
|
||||
return SessionInfo(session_id=session_id, is_low_liquidity=session_id in _LOW_LIQUIDITY_SESSIONS)
|
||||
|
||||
|
||||
def validate_order_policy(
|
||||
*,
|
||||
market: MarketInfo,
|
||||
order_type: str,
|
||||
price: float,
|
||||
now: datetime | None = None,
|
||||
) -> SessionInfo:
|
||||
"""Validate order against session policy and return resolved session info."""
|
||||
info = get_session_info(market, now)
|
||||
|
||||
is_market_order = price <= 0
|
||||
if info.is_low_liquidity and is_market_order:
|
||||
raise OrderPolicyRejected(
|
||||
f"Market order is forbidden in low-liquidity session ({info.session_id})",
|
||||
session_id=info.session_id,
|
||||
market_code=market.code,
|
||||
)
|
||||
|
||||
# Guard against accidental unsupported actions.
|
||||
if order_type not in {"BUY", "SELL"}:
|
||||
raise OrderPolicyRejected(
|
||||
f"Unsupported order_type={order_type}",
|
||||
session_id=info.session_id,
|
||||
market_code=market.code,
|
||||
)
|
||||
|
||||
return info
|
||||
162
src/main.py
162
src/main.py
@@ -27,6 +27,8 @@ from src.context.layer import ContextLayer
|
||||
from src.context.scheduler import ContextScheduler
|
||||
from src.context.store import ContextStore
|
||||
from src.core.criticality import CriticalityAssessor
|
||||
from src.core.kill_switch import KillSwitchOrchestrator
|
||||
from src.core.order_policy import OrderPolicyRejected, validate_order_policy
|
||||
from src.core.priority_queue import PriorityTaskQueue
|
||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
||||
from src.db import (
|
||||
@@ -43,11 +45,14 @@ from src.logging_config import setup_logging
|
||||
from src.markets.schedule import MARKETS, MarketInfo, get_next_market_open, get_open_markets
|
||||
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
|
||||
from src.strategy.models import DayPlaybook, MarketOutlook
|
||||
from src.strategy.exit_rules import ExitRuleConfig, ExitRuleInput, evaluate_exit
|
||||
from src.strategy.playbook_store import PlaybookStore
|
||||
from src.strategy.pre_market_planner import PreMarketPlanner
|
||||
from src.strategy.position_state_machine import PositionState
|
||||
from src.strategy.scenario_engine import ScenarioEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
KILL_SWITCH = KillSwitchOrchestrator()
|
||||
|
||||
|
||||
def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||
@@ -784,7 +789,24 @@ async def trading_cycle(
|
||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
||||
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
||||
|
||||
if loss_pct <= stop_loss_threshold:
|
||||
exit_eval = evaluate_exit(
|
||||
current_state=PositionState.HOLDING,
|
||||
config=ExitRuleConfig(
|
||||
hard_stop_pct=stop_loss_threshold,
|
||||
be_arm_pct=max(0.5, take_profit_threshold * 0.4),
|
||||
arm_pct=take_profit_threshold,
|
||||
),
|
||||
inp=ExitRuleInput(
|
||||
current_price=current_price,
|
||||
entry_price=entry_price,
|
||||
peak_price=max(entry_price, current_price),
|
||||
atr_value=0.0,
|
||||
pred_down_prob=0.0,
|
||||
liquidity_weak=market_data.get("volume_ratio", 1.0) < 1.0,
|
||||
),
|
||||
)
|
||||
|
||||
if exit_eval.reason == "hard_stop":
|
||||
decision = TradeDecision(
|
||||
action="SELL",
|
||||
confidence=95,
|
||||
@@ -800,7 +822,7 @@ async def trading_cycle(
|
||||
loss_pct,
|
||||
stop_loss_threshold,
|
||||
)
|
||||
elif loss_pct >= take_profit_threshold:
|
||||
elif exit_eval.reason == "arm_take_profit":
|
||||
decision = TradeDecision(
|
||||
action="SELL",
|
||||
confidence=90,
|
||||
@@ -876,6 +898,15 @@ async def trading_cycle(
|
||||
trade_price = current_price
|
||||
trade_pnl = 0.0
|
||||
if decision.action in ("BUY", "SELL"):
|
||||
if KILL_SWITCH.new_orders_blocked:
|
||||
logger.critical(
|
||||
"KillSwitch block active: skip %s order for %s (%s)",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
)
|
||||
return
|
||||
|
||||
broker_held_qty = (
|
||||
_extract_held_qty_from_balance(
|
||||
balance_data, stock_code, is_domestic=market.is_domestic
|
||||
@@ -944,6 +975,25 @@ async def trading_cycle(
|
||||
except Exception as notify_exc:
|
||||
logger.warning("Fat finger notification failed: %s", notify_exc)
|
||||
raise # Re-raise to prevent trade
|
||||
except CircuitBreakerTripped as exc:
|
||||
ks_report = await KILL_SWITCH.trigger(
|
||||
reason=f"circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
||||
snapshot_state=lambda: logger.critical(
|
||||
"KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
||||
market.code,
|
||||
stock_code,
|
||||
exc.pnl_pct,
|
||||
exc.threshold,
|
||||
),
|
||||
)
|
||||
if ks_report.errors:
|
||||
logger.critical(
|
||||
"KillSwitch step errors for %s/%s: %s",
|
||||
market.code,
|
||||
stock_code,
|
||||
"; ".join(ks_report.errors),
|
||||
)
|
||||
raise
|
||||
|
||||
# 5. Send order
|
||||
order_succeeded = True
|
||||
@@ -956,6 +1006,22 @@ async def trading_cycle(
|
||||
order_price = kr_round_down(current_price * 1.002)
|
||||
else:
|
||||
order_price = kr_round_down(current_price * 0.998)
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(order_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
return
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
@@ -978,6 +1044,22 @@ async def trading_cycle(
|
||||
overseas_price = round(current_price * 1.002, _price_decimals)
|
||||
else:
|
||||
overseas_price = round(current_price * 0.998, _price_decimals)
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(overseas_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
return
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
@@ -1222,6 +1304,11 @@ async def handle_domestic_pending_orders(
|
||||
f"Invalid price ({last_price}) for {stock_code}"
|
||||
)
|
||||
new_price = kr_round_down(last_price * 0.996)
|
||||
validate_order_policy(
|
||||
market=MARKETS["KR"],
|
||||
order_type="SELL",
|
||||
price=float(new_price),
|
||||
)
|
||||
await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
@@ -1395,6 +1482,19 @@ async def handle_overseas_pending_orders(
|
||||
f"Invalid price ({last_price}) for {stock_code}"
|
||||
)
|
||||
new_price = round(last_price * 0.996, 4)
|
||||
market_info = next(
|
||||
(
|
||||
m for m in MARKETS.values()
|
||||
if m.exchange_code == order_exchange and not m.is_domestic
|
||||
),
|
||||
None,
|
||||
)
|
||||
if market_info is not None:
|
||||
validate_order_policy(
|
||||
market=market_info,
|
||||
order_type="SELL",
|
||||
price=float(new_price),
|
||||
)
|
||||
await overseas_broker.send_overseas_order(
|
||||
exchange_code=order_exchange,
|
||||
stock_code=stock_code,
|
||||
@@ -1845,6 +1945,15 @@ async def run_daily_session(
|
||||
trade_pnl = 0.0
|
||||
order_succeeded = True
|
||||
if decision.action in ("BUY", "SELL"):
|
||||
if KILL_SWITCH.new_orders_blocked:
|
||||
logger.critical(
|
||||
"KillSwitch block active: skip %s order for %s (%s)",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
)
|
||||
continue
|
||||
|
||||
daily_broker_held_qty = (
|
||||
_extract_held_qty_from_balance(
|
||||
balance_data, stock_code, is_domestic=market.is_domestic
|
||||
@@ -1911,6 +2020,16 @@ async def run_daily_session(
|
||||
logger.warning("Fat finger notification failed: %s", notify_exc)
|
||||
continue # Skip this order
|
||||
except CircuitBreakerTripped as exc:
|
||||
ks_report = await KILL_SWITCH.trigger(
|
||||
reason=f"daily_circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
||||
snapshot_state=lambda: logger.critical(
|
||||
"Daily KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
||||
market.code,
|
||||
stock_code,
|
||||
exc.pnl_pct,
|
||||
exc.threshold,
|
||||
),
|
||||
)
|
||||
logger.critical("Circuit breaker tripped — stopping session")
|
||||
try:
|
||||
await telegram.notify_circuit_breaker(
|
||||
@@ -1921,6 +2040,13 @@ async def run_daily_session(
|
||||
logger.warning(
|
||||
"Circuit breaker notification failed: %s", notify_exc
|
||||
)
|
||||
if ks_report.errors:
|
||||
logger.critical(
|
||||
"Daily KillSwitch step errors for %s/%s: %s",
|
||||
market.code,
|
||||
stock_code,
|
||||
"; ".join(ks_report.errors),
|
||||
)
|
||||
raise
|
||||
|
||||
# Send order
|
||||
@@ -1937,6 +2063,22 @@ async def run_daily_session(
|
||||
order_price = kr_round_down(
|
||||
stock_data["current_price"] * 0.998
|
||||
)
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(order_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
continue
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
@@ -1949,6 +2091,22 @@ async def run_daily_session(
|
||||
order_price = round(stock_data["current_price"] * 1.005, 4)
|
||||
else:
|
||||
order_price = stock_data["current_price"]
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(order_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
continue
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
|
||||
104
src/strategy/exit_rules.py
Normal file
104
src/strategy/exit_rules.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Composite exit rules: hard stop, break-even lock, ATR trailing, model assist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.strategy.position_state_machine import PositionState, StateTransitionInput, promote_state
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitRuleConfig:
|
||||
hard_stop_pct: float = -2.0
|
||||
be_arm_pct: float = 1.2
|
||||
arm_pct: float = 3.0
|
||||
atr_multiplier_k: float = 2.2
|
||||
model_prob_threshold: float = 0.62
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitRuleInput:
|
||||
current_price: float
|
||||
entry_price: float
|
||||
peak_price: float
|
||||
atr_value: float = 0.0
|
||||
pred_down_prob: float = 0.0
|
||||
liquidity_weak: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitEvaluation:
|
||||
state: PositionState
|
||||
should_exit: bool
|
||||
reason: str
|
||||
unrealized_pnl_pct: float
|
||||
trailing_stop_price: float | None
|
||||
|
||||
|
||||
def evaluate_exit(
|
||||
*,
|
||||
current_state: PositionState,
|
||||
config: ExitRuleConfig,
|
||||
inp: ExitRuleInput,
|
||||
) -> ExitEvaluation:
|
||||
"""Evaluate composite exit logic and return updated state."""
|
||||
if inp.entry_price <= 0 or inp.current_price <= 0:
|
||||
return ExitEvaluation(
|
||||
state=current_state,
|
||||
should_exit=False,
|
||||
reason="invalid_price",
|
||||
unrealized_pnl_pct=0.0,
|
||||
trailing_stop_price=None,
|
||||
)
|
||||
|
||||
unrealized = (inp.current_price - inp.entry_price) / inp.entry_price * 100.0
|
||||
hard_stop_hit = unrealized <= config.hard_stop_pct
|
||||
take_profit_hit = unrealized >= config.arm_pct
|
||||
|
||||
trailing_stop_price: float | None = None
|
||||
trailing_stop_hit = False
|
||||
if inp.atr_value > 0 and inp.peak_price > 0:
|
||||
trailing_stop_price = inp.peak_price - (config.atr_multiplier_k * inp.atr_value)
|
||||
trailing_stop_hit = inp.current_price <= trailing_stop_price
|
||||
|
||||
be_lock_threat = current_state in (PositionState.BE_LOCK, PositionState.ARMED) and (
|
||||
inp.current_price <= inp.entry_price
|
||||
)
|
||||
model_exit_signal = inp.pred_down_prob >= config.model_prob_threshold and inp.liquidity_weak
|
||||
|
||||
next_state = promote_state(
|
||||
current=current_state,
|
||||
inp=StateTransitionInput(
|
||||
unrealized_pnl_pct=unrealized,
|
||||
be_arm_pct=config.be_arm_pct,
|
||||
arm_pct=config.arm_pct,
|
||||
hard_stop_hit=hard_stop_hit,
|
||||
trailing_stop_hit=trailing_stop_hit,
|
||||
model_exit_signal=model_exit_signal,
|
||||
be_lock_threat=be_lock_threat,
|
||||
),
|
||||
)
|
||||
|
||||
if hard_stop_hit:
|
||||
reason = "hard_stop"
|
||||
elif trailing_stop_hit:
|
||||
reason = "atr_trailing_stop"
|
||||
elif be_lock_threat:
|
||||
reason = "be_lock_threat"
|
||||
elif model_exit_signal:
|
||||
reason = "model_liquidity_exit"
|
||||
elif take_profit_hit:
|
||||
# Backward-compatible immediate profit-taking path.
|
||||
reason = "arm_take_profit"
|
||||
else:
|
||||
reason = "hold"
|
||||
|
||||
should_exit = next_state == PositionState.EXITED or take_profit_hit
|
||||
|
||||
return ExitEvaluation(
|
||||
state=next_state,
|
||||
should_exit=should_exit,
|
||||
reason=reason,
|
||||
unrealized_pnl_pct=unrealized,
|
||||
trailing_stop_price=trailing_stop_price,
|
||||
)
|
||||
70
src/strategy/position_state_machine.py
Normal file
70
src/strategy/position_state_machine.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Position state machine for staged exit control.
|
||||
|
||||
State progression is monotonic (promotion-only) except terminal EXITED.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PositionState(str, Enum):
|
||||
HOLDING = "HOLDING"
|
||||
BE_LOCK = "BE_LOCK"
|
||||
ARMED = "ARMED"
|
||||
EXITED = "EXITED"
|
||||
|
||||
|
||||
_STATE_RANK: dict[PositionState, int] = {
|
||||
PositionState.HOLDING: 0,
|
||||
PositionState.BE_LOCK: 1,
|
||||
PositionState.ARMED: 2,
|
||||
PositionState.EXITED: 3,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateTransitionInput:
|
||||
unrealized_pnl_pct: float
|
||||
be_arm_pct: float
|
||||
arm_pct: float
|
||||
hard_stop_hit: bool = False
|
||||
trailing_stop_hit: bool = False
|
||||
model_exit_signal: bool = False
|
||||
be_lock_threat: bool = False
|
||||
|
||||
|
||||
def evaluate_exit_first(inp: StateTransitionInput) -> bool:
|
||||
"""Return True when terminal exit conditions are met.
|
||||
|
||||
EXITED must be evaluated before any promotion.
|
||||
"""
|
||||
return (
|
||||
inp.hard_stop_hit
|
||||
or inp.trailing_stop_hit
|
||||
or inp.model_exit_signal
|
||||
or inp.be_lock_threat
|
||||
)
|
||||
|
||||
|
||||
def promote_state(current: PositionState, inp: StateTransitionInput) -> PositionState:
|
||||
"""Promote to highest admissible state for current tick/bar.
|
||||
|
||||
Rules:
|
||||
- EXITED has highest precedence and is terminal.
|
||||
- Promotions are monotonic (no downgrade).
|
||||
"""
|
||||
if current == PositionState.EXITED:
|
||||
return PositionState.EXITED
|
||||
|
||||
if evaluate_exit_first(inp):
|
||||
return PositionState.EXITED
|
||||
|
||||
target = PositionState.HOLDING
|
||||
if inp.unrealized_pnl_pct >= inp.arm_pct:
|
||||
target = PositionState.ARMED
|
||||
elif inp.unrealized_pnl_pct >= inp.be_arm_pct:
|
||||
target = PositionState.BE_LOCK
|
||||
|
||||
return target if _STATE_RANK[target] > _STATE_RANK[current] else current
|
||||
55
tests/test_kill_switch.py
Normal file
55
tests/test_kill_switch.py
Normal 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)
|
||||
@@ -8,11 +8,13 @@ import pytest
|
||||
from src.config import Settings
|
||||
from src.context.layer import ContextLayer
|
||||
from src.context.scheduler import ScheduleResult
|
||||
from src.core.order_policy import OrderPolicyRejected
|
||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||
from src.db import init_db, log_trade
|
||||
from src.evolution.scorecard import DailyScorecard
|
||||
from src.logging.decision_logger import DecisionLogger
|
||||
from src.main import (
|
||||
KILL_SWITCH,
|
||||
_apply_dashboard_flag,
|
||||
_determine_order_quantity,
|
||||
_extract_avg_price_from_balance,
|
||||
@@ -77,6 +79,14 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_kill_switch_state() -> None:
|
||||
"""Prevent cross-test leakage from global kill-switch state."""
|
||||
KILL_SWITCH.clear_block()
|
||||
yield
|
||||
KILL_SWITCH.clear_block()
|
||||
|
||||
|
||||
class TestExtractAvgPriceFromBalance:
|
||||
"""Tests for _extract_avg_price_from_balance() (issue #249)."""
|
||||
|
||||
@@ -5039,3 +5049,143 @@ class TestOverseasGhostPositionClose:
|
||||
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
|
||||
]
|
||||
assert not ghost_close_calls, "Ghost-close must NOT be triggered for non-잔고없음 errors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_switch_block_skips_actionable_order_execution() -> None:
|
||||
"""Active kill-switch must prevent actionable order execution."""
|
||||
db_conn = init_db(":memory:")
|
||||
decision_logger = DecisionLogger(db_conn)
|
||||
|
||||
broker = MagicMock()
|
||||
broker.get_current_price = AsyncMock(return_value=(100.0, 0.5, 0.0))
|
||||
broker.get_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [],
|
||||
"output2": [
|
||||
{
|
||||
"tot_evlu_amt": "100000",
|
||||
"dnca_tot_amt": "50000",
|
||||
"pchs_amt_smtl_amt": "50000",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||
|
||||
market = MagicMock()
|
||||
market.name = "Korea"
|
||||
market.code = "KR"
|
||||
market.exchange_code = "KRX"
|
||||
market.is_domestic = True
|
||||
|
||||
telegram = MagicMock()
|
||||
telegram.notify_trade_execution = AsyncMock()
|
||||
telegram.notify_fat_finger = AsyncMock()
|
||||
telegram.notify_circuit_breaker = AsyncMock()
|
||||
telegram.notify_scenario_matched = AsyncMock()
|
||||
|
||||
settings = MagicMock()
|
||||
settings.POSITION_SIZING_ENABLED = False
|
||||
settings.CONFIDENCE_THRESHOLD = 80
|
||||
|
||||
try:
|
||||
KILL_SWITCH.new_orders_blocked = True
|
||||
await trading_cycle(
|
||||
broker=broker,
|
||||
overseas_broker=MagicMock(),
|
||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match())),
|
||||
playbook=_make_playbook(),
|
||||
risk=MagicMock(),
|
||||
db_conn=db_conn,
|
||||
decision_logger=decision_logger,
|
||||
context_store=MagicMock(
|
||||
get_latest_timeframe=MagicMock(return_value=None),
|
||||
set_context=MagicMock(),
|
||||
),
|
||||
criticality_assessor=MagicMock(
|
||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||
get_timeout=MagicMock(return_value=5.0),
|
||||
),
|
||||
telegram=telegram,
|
||||
market=market,
|
||||
stock_code="005930",
|
||||
scan_candidates={},
|
||||
settings=settings,
|
||||
)
|
||||
finally:
|
||||
KILL_SWITCH.clear_block()
|
||||
|
||||
broker.send_order.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_policy_rejection_skips_order_execution() -> None:
|
||||
"""Order policy rejection must prevent order submission."""
|
||||
db_conn = init_db(":memory:")
|
||||
decision_logger = DecisionLogger(db_conn)
|
||||
|
||||
broker = MagicMock()
|
||||
broker.get_current_price = AsyncMock(return_value=(100.0, 0.5, 0.0))
|
||||
broker.get_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [],
|
||||
"output2": [
|
||||
{
|
||||
"tot_evlu_amt": "100000",
|
||||
"dnca_tot_amt": "50000",
|
||||
"pchs_amt_smtl_amt": "50000",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||
|
||||
market = MagicMock()
|
||||
market.name = "Korea"
|
||||
market.code = "KR"
|
||||
market.exchange_code = "KRX"
|
||||
market.is_domestic = True
|
||||
|
||||
telegram = MagicMock()
|
||||
telegram.notify_trade_execution = AsyncMock()
|
||||
telegram.notify_fat_finger = AsyncMock()
|
||||
telegram.notify_circuit_breaker = AsyncMock()
|
||||
telegram.notify_scenario_matched = AsyncMock()
|
||||
|
||||
settings = MagicMock()
|
||||
settings.POSITION_SIZING_ENABLED = False
|
||||
settings.CONFIDENCE_THRESHOLD = 80
|
||||
|
||||
with patch(
|
||||
"src.main.validate_order_policy",
|
||||
side_effect=OrderPolicyRejected(
|
||||
"rejected",
|
||||
session_id="NXT_AFTER",
|
||||
market_code="KR",
|
||||
),
|
||||
):
|
||||
await trading_cycle(
|
||||
broker=broker,
|
||||
overseas_broker=MagicMock(),
|
||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match())),
|
||||
playbook=_make_playbook(),
|
||||
risk=MagicMock(),
|
||||
db_conn=db_conn,
|
||||
decision_logger=decision_logger,
|
||||
context_store=MagicMock(
|
||||
get_latest_timeframe=MagicMock(return_value=None),
|
||||
set_context=MagicMock(),
|
||||
),
|
||||
criticality_assessor=MagicMock(
|
||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||
get_timeout=MagicMock(return_value=5.0),
|
||||
),
|
||||
telegram=telegram,
|
||||
market=market,
|
||||
stock_code="005930",
|
||||
scan_candidates={},
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
broker.send_order.assert_not_called()
|
||||
|
||||
40
tests/test_order_policy.py
Normal file
40
tests/test_order_policy.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.order_policy import OrderPolicyRejected, classify_session_id, validate_order_policy
|
||||
from src.markets.schedule import MARKETS
|
||||
|
||||
|
||||
def test_classify_kr_nxt_after() -> None:
|
||||
# 2026-02-26 16:00 KST == 07:00 UTC
|
||||
now = datetime(2026, 2, 26, 7, 0, tzinfo=UTC)
|
||||
assert classify_session_id(MARKETS["KR"], now) == "NXT_AFTER"
|
||||
|
||||
|
||||
def test_classify_us_pre() -> None:
|
||||
# 2026-02-26 19:00 KST == 10:00 UTC
|
||||
now = datetime(2026, 2, 26, 10, 0, tzinfo=UTC)
|
||||
assert classify_session_id(MARKETS["US_NASDAQ"], now) == "US_PRE"
|
||||
|
||||
|
||||
def test_reject_market_order_in_low_liquidity_session() -> None:
|
||||
now = datetime(2026, 2, 26, 10, 0, tzinfo=UTC) # 19:00 KST -> US_PRE
|
||||
with pytest.raises(OrderPolicyRejected):
|
||||
validate_order_policy(
|
||||
market=MARKETS["US_NASDAQ"],
|
||||
order_type="BUY",
|
||||
price=0.0,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def test_allow_limit_order_in_low_liquidity_session() -> None:
|
||||
now = datetime(2026, 2, 26, 10, 0, tzinfo=UTC) # 19:00 KST -> US_PRE
|
||||
info = validate_order_policy(
|
||||
market=MARKETS["US_NASDAQ"],
|
||||
order_type="BUY",
|
||||
price=100.0,
|
||||
now=now,
|
||||
)
|
||||
assert info.session_id == "US_PRE"
|
||||
38
tests/test_strategy_exit_rules.py
Normal file
38
tests/test_strategy_exit_rules.py
Normal 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"
|
||||
30
tests/test_strategy_state_machine.py
Normal file
30
tests/test_strategy_state_machine.py
Normal 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
|
||||
Reference in New Issue
Block a user