auto: 일일 백업 2026-08-01 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-01 02:00:02 +09:00
parent d7042430c3
commit 30be97ab2f
177 changed files with 4124 additions and 3489 deletions
+61 -11
View File
@@ -50,6 +50,11 @@ def _pct(p: float, digits: int = 2) -> str:
return f'{sign}{p:.{digits}f}%'
def _num(p) -> str:
"""계단 비율 표기 — 10.0 은 '10', 10.5 는 '10.5' 로. 불필요한 .0 을 지운다."""
return f'{float(p or 0):g}'
def format_card(request: dict, market_data: dict, card_id: str,
estimate: Optional[dict] = None,
budget_conversion: Optional[dict] = None,
@@ -94,25 +99,34 @@ def format_card(request: dict, market_data: dict, card_id: str,
lines.append(f'{marker} 조건: {_md_bold(_money(stop_p) + " 도달 시")}{cur_part}')
lines.append(f'{marker} 매도가격: {_md_bold(_money(request["price"]))} (지정가)')
elif request['order_type'] == 'TRAILING_STOP':
# 승인 대상은 "트레일 폭" — 조건단가는 고점이 오르면 이 폭을 유지하며 따라 올라간다.
stop_p = request.get('stop_price') or 0
# 승인 대상은 "계단 정의" — 각 계단의 조건단가는 고점이 오르면 제 하락률을 유지하며
# 함께 따라 올라간다. 계단이 1개면 종전의 단일 트레일링과 같다.
cur = market_data.get('current_price')
cur_part = f' (현재가 {_money(cur)})' if cur else ''
# 초기 고점은 항상 등록 시점 현재가 — 과거 고점 기준 옵션은 2026-07-30 제거됨.
peak_v = request.get('trail_peak') or 0
trail_pct_part = f'고점 대비 {request.get("trail_pct")}%'
lines.append(f'{marker} 시작 고점: {_md_bold(_money(peak_v))} (등록 시점)')
lines.append(f'{marker} 트레일 폭: {_md_bold(trail_pct_part)}')
steps = request.get('trail_steps') or []
lines.append(f'{marker} 시작 고점: {_md_bold(_money(peak_v))} (등록 시점){cur_part}')
min_sell = request.get('min_sell_price')
if min_sell:
# 최저 매도가가 손절선을 결정했는지(=트레일보다 높았는지) 명시 — 둘 중 어느 게
# 지배했는지 안 보이면 관리자님이 값을 잘못 읽는다.
raw_trail = int(peak_v * (1 - float(request['trail_pct']) / 100)) if peak_v else 0
who = '최저가 적용' if stop_p >= raw_trail else '트레일 적용'
tied = sum(1 for s in steps if s.get('floor_applied'))
who = f'{tied}개 계단 적용' if tied else '미적용 (트레일이 더 높음)'
lines.append(f'{marker} 최저 매도가: {_md_bold(_money(min_sell))} ({who})')
lines.append(f'{marker} 현재 손절선: {_md_bold(_money(stop_p) + " 도달 시")}{cur_part}')
lines.append(f'{marker} 매도가격: {_md_bold(_money(request["price"]))} (지정가)')
lines.append(f'{marker} 고점이 오르면 손절선도 따라 올라갑니다 (내려가지 않음)')
lines.append('')
lines.append(f'{marker} 매도 계단 {len(steps)}')
for s in steps:
cum = s.get('cum', 0)
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
lines.append(f' {s["n"]}단계 {_num(s["pct"])}% '
f'{_md_bold(_money(s["cond_uv"]) + " 도달")}'
f'{s["qty"]:,}주 ({cum_part})')
rest = request['qty'] - sum(s['qty'] for s in steps)
if rest > 0:
lines.append(f' ↳ 나머지 {rest:,}주는 스톱 없음 (계속 보유)')
lines.append('')
lines.append(f'{marker} 고점이 오르면 계단이 통째로 따라 올라갑니다 (내려가지 않음)')
elif request['order_type'] == 'MARKET':
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
else:
@@ -245,6 +259,40 @@ def format_submitted(card_id: str, side: str, symbol_name: str, qty: int,
return f'📨 [#{card_id}] {side_word} 접수: {symbol_name} {qty:,}주 @ {price_str}\n주문번호: {ord_no}'
def format_trailing_submitted(info: dict) -> str:
"""트레일링 계단 접수 결과 — 몇 단이 걸렸고 감시가 붙었는지를 한 화면에 담는다.
계단 일부만 접수될 수 있어(레그 독립 제출) 성공·실패를 나란히 적는다.
실패분을 요약해 버리면 방어선이 몇 개인지 모르는 채로 남게 된다.
"""
placed = info.get('placed') or []
failed = info.get('failed') or []
acct = _account_display(info.get('account', ''))
head = '🧪 [모의] 트레일링' if info.get('dry_run') else '✅ 트레일링'
lines = [f'{head} {len(placed)}단 등록: {info["symbol_name"]}',
f'[{acct}] 총 {info["total_qty"]:,}주 · 시작 고점 {_money(info["peak"])}']
if info.get('min_sell_price'):
lines.append(f'최저 매도가 {_money(info["min_sell_price"])}')
for s in placed:
cum = s.get('cum', 0)
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
no = f' · 주문 {s["ord_no"]}' if s.get('ord_no') else ''
lines.append(f'{s["n"]}단계 {_num(s["pct"])}% {_money(s["cond_uv"])} 도달 → '
f'{s["qty"]:,}주 ({cum_part}){no}')
rest = info['total_qty'] - sum(s['qty'] for s in placed)
if rest > 0:
lines.append(f'↳ 나머지 {rest:,}주는 스톱 없음 (계속 보유)')
for s in failed:
lines.append(f'⚠️ {s["n"]}단계 {_num(s["pct"])}% {s["qty"]:,}주 접수 실패 '
f'({s.get("reason")}) {s.get("error", "")}'.rstrip())
if info.get('register_error'):
lines.append('⚠️ 주문은 접수됐지만 트레일링 등록 실패 — 손절선이 따라 올라가지 않습니다. '
f'({info["register_error"]})')
elif info.get('res_id'):
lines.append(f'감시 시작 ({info["res_id"]}) — 고점이 오르면 계단이 함께 올라갑니다')
return '\n'.join(lines)
def format_expired(card_id: str, side: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
return f'⏱️ [#{card_id}] 승인 만료. {side_word}가 취소되었습니다.'
@@ -323,7 +371,9 @@ def format_card_locked(active: Optional[dict] = None) -> str:
elif order_type == 'AGGRESSIVE_LIMIT':
price_str = f'공격적 지정가 {_money(price)}' if price else '공격적 지정가'
elif order_type == 'TRAILING_STOP':
price_str = f'트레일링 {active.get("trail_pct")}% (손절 {_money(active.get("stop_price"))})'
steps = active.get('trail_steps') or []
pcts = '/'.join(f'{_num(s["pct"])}%' for s in steps)
price_str = f'트레일링 {len(steps)}{pcts} (1단계 손절 {_money(active.get("stop_price"))})'
else:
price_str = _money(price) if price is not None else '지정가'
+126 -55
View File
@@ -165,7 +165,7 @@ def propose_trade(
routing_force: Optional[str] = None,
budget: Optional[int] = None,
stop_price: Optional[int] = None,
trail_pct: Optional[float] = None,
trail_steps: Optional[list] = None,
min_sell_price: Optional[int] = None,
) -> dict:
sidecar.guard_or_raise()
@@ -185,27 +185,37 @@ def propose_trade(
return {'ok': False,
'message': card.format_rejected('STOP_INPUT',
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
norm_steps = None
if order_type == 'TRAILING_STOP':
# 트레일링은 트레일 폭(%)만 입력받고 조건단가·지정가는 현재가 기준 자동 계산 (md 수집 후).
# 트레일링은 계단 정의(하락률·누적 비중)만 입력받고, 계단별 조건단가·지정가·주수
# 현재가와 보유수량 기준으로 자동 계산한다 (md 수집 후).
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected('STOP_SELL_ONLY', '트레일링 스톱은 매도 전용')}
if trail_pct is None or not (trailing.MIN_TRAIL_PCT <= trail_pct <= trailing.MAX_TRAIL_PCT):
try:
norm_steps = trailing.normalize_steps(trail_steps or [])
except (ValueError, KeyError, TypeError) as e:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_PCT_RANGE',
f'트레일 폭은 {trailing.MIN_TRAIL_PCT}~{trailing.MAX_TRAIL_PCT}% 범위')}
'message': card.format_rejected('TRAIL_STEPS', f'계단 정의 오류: {e}')}
if budget is not None:
# 계단별 주수는 보유수량을 나눠 만든다 — 예산 환산이 끼면 기준이 두 개가 된다.
return {'ok': False,
'message': card.format_rejected('TRAIL_BUDGET',
'트레일링은 수량으로만 지정 (예산 환산 불가)')}
if min_sell_price is not None and min_sell_price <= 0:
return {'ok': False,
'message': card.format_rejected('TRAIL_MIN_PRICE',
'최저 매도가는 0보다 커야 함 (제한 없으면 비우세요)')}
dup = trailing.find_by_symbol(account, symbol)
if dup:
dup_steps = dup.get('steps') or []
top_uv = max((s['cond_uv'] for s in dup_steps), default=0)
return {'ok': False,
'message': card.format_rejected(
'TRAIL_DUPLICATE',
f'{symbol_name or symbol} 트레일링 예약이 이미 있음 '
f'({dup["id"]} · 손절 {dup["cond_uv"]:,}원) — 먼저 취소하세요')}
f'({dup["id"]} · {len(dup_steps)}단 · 1단계 손절 {top_uv:,}원) '
f'— 먼저 취소하세요')}
if budget is not None and qty is not None:
return {'ok': False,
'message': card.format_rejected('AMBIGUOUS_INPUT',
@@ -218,7 +228,8 @@ def propose_trade(
# collect_market_data 의 잔고/보유 조회는 qty 와 무관.
md = datasource.collect_market_data(account, symbol, side, qty or 0)
trail_levels = None
trail_final = None
trail_peak = None
if order_type == 'TRAILING_STOP':
# 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 fallback 금지 — 기준이 흔들림).
cur = md.get('current_price') or 0
@@ -229,24 +240,33 @@ def propose_trade(
# 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
# 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
# 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
peak = cur
trail_peak = cur
try:
trail_levels = trailing.compute_levels(peak, trail_pct, min_sell_price)
leveled = trailing.compute_step_levels(trail_peak, norm_steps, min_sell_price)
except ValueError as e:
return {'ok': False, 'message': card.format_rejected('TRAIL_LEVEL', str(e))}
# 손절선이 현재가 이상이면 등록하는 순간 발동한다. 고점=현재가로 고정된 뒤로는
# 최저 매도가가 현재가보다 높은 경우만 남는다(트레일 폭은 항상 현재가 아래로 내려감).
if trail_levels['cond_uv'] >= cur:
if trail_levels['floor_applied']:
# 계단별 주수 배분. 수량이 적어 0주가 된 계단은 발주할 수 없으니 떨어낸다 —
# 단계 번호(n)는 그대로 두어 "몇 단계가 빠졌는지" 가 카드에서 보이게 한다.
qtys = trailing.allocate_step_qty(qty, [s['weight'] for s in norm_steps])
trail_final = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
if not trail_final:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_QTY', f'{qty:,}주로는 계단을 나눌 수 없음 — 수량을 늘리거나 계단을 줄이세요')}
# 가장 얕은 계단이 제일 먼저 발동한다. 이게 현재가 이상이면 등록하는 순간 터진다.
# 고점=현재가로 고정된 뒤로는 최저 매도가가 현재가보다 높은 경우만 남는다
# (트레일 폭은 항상 현재가 아래로 내려감).
top = trail_final[0]
if top['cond_uv'] >= cur:
if top['floor_applied']:
msg = (f'최저 매도가 {min_sell_price:,}원이 현재가 {cur:,}원보다 높아 '
f'등록 즉시 발동됩니다 — 현재가보다 낮게 설정하세요')
else:
msg = (f'손절선 {trail_levels["cond_uv"]:,}원이 현재가 {cur:,}원 이상이라 '
msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 현재가 {cur:,}원 이상이라 '
f'등록 즉시 발동됩니다')
return {'ok': False, 'message': card.format_rejected('TRAIL_IMMEDIATE', msg)}
price = trail_levels['ord_uv']
stop_price = trail_levels['cond_uv']
trail_levels['peak'] = peak
price = top['ord_uv']
stop_price = top['cond_uv']
budget_conversion = None
if budget is not None:
@@ -277,9 +297,9 @@ def propose_trade(
request['price'] = price
request['stop_price'] = stop_price
if order_type == 'TRAILING_STOP':
request['trail_pct'] = trail_pct
request['trail_steps'] = trail_final
request['min_sell_price'] = min_sell_price
request['trail_peak'] = trail_levels['peak']
request['trail_peak'] = trail_peak
r = guards.validate_request(request, md)
if not r.ok:
@@ -326,11 +346,11 @@ def propose_trade(
if order_type == 'STOP_LIMIT':
payload['stop_price'] = stop_price
elif order_type == 'TRAILING_STOP':
# 승인 시점에 확정되는 것: 계좌·종목·수량·트레일 폭·최저 매도가.
# 이후 감시 루프는 조건단가만 올린다.
# 승인 시점에 확정되는 것: 계좌·종목·계단별 수량·하락률·최저 매도가.
# 이후 감시 루프는 계단별 조건단가만 올린다 (수량·계단 구성은 불변).
payload['stop_price'] = stop_price
payload['trail_pct'] = trail_pct
payload['trail_peak'] = trail_levels['peak']
payload['trail_steps'] = trail_final
payload['trail_peak'] = trail_peak
payload['min_sell_price'] = min_sell_price
try:
@@ -516,14 +536,10 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
if p.get('multi'):
return _submit_multi_legs(card_obj, dry_run)
if p['order_type'] == 'TRAILING_STOP':
return _submit_trailing_steps(card_obj, dry_run)
# 트레일링은 키움에 스톱지정가(trde_tp=28)로 나간다 — 트레일링은 우리 쪽 개념.
if p['order_type'] == 'AGGRESSIVE_LIMIT':
submit_order_type = 'LIMIT'
elif p['order_type'] == 'TRAILING_STOP':
submit_order_type = 'STOP_LIMIT'
else:
submit_order_type = p['order_type']
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
res = kiwoom_order.submit(
account_label=p['account'],
side=p['side'],
@@ -545,30 +561,7 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
msg = card.format_submitted(card_obj.card_id, p['side'],
p.get('symbol_name', p['symbol']),
p['qty'], p.get('price'), ord_no)
if p['order_type'] == 'TRAILING_STOP':
# 트레일링은 조건 도달까지 며칠 미체결로 남는 게 정상 — fill_watcher 를 걸면
# 30분 미체결 알림이 오탐으로 간다. 생존·체결 감시는 trailing_monitor 가 담당.
if ord_no:
try:
res_trl = trailing.register(
ord_no=ord_no, account=p['account'], symbol=p['symbol'],
symbol_name=p.get('symbol_name', p['symbol']), qty=p['qty'],
trail_pct=p['trail_pct'], peak=p['trail_peak'],
cond_uv=p['stop_price'], ord_uv=p['price'],
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id,
min_sell_price=p.get('min_sell_price'),
)
msg += (f'\n트레일링 감시 시작 ({res_trl["id"]}) — '
f'고점 {p["trail_peak"]:,}원 기준 {p["trail_pct"]}% 아래 '
f'{p["stop_price"]:,}')
except Exception as e:
# 주문은 이미 접수됨 — 등록 실패를 숨기면 트레일링이 안 도는 걸 모른다.
ledger.append('trailing_register_failed',
{'card_id': card_obj.card_id, 'ord_no': ord_no,
'error': repr(e), **p})
msg += ('\n⚠️ 주문은 접수됐지만 트레일링 등록 실패 — '
'손절선이 따라 올라가지 않습니다. 확인 필요.')
elif ord_no:
if ord_no:
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
fill_watcher.watch(
@@ -583,6 +576,84 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
'detail': res}
def _submit_trailing_steps(card_obj, dry_run: bool) -> dict:
"""트레일링 계단 카드 — 계단마다 스톱주문을 내고 예약 1건으로 묶어 등록한다.
키움엔 스톱지정가(trde_tp=28)로 나간다 — 트레일링은 우리 쪽 개념이다.
얕은 계단(먼저 발동할 것)부터 접수해, 중간에 막히더라도 가장 가까운 방어선이 먼저 걸리게 한다.
한 레그가 실패해도 나머지는 계속 시도하고, 접수된 레그만 예약에 등록한다. 이미 나간 주문을
되돌리지 않는 이유는 취소 자체도 실패할 수 있어 상태가 더 불분명해지기 때문이다 —
대신 몇 단이 걸리고 몇 단이 실패했는지를 메시지에 그대로 드러낸다.
fill_watcher 는 걸지 않는다. 스톱 예약은 조건 도달까지 미체결로 남는 게 정상이라
30분 미체결 알림이 오탐이 된다 — 생존·체결 감시는 trailing_monitor 가 담당한다.
"""
p = card_obj.payload
steps = p.get('trail_steps') or []
name = p.get('symbol_name', p['symbol'])
placed, failed, details = [], [], []
for s in steps:
res = kiwoom_order.submit(
account_label=p['account'],
side='SELL',
symbol=p['symbol'],
qty=s['qty'],
price=s['ord_uv'],
order_type='STOP_LIMIT',
routing_suffix=p['routing_suffix'],
dry_run=dry_run,
card_id=card_obj.card_id,
stop_price=s['cond_uv'],
)
details.append(res)
if res['ok']:
# dry_run 은 주문번호가 없다 — 예약 등록 없이 접수될 내용만 확인한다.
placed.append(dict(s, ord_no=res.get('ord_no', '')))
else:
failed.append(dict(s, reason=res.get('reason', 'UNKNOWN'),
error=str(res.get('error', ''))))
if not placed:
return {'ok': False,
'message': card.format_rejected(
(failed[0]['reason'] if failed else 'UNKNOWN'),
f'{name} 계단 {len(steps)}단 전부 접수 실패 — '
+ (failed[0]['error'] if failed else '')),
'detail': details}
info = {'card_id': card_obj.card_id, 'symbol_name': name, 'account': p['account'],
'total_qty': p['qty'], 'peak': p['trail_peak'],
'min_sell_price': p.get('min_sell_price'),
'placed': placed, 'failed': failed, 'dry_run': dry_run,
'res_id': None, 'register_error': None}
if not dry_run:
registerable = [s for s in placed if s['ord_no']]
if not registerable:
# 접수는 됐는데 주문번호를 못 받았다 — 감시가 붙을 수 없으니 반드시 알린다.
ledger.append('trailing_register_failed',
{'card_id': card_obj.card_id, 'error': 'no ord_no', **p})
info['register_error'] = '주문번호를 못 받아 감시를 걸지 못했습니다'
else:
try:
res_trl = trailing.register_steps(
account=p['account'], symbol=p['symbol'], symbol_name=name,
total_qty=p['qty'], peak=p['trail_peak'], steps=registerable,
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id,
min_sell_price=p.get('min_sell_price'),
)
info['res_id'] = res_trl['id']
except Exception as e:
# 주문은 이미 접수됨 — 등록 실패를 숨기면 트레일링이 안 도는 걸 모른다.
ledger.append('trailing_register_failed',
{'card_id': card_obj.card_id, 'error': repr(e), **p})
info['register_error'] = str(e)
return {'ok': True, 'message': card.format_trailing_submitted(info), 'detail': details}
def _submit_multi_legs(card_obj, dry_run: bool) -> dict:
"""multi 카드(propose_trade_multi)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.
@@ -0,0 +1,363 @@
"""계단식 트레일링 단위테스트 (2026-07-31).
층을 본다:
1. 순수 함수 누적추가 환산, 수량 배분, 계단별 손절선, 상향 판단
2. 감시 루프 레그 체결/소멸 판정과 알림 (키움 호출은 전부 stub, 상태파일은 임시경로로 격리)
3. JS 대조 렌더된 페이지의 미리보기 계산이 서버 계산과 같은 값을 내는지 (node 있을 때만)
3번이 특히 중요하다. 미리보기와 발주값이 어긋나면 관리자님이 것과 다른 주문이 나간다.
"""
from __future__ import annotations
import json
import random
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from orders import trailing as t
class NormalizeStepsTests(unittest.TestCase):
"""입력한 누적 비중 → 내부 추가 비중."""
def test_cumulative_to_incremental(self):
st = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}])
self.assertEqual([s['weight'] for s in st], [20.0, 30.0, 50.0])
self.assertEqual([s['n'] for s in st], [1, 2, 3])
self.assertEqual([s['pct'] for s in st], [10.0, 20.0, 30.0])
def test_single_step(self):
st = t.normalize_steps([{'pct': 8, 'cum': 100}])
self.assertEqual([(s['pct'], s['weight']) for s in st], [(8.0, 100.0)])
def test_partial_liquidation_allowed(self):
"""마지막 누적이 100 미만이어도 통과 — 일부만 계단 청산하고 나머지는 계속 보유."""
st = t.normalize_steps([{'pct': 10, 'cum': 30}, {'pct': 20, 'cum': 60}])
self.assertEqual([s['weight'] for s in st], [30.0, 30.0])
def test_rejects(self):
cases = [
('빈 입력', [], '비어'),
('계단 초과', [{'pct': i, 'cum': i * 10} for i in range(1, 7)], '최대'),
('하락률 역순', [{'pct': 20, 'cum': 30}, {'pct': 10, 'cum': 60}], '깊지 않음'),
('하락률 동일', [{'pct': 10, 'cum': 30}, {'pct': 10, 'cum': 60}], '깊지 않음'),
('누적 역순', [{'pct': 10, 'cum': 60}, {'pct': 20, 'cum': 30}], '크지 않음'),
('누적 100 초과', [{'pct': 10, 'cum': 50}, {'pct': 20, 'cum': 120}], '100'),
('하락률 상한 초과', [{'pct': 40, 'cum': 100}], '범위'),
('하락률 하한 미만', [{'pct': 0.1, 'cum': 100}], '범위'),
]
for label, raw, needle in cases:
with self.subTest(label):
with self.assertRaises(ValueError) as cm:
t.normalize_steps(raw)
self.assertIn(needle, str(cm.exception))
class AllocateStepQtyTests(unittest.TestCase):
"""계단별 주수 배분 — 최대잔여법."""
def test_exact(self):
self.assertEqual(t.allocate_step_qty(100, [20, 30, 50]), [20, 30, 50])
self.assertEqual(t.allocate_step_qty(1943, [20, 30, 50]), [389, 583, 971])
def test_remainder_goes_to_largest_fraction(self):
self.assertEqual(t.allocate_step_qty(7, [20, 30, 50]), [1, 2, 4])
def test_small_qty_keeps_shallow_step_alive(self):
"""2주를 3계단에 나누면 [0,1,1] — '마지막에 몰아주기'였다면 [0,0,2]로 1계단만 남는다."""
self.assertEqual(t.allocate_step_qty(2, [20, 30, 50]), [0, 1, 1])
self.assertEqual(t.allocate_step_qty(1, [20, 30, 50]), [0, 0, 1])
self.assertEqual(t.allocate_step_qty(0, [20, 30, 50]), [0, 0, 0])
def test_sum_preserved_exhaustive(self):
"""전량(합 100%) 조합은 배분 합이 정확히 보유수량이어야 한다."""
for total in range(1, 400):
for w in ([20, 30, 50], [30, 30, 40], [10, 20, 30, 40], [50, 50], [100]):
got = t.allocate_step_qty(total, w)
self.assertEqual(sum(got), total, f'total={total} w={w}{got}')
self.assertTrue(all(x >= 0 for x in got))
def test_partial_never_exceeds_holding(self):
for total in range(1, 400):
self.assertLessEqual(sum(t.allocate_step_qty(total, [30, 30])), total)
class ComputeLevelsTests(unittest.TestCase):
def test_step_prices(self):
st = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}])
lv = t.compute_step_levels(5000, st)
self.assertEqual([x['cond_uv'] for x in lv], [4500, 4000, 3500])
self.assertEqual([x['ord_uv'] for x in lv], [4490, 3990, 3490]) # 각 2틱 아래
def test_float_noise_removed(self):
"""5500×(130/100) 이 3849.9999999999995 라 int() 가 3849 로 깎고
호가내림이 3845 까지 끌어내리던 버그(2026-07-31 수정). 의도는 3850."""
self.assertEqual(t.compute_levels(5500, 30)['cond_uv'], 3850)
self.assertEqual(t.compute_levels(5500, 10)['cond_uv'], 4950)
self.assertEqual(t.compute_levels(4870, 10)['cond_uv'], 4380)
def test_no_tick_loss_when_exact(self):
"""정수로 딱 떨어지는 (고점,폭)은 호가단위 내림 결과와 정확히 같아야 한다."""
for peak in range(1000, 300000, 977):
for pct in (5, 10, 15, 20, 25, 30):
exact = peak * (100 - pct) / 100.0
if abs(exact - round(exact)) > 1e-9:
continue
want = t.floor_to_tick(int(round(exact)))
self.assertEqual(t.compute_levels(peak, pct)['cond_uv'], want,
f'peak={peak} pct={pct}')
def test_floor_ties_all_steps_then_they_spread(self):
"""최저 매도가는 모든 계단에 같은 하한 → 처음엔 뭉치고, 고점이 오르면 다시 벌어진다.
(그래서 주문을 병합하면 된다 정정만으로는 다시 쪼갠다.)"""
st = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}])
tied = t.compute_step_levels(5000, st, min_sell_price=4600)
self.assertEqual([x['cond_uv'] for x in tied], [4600, 4600, 4600])
self.assertTrue(all(x['floor_applied'] for x in tied))
spread = t.compute_step_levels(6000, st, min_sell_price=4600)
self.assertEqual([x['cond_uv'] for x in spread], [5400, 4800, 4600])
class NextStepLevelsTests(unittest.TestCase):
def setUp(self):
st = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}])
self.res = {'peak': 5000, 'min_sell_price': None,
'steps': [dict(s, cond_uv=c, ord_uv=o) for s, c, o in
zip(st, [4500, 4000, 3500], [4490, 3990, 3490])]}
def test_no_new_high(self):
for price in (0, 4900, 5000):
self.assertIsNone(t.next_step_levels(self.res, price))
def test_all_steps_raise(self):
up = t.next_step_levels(self.res, 6000)
self.assertEqual(up['peak'], 6000)
self.assertEqual([(s['n'], s['cond_uv']) for s in up['steps']],
[(1, 5400), (2, 4800), (3, 4200)])
def test_peak_updates_even_when_no_step_moves(self):
"""호가단위 내림 탓에 올릴 조건단가가 없어도 고점은 갱신돼야 한다(정정 콜은 0)."""
res = {'peak': 5000, 'min_sell_price': None,
'steps': [{'n': 1, 'pct': 10.0, 'cum': 100.0, 'weight': 100.0,
'cond_uv': 4500, 'ord_uv': 4490}]}
up = t.next_step_levels(res, 5001)
self.assertEqual(up['peak'], 5001)
self.assertEqual(up['steps'], [])
def test_floor_dominant_then_overtaken(self):
res = {'peak': 5000, 'min_sell_price': 4600,
'steps': [{'n': 1, 'pct': 10.0, 'cum': 100.0, 'weight': 100.0,
'cond_uv': 4600, 'ord_uv': 4590}]}
self.assertEqual(t.next_step_levels(res, 5050)['steps'], []) # 아직 floor 지배
self.assertEqual([(s['n'], s['cond_uv'])
for s in t.next_step_levels(res, 5200)['steps']], [(1, 4680)])
def test_stop_never_moves_down(self):
"""무작위 시세에도 손절선은 단조 증가여야 한다 (상향 전용 불변식)."""
cur = {'peak': 5000, 'min_sell_price': None,
'steps': [{'n': 1, 'pct': 10.0, 'cum': 100.0, 'weight': 100.0,
'cond_uv': 4500, 'ord_uv': 4490}]}
rnd = random.Random(7)
prev = 4500
for _ in range(3000):
nx = t.next_step_levels(cur, rnd.randint(3000, 9000))
if nx is None:
continue
cur['peak'] = nx['peak']
for s in nx['steps']:
self.assertGreaterEqual(s['cond_uv'], prev)
prev = s['cond_uv']
cur['steps'][0]['cond_uv'] = s['cond_uv']
class MonitorFlowTests(unittest.TestCase):
"""감시 루프 — 레그 단위 생존 판정·정정·알림. 키움 호출은 stub."""
def setUp(self):
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
self._orig = (t.STATE_FILE, t._LOCK_FILE)
t.STATE_FILE = self.tmp
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
import trailing_monitor as tm
self.tm = tm
self.sent = []
self.state = {'open': {}, 'quote': 0, 'execs': [], 'modify': []}
self._orig_fns = (tm.kc.get_open_orders, tm.kc.get_watchlist_quotes,
tm.kc.get_order_executions, tm.kiwoom_order.modify_order,
tm.send_telegram)
tm.kc.get_open_orders = lambda acct, side=None: list(self.state['open'].get(acct, []))
tm.kc.get_watchlist_quotes = lambda codes, exchange='AL': {
c: {'price': self.state['quote']} for c in codes}
tm.kc.get_order_executions = lambda acct: list(self.state['execs'])
tm.kiwoom_order.modify_order = self._fake_modify
tm.send_telegram = lambda msg, parse_mode=None: self.sent.append(msg)
st = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}])
lv = t.compute_step_levels(5500, st, None)
qtys = t.allocate_step_qty(1943, [s['weight'] for s in st])
legs = [dict(s, qty=q, ord_no=f'A{s["n"]:04d}') for s, q in zip(lv, qtys)]
self.res = t.register_steps(account='일반', symbol='381620',
symbol_name='제닉스로보틱스', total_qty=1943,
peak=5500, steps=legs)
self.state['open']['일반'] = [{'ord_no': s['ord_no'], 'unfilled_qty': s['qty'],
'routing_suffix': ''} for s in legs]
def tearDown(self):
(self.tm.kc.get_open_orders, self.tm.kc.get_watchlist_quotes,
self.tm.kc.get_order_executions, self.tm.kiwoom_order.modify_order,
self.tm.send_telegram) = self._orig_fns
t.STATE_FILE, t._LOCK_FILE = self._orig
shutil.rmtree(self.tmp.parent, ignore_errors=True)
def _fake_modify(self, account_label, orig_ord_no, symbol, modify_qty, modify_price,
routing_suffix='', dry_run=False, card_id=None, modify_cond_price=None):
self.state['modify'].append({'orig': orig_ord_no, 'cond': modify_cond_price})
new_no = f'N{len(self.state["modify"]):04d}'
for row in self.state['open'].get(account_label, []):
if row['ord_no'] == orig_ord_no:
row['ord_no'] = new_no # 정정하면 키움이 새 주문번호를 발급한다
return {'ok': True, 'new_ord_no': new_no}
def test_registered_shape(self):
r = t.get(self.res['id'])
self.assertEqual([s['cond_uv'] for s in r['steps']], [4950, 4400, 3850])
self.assertEqual(sum(s['qty'] for s in r['steps']), 1943)
def test_price_drop_does_nothing(self):
self.state['quote'] = 5200
self.tm.check(force=True)
self.assertEqual(self.state['modify'], [])
self.assertEqual(t.get(self.res['id'])['peak'], 5500)
self.assertEqual(self.sent, [])
def test_new_high_raises_every_leg_and_rotates_ord_no(self):
self.state['quote'] = 6000
self.tm.check(force=True)
self.assertEqual([c['cond'] for c in self.state['modify']], [5400, 4800, 4200])
r = t.get(self.res['id'])
self.assertEqual(r['peak'], 6000)
# ord_no 를 갱신하지 않으면 다음 정정이 전부 실패한다 — 최대 함정
self.assertEqual([s['ord_no'] for s in r['steps']], ['N0001', 'N0002', 'N0003'])
self.assertEqual([s['modify_count'] for s in r['steps']], [1, 1, 1])
self.assertEqual(self.sent, []) # 상향은 조용히
def test_one_leg_fills_others_keep_watching(self):
self.state['quote'] = 5500
gone = self.state['open']['일반'].pop(0)
self.state['execs'] = [{'ord_no': gone['ord_no'], 'cntr_qty': 389, 'cntr_uv': 4948}]
self.tm.check(force=True)
r = t.get(self.res['id'])
self.assertEqual([s['n'] for s in r['steps']], [2, 3])
self.assertEqual(len(self.sent), 1)
self.assertIn('체결 389주', self.sent[0])
self.assertIn('남은 계단 2개', self.sent[0])
def test_expiry_not_misread_as_fill(self):
"""1단계가 체결된 날 나머지가 장 마감으로 소멸 — 종목 매도기록이 있어도 소멸로 갈려야 한다.
(종목 단위 ka10170 으로 판정하면 여기서 '체결' 오판한다.)"""
self.state['quote'] = 5500
self.state['execs'] = [{'ord_no': 'A0001', 'cntr_qty': 389, 'cntr_uv': 4948}]
self.state['open']['일반'] = []
self.tm.check(force=True)
self.assertIsNone(t.get(self.res['id']))
self.assertEqual(len(self.sent), 1) # 레그마다가 아니라 예약 단위 1건
self.assertIn('미체결 소멸', self.sent[0])
self.assertIn('다시 등록', self.sent[0])
def test_open_order_query_failure_preserves_reservation(self):
"""조회 실패를 '사라짐'으로 단정하면 살아있는 예약을 지운다."""
def boom(acct, side=None):
raise RuntimeError('조회 실패')
self.tm.kc.get_open_orders = boom
self.tm.check(force=True)
self.assertEqual(len(t.get(self.res['id'])['steps']), 3)
self.assertEqual(self.sent, [])
def _node_available() -> bool:
return shutil.which('node') is not None
@unittest.skipUnless(_node_available(), 'node 없음 — JS 대조 생략')
class JsParityTests(unittest.TestCase):
"""렌더된 페이지의 미리보기 계산 == 서버 발주 계산.
손으로 옮긴 사본이 아니라 실제 페이지에서 함수 소스를 뽑아 돌린다.
어긋나면 관리자님이 화면에서 것과 다른 주문이 나간다.
"""
@classmethod
def setUpClass(cls):
import behive_web
cls.html = behive_web.render_html()
def _grab(self, name: str) -> str:
i = self.html.index(f'function {name}(')
depth, j, started = 0, i, False
while j < len(self.html):
if self.html[j] == '{':
depth += 1
started = True
elif self.html[j] == '}':
depth -= 1
if started and depth == 0:
return self.html[i:j + 1]
j += 1
raise AssertionError(f'{name} 추출 실패 — 함수명이 바뀌었나?')
def test_parity(self):
src = '\n'.join(self._grab(f) for f in
('tickSize', 'floorTick', 'trailCondPrice',
'allocStepQty', 'trailWeights'))
cond_cases = [{'peak': p, 'pct': c, 'floor': f}
for p in (1000, 1943, 4805, 4870, 5500, 12345, 55500, 262500, 1718000)
for c in (0.5, 1, 3, 5, 7.5, 10, 15, 20, 25, 30)
for f in (0, 4500, 5000, 250000)]
qty_cases = [{'total': q, 'w': w}
for q in (1, 2, 3, 7, 19, 100, 218, 1395, 1943)
for w in ([20, 30, 50], [30, 30, 40], [10, 20, 30, 40],
[50, 50], [100], [30, 30])]
cum_cases = [[20, 50, 100], [30, 70, 100], [25, 50, 100], [100]]
js = src + f'''
const out = {{
cond: {json.dumps(cond_cases)}.map(c => {{
const r = trailCondPrice(c.peak, c.pct, c.floor); return [r.cond, r.floorApplied]; }}),
qty: {json.dumps(qty_cases)}.map(c => allocStepQty(c.total, c.w)),
cum: {json.dumps(cum_cases)}.map(cs => trailWeights(cs.map(x => ({{cum: x}})))),
}};
console.log(JSON.stringify(out));
'''
proc = subprocess.run([shutil.which('node'), '-e', js],
capture_output=True, text=True)
self.assertEqual(proc.returncode, 0, proc.stderr)
got = json.loads(proc.stdout)
for c, (jcond, jfloor) in zip(cond_cases, got['cond']):
lv = t.compute_levels(c['peak'], c['pct'], c['floor'] or None)
self.assertEqual((lv['cond_uv'], bool(lv['floor_applied'])),
(jcond, bool(jfloor)),
f"손절선 peak={c['peak']} pct={c['pct']} floor={c['floor']}")
for c, jq in zip(qty_cases, got['qty']):
self.assertEqual(t.allocate_step_qty(c['total'], c['w']), jq,
f"수량배분 total={c['total']} w={c['w']}")
for cums, jw in zip(cum_cases, got['cum']):
raw = [{'pct': (i + 1) * 3, 'cum': v} for i, v in enumerate(cums)]
self.assertEqual([s['weight'] for s in t.normalize_steps(raw)], jw,
f'비중환산 {cums}')
if __name__ == '__main__':
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
unittest.main(verbosity=2)
+188 -38
View File
@@ -1,13 +1,24 @@
"""트레일링 스톱 예약 상태 관리.
"""트레일링 스톱 예약 상태 관리 (계단식 분할 매도).
키움에 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_modify 이걸 담당.
번째 정정부터 orig_ord_no 틀려서 전부 실패한다. commit_step_modify 이걸 담당.
상태는 파일(state/trailing_stops.json) 저장 ·감시 루프가 각각 다른 프로세스라
같은 예약 목록을 봐야 한다. 동시성은 fcntl flock 으로 직렬화 (pin.py 같은 패턴).
@@ -42,6 +53,10 @@ MAX_TRAIL_PCT = 30.0
# 반대로 너무 벌리면 급락 시 헐값 매도가 되니 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')
@@ -67,7 +82,10 @@ def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] =
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)))
# round(_, 6) 은 부동소수점 잡음만 걷어낸다 — 5500×(130/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:
@@ -82,21 +100,96 @@ def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] =
return {'cond_uv': cond_uv, 'ord_uv': ord_uv, 'floor_applied': floor_applied}
def next_levels(res: dict, cur_price: int) -> Optional[dict]:
"""현재가를 보고 올릴 값이 있으면 반환, 없으면 None (순수 함수 — 단위테스트 대상).
def normalize_steps(raw: list) -> list:
"""입력한 계단 정의(누적 비중)를 내부 표현(추가 비중)으로 환산·검증한다.
상향 전용: 고점이 갱신되고 결과 조건단가가 실제로 올라갈 때만 정정 대상.
고점이 올라도 호가단위 내림 때문에 조건단가가 그대로면 정정하지 않는다(불필요한 API 차단).
최저 매도가가 아직 지배 중이면 트레일 손절선이 올라도 조건단가가 변해 None 된다.
입력 [{'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 cur_price <= 0:
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
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']}
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:
@@ -158,36 +251,43 @@ def find_by_symbol(account: str, symbol: str) -> Optional[dict]:
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 를 받아 예약으로 등록.
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 ord_no:
raise ValueError('ord_no required — 접수 확인된 주문만 등록')
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)),
'ord_no': ord_no,
'account': account,
'symbol': symbol,
'symbol_name': symbol_name,
'qty': qty,
'trail_pct': trail_pct,
'qty': total_qty,
'min_sell_price': min_sell_price,
'peak': peak,
'cond_uv': cond_uv,
'ord_uv': ord_uv,
'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(),
'modify_count': 0,
'entry_peak': peak,
'entry_cond_uv': cond_uv,
}
with _FileLock(_LOCK_FILE):
reservations = _read()
@@ -196,25 +296,75 @@ def register(ord_no: str, account: str, symbol: str, symbol_name: str, qty: int,
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 응답의 신규 주문번호.
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
ord_no 갱신이 함수의 핵심 하면 다음 정정이 실패한다.
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:
r['ord_no'] = new_ord_no
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['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
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