"""트레일링 스톱 예약 상태 관리. 키움에 REST 트레일링 주문 TR 이 없어서, 스톱지정가(trde_tp=28) 주문을 실제로 걸어두고 고점이 갱신될 때마다 조건단가를 kt10002 정정으로 올려 트레일링을 구현한다. 주문이 키움 서버에 있으므로 감시 루프가 죽어도 마지막 손절선은 살아있다. 감시 루프가 하는 일은 "이미 승인된 주문의 조건단가 상향"뿐 — 신규 발주·수량 변경 없음. ⚠️ kt10002 정정 응답의 ord_no 는 신규 주문번호다. 정정할 때마다 ord_no 를 갱신하지 않으면 두 번째 정정부터 orig_ord_no 가 틀려서 전부 실패한다. commit_modify 가 이걸 담당. 상태는 파일(state/trailing_stops.json)에 저장 — 웹·감시 루프가 각각 다른 프로세스라 같은 예약 목록을 봐야 한다. 동시성은 fcntl flock 으로 직렬화 (pin.py 와 같은 패턴). """ from __future__ import annotations import fcntl import json import os import secrets from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional from .guards import tick_size WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent STATE_FILE = WORKSPACE_ROOT / 'state' / 'trailing_stops.json' KST = timezone(timedelta(hours=9)) # 트레일 폭 허용 범위 (%). 너무 좁으면 정상 출렁임에 즉시 털리고, 너무 넓으면 스톱 의미가 없다. # ⚠️ 상한을 바꾸면 **세 곳**이 같이 움직여야 한다 — 여기(서버 검증) / # behive_web 트레일 폭 드롭다운 옵션 / behive_web `trailBlockReason` 의 범위 체크. # 하나만 고치면 드롭다운에서 고를 수 있는데 서버가 거부하거나 그 반대가 된다. MIN_TRAIL_PCT = 0.5 MAX_TRAIL_PCT = 30.0 # 지정가(ord_uv) = 조건단가(cond_uv) 보다 이 틱수만큼 아래. # 조건단가와 같게 두면 발동 후 그 가격 아래로는 안 팔려 미체결로 남는다. # 반대로 너무 벌리면 급락 시 헐값 매도가 되니 2틱. ORD_UV_GAP_TICKS = 2 def _now_iso() -> str: return datetime.now(KST).isoformat(timespec='seconds') def floor_to_tick(price: int) -> int: """호가단위로 내림. 매도 조건단가·지정가는 내림이 안전(더 빨리 발동/체결).""" t = tick_size(price) return price - (price % t) def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] = None) -> dict: """고점·트레일 폭(·최저 매도가) → 조건단가(트리거)·지정가(체결가). cond_uv = max(peak × (1 − pct/100), min_sell_price) 를 호가단위 내림 ord_uv = cond_uv − ORD_UV_GAP_TICKS 틱 min_sell_price(최저 매도가) = 손절선의 하한. 트레일 폭을 넓게 잡아도 이 가격 아래로는 손절선이 내려가지 않는다 — 초기 구간 손실 제한용. 고점이 올라 트레일 손절선이 이 값을 추월하면 그 뒤로는 트레일이 지배한다(최저가는 자연히 무의미해짐). """ if peak <= 0: raise ValueError(f'peak must be > 0 (got {peak})') if not (MIN_TRAIL_PCT <= trail_pct <= MAX_TRAIL_PCT): raise ValueError(f'trail_pct out of range: {trail_pct}') cond_uv = floor_to_tick(int(peak * (1 - trail_pct / 100))) floor_applied = False if min_sell_price: if min_sell_price <= 0: raise ValueError(f'min_sell_price must be > 0 (got {min_sell_price})') floor_uv = floor_to_tick(int(min_sell_price)) if floor_uv > cond_uv: cond_uv = floor_uv floor_applied = True ord_uv = floor_to_tick(cond_uv - ORD_UV_GAP_TICKS * tick_size(cond_uv)) if cond_uv <= 0 or ord_uv <= 0: raise ValueError(f'computed level <= 0 (peak={peak}, pct={trail_pct})') return {'cond_uv': cond_uv, 'ord_uv': ord_uv, 'floor_applied': floor_applied} def next_levels(res: dict, cur_price: int) -> Optional[dict]: """현재가를 보고 올릴 값이 있으면 반환, 없으면 None (순수 함수 — 단위테스트 대상). 상향 전용: 고점이 갱신되고 그 결과 조건단가가 실제로 올라갈 때만 정정 대상. 고점이 올라도 호가단위 내림 때문에 조건단가가 그대로면 정정하지 않는다(불필요한 API 콜 차단). 최저 매도가가 아직 지배 중이면 트레일 손절선이 올라도 조건단가가 안 변해 None 이 된다. """ if cur_price <= 0: return None if cur_price <= res['peak']: return None lv = compute_levels(cur_price, res['trail_pct'], res.get('min_sell_price')) if lv['cond_uv'] <= res['cond_uv']: return None return {'peak': cur_price, 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv']} class _FileLock: def __init__(self, path: Path): self.path = path self._fp = None def __enter__(self): self.path.parent.mkdir(parents=True, exist_ok=True) self._fp = open(self.path, 'w') fcntl.flock(self._fp, fcntl.LOCK_EX) return self def __exit__(self, *args): try: fcntl.flock(self._fp, fcntl.LOCK_UN) finally: self._fp.close() _LOCK_FILE = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock') def _read() -> list: if not STATE_FILE.exists(): return [] try: data = json.loads(STATE_FILE.read_text(encoding='utf-8')) except (OSError, ValueError): return [] return data.get('reservations') or [] def _write(reservations: list) -> None: STATE_FILE.parent.mkdir(parents=True, exist_ok=True) tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + '.tmp') tmp.write_text(json.dumps({'reservations': reservations}, ensure_ascii=False, indent=2), encoding='utf-8') os.replace(tmp, STATE_FILE) def list_active() -> list: """등록된 예약 전체 (락 없이 읽기 — 감시 루프의 스냅샷용).""" return _read() def get(res_id: str) -> Optional[dict]: for r in _read(): if r['id'] == res_id: return r return None def find_by_symbol(account: str, symbol: str) -> Optional[dict]: """같은 계좌·종목의 기존 예약. 중복 등록 차단용.""" for r in _read(): if r['account'] == account and r['symbol'] == symbol: return r return None def register(ord_no: str, account: str, symbol: str, symbol_name: str, qty: int, trail_pct: float, peak: int, cond_uv: int, ord_uv: int, routing_suffix: str = '', card_id: Optional[str] = None, min_sell_price: Optional[int] = None) -> dict: """키움에 스톱주문이 접수된 직후 호출 — ord_no 를 받아 예약으로 등록. 초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이 현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다. """ if not ord_no: raise ValueError('ord_no required — 접수 확인된 주문만 등록') res = { 'id': 'TRL-' + ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4)), 'ord_no': ord_no, 'account': account, 'symbol': symbol, 'symbol_name': symbol_name, 'qty': qty, 'trail_pct': trail_pct, 'min_sell_price': min_sell_price, 'peak': peak, 'cond_uv': cond_uv, 'ord_uv': ord_uv, 'routing_suffix': routing_suffix, 'card_id': card_id, 'created_at': _now_iso(), 'updated_at': _now_iso(), 'modify_count': 0, 'entry_peak': peak, 'entry_cond_uv': cond_uv, } with _FileLock(_LOCK_FILE): reservations = _read() reservations.append(res) _write(reservations) return res def commit_modify(res_id: str, new_ord_no: str, peak: int, cond_uv: int, ord_uv: int) -> Optional[dict]: """정정 성공 후 상태 갱신. new_ord_no 는 kt10002 응답의 신규 주문번호. ⚠️ ord_no 갱신이 이 함수의 핵심 — 안 하면 다음 정정이 실패한다. """ if not new_ord_no: raise ValueError('new_ord_no required') with _FileLock(_LOCK_FILE): reservations = _read() for r in reservations: if r['id'] == res_id: r['ord_no'] = new_ord_no r['peak'] = peak r['cond_uv'] = cond_uv r['ord_uv'] = ord_uv r['modify_count'] = r.get('modify_count', 0) + 1 r['updated_at'] = _now_iso() _write(reservations) return r return None def should_notify_failure(res_id: str, now_ts: float, cooldown_sec: int) -> bool: """정정 실패 알림을 보낼지. 보낸다고 판단하면 그 시점을 기록한다 (락 안에서 원자적). NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 실패가 반복된다 — 쿨다운 없이 알리면 20:00까지 매분 텔레그램이 쏟아진다. """ with _FileLock(_LOCK_FILE): reservations = _read() for r in reservations: if r['id'] != res_id: continue last = r.get('last_fail_notice_ts') or 0 if now_ts - last < cooldown_sec: return False r['last_fail_notice_ts'] = now_ts _write(reservations) return True return False def remove(res_id: str, reason: str = '') -> Optional[dict]: """예약 종료 (체결·취소·소멸). 반환값은 제거된 예약 — 알림 메시지용.""" with _FileLock(_LOCK_FILE): reservations = _read() for i, r in enumerate(reservations): if r['id'] == res_id: gone = reservations.pop(i) _write(reservations) gone['removed_reason'] = reason return gone return None