d7042430c3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
9.8 KiB
Python
252 lines
9.8 KiB
Python
"""트레일링 스톱 예약 상태 관리.
|
||
|
||
키움에 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
|