#!/usr/bin/env python3
"""트레일링 스톱 감시 — 고점이 갱신되면 키움 스톱주문의 조건단가를 상향 정정한다.

**예약 1건 = 계단(레그) N개.** 고점 대비 하락률이 깊어질수록 더 많이 파는 계단식 청산이라
계단마다 스톱주문이 따로 걸려 있다. 고점이 오르면 살아있는 레그를 전부 상향 정정한다.

LLM을 깨우지 않는다. 하는 일은 딱 두 가지:
  1. 레그가 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 정리+알림
  2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 계단별 조건단가·지정가를 함께 올림

신규 발주·수량 변경·방향 변경은 하지 않는다. 사람이 PIN으로 승인한 주문의
조건단가를 올리는 것뿐이고, 상향 전용이라 손절선이 내려가는 일은 없다.
한 계단이 체결돼도 남은 계단을 재배치하지 않는다 — 재배치엔 취소+신규 발주가 필요해
감시 루프에 자동 발주 경로가 생긴다(2026-07-31 관리자님 결정).

⚠️ 레그가 사라진 사유는 **주문번호 단위**인 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 guards, kiwoom_order, trailing  # noqa: E402
from orders.handler import send_telegram  # noqa: E402

# 정정 실패 알림 쿨다운 — NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다.
FAIL_NOTICE_COOLDOWN_SEC = 1800


def _log(msg: str) -> None:
    print(f'[{datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S")}] {msg}', flush=True)


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 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, 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 _worth_running(force: bool) -> bool:
    """예약이 있고 거래 세션인가. 루프 제어와 단발 실행이 같은 기준을 쓰게 분리."""
    if not trailing.list_active():
        return False
    return force or session_now()[0]


def check(force: bool = False, dry_run: bool = False) -> int:
    reservations = trailing.list_active()
    if not reservations:
        return 0
    ok_sess, sess = session_now()
    if not force and not ok_sess:
        _log(f'{sess} — skip ({len(reservations)}건 예약 대기)')
        return 0
    # NXT 단독 시간대엔 KRX 호가가 15:30 종가로 고정돼 현재가와 갭이 생긴다 → NXT 시세로 조회.
    quote_exchange = 'NX' if sess in ('NXT_PRE', 'NXT_AFTER') else 'AL'

    # 계좌별 미체결 스냅샷 (계좌당 1콜). 스톱주문도 조건 도달 전까지 미체결로 잡힌다.
    open_by_acct: dict[str, list] = {}
    for acct in sorted({r['account'] for r in reservations}):
        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}')

    if not alive:
        return 0

    try:
        quotes = kc.get_watchlist_quotes([r['symbol'] for r, _ in alive], 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}초 내 이미 발송)')

    if raised:
        _log(f'완료 — {raised}개 계단 상향 / 예약 {len(alive)}건')
    return 0


def cmd_list() -> int:
    reservations = trailing.list_active()
    if not reservations:
        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"]} · '
              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))
