"""주문 접수 후 체결·취소 추적 — on-demand 데몬 패턴. 알바 측: watch(...) [fill kind] / watch_cancel(...) [cancel kind] → 큐 파일(state/fill_pending.jsonl)에 entry append + 데몬 ensure_running. 데몬 측 (orders/fill_watcher_daemon.py main): 큐 파일 읽어 _FillWatcher._tracked 에 동기화 → kt00007 폴링(체결 추적) + ka10075 폴링(취소 추적, cancel watch 존재 시만) → 알림 → 추적 끝난 entry 큐에서 제거 → 큐 비면 자기 종료(sys.exit + PID 파일 삭제). 폴링 스케줄 (모든 주문 공통, 가장 어린 주문 경과 시간 기준): - 0~30초: 5초 간격 - 30~120초: 10초 간격 - 120~600초: 30초 간격 - 600~1800초: 60초 간격 - 1800초(30분) 경과 시 미체결/미확인 알림 1회 + 추적 종료 cancel kind: 원주문(orig_ord_no)이 ka10075 미체결 목록에서 사라지면 취소 확정. 사용자가 직접 발주한 fill watch 가 동시에 있으면 mdfy_cncl 발생 시 '사후거절' 메시지를 억제(취소 watch 가 확정 메시지 책임). """ from __future__ import annotations import contextlib import fcntl import json import os import subprocess import sys import threading import time from dataclasses import asdict, dataclass from pathlib import Path from typing import Callable, Optional from . import card, ledger UNFILLED_TIMEOUT_SECONDS = 1800 # 30분 def _spawn_journal_collect() -> None: """전량 체결(filled) 직후 trade_journal.collect 비동기 호출. scripts/는 _SCRIPTS_DIR(=parent) 기준 sys.path 추가 후 import. 실패해도 fill 흐름·21:00 launchd 재적재에 영향 없음.""" def _run(): try: if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) import trade_journal as tj tj.collect(quiet=True) except Exception as e: sys.stderr.write(f'[fill→journal] collect failed: {e}\n') threading.Thread(target=_run, daemon=True).start() _ORDERS_DIR = Path(__file__).resolve().parent _SCRIPTS_DIR = _ORDERS_DIR.parent _WORKSPACE_ROOT = _SCRIPTS_DIR.parent _STATE_DIR = _WORKSPACE_ROOT / 'state' QUEUE_FILE = _STATE_DIR / 'fill_pending.jsonl' QUEUE_LOCK = _STATE_DIR / 'fill_pending.jsonl.lock' PID_FILE = _STATE_DIR / 'fill_watcher.pid' # ---------- Tracked entry ---------- @dataclass class Tracked: ord_no: str account: str side: str symbol: str symbol_name: str order_qty: int price: Optional[int] order_type: str card_id: str started_at: float last_cntr_qty: int = 0 kind: str = 'fill' # 'fill' (체결추적) | 'cancel' (취소확정추적) orig_ord_no: Optional[str] = None # cancel kind 전용 — 취소 대상 원주문 번호 # ---------- 큐 파일 IO (atomic, file-locked) ---------- @contextlib.contextmanager def _file_lock(path: Path): path.parent.mkdir(parents=True, exist_ok=True) fp = open(path, 'w') try: fcntl.flock(fp, fcntl.LOCK_EX) yield finally: try: fcntl.flock(fp, fcntl.LOCK_UN) finally: fp.close() def append_queue_entry(entry: dict) -> None: """큐 파일에 한 줄 append (lock 보호).""" with _file_lock(QUEUE_LOCK): QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) with QUEUE_FILE.open('a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') def read_queue() -> list[dict]: """큐 파일을 읽어 entry 리스트 반환. 빈 줄/잘못된 JSON 은 스킵.""" if not QUEUE_FILE.exists(): return [] with _file_lock(QUEUE_LOCK): out: list[dict] = [] for line in QUEUE_FILE.read_text(encoding='utf-8').splitlines(): line = line.strip() if not line: continue try: out.append(json.loads(line)) except ValueError: continue return out def persist_queue(entries: list[dict]) -> None: """큐 파일 전체 다시 쓰기 (rewrite).""" with _file_lock(QUEUE_LOCK): QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) if not entries: if QUEUE_FILE.exists(): try: QUEUE_FILE.unlink() except OSError: QUEUE_FILE.write_text('', encoding='utf-8') return body = '\n'.join(json.dumps(e, ensure_ascii=False) for e in entries) + '\n' tmp = QUEUE_FILE.with_suffix(QUEUE_FILE.suffix + '.tmp') tmp.write_text(body, encoding='utf-8') os.replace(tmp, QUEUE_FILE) # ---------- PID 파일 / 데몬 기동 ---------- def is_daemon_alive() -> bool: """PID 파일 기반 데몬 생존 확인. stale 자동 검출.""" if not PID_FILE.exists(): return False try: pid = int(PID_FILE.read_text().strip()) except (ValueError, OSError): return False if pid <= 0: return False try: os.kill(pid, 0) return True except ProcessLookupError: return False except PermissionError: # 다른 사용자 PID 와 충돌 — 매우 드묾. 살아있다고 보수적 가정. return True def ensure_daemon_running() -> None: """데몬 살아있으면 패스, 죽었으면 fork. 두 알바 동시 호출에도 PID 파일 lock 으로 한 데몬만 살아남음.""" if is_daemon_alive(): return # 데몬 자체가 PID 파일 작성·정리 — 알바는 fork 만. subprocess.Popen( [sys.executable, '-m', 'orders.fill_watcher_daemon'], cwd=str(_SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True, ) # ---------- 데몬 entry — _FillWatcher 가 사용 ---------- class _FillWatcher: """데몬 프로세스 안에서 사용되는 추적 워커. in-memory _tracked + 텔레그램·키움 콜백.""" def __init__(self): self._lock = threading.RLock() self._tracked: dict[str, Tracked] = {} self._send: Callable[[str], bool] = lambda msg: True self._fetch_executions: Callable[[str], list[dict]] = lambda account: [] self._fetch_open_orders: Callable[[str], list[dict]] = lambda account: [] def configure(self, send_func, fetch_executions, fetch_open_orders=None) -> None: with self._lock: self._send = send_func self._fetch_executions = fetch_executions if fetch_open_orders is not None: self._fetch_open_orders = fetch_open_orders def sync_from_queue(self, entries: list[dict]) -> None: """큐 파일 entry 리스트로 _tracked 동기화. 큐에 있고 _tracked 에 없으면 추가, 큐에서 사라진 ord_no 는 _tracked 에서도 제거.""" with self._lock: queue_ord_nos = {e['ord_no'] for e in entries} # 추가 for e in entries: ord_no = e['ord_no'] if ord_no not in self._tracked: self._tracked[ord_no] = Tracked( ord_no=ord_no, account=e['account'], side=e['side'], symbol=e['symbol'], symbol_name=e['symbol_name'], order_qty=int(e['order_qty']), price=e.get('price'), order_type=e['order_type'], card_id=e['card_id'], started_at=float(e['started_at']), last_cntr_qty=int(e.get('last_cntr_qty', 0)), kind=e.get('kind', 'fill'), orig_ord_no=e.get('orig_ord_no'), ) else: # 이미 있으면 last_cntr_qty 만 동기화 (큐가 진실 소스) self._tracked[ord_no].last_cntr_qty = int(e.get('last_cntr_qty', 0)) # 제거 for ord_no in list(self._tracked.keys()): if ord_no not in queue_ord_nos: self._tracked.pop(ord_no, None) def snapshot_entries(self) -> list[dict]: """현재 _tracked 를 큐 파일에 쓸 수 있는 entry 리스트로 직렬화.""" with self._lock: return [asdict(t) for t in self._tracked.values()] def _next_sleep_seconds(self) -> int: with self._lock: if not self._tracked: return 5 now = time.time() min_elapsed = min(now - t.started_at for t in self._tracked.values()) if min_elapsed < 30: return 5 if min_elapsed < 120: return 10 if min_elapsed < 600: return 30 return 60 def _poll_once(self) -> None: with self._lock: fill_accounts = sorted({t.account for t in self._tracked.values() if t.kind == 'fill'}) cancel_accounts = sorted({t.account for t in self._tracked.values() if t.kind == 'cancel'}) tracked_snapshot = dict(self._tracked) rows_by_account: dict[str, list[dict]] = {} for account in fill_accounts: try: rows_by_account[account] = self._fetch_executions(account) or [] except Exception as e: ledger.append('rejected', {'reason': 'FILL_WATCHER_FETCH_ERROR', 'account': account, 'message': repr(e)}) open_orders_by_account: dict[str, list[dict]] = {} for account in cancel_accounts: try: open_orders_by_account[account] = self._fetch_open_orders(account) or [] except Exception as e: ledger.append('rejected', {'reason': 'CANCEL_WATCHER_FETCH_ERROR', 'account': account, 'message': repr(e)}) now = time.time() for ord_no, t in tracked_snapshot.items(): elapsed = now - t.started_at if t.kind == 'cancel': # 원주문이 미체결 목록에서 사라지면 취소 확정 open_rows = open_orders_by_account.get(t.account) if open_rows is None: # fetch 실패 시 다음 폴링으로 미룸 continue still_open = any((r.get('ord_no') or '').strip() == t.orig_ord_no for r in open_rows) if not still_open: self._handle_cancel_confirmed(t) elif elapsed >= UNFILLED_TIMEOUT_SECONDS: self._handle_cancel_timeout(t) continue # fill kind (기본) rows = rows_by_account.get(t.account, []) row = next((r for r in rows if (r.get('ord_no') or '').strip() == ord_no), None) if row is not None: self._handle_row(t, row) elif elapsed >= UNFILLED_TIMEOUT_SECONDS: self._handle_timeout(t) def _handle_row(self, t: Tracked, row: dict) -> None: cntr_qty = int(row.get('cntr_qty') or 0) cntr_uv = int(row.get('cntr_uv') or 0) mdfy_cncl = (row.get('mdfy_cncl') or '').strip() if mdfy_cncl: # 사용자가 cancel_open_order 로 발주한 취소가 잡힌 거면, cancel watch 가 # 확정 메시지를 보낸다 — 여기서는 사후거절 알림 억제 + 조용히 fill watch 해제. with self._lock: user_initiated = any( x.kind == 'cancel' and x.orig_ord_no == t.ord_no for x in self._tracked.values() ) self._tracked.pop(t.ord_no, None) if user_initiated: ledger.append('canceled', {'card_id': t.card_id, 'ord_no': t.ord_no, 'account': t.account, 'symbol': t.symbol, 'reason': 'USER_CANCEL_VIA_CANCEL_ORDER'}) return ledger.append('failed', {'card_id': t.card_id, 'ord_no': t.ord_no, 'account': t.account, 'symbol': t.symbol, 'reason': 'BROKER_POST_REJECT', 'mdfy_cncl': mdfy_cncl}) self._send(card.format_broker_post_reject(t.card_id, t.side, t.symbol_name, t.ord_no, mdfy_cncl)) return if cntr_qty > t.last_cntr_qty: new_fill = cntr_qty - t.last_cntr_qty t.last_cntr_qty = cntr_qty if cntr_qty >= t.order_qty: with self._lock: self._tracked.pop(t.ord_no, None) ledger.append('filled', {'card_id': t.card_id, 'ord_no': t.ord_no, 'account': t.account, 'symbol': t.symbol, 'qty': cntr_qty, 'price': cntr_uv}) self._send(card.format_filled(t.card_id, t.side, t.symbol_name, cntr_qty, cntr_uv, t.ord_no)) # 전량 체결 → 자산웹 거래내역 즉시 갱신 (별도 스레드, 추적 블로킹 X) _spawn_journal_collect() else: ledger.append('partial', {'card_id': t.card_id, 'ord_no': t.ord_no, 'account': t.account, 'symbol': t.symbol, 'cntr_qty': cntr_qty, 'order_qty': t.order_qty, 'price': cntr_uv, 'new_fill': new_fill}) self._send(card.format_partial(t.card_id, t.side, t.symbol_name, cntr_qty, t.order_qty, cntr_uv, t.ord_no)) def _handle_timeout(self, t: Tracked) -> None: with self._lock: self._tracked.pop(t.ord_no, None) ledger.append('expired', {'card_id': t.card_id, 'ord_no': t.ord_no, 'account': t.account, 'symbol': t.symbol, 'reason': 'FILL_WATCH_TIMEOUT', 'order_qty': t.order_qty, 'last_cntr_qty': t.last_cntr_qty}) self._send(card.format_unfilled_timeout(t.card_id, t.side, t.symbol_name, t.last_cntr_qty, t.order_qty, t.ord_no)) def _handle_cancel_confirmed(self, t: Tracked) -> None: with self._lock: self._tracked.pop(t.ord_no, None) if t.orig_ord_no: self._tracked.pop(str(t.orig_ord_no), None) ledger.append('cancel_confirmed', {'card_id': t.card_id, 'new_ord_no': t.ord_no, 'orig_ord_no': t.orig_ord_no, 'account': t.account, 'symbol': t.symbol, 'cancel_qty': t.order_qty}) self._send(card.format_cancel_confirmed(t.side, t.symbol_name, t.orig_ord_no, t.ord_no, t.order_qty)) def _handle_cancel_timeout(self, t: Tracked) -> None: with self._lock: self._tracked.pop(t.ord_no, None) ledger.append('cancel_unconfirmed_timeout', {'card_id': t.card_id, 'new_ord_no': t.ord_no, 'orig_ord_no': t.orig_ord_no, 'account': t.account, 'symbol': t.symbol, 'cancel_qty': t.order_qty}) self._send(card.format_cancel_unconfirmed_timeout(t.side, t.symbol_name, t.orig_ord_no, t.ord_no)) _watcher = _FillWatcher() # ---------- 외부 진입점 ---------- def configure(send_func, fetch_executions, fetch_open_orders=None): _watcher.configure(send_func, fetch_executions, fetch_open_orders) def watch(ord_no, account, side, symbol, symbol_name, order_qty, price, order_type, card_id): """알바 측 진입점 — 큐에 append + 데몬 ensure_running. in-memory 가 아니라 별도 데몬 프로세스가 추적. 호출 프로세스는 즉시 반환. """ if not ord_no: return entry = { 'ord_no': str(ord_no), 'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name, 'order_qty': int(order_qty), 'price': price, 'order_type': order_type, 'card_id': card_id, 'started_at': time.time(), 'last_cntr_qty': 0, 'kind': 'fill', } # 큐에 이미 있는 ord_no 면 중복 append 방지 existing = read_queue() if any(e.get('ord_no') == entry['ord_no'] for e in existing): ensure_daemon_running() return append_queue_entry(entry) ensure_daemon_running() def watch_cancel(new_ord_no, orig_ord_no, account, side, symbol, symbol_name, cancel_qty, card_id=None): """취소 주문(kt10003) 접수 후 broker 확정 추적. new_ord_no: kt10003 응답의 ord_no (취소 주문 자체 번호 — _tracked dict key) orig_ord_no: 취소 대상 원주문 ord_no — ka10075 폴링으로 사라지는지 감시 cancel_qty: 취소 요청 수량 (cancel_qty=0 호출 시 발주 시점 unfilled_qty 전달) side/symbol/symbol_name: 원주문의 것 (텔레그램 메시지 가독성용) """ if not new_ord_no or not orig_ord_no: return entry = { 'ord_no': str(new_ord_no), 'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name, 'order_qty': int(cancel_qty), 'price': None, 'order_type': 'CANCEL', 'card_id': card_id or '', 'started_at': time.time(), 'last_cntr_qty': 0, 'kind': 'cancel', 'orig_ord_no': str(orig_ord_no), } existing = read_queue() if any(e.get('ord_no') == entry['ord_no'] for e in existing): ensure_daemon_running() return append_queue_entry(entry) ensure_daemon_running() # ---------- 테스트 헬퍼 ---------- def _reset_for_test(): with _watcher._lock: _watcher._tracked.clear() if QUEUE_FILE.exists(): try: QUEUE_FILE.unlink() except OSError: pass if PID_FILE.exists(): try: PID_FILE.unlink() except OSError: pass def _peek_for_test(): with _watcher._lock: return dict(_watcher._tracked)