auto: 일일 백업 2026-08-26 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,26 @@
|
||||
**예약 1건 = 계단(레그) N개.** 고점 대비 하락률이 깊어질수록 더 많이 파는 계단식 청산이라
|
||||
계단마다 스톱주문이 따로 걸려 있다. 고점이 오르면 살아있는 레그를 전부 상향 정정한다.
|
||||
|
||||
LLM을 깨우지 않는다. 하는 일은 딱 두 가지:
|
||||
LLM을 깨우지 않는다. 하는 일은 세 가지:
|
||||
1. 레그가 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 정리+알림
|
||||
2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 계단별 조건단가·지정가를 함께 올림
|
||||
3. **예약 트레일링(TRAILING_ARM) 발동** — 현재가가 발동가에 닿으면 그 시점 현재가를
|
||||
고점으로 계단별 스톱주문을 발주하고, 대기 예약을 트레일링 예약으로 승격 (2026-08-25)
|
||||
|
||||
⚠️ 3번이 **신규 발주 경로**다 (2026-08-25 관리자님 명시 승인). 그전까지 이 감시가 허용된
|
||||
근거는 "modify_order 하나만 부른다" 였는데 이제는 다르다. 바뀐 근거는 **계좌·종목·수량·
|
||||
계단·발동가가 PIN 승인 시점에 전부 확정되고, 감시는 발동가 도달 판정만 한다** 는 것이다.
|
||||
수량 증가·방향 변경 경로는 여전히 없다 (매도가능 부족 시 **축소** 재배분만).
|
||||
|
||||
신규 발주·수량 변경·방향 변경은 하지 않는다. 사람이 PIN으로 승인한 주문의
|
||||
조건단가를 올리는 것뿐이고, 상향 전용이라 손절선이 내려가는 일은 없다.
|
||||
한 계단이 체결돼도 남은 계단을 재배치하지 않는다 — 재배치엔 취소+신규 발주가 필요해
|
||||
감시 루프에 자동 발주 경로가 생긴다(2026-07-31 관리자님 결정).
|
||||
감시 루프에 (승인 범위를 넘는) 자동 발주 경로가 생긴다(2026-07-31 관리자님 결정).
|
||||
|
||||
⚠️ arm 단계는 손절선 상향이 **끝난 뒤** 별도 try/except 로 돈다. 순서가 설계다 — 상향은
|
||||
이미 나간 주문을 지키는 동작이라 arm 쪽 어떤 예외도 그걸 선점해선 안 된다.
|
||||
|
||||
⚠️ 발동은 2단계 마킹으로 보호한다. 발주 전에 firing_at 을 디스크에 남기고 레그마다
|
||||
주문번호를 적는다. **firing_at 이 남은 예약은 자동 재발동하지 않는다** — 주문이 나갔는지
|
||||
모르는 상태에서 다시 내는 게 곧 이중 매도다. 알림만 보내고 사람이 확인해 취소한다.
|
||||
|
||||
⚠️ 레그가 사라진 사유는 **주문번호 단위**인 kt00007 로 판정한다. 종목 단위 집계인
|
||||
당일매매일지(ka10170)를 쓰면 한 계단이 체결된 날 나머지 계단이 장 마감으로 소멸했을 때
|
||||
@@ -60,7 +72,8 @@ 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 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 원주문 정정이 거부되면 매 사이클 반복된다.
|
||||
@@ -180,27 +193,240 @@ def _notify_modify_failed(res: dict, step: dict, reason: str) -> None:
|
||||
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')
|
||||
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('계속 노리시려면 자산웹에서 다시 등록해 주세요.')
|
||||
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():
|
||||
"""볼 게 있고 거래 세션인가. 루프 제어와 단발 실행이 같은 기준을 쓰게 분리."""
|
||||
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()
|
||||
if not reservations:
|
||||
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)}건 예약 대기)')
|
||||
_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}):
|
||||
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:
|
||||
@@ -246,11 +472,14 @@ def check(force: bool = False, dry_run: bool = False) -> int:
|
||||
except Exception as e:
|
||||
_log(f'알림 실패: {e!r}')
|
||||
|
||||
if not alive:
|
||||
# 시세는 예약·대기 예약 심볼을 합쳐 한 번에 받는다 — 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([r['symbol'] for r, _ in alive], exchange=quote_exchange)
|
||||
quotes = kc.get_watchlist_quotes(quote_codes, exchange=quote_exchange)
|
||||
except Exception as e:
|
||||
_log(f'시세 조회 실패 ({quote_exchange}): {e!r}')
|
||||
return 1
|
||||
@@ -330,6 +559,15 @@ def check(force: bool = False, dry_run: bool = False) -> int:
|
||||
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
|
||||
@@ -337,9 +575,29 @@ def check(force: bool = False, dry_run: bool = False) -> int:
|
||||
|
||||
def cmd_list() -> int:
|
||||
reservations = trailing.list_active()
|
||||
if not reservations:
|
||||
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 ''
|
||||
|
||||
Reference in New Issue
Block a user