auto: 일일 백업 2026-08-01 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 '지정가'
|
||||
|
||||
|
||||
@@ -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×(1−30/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)
|
||||
@@ -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×(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:
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user