Files
openclaw/agents/stock/workspace/scripts/orders/fill_watcher.py
T
hyowons 549545bde6 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:10:57 +09:00

458 lines
18 KiB
Python

"""주문 접수 후 체결·취소 추적 — 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)