549545bde6
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
458 lines
18 KiB
Python
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)
|