d7042430c3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
16 KiB
Python
331 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""트레일링 스톱 감시 — 고점이 갱신되면 키움 스톱주문의 조건단가를 상향 정정한다.
|
||
|
||
LLM을 깨우지 않는다. 하는 일은 딱 두 가지:
|
||
1. 예약이 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 체결·취소로 보고 정리+알림
|
||
2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 조건단가·지정가를 함께 올림
|
||
|
||
신규 발주·수량 변경·방향 변경은 하지 않는다. 사람이 PIN으로 승인한 주문의
|
||
조건단가를 올리는 것뿐이고, 상향 전용이라 손절선이 내려가는 일은 없다.
|
||
|
||
⚠️ 정정하면 주문번호가 새로 발급된다 (kt10002 응답 ord_no). trailing.commit_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 _fill_check(account: str, symbol: str) -> tuple:
|
||
"""예약이 사라진 사유 판정용 — 당일매매일지(ka10170)에서 그 종목 매도 기록을 찾는다.
|
||
|
||
⚠️ 미체결 조회에서 사라진 것만으로는 체결·취소·소멸을 구분할 수 없다(셋 다 목록에서 빠짐).
|
||
당일매매일지에 매도 기록이 없으면 **체결 안 된 것이 확정**이다.
|
||
있으면 트레일링 발동분인지 관리자님 수동 매도인지는 구분할 수 없어 '체결로 보임'까지만 말한다.
|
||
|
||
반환: (매도기록 dict 또는 None, 조회 성공 여부).
|
||
조회 실패와 '기록 없음'을 반드시 구분해야 한다 — 실패를 '체결 안 됨'으로 단정하면
|
||
실제로 팔렸는데 안 팔렸다고 알리게 된다.
|
||
"""
|
||
try:
|
||
rows = kc.get_trade_journal(account)
|
||
except Exception as e:
|
||
_log(f'당일매매일지 조회 실패 ({account}/{symbol}): {e!r}')
|
||
return None, False
|
||
for row in rows:
|
||
if row.get('code') == symbol and (row.get('sell_qty') or 0) > 0:
|
||
return row, True
|
||
return None, True
|
||
|
||
|
||
def _notify_gone(res: dict, sold, journal_ok: bool) -> None:
|
||
"""예약이 미체결 목록에서 사라짐 — 사유를 구분해 알린다.
|
||
|
||
체결 / 미체결 소멸(장 마감 취소 등)은 관리자님이 취해야 할 행동이 완전히 다르다.
|
||
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)
|
||
|
||
|
||
def _notify_modify_failed(res: 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'사유: {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
|
||
|
||
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 = '미체결 소멸 (당일 매도 기록 없음 — 장 마감 취소 추정)'
|
||
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))
|
||
|
||
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, row in alive:
|
||
cur = (quotes.get(r['symbol']) or {}).get('price') or 0
|
||
levels = trailing.next_levels(r, cur)
|
||
if not levels:
|
||
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}')
|
||
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
|
||
|
||
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}')
|
||
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:
|
||
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"]}')
|
||
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))
|