9e5b1c211e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
804 lines
37 KiB
Python
804 lines
37 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 와 같은 패턴).
|
||
|
||
── 대기 예약 (arms) — 예약 트레일링, 2026-08-25 ──────────────────────────────
|
||
|
||
같은 파일에 `arms` 키가 하나 더 있다. **발동가에 닿으면 그때 트레일링을 시작하는 예약**으로,
|
||
`reservations`(이미 키움에 주문이 나간 것)와 성질이 정반대다:
|
||
|
||
reservations : 주문이 키움에 있음 → 감시 루프가 죽어도 손절선은 살아있음, 장 마감에 소멸
|
||
arms : 주문이 없고 이 파일에만 있음 → 감시가 죽으면 발동 안 함, **당일만 유효**
|
||
|
||
⚠️ **arm 도 스톱주문과 같은 수명(등록일 당일)이다** — 2026-08-25 관리자님 결정으로 무기한에서
|
||
바뀌었다. 거래소가 지워주는 스톱주문과 달리 arm 은 우리 파일에만 있으니 우리가 지운다:
|
||
`is_arm_expired` 판정 + 감시 루프의 다음 거래일 첫 사이클 정리.
|
||
|
||
여기에 세 번째 키 `recent` 가 더 있다 — 장 마감 소멸·만료된 예약의 설정을 짧게 보관해
|
||
자산웹이 "같은 설정으로 다시 등록" 버튼을 띄울 수 있게 하는 **UI 편의용 링버퍼**다.
|
||
감사 기록이 아니다(그쪽은 state/order_log.jsonl, 무한 append).
|
||
|
||
⚠️ **두 갈래를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적
|
||
전이여야 해서다.** promote_arm 이 락 하나 안에서 os.replace 1회로 끝낸다. 파일을 쪼개면
|
||
락을 중첩해야 하고(데드락), 중간에 죽으면 둘 다 존재하거나 둘 다 사라진다.
|
||
|
||
⚠️ 그 대가로 **_write 는 None 인 쪽을 디스크에서 다시 읽어 보존**한다. 이 계약이 깨지면
|
||
한쪽 쓰기가 나머지 두 갈래를 조용히 전멸시킨다 — reservations 쪽 쓰기만 6곳이다.
|
||
`_read_doc` 이 세 키를 하드 정규화하므로 **키를 늘릴 때 _read_doc 과 _write 를 반드시
|
||
같이** 고쳐야 한다 (테스트로 고정해 둠).
|
||
|
||
⚠️ arm 의 steps 에는 **가격·주수를 저장하지 않는다**(정의 n/pct/cum/weight 만). routing_suffix
|
||
도 저장하지 않는다. 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 무기한 대기라 등록 시점
|
||
세션(KRX)과 발동 시점 세션(NXT)이 달라 옛 suffix 로 발주하면 거부된다. 둘 다 발동 시점에
|
||
plan_arm_fire / guards.determine_routing 으로 새로 계산한다.
|
||
"""
|
||
|
||
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 after_trading_day, 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
|
||
|
||
|
||
# 혼동되는 글자(0/O, 1/I/l)를 뺀 영숫자 — 사람이 눈으로 읽고 옮겨 적는 id 라서.
|
||
_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now(KST).isoformat(timespec='seconds')
|
||
|
||
|
||
def _new_id(prefix: str) -> str:
|
||
return prefix + ''.join(secrets.choice(_ID_CHARS) for _ in range(4))
|
||
|
||
|
||
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_doc() -> dict:
|
||
"""상태파일 전체. `reservations`(키움에 주문이 나가 있는 예약) 와
|
||
`arms`(발동가 도달을 기다리는 대기 예약) 두 갈래가 한 파일에 들어 있다.
|
||
|
||
한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록" 이라는 하나의 원자적 전이이기
|
||
때문이다. 파일이 둘이면 락을 중첩해야 하고, 중간에 죽으면 둘 다 존재하거나 둘 다
|
||
사라진다.
|
||
"""
|
||
empty = {'reservations': [], 'arms': [], 'recent': []}
|
||
if not STATE_FILE.exists():
|
||
return empty
|
||
try:
|
||
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||
except (OSError, ValueError):
|
||
return empty
|
||
if not isinstance(data, dict):
|
||
return empty
|
||
return {'reservations': data.get('reservations') or [],
|
||
'arms': data.get('arms') or [],
|
||
'recent': data.get('recent') or []}
|
||
|
||
|
||
def _read() -> list:
|
||
return _read_doc()['reservations']
|
||
|
||
|
||
def _read_arms() -> list:
|
||
return _read_doc()['arms']
|
||
|
||
|
||
def _read_recent() -> list:
|
||
return _read_doc()['recent']
|
||
|
||
|
||
def _write(reservations: Optional[list] = None, arms: Optional[list] = None,
|
||
recent: Optional[list] = None) -> None:
|
||
"""None 인 쪽은 디스크의 현재 값을 유지한다.
|
||
|
||
⚠️ 세 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
|
||
반드시 _FileLock 안에서 부를 것 — 여기서 다시 읽어 병합한다.
|
||
"""
|
||
doc = _read_doc()
|
||
if reservations is not None:
|
||
doc['reservations'] = reservations
|
||
if arms is not None:
|
||
doc['arms'] = arms
|
||
if recent is not None:
|
||
doc['recent'] = recent
|
||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + '.tmp')
|
||
tmp.write_text(json.dumps(doc, 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 _build_reservation(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||
peak: int, steps: list, routing_suffix: str,
|
||
card_id: Optional[str], min_sell_price: Optional[int]) -> dict:
|
||
"""접수된 레그 목록 → 예약 dict (파일 접근 없는 순수 조립).
|
||
|
||
register_steps(등록 즉시 트레일링)와 promote_arm(대기 예약 발동)이 공유한다.
|
||
"""
|
||
if not steps:
|
||
raise ValueError('등록할 계단이 없음 — 접수 성공한 레그가 하나도 없다')
|
||
for s in steps:
|
||
if not s.get('ord_no'):
|
||
raise ValueError(f'{s.get("n")}단계 ord_no 없음 — 접수 확인된 주문만 등록')
|
||
return {
|
||
'id': _new_id('TRL-'),
|
||
'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],
|
||
# 등록 시점 계단 **정의**(가격·주수 없음). steps 는 레그가 체결·소멸할 때마다 줄어들고
|
||
# 마지막 레그가 빠지면 비어버려서, 재등록용 원본이 여기 없으면 계단을 복원할 수 없다.
|
||
'entry_steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
|
||
for s in steps],
|
||
'routing_suffix': routing_suffix,
|
||
'card_id': card_id,
|
||
'created_at': _now_iso(),
|
||
'updated_at': _now_iso(),
|
||
'entry_peak': peak,
|
||
}
|
||
|
||
|
||
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 제거됐다.
|
||
"""
|
||
res = _build_reservation(account, symbol, symbol_name, total_qty, peak, steps,
|
||
routing_suffix, card_id, min_sell_price)
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 대기 예약 (arm) — 발동가에 닿으면 그때 트레일링을 등록한다
|
||
#
|
||
# 트레일링 스톱은 등록 즉시 계단별 스톱주문이 키움에 나가고 그 순간의 현재가가 고점이
|
||
# 된다. 아직 오르지 않은 종목엔 그게 불리해서, 발동가를 미리 정해두고 현재가가 거기에
|
||
# 올라와 닿는 순간 그때의 현재가를 고점으로 트레일링을 시작하는 갈래를 둔다.
|
||
#
|
||
# 대기 중인 예약은 키움이 아니라 이 파일에만 있다 — 거래소가 지워주지 않으므로 **우리가**
|
||
# 스톱주문과 같은 수명(등록일 당일)을 강제한다(is_arm_expired). 2026-08-25 관리자님 결정.
|
||
# 대신 감시 프로세스가 죽어 있으면 발동하지 않는다 — 이건 스톱주문과 정반대 성질이다.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def list_arms() -> list:
|
||
"""대기 예약 전체 (락 없이 읽기 — 감시 루프의 스냅샷용)."""
|
||
return _read_arms()
|
||
|
||
|
||
def get_arm(arm_id: str) -> Optional[dict]:
|
||
for a in _read_arms():
|
||
if a['id'] == arm_id:
|
||
return a
|
||
return None
|
||
|
||
|
||
def find_arm_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||
"""같은 계좌·종목의 대기 예약. 중복 등록 차단용."""
|
||
for a in _read_arms():
|
||
if a['account'] == account and a['symbol'] == symbol:
|
||
return a
|
||
return None
|
||
|
||
|
||
def register_arm(account: str, symbol: str, symbol_name: str, qty: int,
|
||
trigger_price: int, steps: list,
|
||
min_sell_price: Optional[int] = None,
|
||
card_id: Optional[str] = None) -> dict:
|
||
"""PIN 승인 직후 호출 — 발동가 도달을 기다리는 예약 1건 등록. 키움 주문은 아직 없다.
|
||
|
||
steps 는 normalize_steps 결과, 즉 **정의만**(n/pct/cum/weight)이다. 조건단가·지정가·
|
||
계단별 주수는 저장하지 않는다 — 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고 그 사이
|
||
보유수량이 줄었을 수도 있어서, 발동 시점의 현재가와 매도가능 수량으로 다시 계산해야
|
||
실제 시세와 맞는다.
|
||
|
||
routing_suffix 도 저장하지 않는다. 당일 안에서도 등록 시점 세션(정규장)과 발동 시점
|
||
세션(NXT 애프터)이 다를 수 있고, 옛 suffix 로 발주하면 거부된다.
|
||
"""
|
||
if not steps:
|
||
raise ValueError('계단이 비어 있음')
|
||
if trigger_price <= 0:
|
||
raise ValueError('발동가가 0 이하')
|
||
arm = {
|
||
'id': _new_id('ARM-'),
|
||
'account': account,
|
||
'symbol': symbol,
|
||
'symbol_name': symbol_name,
|
||
'qty': qty,
|
||
'trigger_price': trigger_price,
|
||
'min_sell_price': min_sell_price,
|
||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
|
||
for s in steps],
|
||
'card_id': card_id,
|
||
'created_at': _now_iso(),
|
||
'updated_at': _now_iso(),
|
||
'firing_at': None,
|
||
}
|
||
with _FileLock(_LOCK_FILE):
|
||
arms = _read_arms()
|
||
arms.append(arm)
|
||
_write(arms=arms)
|
||
return arm
|
||
|
||
|
||
def remove_arm(arm_id: str, reason: str = '') -> Optional[dict]:
|
||
"""대기 예약 제거 (취소·수량 소멸·계단 소멸). 반환값은 제거된 arm — 알림 메시지용."""
|
||
with _FileLock(_LOCK_FILE):
|
||
arms = _read_arms()
|
||
for i, a in enumerate(arms):
|
||
if a['id'] == arm_id:
|
||
gone = arms.pop(i)
|
||
_write(arms=arms)
|
||
gone['removed_reason'] = reason
|
||
return gone
|
||
return None
|
||
|
||
|
||
def mark_arm_firing(arm_id: str) -> bool:
|
||
"""발주 직전 마킹. 이미 마킹돼 있으면 False (재발동 차단).
|
||
|
||
키움 발주가 성공한 뒤 promote_arm 전에 프로세스가 죽으면 다음 사이클이 같은 종목을
|
||
또 판다. 창은 1초 남짓이지만 결과가 이중 매도라, 발주를 시작했다는 사실을 먼저
|
||
디스크에 남긴다.
|
||
|
||
⚠️ 마킹이 남아있는 arm 은 **자동으로 재시도하지 않는다** — 주문이 나갔는지 알 수 없는
|
||
상태에서 다시 내는 게 바로 이중 매도다. 감시 루프는 알림만 보내고, 관리자님이 실제
|
||
미체결을 확인한 뒤 자산웹에서 취소한다.
|
||
"""
|
||
with _FileLock(_LOCK_FILE):
|
||
arms = _read_arms()
|
||
for a in arms:
|
||
if a['id'] != arm_id:
|
||
continue
|
||
if a.get('firing_at'):
|
||
return False
|
||
a['firing_at'] = _now_iso()
|
||
a['updated_at'] = _now_iso()
|
||
_write(arms=arms)
|
||
return True
|
||
return False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 최근 종료 (recent) — 재등록 버튼용 단기 캐시
|
||
#
|
||
# 트레일링 스톱은 장 마감에 거래소가 지우고, 대기 예약은 등록일 당일로 우리가 지운다. 둘 다
|
||
# 사라지는 순간 계좌·수량·계단이 함께 없어져서, 다시 걸려면 처음부터 입력해야 했다.
|
||
# 종료 시점의 설정을 짧게 보관해 자산웹이 "이전 설정 그대로 채워진 거래 모달" 을 열 수 있게 한다.
|
||
#
|
||
# ⚠️ 이건 **UI 편의용 단기 캐시이지 감사 기록이 아니다.** 감사 원장은 state/order_log.jsonl 이고
|
||
# 그쪽은 무한 append 로 그대로 둔다. 여기는 링버퍼라 오래된 건 지워진다.
|
||
# ⚠️ 재등록은 **사람이 버튼을 누르고 PIN 을 다시 거친다** — 감시 루프가 스스로 되살리는 경로는
|
||
# 없다. "자동 재등록 안 함"(2026-07-30 결정)은 그대로 유효하다.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
RECENT_MAX = 20
|
||
RECENT_DAYS = 7
|
||
|
||
|
||
def list_recent() -> list:
|
||
"""최근 종료 목록 (최신이 앞). 락 없이 읽기 — 웹 렌더용."""
|
||
return _read_recent()
|
||
|
||
|
||
def get_recent(key: str) -> Optional[dict]:
|
||
for e in _read_recent():
|
||
if e.get('key') == key:
|
||
return e
|
||
return None
|
||
|
||
|
||
def push_recent(entry: dict) -> dict:
|
||
"""종료된 예약 1건을 최근 목록 맨 앞에 넣는다 (링버퍼).
|
||
|
||
⚠️ 제거(remove_step/remove_arm) 와 **같은 락에 묶지 않는다.** 레그 소멸은 레그마다 제거가
|
||
끝난 **뒤에야** "예약이 죽었다" 를 알 수 있어 애초에 한 락에 못 넣기 때문이다. 그 사이에
|
||
죽으면 항목 하나를 잃는데, 순서를 뒤집으면(기록 먼저) 같은 자리에서 중복이 남는다 —
|
||
편의 기능이라 유실이 중복보다 낫다.
|
||
"""
|
||
entry = dict(entry)
|
||
entry.setdefault('ended_at', _now_iso())
|
||
entry['key'] = _new_id('RC-')
|
||
cutoff = (datetime.now(KST) - timedelta(days=RECENT_DAYS)).isoformat(timespec='seconds')
|
||
with _FileLock(_LOCK_FILE):
|
||
recent = [e for e in _read_recent() if (e.get('ended_at') or '') >= cutoff]
|
||
recent.insert(0, entry)
|
||
_write(recent=recent[:RECENT_MAX])
|
||
return entry
|
||
|
||
|
||
def make_recent_entry(kind: str, reason: str, src: dict, steps: list) -> dict:
|
||
"""예약/대기예약 스냅샷 → 재등록에 필요한 것만 뽑은 항목.
|
||
|
||
steps 는 정의만(n/pct/cum/weight) — 가격·주수는 재등록 시점에 다시 계산된다.
|
||
"""
|
||
return {
|
||
'kind': kind, # 'trailing' | 'arm' → 재등록 order_type 을 가른다
|
||
'reason': reason,
|
||
'account': src['account'],
|
||
'symbol': src['symbol'],
|
||
'symbol_name': src.get('symbol_name') or src['symbol'],
|
||
'qty': src.get('qty') or 0,
|
||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s.get('weight')}
|
||
for s in steps],
|
||
'min_sell_price': src.get('min_sell_price'),
|
||
'trigger_price': src.get('trigger_price'), # arm 만. trailing 은 None
|
||
}
|
||
|
||
|
||
def is_arm_expired(arm: dict, now: Optional[datetime] = None) -> bool:
|
||
"""등록일의 거래 세션이 끝났으면 만료 (2026-08-25 관리자님 결정으로 무기한 → 당일).
|
||
|
||
트레일링 스톱주문이 장 마감에 소멸하는 것과 **같은 수명**을 갖게 한 것이다. 대기 예약은
|
||
키움이 아니라 우리 파일에만 있어 거래소가 지워주지 않으므로, 우리가 지워야 한다.
|
||
|
||
판정은 두 갈래:
|
||
· 등록일이 오늘보다 이전 → 만료 (밤새 넘긴 것·주말 넘긴 것)
|
||
· 등록일이 오늘이고 이미 20:00 지남 → 만료
|
||
|
||
⚠️ 감시 루프는 08:00~19:59 에만 도므로 **20:00 직후에 지워지지는 않는다.** 실제 정리는
|
||
다음 거래일 첫 사이클이다. 그 사이(장외)에 자산웹을 열면 만료 상태로 표시된다 —
|
||
화면이 거짓말하지 않게 웹도 같은 판정을 쓴다.
|
||
"""
|
||
now = now or datetime.now(KST)
|
||
created = (arm.get('created_at') or '')[:10]
|
||
if not created:
|
||
return False # 날짜를 모르면 함부로 지우지 않는다
|
||
today = now.strftime('%Y-%m-%d')
|
||
if created < today:
|
||
return True
|
||
return created == today and after_trading_day(now)
|
||
|
||
|
||
def record_arm_leg(arm_id: str, leg: dict) -> bool:
|
||
"""발동 중 레그 하나가 접수될 때마다 즉시 기록 (write-ahead).
|
||
|
||
firing_at 만으로는 중간에 죽었을 때 "몇 단이 실제로 나갔는지" 를 알 수 없다. 키움엔 우리
|
||
주문을 식별하는 client order id 가 없어 미체결 목록만으로는 관리자님이 직접 낸 스톱주문과
|
||
구분되지 않는다 — 그래서 주문번호를 우리가 적어둔다.
|
||
"""
|
||
with _FileLock(_LOCK_FILE):
|
||
arms = _read_arms()
|
||
for a in arms:
|
||
if a['id'] != arm_id:
|
||
continue
|
||
a.setdefault('fired_legs', []).append(
|
||
{'n': leg['n'], 'qty': leg['qty'], 'cond_uv': leg['cond_uv'],
|
||
'ord_uv': leg['ord_uv'], 'ord_no': leg.get('ord_no', '')})
|
||
a['updated_at'] = _now_iso()
|
||
_write(arms=arms)
|
||
return True
|
||
return False
|
||
|
||
|
||
def plan_arm_fire(arm: dict, cur_price: int, sellable_qty: int) -> dict:
|
||
"""대기 예약 + 현재가 + 매도가능 수량 → 발주 계획 (순수 함수 — 단위테스트 대상).
|
||
|
||
반환 {'action', 'reason', 'peak', 'qty_used', 'reduced', 'legs'}
|
||
fire : legs 를 그대로 발주한다
|
||
skip : 아직 아니다 (미도달·시세 없음) — 아무것도 하지 않는다
|
||
drop : 발동할 수 없다 (매도가능 0주·수량 부족) → 예약 삭제 + 알림
|
||
|
||
고점(peak)은 발동 시점 현재가다. 발동가가 아니라 현재가를 쓰는 이유는 갭 상승으로 발동가를
|
||
훌쩍 넘겨 열릴 수 있어서다 — 현재가 >= 발동가 이므로 손절선은 승인 시점 미리보기보다 항상
|
||
같거나 높다(관리자님에게 불리해지지 않는 방향).
|
||
|
||
수량은 min(예약 수량, 매도가능)이다. 대기 중 관리자님이 직접 팔았으면 그만큼 줄여 재배분한다
|
||
— 대기 예약이 보유분을 잠그지 않기로 한 결정의 뒷수습이 여기다.
|
||
"""
|
||
def _out(action, reason):
|
||
return {'action': action, 'reason': reason, 'peak': cur_price,
|
||
'qty_used': 0, 'reduced': False, 'legs': []}
|
||
|
||
if cur_price <= 0:
|
||
return _out('skip', 'no_price')
|
||
if cur_price < arm['trigger_price']:
|
||
return _out('skip', 'not_reached')
|
||
if sellable_qty <= 0:
|
||
return _out('drop', 'no_sellable')
|
||
|
||
qty_used = min(arm['qty'], sellable_qty)
|
||
steps = arm.get('steps') or []
|
||
leveled = compute_step_levels(cur_price, steps, arm.get('min_sell_price'))
|
||
qtys = allocate_step_qty(qty_used, [s['weight'] for s in steps])
|
||
# 0주가 된 계단은 발주할 수 없으니 떨어낸다. 단계 번호(n)는 유지해 몇 단계가 빠졌는지 보인다.
|
||
legs = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
|
||
if not legs:
|
||
return _out('drop', 'qty_too_small')
|
||
# 실주문 직전 마지막 안전망. propose 가 발동가 기준으로 이미 걸렀고 현재가 >= 발동가,
|
||
# 최저 매도가는 승인 후 불변이라 정상 경로에선 도달하지 않는다. 그래도 남기는 이유는
|
||
# 여기를 지나면 되돌릴 수 없는 매도 주문이 나가기 때문이다.
|
||
if legs[0]['cond_uv'] >= cur_price:
|
||
return _out('skip', 'immediate')
|
||
return {'action': 'fire', 'reason': '', 'peak': cur_price, 'qty_used': qty_used,
|
||
'reduced': qty_used < arm['qty'], 'legs': legs}
|
||
|
||
|
||
def promote_arm(arm_id: str, peak: int, steps: list,
|
||
routing_suffix: str = '') -> Optional[dict]:
|
||
"""발동 완료 — 대기 예약을 지우고 같은 내용의 트레일링 예약을 등록 (한 락 안에서 원자적).
|
||
|
||
steps 는 접수 결과까지 채워진 레그 목록(register_steps 와 같은 형식).
|
||
둘을 따로 하면 중간에 죽었을 때 arm 과 예약이 동시에 존재하거나 둘 다 사라진다.
|
||
|
||
반환 {'arm': 제거된 arm, 'reservation': 새 예약} — 알림 메시지 조립용.
|
||
"""
|
||
with _FileLock(_LOCK_FILE):
|
||
doc = _read_doc()
|
||
arms = doc['arms']
|
||
for i, a in enumerate(arms):
|
||
if a['id'] != arm_id:
|
||
continue
|
||
res = _build_reservation(
|
||
a['account'], a['symbol'], a['symbol_name'],
|
||
sum(s['qty'] for s in steps), peak, steps,
|
||
routing_suffix, a.get('card_id'), a.get('min_sell_price'))
|
||
arms.pop(i)
|
||
reservations = doc['reservations']
|
||
reservations.append(res)
|
||
_write(reservations=reservations, arms=arms)
|
||
return {'arm': a, 'reservation': res}
|
||
return None
|
||
|
||
|
||
def should_notify_arm_failure(arm_id: str, now_ts: float, cooldown_sec: int) -> bool:
|
||
"""발동 실패 알림을 보낼지. 보낸다고 판단하면 그 시점을 기록한다 (락 안에서 원자적).
|
||
|
||
firing_at 이 걸린 채 남은 arm 이나 사이드카 ON 상태는 매 사이클 같은 판정이 반복된다 —
|
||
쿨다운 없이 알리면 19:59까지 매분 텔레그램이 쏟아진다. should_notify_failure 와 같은 패턴.
|
||
"""
|
||
with _FileLock(_LOCK_FILE):
|
||
arms = _read_arms()
|
||
for a in arms:
|
||
if a['id'] != arm_id:
|
||
continue
|
||
last = a.get('last_fail_notice_ts') or 0
|
||
if now_ts - last < cooldown_sec:
|
||
return False
|
||
a['last_fail_notice_ts'] = now_ts
|
||
_write(arms=arms)
|
||
return True
|
||
return False
|