feat(safety-sell): dcy-007 core 자율 안전 매도 — BAT-010 기반 + BAT-011 트리거

dcy-007(core 자율 안전 매도) 전체 delivery. BAT-010 기반 인프라가 미커밋
상태였고 BAT-011 트리거 레이어와 공유 파일에서 섞여 있어 한 커밋으로 묶음.

BAT-010 (REQ-034/035/036/041, release-candidate):
- WatchdogStore(application/watchdog.py) + snapshot RPC 워치독 갱신
- Slot stop_loss_price/take_profit_price 필드 + 004_add_price_thresholds.sql
- SlotService.sell() 자율 매도 내부 경로 + idempotency
- SafetySellConfig env 5종 파싱

BAT-011 (REQ-037/038/039/040/042, release-candidate):
- SafetySellService(application/safety_sell.py): forced_close 만기 / stop-loss·
  take-profit KIS polling / 장 마감 강제 청산 + trex-safety-sell 7번째 스레드
- BrokerPort.get_current_price + KIS inquire-price(TTTC8397R/VTTC8397R, stck_prpr)
  + paper stub
- SlotService.update_thresholds() + intent RPC update_thresholds 액션
  (부분 갱신/null 해제, 워치독 last_seen_at 리셋)
- baseline 3문서 정합(interface-contract/runtime-flows/project-structure)

거버넌스: REQ-034~042 Implemented 동기화, BAT-010/011 release-candidate,
dcy-007 discovery 포함.

검증: 신규 38 테스트 green, 단위 236 passed/23 skipped,
인프라 게이트(PG+RabbitMQ) 259 passed(마이그레이션 001~004 round-trip 포함),
ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 08:32:30 +09:00
co-authored by Claude Opus 4.8
parent e4e5228972
commit a30efa8f3b
47 changed files with 3399 additions and 36 deletions
+32 -2
View File
@@ -5,9 +5,12 @@ strategy-facing communication runs over RabbitMQ (REQ-021/022/023).
Threads (started in `run()`):
- intent RPC consumer — `trex.intent.req`, prefetch=1, FIFO, dedup via intent_log
- snapshot RPC consumer — `trex.snapshot.req`
- snapshot RPC consumer — `trex.snapshot.req` (also refreshes the watchdog)
- pause RPC consumer — `trex.pause.req` (REQ-032)
- execution loop — broker dispatch + poll + slot transitions + event emit
- ledger writer — ledger outbox drain (REQ-019)
- snapshot scheduler — daily portfolio-hub snapshot refresh (REQ-030)
- safety-sell worker — forced_close / stop-loss·take-profit / market-close (REQ-037/038/039)
Each consumer/publisher uses its OWN RabbitMQTransport instance because
pika BlockingConnection is not thread-safe across channels.
@@ -56,7 +59,9 @@ from trex_core.application.portfolio import (
PortfolioHubReadPort,
SnapshotStore,
)
from trex_core.application.safety_sell import SafetySellService
from trex_core.application.snapshot_scheduler import SnapshotScheduler
from trex_core.application.watchdog import WatchdogStore
from trex_core.domain.slot import SlotService
from trex_core.runtime.config import DaemonConfig
@@ -140,6 +145,8 @@ class Daemon:
# BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler.
self.snapshot_store = SnapshotStore()
# BAT-010/011: per-slot watchdog (snapshot RPC writer, safety-sell reader).
self.watchdog = WatchdogStore()
self.pause_store = PostgreSQLPauseStore(config.dsn)
self.portfolio_read = _build_portfolio_read(config)
self.snapshot_scheduler = SnapshotScheduler(
@@ -160,6 +167,15 @@ class Daemon:
event_emitter=self._make_event_emitter(),
)
# BAT-011: safety-sell trigger service (forced_close / SL-TP / market-close).
self.safety_sell = SafetySellService(
slot_repo=self.slot_repo,
slot_service=self.slot_service,
broker=self.broker,
watchdog=self.watchdog,
config=config.safety_sell,
)
# ------------------------------------------------------------ event hook
def _make_event_emitter(self):
def emit(event_type: str, slot) -> None:
@@ -195,7 +211,11 @@ class Daemon:
transport.declare_topology()
except Exception: # noqa: BLE001
pass
handler = make_intent_rpc_handler(self.intent_service)
handler = make_intent_rpc_handler(
self.intent_service,
slot_service=self.slot_service,
watchdog_store=self.watchdog,
)
transport.consume_rpc_forever(topology.INTENT_REQ_QUEUE, handler, self.stop)
def _snapshot_rpc_loop(self) -> None:
@@ -210,6 +230,7 @@ class Daemon:
snapshot_store=self.snapshot_store,
pause_store=self.pause_store,
default_total_slots=self.config.total_slots,
watchdog_store=self.watchdog,
)
transport.consume_rpc_forever(topology.SNAPSHOT_REQ_QUEUE, handler, self.stop)
@@ -223,6 +244,14 @@ class Daemon:
handler = make_pause_rpc_handler(self.pause_store)
transport.consume_rpc_forever(topology.PAUSE_REQ_QUEUE, handler, self.stop)
def _safety_sell_loop(self) -> None:
# REQ-037/038/039: forced_close / stop-loss·take-profit / market-close.
while not self.stop.wait(self.safety_sell.base_tick_sec):
try:
self.safety_sell.run_once()
except Exception: # noqa: BLE001 - loop must survive transient errors
continue
def _snapshot_scheduler_loop(self) -> None:
try:
self.snapshot_scheduler.run_forever(
@@ -251,6 +280,7 @@ class Daemon:
("trex-snapshot-rpc", self._snapshot_rpc_loop),
("trex-pause-rpc", self._pause_rpc_loop),
("trex-snapshot-scheduler", self._snapshot_scheduler_loop),
("trex-safety-sell", self._safety_sell_loop),
]
for name, target in workers:
t = threading.Thread(target=target, name=name, daemon=True)