Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
"""주문 접수 후 체결·취소 추적 — 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)
|
||||
Reference in New Issue
Block a user