Files
openclaw/agents/stock/workspace/scripts/orders/trailing.py
T
hyowons d7042430c3 auto: 일일 백업 2026-07-31 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 02:00:02 +09:00

252 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""트레일링 스톱 예약 상태 관리.
키움에 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