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

718 lines
33 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 정정으로 올려 트레일링을 구현한다.
**예약 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` 판정 + 감시 루프의 다음 거래일 첫 사이클 정리.
⚠️ **두 갈래를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적
전이여야 해서다.** promote_arm 이 락 하나 안에서 os.replace 1회로 끝낸다. 파일을 쪼개면
락을 중첩해야 하고(데드락), 중간에 죽으면 둘 다 존재하거나 둘 다 사라진다.
⚠️ 그 대가로 **_write 는 None 인 쪽을 디스크에서 다시 읽어 보존**한다. 이 계약이 깨지면
reservations 쪽 쓰기 6곳이 arms 를 조용히 전멸시킨다 (테스트로 고정해 둠).
⚠️ 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 제거 + 예약 등록" 이라는 하나의 원자적 전이이기
때문이다. 파일이 둘이면 락을 중첩해야 하고, 중간에 죽으면 둘 다 존재하거나 둘 다
사라진다.
"""
if not STATE_FILE.exists():
return {'reservations': [], 'arms': []}
try:
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return {'reservations': [], 'arms': []}
if not isinstance(data, dict):
return {'reservations': [], 'arms': []}
return {'reservations': data.get('reservations') or [],
'arms': data.get('arms') or []}
def _read() -> list:
return _read_doc()['reservations']
def _read_arms() -> list:
return _read_doc()['arms']
def _write(reservations: Optional[list] = None, arms: 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
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],
'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
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