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

400 lines
19 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
"""트레일링 스톱 감시 — 고점이 갱신되면 키움 스톱주문의 조건단가를 상향 정정한다.
**예약 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))