#!/usr/bin/env python3 """트레일링 스톱 감시 — 고점이 갱신되면 키움 스톱주문의 조건단가를 상향 정정한다. **예약 1건 = 계단(레그) N개.** 고점 대비 하락률이 깊어질수록 더 많이 파는 계단식 청산이라 계단마다 스톱주문이 따로 걸려 있다. 고점이 오르면 살아있는 레그를 전부 상향 정정한다. LLM을 깨우지 않는다. 하는 일은 세 가지: 1. 레그가 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 정리+알림 2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 계단별 조건단가·지정가를 함께 올림 3. **예약 트레일링(TRAILING_ARM) 발동** — 현재가가 발동가에 닿으면 그 시점 현재가를 고점으로 계단별 스톱주문을 발주하고, 대기 예약을 트레일링 예약으로 승격 (2026-08-25) ⚠️ 3번이 **신규 발주 경로**다 (2026-08-25 관리자님 명시 승인). 그전까지 이 감시가 허용된 근거는 "modify_order 하나만 부른다" 였는데 이제는 다르다. 바뀐 근거는 **계좌·종목·수량· 계단·발동가가 PIN 승인 시점에 전부 확정되고, 감시는 발동가 도달 판정만 한다** 는 것이다. 수량 증가·방향 변경 경로는 여전히 없다 (매도가능 부족 시 **축소** 재배분만). 한 계단이 체결돼도 남은 계단을 재배치하지 않는다 — 재배치엔 취소+신규 발주가 필요해 감시 루프에 (승인 범위를 넘는) 자동 발주 경로가 생긴다(2026-07-31 관리자님 결정). ⚠️ arm 단계는 손절선 상향이 **끝난 뒤** 별도 try/except 로 돈다. 순서가 설계다 — 상향은 이미 나간 주문을 지키는 동작이라 arm 쪽 어떤 예외도 그걸 선점해선 안 된다. ⚠️ 발동은 2단계 마킹으로 보호한다. 발주 전에 firing_at 을 디스크에 남기고 레그마다 주문번호를 적는다. **firing_at 이 남은 예약은 자동 재발동하지 않는다** — 주문이 나갔는지 모르는 상태에서 다시 내는 게 곧 이중 매도다. 알림만 보내고 사람이 확인해 취소한다. ⚠️ 레그가 사라진 사유는 **주문번호 단위**인 kt00007 로 판정한다. 종목 단위 집계인 당일매매일지(ka10170)를 쓰면 한 계단이 체결된 날 나머지 계단이 장 마감으로 소멸했을 때 그 소멸분까지 '체결' 로 오판한다 — 같은 종목 매도 기록이 이미 남아 있기 때문이다. ⚠️ 정정하면 주문번호가 새로 발급된다 (kt10002 응답 ord_no). trailing.commit_step_modify 가 상태파일의 레그별 ord_no 를 갱신하지 않으면 그 레그의 다음 정정부터 전부 실패한다. ⚠️ 고점은 '현재가' 로만 갱신한다. ka10095 가 당일 고가(high)도 주지만, 등록 시점 이전에 찍힌 고가까지 반영되면 등록 직후 손절선이 현재가 위로 올라가 즉시 발동할 수 있다. (예: 오전 15,000 → 14,000 일 때 3% 등록 → 당일고가 기준이면 조건 14,550 > 현재가) 감시 간격이 2분이라 현재가 샘플링으로 충분하다. 현재 launchd cadence 는 1분 1회 (`check`), 08:00~20:00 전 거래 세션. 상시 daemon 을 띄우지 않는 이유는 매매 API 를 호출할 수 있는 프로세스를 24시간 살려두지 않기 위함. ⚠️ NXT 시간대(프리 08:00~09:00 / 애프터 15:30~20:00) 감시는 2026-07-30 관리자님 요청으로 추가. **정규장에 등록된 주문(SOR `_AL` 또는 KRX `''`)의 정정이 NXT 단독 시간대에 받아들여지는지 실증 안 됨.** 거부되면 고점 갱신마다 실패가 반복되므로 실패 알림에 30분 쿨다운을 걸었다 (로그에는 매번 남으니 진단은 logs/stock-trailing-monitor.log 로). ⚠️ 1분보다 짧게 가려면 --repeat/--gap 을 쓴다. launchd 는 StartCalendarInterval 이 분 단위가 최소라 30초 발화를 만들 수 없고, StartInterval=30 은 GUI 세션 idle 시 발화가 보류돼(timer coalescing) 장중에 안 도는 문제가 있어 못 쓴다 (sim-scan 이 같은 이유로 캘린더로 전환됨). 그래서 30초는 "1분 발화 + 한 프로세스가 30초 간격 2회 검사" 로만 가능하다 — plist ProgramArguments 에 인자를 붙이면 된다. (2026-07-30 30초로 운영했다가 관리자님 지시로 1분 복귀. 옵션은 보존) Usage: python3 trailing_monitor.py check # 1회 감시 (현재 launchd 설정) python3 trailing_monitor.py check --repeat 2 --gap 30 # 30초 간격 2회 python3 trailing_monitor.py check --force # 장외에도 실행 (테스트용) python3 trailing_monitor.py check --dry-run # 정정 API 호출 없이 판단·body 만 출력 python3 trailing_monitor.py list # 현재 예약 목록 """ from __future__ import annotations import sys import time from datetime import datetime, timedelta, timezone from pathlib import Path KST = timezone(timedelta(hours=9)) WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace') sys.path.insert(0, str(WORKSPACE / 'scripts')) import kiwoom_client as kc # noqa: E402 from orders import card, datasource, guards, kiwoom_order, ledger, sidecar, trailing # noqa: E402 from orders import handler # noqa: E402 from orders.handler import send_telegram # noqa: E402 # 정정 실패 알림 쿨다운 — NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다. FAIL_NOTICE_COOLDOWN_SEC = 1800 # 사이클마다 mtime 만 갱신하는 생존 신호. 이 감시가 죽으면 예약 트레일링(ARM)이 # 아예 발동하지 않는데, 예약 0건이면 로그를 한 줄도 남기지 않아 바깥에서 죽었는지 # 구분할 방법이 없었다(2026-08-28 이후 13일간 로그 무갱신이 정상인지 장애인지 불명). # 로그로 찍으면 분당 1줄 × 720회/일이 쌓여 실제 이벤트를 덮으므로 파일 mtime 만 쓴다. # scripts/health_check.py 가 이 파일을 읽는다. HEARTBEAT_FILE = WORKSPACE / 'state' / 'trailing_monitor_heartbeat' def _log(msg: str) -> None: print(f'[{datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S")}] {msg}', flush=True) def _heartbeat() -> None: """감시가 이번 사이클을 돌았다는 신호. 실패해도 감시를 막지 않는다.""" try: HEARTBEAT_FILE.write_text( datetime.now(KST).isoformat(timespec='seconds'), encoding='utf-8') except Exception: pass def session_now() -> tuple[bool, str]: """(감시할 세션인가, 세션명). 주말·휴장일·거래시간 외는 False. 세션 판정은 orders/limits.json 을 단일 진실 소스로 쓰는 guards.session_at 재사용 — NXT 프리(08:00~09:00) / 정규(09:00~15:20) / 종가단일가(15:20~15:30) / NXT 애프터(15:30~20:00). 시간이 바뀌면 limits.json 만 고치면 된다. """ now = datetime.now(KST) if now.weekday() >= 5: return False, 'WEEKEND' if guards.is_today_holiday(now): return False, 'HOLIDAY' sess = guards.session_at(now) return sess != 'CLOSED', sess 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, 조회 성공 여부). 조회 실패와 '체결 안 됨' 을 반드시 구분해야 한다 — 실패를 '체결 안 됨' 으로 단정하면 실제로 팔렸는데 안 팔렸다고 알리게 된다. """ 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('ord_no') or '').strip() == ord_no and (row.get('cntr_qty') or 0) > 0: return row, True return None, True def _notify_gone_batch(events: list) -> None: """한 사이클에 사라진 레그들을 예약 단위로 묶어 한 번만 알린다. 장 마감이면 계단이 통째로 소멸하는데 레그마다 보내면 3~5통이 몰아친다. 체결 / 미체결 소멸(장 마감 취소 등)은 관리자님이 취해야 할 행동이 완전히 다르므로 계단별로 구분해 한 메시지 안에 나열한다. 2026-07-30 실측: 스톱주문은 장 마감 후 체결 없이 소멸한다(15:30~18:21 사이). 자동 재등록은 하지 않는다(관리자님 지시) — 다시 걸려면 자산웹에서 수동 등록. """ 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 remaining == 0 and not any_filled and not unknown: entry_steps = res.get('entry_steps') # ⚠️ 이번 배치 이벤트 수 < 원래 계단 수 = **앞선 사이클에 이미 빠진 레그가 있다** # (거의 항상 장중 체결). 이 배치엔 체결이 없어 위 조건을 통과하므로, 여기서 # 막지 않으면 "일부는 이미 판" 설정을 재등록 후보로 올리게 된다. if entry_steps and len(evs) == len(entry_steps): try: trailing.push_recent(trailing.make_recent_entry( 'trailing', 'not_in_open_orders', res, entry_steps)) except Exception as e: _log(f'재등록 기록 실패 {res["id"]}: {e!r}') 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}개 — 감시 계속합니다.') elif any_filled or unknown: # 체결·판정불가는 재등록 목록에 올리지 않는다(_archive 조건과 동일) — # 없는 목록으로 안내하면 안 되므로 여기서도 재등록 버튼을 언급하지 않는다. lines.append('남은 계단 없음 — 계속 쓰시려면 자산웹에서 다시 등록해주세요 ' '(자동 재등록 안 함).') else: lines.append('남은 계단 없음 — 자산웹 [📋 진행중] → 최근 종료 에서 ↺ 재등록 ' '(같은 설정이 채워집니다 · 자동 재등록은 안 합니다).') send_telegram('\n'.join(lines), parse_mode=None) def _notify_modify_failed(res: dict, step: dict, reason: str) -> None: send_telegram( f'⚠️ 트레일링 손절선 상향 실패 — {res["symbol_name"]} ({res["symbol"]})\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) def _sellable_qty(account: str, symbol: str, open_rows) -> tuple: """(매도가능 수량, 조회성공). 조회 실패를 0 과 반드시 구분한다 — 눈감고 발주하면 안 되고, 반대로 실패를 0 으로 읽으면 살아있는 예약을 지운다. trde_able_qty 가 미체결 매도를 이미 뺐는지 확실하지 않아 min 으로 이중차감을 피한다. """ try: positions = kc.get_positions(account) except Exception as e: _log(f'보유 조회 실패 [{account}]: {e!r}') return 0, False pos = next((p for p in positions if p.get('code') == symbol), None) if pos is None: return 0, True hold = pos.get('qty') or 0 able = pos.get('trde_able_qty') or 0 pending = sum((o.get('unfilled_qty') or 0) for o in (open_rows or []) if o.get('code') == symbol and o.get('side') == 'SELL') return max(0, min(able, hold - pending)), True def _notify_arm_issue(arm: dict, text: str) -> None: """대기 예약 관련 경고 — 예약 단위 쿨다운(매 사이클 반복되는 사유가 많다).""" if not trailing.should_notify_arm_failure(arm['id'], time.time(), FAIL_NOTICE_COOLDOWN_SEC): _log(f' 알림 생략 (쿨다운 {FAIL_NOTICE_COOLDOWN_SEC}초 내 이미 발송)') return try: send_telegram( f'⚠️ 예약 트레일링 — {arm["symbol_name"]} ({arm["symbol"]})\n' f'계좌: {arm["account"]} · 발동가 {arm["trigger_price"]:,}원 · {arm["qty"]:,}주\n' f'{text}', parse_mode=None) except Exception as e: _log(f'알림 실패: {e!r}') def _fire_arm(arm: dict, plan: dict, sess: str, dry_run: bool) -> None: """발동 — 계단 발주 후 대기 예약을 트레일링 예약으로 승격. 여기부터 되돌릴 수 없다.""" symbol = arm['symbol'] # 라우팅은 등록 시점이 아니라 지금 정한다 — 같은 날 안에서도 정규장에 등록해 NXT # 애프터에 발동할 수 있고, 옛 suffix 로 발주하면 거부된다. try: suffix = guards.determine_routing(datetime.now(KST), datasource.nxt_eligible(symbol), None, 'STOP_LIMIT') except ValueError as e: _log(f'라우팅 결정 실패 {arm["id"]} (세션 {sess}): {e!r}') return if dry_run: _log(f'[dry-run] 발동 {arm["id"]} {arm["symbol_name"]} ' f'발동가 {arm["trigger_price"]:,} ≤ 현재가 {plan["peak"]:,} · ' f'{plan["qty_used"]:,}주 · routing {suffix or "KRX"}') for s in plan['legs']: _log(f' {s["n"]}단계 −{s["pct"]}% 조건 {s["cond_uv"]:,} / 지정 {s["ord_uv"]:,} ' f'· {s["qty"]:,}주') return # write-ahead: 발주를 시작했다는 사실을 먼저 디스크에 남긴다. 이 마킹이 있으면 다음 # 사이클은 재발동하지 않는다 — 주문이 나갔는지 모르는 상태에서 다시 내는 게 이중 매도다. if not trailing.mark_arm_firing(arm['id']): _log(f'발동 마킹 실패 {arm["id"]} — 이미 발주 시도된 예약 (재발동 안 함)') return placed, failed, _details = handler.place_trailing_legs( account=arm['account'], symbol=symbol, steps=plan['legs'], routing_suffix=suffix, card_id=arm.get('card_id'), dry_run=False, on_placed=lambda leg: trailing.record_arm_leg(arm['id'], leg)) if not placed: reason = failed[0]['reason'] if failed else 'UNKNOWN' detail = failed[0].get('error', '') if failed else '' _log(f'발동 전량 실패 {arm["id"]} {arm["symbol_name"]}: {reason} {detail}') ledger.append('arm_fire_failed', {'arm_id': arm['id'], 'account': arm['account'], 'symbol': symbol, 'reason': reason, 'error': str(detail), 'stage': 'submit'}) # firing 마킹은 그대로 둔다 — 정말 한 건도 안 나갔는지 우리가 단정할 수 없다. _notify_arm_issue(arm, f'계단 {len(plan["legs"])}단 전부 접수 실패 ({reason}) {detail}\n' f'예약은 발동 처리 중단 상태로 남습니다 — 미체결을 확인하고 ' f'자산웹에서 취소해 주세요.') return registerable = [s for s in placed if s.get('ord_no')] promoted = None if registerable: try: promoted = trailing.promote_arm(arm['id'], plan['peak'], registerable, suffix) except Exception as e: _log(f'⚠️ 승격 실패 {arm["id"]}: {e!r}') info = {'card_id': arm.get('card_id'), 'symbol': symbol, 'symbol_name': arm['symbol_name'], 'account': arm['account'], 'total_qty': plan['qty_used'], 'arm_qty': arm['qty'], 'qty_reduced': plan['reduced'], 'trigger_price': arm['trigger_price'], 'peak': plan['peak'], 'min_sell_price': arm.get('min_sell_price'), 'placed': placed, 'failed': failed, 'dry_run': False, 'res_id': (promoted['reservation']['id'] if promoted else None), 'register_error': None if promoted else '감시 등록 실패 — 손절선이 따라 올라가지 않습니다'} ledger.append('arm_fired', {'arm_id': arm['id'], 'account': arm['account'], 'symbol': symbol, 'peak': plan['peak'], 'qty': plan['qty_used'], 'legs': len(placed), 'res_id': info['res_id']}) _log(f'발동 {arm["id"]} → {info["res_id"]} {arm["symbol_name"]} ' f'{len(placed)}단 · {plan["qty_used"]:,}주 · 고점 {plan["peak"]:,}') try: send_telegram(card.format_arm_fired(info), parse_mode=None) except Exception as e: _log(f'알림 실패: {e!r}') def _expire_arms(arms: list, dry_run: bool) -> list: """만료된 대기 예약을 지우고 살아있는 것만 돌려준다 (2026-08-25). 스톱주문은 장 마감에 거래소가 지워주지만 대기 예약은 우리 파일에만 있으니 우리가 지운다. 감시 루프가 08:00~19:59 에만 도므로 실제 정리 시점은 **다음 거래일 첫 사이클**이다. 알림은 한 통으로 묶는다 — 여러 종목을 걸어두고 하루가 지나면 예약마다 보내면 아침에 텔레그램이 몰아친다. """ alive, dead = [], [] for a in arms: (dead if trailing.is_arm_expired(a) else alive).append(a) if not dead: return alive for a in dead: _log(f'대기 예약 만료 {a["id"]} {a["symbol_name"]} ' f'(등록 {a.get("created_at", "?")[:16]} · 발동가 {a["trigger_price"]:,})') if not dry_run: trailing.remove_arm(a['id'], 'expired_end_of_day') # 재등록 버튼용 스냅샷. 삭제 뒤에 남긴다 — 앞에 남기고 죽으면 중복이 생긴다. # ⚠️ firing_at 이 걸린 예약은 제외 — 주문이 나갔는지 모르는 상태라, 재등록을 # 권하면 이중 매도로 유도하는 셈이다(사람이 미체결부터 확인해야 한다). if not a.get('firing_at'): try: trailing.push_recent(trailing.make_recent_entry( 'arm', 'expired_end_of_day', a, a.get('steps') or [])) except Exception as e: _log(f'재등록 기록 실패 {a["id"]}: {e!r}') if dry_run: return alive lines = [f'🗓 예약 트레일링 만료 취소 — {len(dead)}건', '장 마감이 지나 자동 취소했습니다 (스톱주문과 같은 당일 유효).'] for a in dead: lines.append(f'· {a["symbol_name"]} ({a["symbol"]}) [{a["account"]}] ' f'{a["qty"]:,}주 · 발동가 {a["trigger_price"]:,}원 — 미발동') lines.append('계속 노리시려면 자산웹 [📋 진행중] → 최근 종료 에서 ↺ 재등록 을 누르세요 ' '(같은 설정이 채워집니다 · PIN 은 평소대로).') try: send_telegram('\n'.join(lines), parse_mode=None) except Exception as e: _log(f'알림 실패: {e!r}') return alive def _check_arms(arms: list, quotes: dict, open_by_acct: dict, sess: str, dry_run: bool) -> None: """대기 예약 발동 판정. 평시(미도달)엔 추가 API 콜 0 — 시세는 호출측 배치를 재사용한다. 만료분은 호출측(check)이 이미 걷어낸 상태로 넘어온다 — 시세 조회가 실패해도 정리는 돼야 하므로 순서상 앞에 둔다. """ # 사이드카는 마킹 전에 본다. 마킹 후 전 레그가 SidecarBlocked 로 죽으면 예약이 발동 처리 # 중단 상태로 갇힌다. if sidecar.is_disabled(): reached = [a for a in arms if ((quotes.get(a['symbol']) or {}).get('price') or 0) >= a['trigger_price']] for a in reached: _log(f'사이드카 ON — 발동 보류 {a["id"]} {a["symbol_name"]}') _notify_arm_issue(a, '사이드카가 켜져 있어 발동을 보류했습니다 (매매 차단). ' '끄면 다음 사이클에 발동합니다.') return for arm in arms: if arm.get('firing_at'): _log(f'발동 처리 중단 상태 {arm["id"]} {arm["symbol_name"]} ' f'(firing_at={arm["firing_at"]}) — 재발동 안 함') legs = arm.get('fired_legs') or [] nos = ', '.join(s.get('ord_no', '') for s in legs if s.get('ord_no')) or '없음' _notify_arm_issue(arm, f'발동 처리가 중단된 상태입니다 (시작 {arm["firing_at"]}).\n' f'접수된 주문: {nos}\n' f'키움 미체결을 확인하고 자산웹에서 예약을 취소해 주세요. ' f'중복 매도를 막기 위해 자동 재시도하지 않습니다.') continue cur = (quotes.get(arm['symbol']) or {}).get('price') or 0 if cur < arm['trigger_price']: continue # 평시 경로 — 여기서 끝나면 API 콜 0 # 미체결 조회가 실패한 계좌는 판단을 보류한다 — 예약 경로와 같은 규칙이다. # 미체결 매도 수량을 모르는 채로 발주하면 과다 매도가 될 수 있다(0 으로 단정 금지). open_rows = open_by_acct.get(arm['account']) if open_rows is None: _log(f'미체결 조회 실패로 발동 보류 {arm["id"]} {arm["symbol_name"]}') continue sellable, ok = _sellable_qty(arm['account'], arm['symbol'], open_rows) if not ok: _log(f'보유 조회 실패로 발동 보류 {arm["id"]} {arm["symbol_name"]}') continue # 눈감고 발주하지 않는다. 다음 사이클에 다시 본다 plan = trailing.plan_arm_fire(arm, cur, sellable) if plan['action'] == 'drop': why = {'no_sellable': '보유 수량이 없어', 'qty_too_small': '수량이 부족해 계단을 나눌 수 없어'} _log(f'발동 불가 {arm["id"]} {arm["symbol_name"]} — {plan["reason"]}') if not dry_run: trailing.remove_arm(arm['id'], plan['reason']) send_telegram( f'🗑 예약 트레일링 삭제 — {arm["symbol_name"]} ({arm["symbol"]})\n' f'계좌: {arm["account"]} · 발동가 {arm["trigger_price"]:,}원 도달했지만 ' f'{why.get(plan["reason"], plan["reason"])} 발동하지 못했습니다.\n' f'예약 {arm["qty"]:,}주 · 매도가능 {sellable:,}주', parse_mode=None) continue if plan['action'] != 'fire': _log(f'발동 보류 {arm["id"]} {arm["symbol_name"]} — {plan["reason"]}') continue _fire_arm(arm, plan, sess, dry_run) def _worth_running(force: bool) -> bool: """볼 게 있고 거래 세션인가. 루프 제어와 단발 실행이 같은 기준을 쓰게 분리.""" if not trailing.list_active() and not trailing.list_arms(): return False return force or session_now()[0] def check(force: bool = False, dry_run: bool = False) -> int: _heartbeat() reservations = trailing.list_active() arms = trailing.list_arms() if not reservations and not arms: return 0 ok_sess, sess = session_now() if not force and not ok_sess: _log(f'{sess} — skip (예약 {len(reservations)}건 · 대기 예약 {len(arms)}건)') return 0 # NXT 단독 시간대엔 KRX 호가가 15:30 종가로 고정돼 현재가와 갭이 생긴다 → NXT 시세로 조회. quote_exchange = 'NX' if sess in ('NXT_PRE', 'NXT_AFTER') else 'AL' # 만료된 대기 예약은 시세보다 먼저 걷어낸다 — 시세 조회가 실패해도 정리는 돼야 하고, # 어제 예약이 오늘 시세로 발동하는 일은 절대 없어야 한다. if arms: arms = _expire_arms(arms, dry_run) if not reservations and not arms: return 0 # 계좌별 미체결 스냅샷 (계좌당 1콜). 스톱주문도 조건 도달 전까지 미체결로 잡힌다. open_by_acct: dict[str, list] = {} for acct in sorted({r['account'] for r in reservations} | {a['account'] for a in arms}): try: open_by_acct[acct] = kc.get_open_orders(acct, side='sell') except Exception as e: # 한 계좌 조회 실패로 다른 계좌 감시를 멈추지 않는다. 생존 판정은 건너뛴다 # (없다고 단정하면 살아있는 예약을 지워버린다). _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 # 조회 실패 계좌 — 이번 사이클 판단 보류 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"]}/{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}') # 시세는 예약·대기 예약 심볼을 합쳐 한 번에 받는다 — ka10095 배치 1콜이라 대기 예약이 # 늘어도 API 콜이 추가되지 않는다. 실패하면 두 단계 모두 이번 사이클을 건너뛴다. quote_codes = sorted({r['symbol'] for r, _ in alive} | {a['symbol'] for a in arms}) if not quote_codes: return 0 try: quotes = kc.get_watchlist_quotes(quote_codes, exchange=quote_exchange) except Exception as e: _log(f'시세 조회 실패 ({quote_exchange}): {e!r}') return 1 raised = 0 for r, live_steps in alive: cur = (quotes.get(r['symbol']) or {}).get('price') or 0 # 살아있는 레그만 담은 스냅샷으로 판단 — 이미 빠진 계단은 따라 올릴 대상이 아니다. 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 if not nx['steps']: # 고점은 올랐지만 호가단위·최저 매도가 때문에 올릴 조건단가가 없다 → 정정 API 콜 0. if not dry_run: trailing.commit_peak(r['id'], nx['peak']) 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 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: 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}초 내 이미 발송)') # 대기 예약 발동은 손절선 상향이 **끝난 뒤에** 한다. 순서가 설계다 — 상향은 이미 나간 # 주문을 지키는 동작이라 arm 쪽 어떤 예외도 그걸 선점하면 안 된다. 그래서 통째로 감싼다. # arm 이 0건이면 이 아래는 실행되지 않아 기존 동작과 동일하다. if arms: try: _check_arms(arms, quotes, open_by_acct, sess, dry_run) except Exception as e: _log(f'대기 예약 단계 예외 (트레일링 감시는 정상 완료): {e!r}') if raised: _log(f'완료 — {raised}개 계단 상향 / 예약 {len(alive)}건') return 0 def cmd_list() -> int: reservations = trailing.list_active() arms = trailing.list_arms() if not reservations and not arms: print('트레일링 예약 없음') return 0 if arms: print(f'── 대기 예약 (발동가 도달 시 시작) {len(arms)}건 ──') for a in arms: steps = a.get('steps') or [] floor_part = f' · 최저 {a["min_sell_price"]:,}' if a.get('min_sell_price') else '' print(f'{a["id"]} · {a["symbol_name"]}({a["symbol"]}) · {a["account"]} · ' f'{a["qty"]:,}주 · {len(steps)}단') print(f' 발동가 {a["trigger_price"]:,}{floor_part} · 등록 {a["created_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"])}% · {cum_part} ' f'(주수는 발동 시점 매도가능 기준으로 배분)') if a.get('firing_at'): legs = a.get('fired_legs') or [] nos = ', '.join(s.get('ord_no', '') for s in legs if s.get('ord_no')) or '없음' print(f' ⚠️ 발동 처리 중단 (시작 {a["firing_at"]}) · 접수된 주문: {nos}') if reservations and arms: print(f'── 트레일링 예약 {len(reservations)}건 ──') 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"]} · ' 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 def _int_opt(argv: list[str], name: str, default: int) -> int: if name not in argv: return default i = argv.index(name) if i + 1 >= len(argv): return default try: return int(argv[i + 1]) except ValueError: return default def main(argv: list[str]) -> int: cmd = argv[1] if len(argv) > 1 else 'check' if cmd == 'list': return cmd_list() if cmd == 'check': force = '--force' in argv dry_run = '--dry-run' in argv repeat = max(1, _int_opt(argv, '--repeat', 1)) gap = max(1, _int_opt(argv, '--gap', 30)) rc = 0 for i in range(repeat): rc = check(force=force, dry_run=dry_run) or rc if i >= repeat - 1: break # 예약 0건이거나 장 마감이면 남은 sweep 을 버린다 — 빈 sleep 으로 프로세스가 # 살아있을 이유가 없다. sleep '전에' 확인해야 한다. if not _worth_running(force): break time.sleep(gap) return rc print(__doc__, file=sys.stderr) return 2 if __name__ == '__main__': sys.exit(main(sys.argv))