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

687 lines
36 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 정정으로 계단별 조건단가·지정가를 함께 올림
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
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 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)
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:
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))