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
+191 -122
View File
@@ -1,15 +1,24 @@
#!/usr/bin/env python3
"""트레일링 스톱 감시 — 고점이 갱신되면 키움 스톱주문의 조건단가를 상향 정정한다.
**예약 1건 = 계단(레그) N개.** 고점 대비 하락률이 깊어질수록 더 많이 파는 계단식 청산이라
계단마다 스톱주문이 따로 걸려 있다. 고점이 오르면 살아있는 레그를 전부 상향 정정한다.
LLM을 깨우지 않는다. 하는 일은 딱 두 가지:
1. 예약이 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 체결·취소로 보고 정리+알림
2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 조건단가·지정가를 함께 올림
1. 레그가 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 정리+알림
2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 계단별 조건단가·지정가를 함께 올림
신규 발주·수량 변경·방향 변경은 하지 않는다. 사람이 PIN으로 승인한 주문의
조건단가를 올리는 것뿐이고, 상향 전용이라 손절선이 내려가는 일은 없다.
한 계단이 체결돼도 남은 계단을 재배치하지 않는다 — 재배치엔 취소+신규 발주가 필요해
감시 루프에 자동 발주 경로가 생긴다(2026-07-31 관리자님 결정).
⚠️ 정정하면 주문번호가 새로 발급된다 (kt10002 응답 ord_no). trailing.commit_modify 가
상태파일의 ord_no 를 갱신하지 않으면 다음 정정부터 전부 실패한다.
⚠️ 레그가 사라진 사유는 **주문번호 단위**인 kt00007 로 판정한다. 종목 단위 집계인
당일매매일지(ka10170)를 쓰면 한 계단이 체결된 날 나머지 계단이 장 마감으로 소멸했을 때
그 소멸분까지 '체결' 로 오판한다 — 같은 종목 매도 기록이 이미 남아 있기 때문이다.
⚠️ 정정하면 주문번호가 새로 발급된다 (kt10002 응답 ord_no). trailing.commit_step_modify 가
상태파일의 레그별 ord_no 를 갱신하지 않으면 그 레그의 다음 정정부터 전부 실패한다.
⚠️ 고점은 '현재가' 로만 갱신한다. ka10095 가 당일 고가(high)도 주지만, 등록 시점 이전에
찍힌 고가까지 반영되면 등록 직후 손절선이 현재가 위로 올라가 즉시 발동할 수 있다.
@@ -78,66 +87,95 @@ def session_now() -> tuple[bool, str]:
return sess != 'CLOSED', sess
def _fill_check(account: str, symbol: str) -> tuple:
"""예약이 사라진 사유 판정용 — 당일매매일지(ka10170)에서 그 종목 매도 기록을 찾는다.
def _pct_s(v) -> str:
"""10.0 → '10' — 계단 하락률 표기."""
return f'{float(v or 0):g}'
def _leg_fill_check(account: str, ord_no: str, cache: dict) -> tuple:
"""레그가 사라진 사유 판정 — kt00007 에서 **그 주문번호의** 체결수량을 본다.
⚠️ 미체결 조회에서 사라진 것만으로는 체결·취소·소멸을 구분할 수 없다(셋 다 목록에서 빠짐).
당일매매일지에 매도 기록이 없으면 **체결 안 된 것이 확정**이다.
있으면 트레일링 발동분인지 관리자님 수동 매도인지는 구분할 수 없어 '체결로 보임'까지만 말한다.
종목 단위인 당일매매일지(ka10170)로 판정하면 계단 하나가 체결된 날 나머지 계단이
장 마감으로 소멸했을 때 그 소멸분까지 '체결' 로 오판한다 — 같은 종목의 매도 기록이
이미 남아 있기 때문이다. 주문번호 단위인 kt00007 은 레그마다 정확히 갈린다.
반환: (매도기록 dict 또는 None, 조회 성공 여부).
조회 실패와 '기록 없음'을 반드시 구분해야 한다 — 실패를 '체결 안 됨'으로 단정하면
반환: (체결행 dict 또는 None, 조회 성공 여부).
조회 실패와 '체결 안 됨' 을 반드시 구분해야 한다 — 실패를 '체결 안 됨' 으로 단정하면
실제로 팔렸는데 안 팔렸다고 알리게 된다.
"""
try:
rows = kc.get_trade_journal(account)
except Exception as e:
_log(f'당일매매일지 조회 실패 ({account}/{symbol}): {e!r}')
if account not in cache:
try:
cache[account] = kc.get_order_executions(account)
except Exception as e:
_log(f'주문체결내역 조회 실패 ({account}): {e!r}')
cache[account] = None
rows = cache[account]
if rows is None:
return None, False
for row in rows:
if row.get('code') == symbol and (row.get('sell_qty') or 0) > 0:
if (row.get('ord_no') or '').strip() == ord_no and (row.get('cntr_qty') or 0) > 0:
return row, True
return None, True
def _notify_gone(res: dict, sold, journal_ok: bool) -> None:
"""예약이 미체결 목록에서 사라짐 — 사유를 구분해 알린다.
def _notify_gone_batch(events: list) -> None:
"""한 사이클에 사라진 레그들을 예약 단위로 묶어 한 번만 알린다.
장 마감이면 계단이 통째로 소멸하는데 레그마다 보내면 3~5통이 몰아친다.
체결 / 미체결 소멸(장 마감 취소 등)은 관리자님이 취해야 할 행동이 완전히 다르므로
계단별로 구분해 한 메시지 안에 나열한다.
체결 / 미체결 소멸(장 마감 취소 등)은 관리자님이 취해야 할 행동이 완전히 다르다.
2026-07-30 실측: 스톱주문은 장 마감 후 체결 없이 소멸한다(15:30~18:21 사이).
자동 재등록은 하지 않는다(관리자님 지시) — 다시 걸려면 자산웹에서 수동 등록.
"""
floor_part = ''
if res.get('min_sell_price'):
floor_part = f' · 최저 매도가 {res["min_sell_price"]:,}'
head_lines = [
f'계좌: {res["account"]} · {res["qty"]:,}',
f'마지막 손절선: {res["cond_uv"]:,}'
f'(고점 {res["peak"]:,}원 대비 {res["trail_pct"]}%){floor_part}',
f'손절선 상향 {res.get("modify_count", 0)}',
]
if sold:
title = f' 트레일링 스톱 체결 — {res["symbol_name"]} ({res["symbol"]})'
tail = [f'당일 매도 {sold["sell_qty"]:,}주 @ 평균 {sold.get("sell_avg", 0):,}',
f'실현손익 {sold.get("pl_amt", 0):,}원 (수수료·세금 차감)',
'※ 같은 종목을 직접 매도하신 경우 그 기록일 수도 있습니다.']
elif journal_ok:
title = f'⚠️ 트레일링 스톱 소멸 — {res["symbol_name"]} ({res["symbol"]})'
tail = ['체결되지 않았습니다 — 당일 매도 기록 없음, 보유수량 그대로입니다.',
'장 마감으로 스톱주문이 취소된 것으로 보입니다.',
'계속 쓰시려면 자산웹에서 다시 등록해주세요 (자동 재등록 안 함).']
else:
title = f'🎯 트레일링 스톱 종료 — {res["symbol_name"]} ({res["symbol"]})'
tail = ['미체결 목록에서 사라졌습니다.',
'당일매매일지 조회가 안 돼 체결 여부를 판정하지 못했습니다 — 확인해주세요.']
send_telegram('\n'.join([title] + head_lines + tail), parse_mode=None)
by_res: dict = {}
for ev in events:
by_res.setdefault(ev['res']['id'], []).append(ev)
for evs in by_res.values():
res = evs[0]['res']
remaining = min(e['remaining'] for e in evs)
any_filled = any(e['filled'] for e in evs)
unknown = any(not e['ok'] for e in evs)
if any_filled:
icon = ''
word = '체결'
elif unknown:
icon = '🎯'
word = '종료'
else:
icon = '⚠️'
word = '소멸'
lines = [f'{icon} 트레일링 {word}{res["symbol_name"]} ({res["symbol"]})',
f'계좌: {res["account"]} · 고점 {res["peak"]:,}']
if res.get('min_sell_price'):
lines.append(f'최저 매도가 {res["min_sell_price"]:,}')
for ev in sorted(evs, key=lambda e: e['step']['n']):
s = ev['step']
head = (f'{s["n"]}단계 {_pct_s(s["pct"])}% {s["cond_uv"]:,}원 · {s["qty"]:,}')
if ev['filled']:
f = ev['filled']
lines.append(f'{head} → 체결 {f.get("cntr_qty", 0):,}'
f'@ {f.get("cntr_uv", 0):,}')
elif ev['ok']:
lines.append(f' ⚠️ {head} → 미체결 소멸 (보유수량 그대로)')
else:
lines.append(f' 🎯 {head} → 체결 여부 판정 불가 (체결내역 조회 실패)')
if remaining > 0:
lines.append(f'남은 계단 {remaining}개 — 감시 계속합니다.')
else:
lines.append('남은 계단 없음 — 계속 쓰시려면 자산웹에서 다시 등록해주세요 '
'(자동 재등록 안 함).')
send_telegram('\n'.join(lines), parse_mode=None)
def _notify_modify_failed(res: dict, reason: str) -> None:
def _notify_modify_failed(res: dict, step: dict, reason: str) -> None:
send_telegram(
f'⚠️ 트레일링 손절선 상향 실패 — {res["symbol_name"]} ({res["symbol"]})\n'
f'계좌: {res["account"]} · 주문번호 {res["ord_no"]}\n'
f'현재 손절선 {res["cond_uv"]:,}원은 그대로 유지됩니다.\n'
f'계좌: {res["account"]} · {step["n"]}단계 {_pct_s(step["pct"])}% '
f'· 주문번호 {step["ord_no"]}\n'
f'현재 손절선 {step["cond_uv"]:,}원은 그대로 유지됩니다.\n'
f'사유: {reason}',
parse_mode=None)
@@ -171,30 +209,42 @@ def check(force: bool = False, dry_run: bool = False) -> int:
_log(f'미체결 조회 실패 [{acct}]: {e!r}')
open_by_acct[acct] = None
# 레그가 사라질 때만 kt00007 을 계좌당 1콜 추가로 부른다 (평시엔 호출 안 함).
exec_cache: dict = {}
gone_events = []
alive = []
for r in reservations:
rows = open_by_acct.get(r['account'])
if rows is None:
continue # 조회 실패 계좌 — 이번 사이클 판단 보류
row = next((x for x in rows if x['ord_no'] == r['ord_no']), None)
if row is None:
gone = trailing.remove(r['id'], 'not_in_open_orders')
# 사유 판정 — 예약이 사라질 때만 ka10170 1콜 추가(평시엔 호출 안 함).
sold, journal_ok = _fill_check(r['account'], r['symbol'])
if sold:
why = f'체결 (당일 매도 {sold["sell_qty"]:,}주 @ {sold.get("sell_avg", 0):,})'
elif journal_ok:
why = '미체결 소멸 (당일 매도 기록 없음 — 장 마감 취소 추정)'
live_steps = []
for s in list(r.get('steps') or []):
row = next((x for x in rows if x['ord_no'] == s['ord_no']), None)
if row is not None:
live_steps.append((s, row))
continue
filled, exec_ok = _leg_fill_check(r['account'], s['ord_no'], exec_cache)
gone = trailing.remove_step(r['id'], s['n'], 'not_in_open_orders')
if filled:
why = f'체결 {filled.get("cntr_qty", 0):,}주 @ {filled.get("cntr_uv", 0):,}'
elif exec_ok:
why = '미체결 소멸 (체결수량 0 — 장 마감 취소 추정)'
else:
why = '사유 판정 불가 (당일매매일지 조회 실패)'
_log(f'예약 종료 {r["id"]} {r["symbol_name"]}{why}')
if gone and not dry_run:
try:
_notify_gone(gone, sold, journal_ok)
except Exception as e:
_log(f'알림 실패: {e!r}')
continue
alive.append((r, row))
why = '사유 판정 불가 (주문체결내역 조회 실패)'
_log(f'레그 종료 {r["id"]}/{s["n"]}단계 {r["symbol_name"]}{why}')
if gone:
gone_events.append({'res': gone['reservation'], 'step': gone['step'],
'filled': filled, 'ok': exec_ok,
'remaining': gone['remaining']})
if live_steps:
alive.append((r, live_steps))
if gone_events and not dry_run:
try:
_notify_gone_batch(gone_events)
except Exception as e:
_log(f'알림 실패: {e!r}')
if not alive:
return 0
@@ -206,68 +256,82 @@ def check(force: bool = False, dry_run: bool = False) -> int:
return 1
raised = 0
for r, row in alive:
for r, live_steps in alive:
cur = (quotes.get(r['symbol']) or {}).get('price') or 0
levels = trailing.next_levels(r, cur)
if not levels:
# 살아있는 레그만 담은 스냅샷으로 판단 — 이미 빠진 계단은 따라 올릴 대상이 아니다.
snap = {'peak': r['peak'], 'min_sell_price': r.get('min_sell_price'),
'steps': [s for s, _ in live_steps]}
nx = trailing.next_step_levels(snap, cur)
if not nx:
continue
# 미체결 잔량으로 정정 — 부분체결됐으면 잔량만 남는다 (저장된 qty 는 최초 주문수량).
qty = row.get('unfilled_qty') or r['qty']
try:
res = kiwoom_order.modify_order(
account_label=r['account'],
orig_ord_no=r['ord_no'],
symbol=r['symbol'],
modify_qty=qty,
modify_price=levels['ord_uv'],
routing_suffix=row.get('routing_suffix', ''),
dry_run=dry_run,
card_id=r.get('card_id'),
modify_cond_price=levels['cond_uv'],
)
except Exception as e:
# sidecar 비활성(매매 차단) 포함. 손절선은 기존 값이 그대로 살아있다.
_log(f'정정 예외 {r["id"]} {r["symbol_name"]}: {e!r}')
if not nx['steps']:
# 고점은 올랐지만 호가단위·최저 매도가 때문에 올릴 조건단가가 없다 → 정정 API 콜 0.
if not dry_run:
trailing.commit_peak(r['id'], nx['peak'])
continue
if dry_run:
_log(f'[dry-run] {r["id"]} {r["symbol_name"]} '
f'고점 {r["peak"]:,}{levels["peak"]:,} · '
f'손절 {r["cond_uv"]:,}{levels["cond_uv"]:,} / 지정 {levels["ord_uv"]:,} · '
f'body={res.get("body")}')
continue
for up in nx['steps']:
s, row = next(x for x in live_steps if x[0]['n'] == up['n'])
# 미체결 잔량으로 정정 — 부분체결됐으면 잔량만 남는다 (저장된 qty 는 최초 주문수량).
qty = row.get('unfilled_qty') or s['qty']
try:
res = kiwoom_order.modify_order(
account_label=r['account'],
orig_ord_no=s['ord_no'],
symbol=r['symbol'],
modify_qty=qty,
modify_price=up['ord_uv'],
routing_suffix=row.get('routing_suffix', ''),
dry_run=dry_run,
card_id=r.get('card_id'),
modify_cond_price=up['cond_uv'],
)
except Exception as e:
# sidecar 비활성(매매 차단) 포함. 손절선은 기존 값이 그대로 살아있다.
_log(f'정정 예외 {r["id"]}/{s["n"]}단계 {r["symbol_name"]}: {e!r}')
continue
if res.get('ok'):
new_ord_no = res.get('new_ord_no') or ''
updated = trailing.commit_modify(r['id'], new_ord_no, levels['peak'],
levels['cond_uv'], levels['ord_uv'])
raised += 1
_log(f'손절선 상향 {r["id"]} {r["symbol_name"]} '
f'{r["cond_uv"]:,}{levels["cond_uv"]:,}원 (고점 {levels["peak"]:,}) '
f'ord_no {r["ord_no"]}{new_ord_no}')
if not updated:
# 상태 갱신 실패 = 다음 정정이 옛 ord_no 로 나가 실패한다. 반드시 알린다.
_log(f'⚠️ 상태 갱신 실패 {r["id"]} — ord_no 불일치 위험')
try:
_notify_modify_failed(r, f'정정은 성공했지만 상태 갱신 실패 (새 주문번호 {new_ord_no})')
except Exception:
pass
else:
reason = res.get('reason', 'UNKNOWN')
detail = (res.get('response') or {}).get('return_msg') or res.get('error') or ''
_log(f'정정 실패 {r["id"]} {r["symbol_name"]}: {reason} {detail} (세션 {sess})')
# NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다 — 쿨다운으로 스팸 차단.
# 로그에는 매번 남으니 진단은 로그로 한다.
if trailing.should_notify_failure(r['id'], time.time(), FAIL_NOTICE_COOLDOWN_SEC):
try:
_notify_modify_failed(r, f'{reason} {detail}'.strip() + f' · 세션 {sess}')
except Exception as e:
_log(f'알림 실패: {e!r}')
if dry_run:
_log(f'[dry-run] {r["id"]}/{s["n"]}단계 {r["symbol_name"]} '
f'고점 {r["peak"]:,}{nx["peak"]:,} · '
f'손절 {s["cond_uv"]:,}{up["cond_uv"]:,} / 지정 {up["ord_uv"]:,} · '
f'body={res.get("body")}')
continue
if res.get('ok'):
new_ord_no = res.get('new_ord_no') or ''
updated = trailing.commit_step_modify(r['id'], s['n'], new_ord_no,
nx['peak'], up['cond_uv'], up['ord_uv'])
raised += 1
_log(f'손절선 상향 {r["id"]}/{s["n"]}단계 {r["symbol_name"]} '
f'{s["cond_uv"]:,}{up["cond_uv"]:,}원 (고점 {nx["peak"]:,}) '
f'ord_no {s["ord_no"]}{new_ord_no}')
if not updated:
# 상태 갱신 실패 = 다음 정정이 옛 ord_no 로 나가 실패한다. 반드시 알린다.
_log(f'⚠️ 상태 갱신 실패 {r["id"]}/{s["n"]}단계 — ord_no 불일치 위험')
try:
_notify_modify_failed(r, s, f'정정은 성공했지만 상태 갱신 실패 '
f'(새 주문번호 {new_ord_no})')
except Exception:
pass
else:
_log(f' 알림 생략 (쿨다운 {FAIL_NOTICE_COOLDOWN_SEC}초 내 이미 발송)')
reason = res.get('reason', 'UNKNOWN')
detail = (res.get('response') or {}).get('return_msg') or res.get('error') or ''
_log(f'정정 실패 {r["id"]}/{s["n"]}단계 {r["symbol_name"]}: '
f'{reason} {detail} (세션 {sess})')
# NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다 — 쿨다운으로
# 스팸 차단. 쿨다운은 예약 단위다(같은 원인으로 전 계단이 함께 실패하므로
# 계단마다 알리면 한 사이클에 3~5통이 된다). 로그에는 매번 남는다.
if trailing.should_notify_failure(r['id'], time.time(), FAIL_NOTICE_COOLDOWN_SEC):
try:
_notify_modify_failed(r, s, f'{reason} {detail}'.strip() + f' · 세션 {sess}')
except Exception as e:
_log(f'알림 실패: {e!r}')
else:
_log(f' 알림 생략 (쿨다운 {FAIL_NOTICE_COOLDOWN_SEC}초 내 이미 발송)')
if raised:
_log(f'완료 — {raised} 상향 / 예약 {len(alive)}')
_log(f'완료 — {raised}개 계단 상향 / 예약 {len(alive)}')
return 0
@@ -277,16 +341,21 @@ def cmd_list() -> int:
print('트레일링 예약 없음')
return 0
for r in reservations:
steps = r.get('steps') or []
floor_part = f' · 최저 {r["min_sell_price"]:,}' if r.get('min_sell_price') else ''
entry_peak = r.get('entry_peak') or 0
peak_part = f'고점 {r["peak"]:,}'
if entry_peak and r['peak'] > entry_peak:
peak_part += f' (등록 시 {entry_peak:,})'
print(f'{r["id"]} · {r["symbol_name"]}({r["symbol"]}) · {r["account"]} · {r["qty"]:,}')
print(f' 트레일 {r["trail_pct"]}%{floor_part} · {peak_part} · '
f'손절 {r["cond_uv"]:,} / 지정 {r["ord_uv"]:,}')
print(f' ord_no {r["ord_no"]} · 상향 {r.get("modify_count", 0)}회 · '
f'등록 {r["created_at"]} · 갱신 {r["updated_at"]}')
print(f'{r["id"]} · {r["symbol_name"]}({r["symbol"]}) · {r["account"]} · '
f'{r["qty"]:,} · {len(steps)}')
print(f' {peak_part}{floor_part} · 등록 {r["created_at"]} · 갱신 {r["updated_at"]}')
for s in steps:
cum = s.get('cum', 0)
cum_part = '전량' if cum >= 100 else f'누적 {_pct_s(cum)}%'
print(f' {s["n"]}단계 {_pct_s(s["pct"])}% · 손절 {s["cond_uv"]:,} / '
f'지정 {s["ord_uv"]:,} · {s["qty"]:,}주 ({cum_part}) · '
f'ord_no {s["ord_no"]} · 상향 {s.get("modify_count", 0)}')
return 0