30be97ab2f
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
18 KiB
Python
402 lines
18 KiB
Python
"""트레일링 스톱 예약 상태 관리 (계단식 분할 매도).
|
||
|
||
키움에 REST 트레일링 주문 TR 이 없어서, 스톱지정가(trde_tp=28) 주문을 실제로 걸어두고
|
||
고점이 갱신될 때마다 조건단가를 kt10002 정정으로 올려 트레일링을 구현한다.
|
||
|
||
**예약 1건 = 계단(레그) N개 = 키움 스톱주문 N건.** 고점 대비 하락률이 깊어질수록 더 많이
|
||
파는 계단식 청산이다 (예: −10% 20% / −20% 50% / −30% 전량). 계단별로 수량과 조건단가가
|
||
다른 스톱주문을 동시에 걸어두고, 고점이 오르면 살아있는 레그를 전부 상향 정정한다.
|
||
|
||
주문이 키움 서버에 있으므로 감시 루프가 죽어도 계단별 손절선은 살아있다.
|
||
감시 루프가 하는 일은 "이미 승인된 주문의 조건단가 상향"뿐 — 신규 발주·수량 변경 없음.
|
||
|
||
⚠️ 계단 비중은 관리자님이 **누적**으로 입력하고(20/50/100 = 전량), 내부에는 계단별
|
||
**추가분**(20/30/50)으로 환산해 저장한다. 주문 수량이 곧 추가분이기 때문이다.
|
||
|
||
⚠️ 한 계단이 체결된 뒤 반등해 신고점을 찍어도 **남은 계단만 새 고점 기준으로 따라 올린다**.
|
||
남은 수량을 계단 비율대로 재배치하려면 기존 주문 취소 + 신규 발주가 필요한데, 그러면
|
||
감시 루프에 자동 발주 경로가 생긴다(매매 자동 트리거 금지). 2026-07-31 관리자님 결정.
|
||
|
||
⚠️ kt10002 정정 응답의 ord_no 는 신규 주문번호다. 정정할 때마다 ord_no 를 갱신하지 않으면
|
||
두 번째 정정부터 orig_ord_no 가 틀려서 전부 실패한다. commit_step_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
|
||
|
||
# 계단 개수 상한. 고점이 오를 때마다 살아있는 레그를 전부 정정하므로 정정 콜이 계단 수에
|
||
# 비례한다 (예약 3건 × 5계단 = 분당 15콜). 키움 rate limit 여유를 보고 5로 둔다.
|
||
MAX_STEPS = 5
|
||
|
||
|
||
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}')
|
||
# round(_, 6) 은 부동소수점 잡음만 걷어낸다 — 5500×(1−30/100) 이 3849.9999999999995 로
|
||
# 나오는 탓에 int() 가 3849 로 깎고 호가단위 내림이 3845 까지 끌어내리던 버그가 있었다
|
||
# (의도는 3850). 잡음을 없앤 뒤 내림하므로 "정확히 그 폭 아래" 성격은 그대로다.
|
||
cond_uv = floor_to_tick(int(round(peak * (1 - trail_pct / 100), 6)))
|
||
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 normalize_steps(raw: list) -> list:
|
||
"""입력한 계단 정의(누적 비중)를 내부 표현(추가 비중)으로 환산·검증한다.
|
||
|
||
입력 [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, {'pct': 30, 'cum': 100}]
|
||
출력 [{'n': 1, 'pct': 10.0, 'cum': 20.0, 'weight': 20.0}, ... weight 30, 50]
|
||
|
||
cum(누적 비중)은 "이 하락률에 닿았을 때 최초 보유의 몇 %가 팔려 있어야 하는가"다.
|
||
주문 수량은 계단마다의 추가분이므로 weight = cum − 직전 cum 으로 환산한다.
|
||
|
||
마지막 cum 이 100 미만이어도 허용한다(일부만 계단 청산하고 나머지는 계속 보유).
|
||
남는 수량은 스톱이 걸리지 않으므로 호출측이 화면에 표시해 준다.
|
||
"""
|
||
if not raw:
|
||
raise ValueError('계단이 비어 있음')
|
||
if len(raw) > MAX_STEPS:
|
||
raise ValueError(f'계단은 최대 {MAX_STEPS}개 (입력 {len(raw)}개)')
|
||
steps = []
|
||
prev_pct = 0.0
|
||
prev_cum = 0.0
|
||
for i, s in enumerate(raw, start=1):
|
||
pct = float(s['pct'])
|
||
cum = float(s['cum'])
|
||
if not (MIN_TRAIL_PCT <= pct <= MAX_TRAIL_PCT):
|
||
raise ValueError(f'{i}단계 하락률 {pct}% 는 {MIN_TRAIL_PCT}~{MAX_TRAIL_PCT}% 범위를 벗어남')
|
||
if pct <= prev_pct:
|
||
raise ValueError(f'{i}단계 하락률 {pct}% 가 앞 계단({prev_pct}%)보다 깊지 않음')
|
||
if cum <= prev_cum:
|
||
raise ValueError(f'{i}단계 누적 비중 {cum}% 가 앞 계단({prev_cum}%)보다 크지 않음')
|
||
if cum > 100:
|
||
raise ValueError(f'{i}단계 누적 비중 {cum}% 가 100% 를 넘음')
|
||
steps.append({'n': i, 'pct': pct, 'cum': cum, 'weight': cum - prev_cum})
|
||
prev_pct, prev_cum = pct, cum
|
||
return steps
|
||
|
||
|
||
def allocate_step_qty(total_qty: int, weights: list) -> list:
|
||
"""계단별 추가 비중(%) → 정수 주수. 최대잔여법(Hare quota).
|
||
|
||
내림으로 나눈 뒤 남는 주를 소수부가 큰 계단부터 1주씩 배분한다.
|
||
"내림 후 마지막 계단에 몰아주기" 보다 얕은 계단이 0주로 죽는 일이 적다
|
||
(2주 · 20/30/50 → 최대잔여 [0,1,1] 2계단 생존 / 몰아주기 [0,0,2] 1계단).
|
||
|
||
수량이 적어 0주가 되는 계단은 그대로 0 을 반환한다 — 발주할 수 없으므로
|
||
호출측이 걸러내고 "몇 계단으로 줄었는지" 를 사용자에게 알린다.
|
||
"""
|
||
if total_qty <= 0 or not weights:
|
||
return [0] * len(weights)
|
||
exact = [total_qty * w / 100.0 for w in weights]
|
||
base = [int(x) for x in exact]
|
||
remain = int(round(sum(exact))) - sum(base)
|
||
order = sorted(range(len(weights)), key=lambda i: exact[i] - base[i], reverse=True)
|
||
for i in order[:max(0, remain)]:
|
||
base[i] += 1
|
||
return base
|
||
|
||
|
||
def compute_step_levels(peak: int, steps: list, min_sell_price: Optional[int] = None) -> list:
|
||
"""고점 + 계단 정의 → 계단별 조건단가·지정가.
|
||
|
||
최저 매도가는 모든 계단에 같은 하한으로 걸린다. 그래서 초기에는 여러 계단이 같은
|
||
가격으로 뭉칠 수 있다(전부 최저 매도가). 그래도 주문을 병합하지 않는 이유는, 고점이
|
||
올라 트레일 손절선이 최저 매도가를 추월하면 각 계단이 제 하락률대로 다시 벌어지기
|
||
때문이다 — 병합해 버리면 정정만으로는 다시 쪼갤 수 없다(신규 발주가 필요해진다).
|
||
"""
|
||
out = []
|
||
for s in steps:
|
||
lv = compute_levels(peak, s['pct'], min_sell_price)
|
||
out.append({**s, 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv'],
|
||
'floor_applied': lv['floor_applied']})
|
||
return out
|
||
|
||
|
||
def next_step_levels(res: dict, cur_price: int) -> Optional[dict]:
|
||
"""현재가가 고점을 넘었으면 새 고점과 상향 대상 레그를 반환 (순수 함수 — 단위테스트 대상).
|
||
|
||
반환 {'peak': 새 고점, 'steps': [{'n', 'cond_uv', 'ord_uv'}, ...]}.
|
||
고점이 안 올랐으면 None.
|
||
|
||
steps 가 빈 리스트일 수 있다 — 고점은 올랐지만 호가단위 내림이나 최저 매도가 지배로
|
||
조건단가가 하나도 안 올라간 경우다. 이때 호출측은 정정 API 없이 고점만 갱신한다
|
||
(불필요한 콜 0, 화면의 고점 표시는 정확하게 유지).
|
||
"""
|
||
if cur_price <= 0 or cur_price <= res['peak']:
|
||
return None
|
||
ups = []
|
||
for s in res.get('steps') or []:
|
||
lv = compute_levels(cur_price, s['pct'], res.get('min_sell_price'))
|
||
if lv['cond_uv'] > s['cond_uv']:
|
||
ups.append({'n': s['n'], 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv']})
|
||
return {'peak': cur_price, 'steps': ups}
|
||
|
||
|
||
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_steps(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||
peak: int, steps: list, routing_suffix: str = '',
|
||
card_id: Optional[str] = None,
|
||
min_sell_price: Optional[int] = None) -> dict:
|
||
"""키움에 계단별 스톱주문이 접수된 직후 호출 — 레그 목록을 예약 1건으로 등록.
|
||
|
||
steps 각 항목은 접수 결과까지 채워져 있어야 한다:
|
||
{'n', 'pct', 'cum', 'weight', 'qty', 'ord_no', 'cond_uv', 'ord_uv'}
|
||
|
||
접수에 실패한 레그는 호출측이 빼고 넘긴다 — 여기 등록된 것은 전부 키움에 살아있는
|
||
주문이어야 감시 루프의 생존 판정이 성립한다.
|
||
|
||
초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이
|
||
현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다.
|
||
"""
|
||
if not steps:
|
||
raise ValueError('등록할 계단이 없음 — 접수 성공한 레그가 하나도 없다')
|
||
for s in steps:
|
||
if not s.get('ord_no'):
|
||
raise ValueError(f'{s.get("n")}단계 ord_no 없음 — 접수 확인된 주문만 등록')
|
||
res = {
|
||
'id': 'TRL-' + ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4)),
|
||
'account': account,
|
||
'symbol': symbol,
|
||
'symbol_name': symbol_name,
|
||
'qty': total_qty,
|
||
'min_sell_price': min_sell_price,
|
||
'peak': peak,
|
||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight'],
|
||
'qty': s['qty'], 'ord_no': s['ord_no'],
|
||
'cond_uv': s['cond_uv'], 'ord_uv': s['ord_uv'],
|
||
'entry_cond_uv': s['cond_uv'], 'modify_count': 0} for s in steps],
|
||
'routing_suffix': routing_suffix,
|
||
'card_id': card_id,
|
||
'created_at': _now_iso(),
|
||
'updated_at': _now_iso(),
|
||
'entry_peak': peak,
|
||
}
|
||
with _FileLock(_LOCK_FILE):
|
||
reservations = _read()
|
||
reservations.append(res)
|
||
_write(reservations)
|
||
return res
|
||
|
||
|
||
def commit_peak(res_id: str, peak: int) -> Optional[dict]:
|
||
"""고점만 갱신 (정정할 레그가 없었던 경우). 화면의 고점 표시를 정확하게 유지한다."""
|
||
with _FileLock(_LOCK_FILE):
|
||
reservations = _read()
|
||
for r in reservations:
|
||
if r['id'] == res_id:
|
||
r['peak'] = peak
|
||
r['updated_at'] = _now_iso()
|
||
_write(reservations)
|
||
return r
|
||
return None
|
||
|
||
|
||
def commit_step_modify(res_id: str, step_n: int, 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:
|
||
continue
|
||
for s in r.get('steps') or []:
|
||
if s['n'] != step_n:
|
||
continue
|
||
s['ord_no'] = new_ord_no
|
||
s['cond_uv'] = cond_uv
|
||
s['ord_uv'] = ord_uv
|
||
s['modify_count'] = s.get('modify_count', 0) + 1
|
||
r['peak'] = peak
|
||
r['updated_at'] = _now_iso()
|
||
_write(reservations)
|
||
return r
|
||
return None
|
||
return None
|
||
|
||
|
||
def remove_step(res_id: str, step_n: int, reason: str = '') -> Optional[dict]:
|
||
"""레그 하나를 예약에서 제거 (체결·취소·소멸). 마지막 레그면 예약 자체를 지운다.
|
||
|
||
반환 {'step': 제거된 레그, 'reservation': 예약 스냅샷, 'remaining': 남은 레그 수,
|
||
'reservation_removed': bool} — 알림 메시지 조립용.
|
||
"""
|
||
with _FileLock(_LOCK_FILE):
|
||
reservations = _read()
|
||
for i, r in enumerate(reservations):
|
||
if r['id'] != res_id:
|
||
continue
|
||
steps = r.get('steps') or []
|
||
hit = next((s for s in steps if s['n'] == step_n), None)
|
||
if hit is None:
|
||
return None
|
||
steps.remove(hit)
|
||
hit['removed_reason'] = reason
|
||
snapshot = json.loads(json.dumps(r))
|
||
if not steps:
|
||
reservations.pop(i)
|
||
_write(reservations)
|
||
return {'step': hit, 'reservation': snapshot, 'remaining': 0,
|
||
'reservation_removed': True}
|
||
r['updated_at'] = _now_iso()
|
||
_write(reservations)
|
||
return {'step': hit, 'reservation': snapshot, 'remaining': len(steps),
|
||
'reservation_removed': False}
|
||
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
|