Files
openclaw/agents/stock/workspace/scripts/trailing_monitor.py
T
hyowons d7042430c3 auto: 일일 백업 2026-07-31 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 02:00:02 +09:00

331 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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))