blackout: enforce bounded oldest-drop queue policy on overflow (#371)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Doc-ID: DOC-REQ-001
|
Doc-ID: DOC-REQ-001
|
||||||
Version: 1.0.5
|
Version: 1.0.6
|
||||||
Status: active
|
Status: active
|
||||||
Owner: strategy
|
Owner: strategy
|
||||||
Updated: 2026-03-02
|
Updated: 2026-03-02
|
||||||
@@ -26,7 +26,7 @@ Updated: 2026-03-02
|
|||||||
- `REQ-V3-001`: 모든 신호/주문/로그는 `session_id`를 포함해야 한다.
|
- `REQ-V3-001`: 모든 신호/주문/로그는 `session_id`를 포함해야 한다.
|
||||||
- `REQ-V3-002`: 세션 전환 시 리스크 파라미터 재로딩이 수행되어야 한다.
|
- `REQ-V3-002`: 세션 전환 시 리스크 파라미터 재로딩이 수행되어야 한다.
|
||||||
- `REQ-V3-003`: 브로커 블랙아웃 시간대에는 신규 주문이 금지되어야 한다.
|
- `REQ-V3-003`: 브로커 블랙아웃 시간대에는 신규 주문이 금지되어야 한다.
|
||||||
- `REQ-V3-004`: 블랙아웃 중 신호는 Queue에 적재되고, 복구 후 유효성 재검증을 거친다.
|
- `REQ-V3-004`: 블랙아웃 중 신호는 bounded Queue에 적재되며, 포화 시 oldest-drop 정책으로 최신 intent를 보존하고 복구 후 유효성 재검증을 거친다.
|
||||||
- `REQ-V3-005`: 저유동 세션(`NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`)은 시장가 주문 금지다.
|
- `REQ-V3-005`: 저유동 세션(`NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`)은 시장가 주문 금지다.
|
||||||
- `REQ-V3-006`: 백테스트 체결가는 불리한 방향 체결 가정을 기본으로 한다.
|
- `REQ-V3-006`: 백테스트 체결가는 불리한 방향 체결 가정을 기본으로 한다.
|
||||||
- `REQ-V3-007`: US 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.
|
- `REQ-V3-007`: US 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Updated: 2026-03-02
|
|||||||
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
|
||||||
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | 구현 존재, 세션 경계 E2E 회귀 보강 필요 (`#376`) |
|
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | 구현 존재, 세션 경계 E2E 회귀 보강 필요 (`#376`) |
|
||||||
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
|
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
|
||||||
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화 시 intent 유실 경로 존재 (`#371`), 재검증 강화를 `#328`에서 추적 |
|
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화는 oldest-drop 정책으로 정합화 (`#371`), 재검증 강화는 `#328` 추적 |
|
||||||
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
|
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
|
||||||
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
|
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
|
||||||
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 런타임 분리 계산/전달 적용 (`#370`), buy-side `fx_rate` 미관측 시 `fx_pnl=0` fallback |
|
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 런타임 분리 계산/전달 적용 (`#370`), buy-side `fx_rate` 미관측 시 `fx_pnl=0` fallback |
|
||||||
@@ -95,7 +95,7 @@ Updated: 2026-03-02
|
|||||||
- **현 상태**:
|
- **현 상태**:
|
||||||
- #324 추적 범위(DB 기록)는 구현 경로가 존재
|
- #324 추적 범위(DB 기록)는 구현 경로가 존재
|
||||||
- #328 범위(가격/세션 재검증 강화)는 추적 이슈 오픈 상태
|
- #328 범위(가격/세션 재검증 강화)는 추적 이슈 오픈 상태
|
||||||
- #371: 큐 포화 시 intent 유실 경로가 남아 있어 `REQ-V3-004`를 완료로 보기 어려움
|
- #371: 큐 포화 정책을 oldest-drop으로 명시/구현해 최신 intent 유실 경로를 제거
|
||||||
- **요구사항**: REQ-V3-004
|
- **요구사항**: REQ-V3-004
|
||||||
|
|
||||||
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
|
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
|
||||||
|
|||||||
@@ -68,11 +68,16 @@ class BlackoutOrderManager:
|
|||||||
self._queue: deque[QueuedOrderIntent] = deque()
|
self._queue: deque[QueuedOrderIntent] = deque()
|
||||||
self._was_blackout = False
|
self._was_blackout = False
|
||||||
self._max_queue_size = max_queue_size
|
self._max_queue_size = max_queue_size
|
||||||
|
self._overflow_drop_count = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
return len(self._queue)
|
return len(self._queue)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overflow_drop_count(self) -> int:
|
||||||
|
return self._overflow_drop_count
|
||||||
|
|
||||||
def in_blackout(self, now: datetime | None = None) -> bool:
|
def in_blackout(self, now: datetime | None = None) -> bool:
|
||||||
if not self.enabled or not self._windows:
|
if not self.enabled or not self._windows:
|
||||||
return False
|
return False
|
||||||
@@ -81,8 +86,11 @@ class BlackoutOrderManager:
|
|||||||
return any(window.contains(kst_now) for window in self._windows)
|
return any(window.contains(kst_now) for window in self._windows)
|
||||||
|
|
||||||
def enqueue(self, intent: QueuedOrderIntent) -> bool:
|
def enqueue(self, intent: QueuedOrderIntent) -> bool:
|
||||||
if len(self._queue) >= self._max_queue_size:
|
if self._max_queue_size <= 0:
|
||||||
return False
|
return False
|
||||||
|
if len(self._queue) >= self._max_queue_size:
|
||||||
|
self._queue.popleft()
|
||||||
|
self._overflow_drop_count += 1
|
||||||
self._queue.append(intent)
|
self._queue.append(intent)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
28
src/main.py
28
src/main.py
@@ -1031,9 +1031,19 @@ def _maybe_queue_order_intent(
|
|||||||
price: float,
|
price: float,
|
||||||
source: str,
|
source: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
def _coerce_nonnegative_int(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return max(0, parsed)
|
||||||
|
|
||||||
if not BLACKOUT_ORDER_MANAGER.in_blackout():
|
if not BLACKOUT_ORDER_MANAGER.in_blackout():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
before_overflow_drops = _coerce_nonnegative_int(
|
||||||
|
getattr(BLACKOUT_ORDER_MANAGER, "overflow_drop_count", 0)
|
||||||
|
)
|
||||||
queued = BLACKOUT_ORDER_MANAGER.enqueue(
|
queued = BLACKOUT_ORDER_MANAGER.enqueue(
|
||||||
_build_queued_order_intent(
|
_build_queued_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
@@ -1045,6 +1055,9 @@ def _maybe_queue_order_intent(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if queued:
|
if queued:
|
||||||
|
after_overflow_drops = _coerce_nonnegative_int(
|
||||||
|
getattr(BLACKOUT_ORDER_MANAGER, "overflow_drop_count", 0)
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
(
|
(
|
||||||
"Blackout active: queued order intent %s %s (%s) "
|
"Blackout active: queued order intent %s %s (%s) "
|
||||||
@@ -1058,9 +1071,22 @@ def _maybe_queue_order_intent(
|
|||||||
source,
|
source,
|
||||||
BLACKOUT_ORDER_MANAGER.pending_count,
|
BLACKOUT_ORDER_MANAGER.pending_count,
|
||||||
)
|
)
|
||||||
|
if after_overflow_drops > before_overflow_drops:
|
||||||
|
logger.error(
|
||||||
|
(
|
||||||
|
"Blackout queue overflow policy applied: evicted oldest intent "
|
||||||
|
"to keep latest %s %s (%s) source=%s pending=%d total_evicted=%d"
|
||||||
|
),
|
||||||
|
order_type,
|
||||||
|
stock_code,
|
||||||
|
market.code,
|
||||||
|
source,
|
||||||
|
BLACKOUT_ORDER_MANAGER.pending_count,
|
||||||
|
after_overflow_drops,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Blackout queue full: dropped order intent %s %s (%s) qty=%d source=%s",
|
"Blackout queue unavailable: could not queue order intent %s %s (%s) qty=%d source=%s",
|
||||||
order_type,
|
order_type,
|
||||||
stock_code,
|
stock_code,
|
||||||
market.code,
|
market.code,
|
||||||
|
|||||||
@@ -79,3 +79,51 @@ def test_requeued_intent_is_processed_next_non_blackout_cycle() -> None:
|
|||||||
manager.requeue(first_batch[0])
|
manager.requeue(first_batch[0])
|
||||||
second_batch = manager.pop_recovery_batch(outside_blackout)
|
second_batch = manager.pop_recovery_batch(outside_blackout)
|
||||||
assert len(second_batch) == 1
|
assert len(second_batch) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_overflow_drops_oldest_and_keeps_latest() -> None:
|
||||||
|
manager = BlackoutOrderManager(
|
||||||
|
enabled=True,
|
||||||
|
windows=parse_blackout_windows_kst("23:30-00:10"),
|
||||||
|
max_queue_size=2,
|
||||||
|
)
|
||||||
|
first = QueuedOrderIntent(
|
||||||
|
market_code="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
stock_code="000001",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
source="first",
|
||||||
|
queued_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
second = QueuedOrderIntent(
|
||||||
|
market_code="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
stock_code="000002",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=1,
|
||||||
|
price=101.0,
|
||||||
|
source="second",
|
||||||
|
queued_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
third = QueuedOrderIntent(
|
||||||
|
market_code="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
stock_code="000003",
|
||||||
|
order_type="SELL",
|
||||||
|
quantity=2,
|
||||||
|
price=102.0,
|
||||||
|
source="third",
|
||||||
|
queued_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.enqueue(first)
|
||||||
|
assert manager.enqueue(second)
|
||||||
|
assert manager.enqueue(third)
|
||||||
|
assert manager.pending_count == 2
|
||||||
|
assert manager.overflow_drop_count == 1
|
||||||
|
|
||||||
|
outside_blackout = datetime(2026, 1, 1, 15, 20, tzinfo=UTC)
|
||||||
|
batch = manager.pop_recovery_batch(outside_blackout)
|
||||||
|
assert [intent.stock_code for intent in batch] == ["000002", "000003"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import src.main as main_module
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.context.layer import ContextLayer
|
from src.context.layer import ContextLayer
|
||||||
from src.context.scheduler import ScheduleResult
|
from src.context.scheduler import ScheduleResult
|
||||||
|
from src.core.blackout_manager import BlackoutOrderManager
|
||||||
from src.core.order_policy import OrderPolicyRejected, get_session_info
|
from src.core.order_policy import OrderPolicyRejected, get_session_info
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
from src.db import init_db, log_trade
|
from src.db import init_db, log_trade
|
||||||
@@ -33,6 +34,7 @@ from src.main import (
|
|||||||
_extract_held_qty_from_balance,
|
_extract_held_qty_from_balance,
|
||||||
_handle_market_close,
|
_handle_market_close,
|
||||||
_inject_staged_exit_features,
|
_inject_staged_exit_features,
|
||||||
|
_maybe_queue_order_intent,
|
||||||
_resolve_market_setting,
|
_resolve_market_setting,
|
||||||
_resolve_sell_qty_for_pnl,
|
_resolve_sell_qty_for_pnl,
|
||||||
_retry_connection,
|
_retry_connection,
|
||||||
@@ -6529,6 +6531,40 @@ async def test_blackout_queues_order_and_skips_submission() -> None:
|
|||||||
blackout_manager.enqueue.assert_called_once()
|
blackout_manager.enqueue.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blackout_queue_overflow_keeps_latest_intent() -> None:
|
||||||
|
manager = BlackoutOrderManager(enabled=True, windows=[], max_queue_size=1)
|
||||||
|
manager.in_blackout = lambda now=None: True # type: ignore[method-assign]
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
|
||||||
|
with patch("src.main.BLACKOUT_ORDER_MANAGER", manager):
|
||||||
|
assert _maybe_queue_order_intent(
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
source="test-first",
|
||||||
|
)
|
||||||
|
assert _maybe_queue_order_intent(
|
||||||
|
market=market,
|
||||||
|
stock_code="000660",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=2,
|
||||||
|
price=200.0,
|
||||||
|
source="test-second",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.pending_count == 1
|
||||||
|
assert manager.overflow_drop_count == 1
|
||||||
|
manager.in_blackout = lambda now=None: False # type: ignore[method-assign]
|
||||||
|
batch = manager.pop_recovery_batch()
|
||||||
|
assert len(batch) == 1
|
||||||
|
assert batch[0].stock_code == "000660"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
||||||
"""Recovery must execute queued intents that pass revalidation."""
|
"""Recovery must execute queued intents that pass revalidation."""
|
||||||
|
|||||||
Reference in New Issue
Block a user