Files
openclaw/agents/stock/workspace/scripts/orders/handler.py
T
hyowons 34498c554e auto: 일일 백업 2026-09-12 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 02:00:06 +09:00

1565 lines
74 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.
"""
매매 흐름 핸들러.
자연어/단축어 → 카드+PIN 발행, PIN echo → 실주문, 만료/취소 알림.
LLM 결정 금지 원칙: 페이로드 추출은 LLM 가능, 실행은 사람의 PIN echo 가 게이트.
이 모듈은 채널 독립적이다 — 텔레그램 어댑터(launchd polling 또는 OpenClaw 도구)가 호출.
CLI:
python3 -m orders.handler propose <account> <side> <symbol> <name> <qty> <type> [price] [routing]
python3 -m orders.handler pin <input> [--live]
python3 -m orders.handler amend [--qty N] [--price W] [--order-type T] [--budget W] [--routing R] [--send]
python3 -m orders.handler cancel [--send]
python3 -m orders.handler open-orders [--account A]
python3 -m orders.handler cancel-order [--ord-no N] [--account A] [--live] [--send]
python3 -m orders.handler modify-order [--ord-no N] [--account A] [--qty Q] [--price P] [--live] [--send]
python3 -m orders.handler status
python3 -m orders.handler cmd </command>
"""
from __future__ import annotations
import json
import sys
import threading
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
_PARENT = Path(__file__).resolve().parent.parent
if str(_PARENT) not in sys.path:
sys.path.insert(0, str(_PARENT))
import kiwoom_client as kc # noqa: E402
from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar, trailing # noqa: E402
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
OPENCLAW_CONFIG = Path.home() / '.openclaw' / 'openclaw.json'
TELEGRAM_ACCOUNT = 'stock'
_pin_store = pin.PinStore()
_expiry_thread: Optional[threading.Thread] = None
_expiry_send = None
def _limits() -> dict:
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
# ---- 텔레그램 발송 ----
def send_telegram(text: str, parse_mode: str = 'Markdown') -> bool:
try:
cfg = json.loads(OPENCLAW_CONFIG.read_text(encoding='utf-8'))
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
token = acct['botToken']
chat_ids = acct.get('allowFrom') or []
except Exception as e:
print(f'[send_telegram] config error: {e}', file=sys.stderr)
return False
if not chat_ids:
print('[send_telegram] no chat_ids configured', file=sys.stderr)
return False
url = f'https://api.telegram.org/bot{token}/sendMessage'
ok = True
for chat_id in chat_ids:
data = urllib.parse.urlencode({
'chat_id': chat_id,
'text': text[:4000],
'parse_mode': parse_mode,
'disable_web_page_preview': 'true',
}).encode()
try:
req = urllib.request.Request(url, data=data, method='POST')
with urllib.request.urlopen(req, timeout=15) as r:
if r.status != 200:
ok = False
except Exception as e:
print(f'[send_telegram] error: {e}', file=sys.stderr)
ok = False
return ok
ADMIN_IMSG_CRED = Path.home() / '.openclaw' / 'credentials' / 'admin_imessage.json'
IMSG_TIMEOUT_SEC = 60
def _load_admin_imsg_handle() -> Optional[str]:
"""본인 iCloud handle (전화번호 또는 이메일) — admin_imessage.json. 없으면 None."""
if not ADMIN_IMSG_CRED.exists():
return None
try:
return (json.loads(ADMIN_IMSG_CRED.read_text(encoding='utf-8')) or {}).get('handle')
except Exception:
return None
def send_imessage_pin(pin: str, card_id: str, side_label: str, symbol_name: str,
domain: str = 'stock.hyowons.net') -> bool:
"""PIN을 본인 iMessage로 발송 — iOS Safari OTP 자동입력 대상.
Apple 도메인 바인딩 형식: 마지막 줄 `@<domain> #<code>` → iOS가 해당 도메인 페이지에서만 자동입력 제안.
credential `admin_imessage.json` 미설정 시 skip, 텔레그램 발송에는 영향 X.
fire-and-forget: AppleScript ↔ Messages.app AppleEvent 응답이 timeout(-1712) 떨어져도 실제 메시지는
큐에 들어가 발송됨. 응답 기다리지 않고 Popen background로 던지고 즉시 반환 — propose 응답 지연 방지.
"""
import subprocess
handle = _load_admin_imsg_handle()
if not handle:
print('[send_imessage_pin] admin_imessage.json 없음 — iMessage 발송 skip', file=sys.stderr)
return False
text = (
f"[{domain}] {side_label} 인증\n"
f"종목: {symbol_name}\n"
f"카드: {card_id}\n"
f"코드: {pin}\n\n"
f"@{domain} #{pin}"
)
try:
subprocess.Popen(
['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return True
except Exception as e:
print(f'[send_imessage_pin] error: {e}', file=sys.stderr)
return False
# ---- 매매 흐름 ----
def _sweep_expired_and_notify() -> None:
"""진입점에서 호출 — 만료된 카드 정리 + 텔레그램 알림."""
try:
expired = _pin_store.sweep_expired()
except Exception as e:
print(f'[sweep_expired] {e}', file=sys.stderr)
return
if expired:
try:
ledger.append('expired', {'card_id': expired.card_id, **expired.payload})
except Exception:
pass
try:
send_telegram(card.format_expired(expired.card_id, expired.payload.get('side', 'BUY')),
parse_mode=None)
except Exception:
pass
def propose_trade(
account: str,
side: str,
symbol: str,
symbol_name: str,
qty: Optional[int],
order_type: str,
price: Optional[int] = None,
routing_force: Optional[str] = None,
budget: Optional[int] = None,
stop_price: Optional[int] = None,
trail_steps: Optional[list] = None,
min_sell_price: Optional[int] = None,
trigger_price: Optional[int] = None,
) -> dict:
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT',
'TRAILING_STOP', 'TRAILING_ARM'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
if side not in ('BUY', 'SELL'):
return {'ok': False,
'message': card.format_rejected('INVALID_SIDE', f'잘못된 방향: {side}')}
if order_type == 'STOP_LIMIT':
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')}
if not price or price <= 0 or not stop_price or stop_price <= 0:
return {'ok': False,
'message': card.format_rejected('STOP_INPUT',
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
norm_steps = None
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
# 트레일링은 계단 정의(하락률·누적 비중)만 입력받고, 계단별 조건단가·지정가·주수는
# 현재가와 보유수량 기준으로 자동 계산한다 (md 수집 후).
#
# 예약 트레일링(TRAILING_ARM)은 같은 입력에 발동가가 하나 더 붙고, 계단 계산의 기준을
# 현재가 대신 발동가로 쓴다. 그 외 검증은 전부 공유한다 — 발동하면 결국 같은 물건이
# 되므로 여기서 갈라놓으면 한쪽만 고쳐지는 사고가 난다.
is_arm = order_type == 'TRAILING_ARM'
kind = '예약 트레일링' if is_arm else '트레일링 스톱'
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected(
'ARM_SELL_ONLY' if is_arm else 'STOP_SELL_ONLY', f'{kind}은 매도 전용')}
try:
norm_steps = trailing.normalize_steps(trail_steps or [])
except (ValueError, KeyError, TypeError) as e:
return {'ok': False,
'message': card.format_rejected('TRAIL_STEPS', f'계단 정의 오류: {e}')}
if budget is not None:
# 계단별 주수는 보유수량을 나눠 만든다 — 예산 환산이 끼면 기준이 두 개가 된다.
return {'ok': False,
'message': card.format_rejected('TRAIL_BUDGET',
f'{kind}은 수량으로만 지정 (예산 환산 불가)')}
if min_sell_price is not None and min_sell_price <= 0:
return {'ok': False,
'message': card.format_rejected('TRAIL_MIN_PRICE',
'최저 매도가는 0보다 커야 함 (제한 없으면 비우세요)')}
if is_arm and (not trigger_price or trigger_price <= 0):
return {'ok': False,
'message': card.format_rejected('ARM_TRIGGER', '발동가를 입력하세요')}
# 같은 계좌·종목에 활성 트레일링과 대기 예약이 함께 있으면 안 된다 — 어느 쪽이든 먼저 취소.
dup = trailing.find_by_symbol(account, symbol)
if dup:
dup_steps = dup.get('steps') or []
top_uv = max((s['cond_uv'] for s in dup_steps), default=0)
return {'ok': False,
'message': card.format_rejected(
'TRAIL_DUPLICATE',
f'{symbol_name or symbol} 트레일링 예약이 이미 있음 '
f'({dup["id"]} · {len(dup_steps)}단 · 1단계 손절 {top_uv:,}원) '
f'— 먼저 취소하세요')}
dup_arm = trailing.find_arm_by_symbol(account, symbol)
if dup_arm:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_DUPLICATE',
f'{symbol_name or symbol} 예약 트레일링이 이미 있음 '
f'({dup_arm["id"]} · 발동가 {dup_arm["trigger_price"]:,}원 · '
f'{len(dup_arm.get("steps") or [])}단) — 먼저 취소하세요')}
if budget is not None and qty is not None:
return {'ok': False,
'message': card.format_rejected('AMBIGUOUS_INPUT',
'qty와 budget을 동시에 지정할 수 없음 — 하나만 입력')}
if budget is None and qty is None:
return {'ok': False,
'message': card.format_rejected('MISSING_QTY', '수량(qty) 또는 예산(budget)을 입력해야 함')}
# budget 환산은 호가창 필요 — qty 결정 전 호가 한번 받기 위해 임시 qty=0 으로 datasource 호출.
# collect_market_data 의 잔고/보유 조회는 qty 와 무관.
md = datasource.collect_market_data(account, symbol, side, qty or 0)
trail_final = None
trail_peak = None
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
is_arm = order_type == 'TRAILING_ARM'
# 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 fallback 금지 — 기준이 흔들림).
cur = md.get('current_price') or 0
if cur <= 0:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_NO_PRICE',
'현재가를 못 받아 발동가 도달 여부를 판정할 수 없음' if is_arm
else '현재가를 못 받아 트레일링 손절선을 정할 수 없음')}
if is_arm:
# 이미 닿아 있으면 '기다렸다가'라는 이 주문유형의 존재 이유가 사라진다 —
# 등록하는 순간 다음 감시(1분 내)에 바로 발동한다. 그럴 거면 일반 트레일링이 맞다.
if cur >= trigger_price:
return {'ok': False,
'message': card.format_rejected(
'ARM_ALREADY_REACHED',
f'현재가 {cur:,}원이 이미 발동가 {trigger_price:,}원 이상입니다 — '
f'발동가를 올리시거나 일반 트레일링 스톱으로 등록하세요')}
# 계단 계산 기준 = 발동가. 실제 발주는 발동 시점 현재가로 다시 계산하지만,
# 여기서 미리 재보는 이유는 ①카드에 예상 손절선·주수를 보이고 ②발동해도 못 쓰는
# 조합(수량 부족·즉시발동)을 지금 걸러내기 위해서다.
# 발동 시점엔 현재가 >= 발동가 이므로 실제 손절선은 이 미리보기 이상이다
# — 관리자님이 승인한 것보다 불리해지는 방향으로는 움직이지 않는다.
basis = trigger_price
else:
# 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
# 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
# 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
basis = cur
trail_peak = basis
try:
leveled = trailing.compute_step_levels(trail_peak, norm_steps, min_sell_price)
except ValueError as e:
return {'ok': False, 'message': card.format_rejected('TRAIL_LEVEL', str(e))}
# 계단별 주수 배분. 수량이 적어 0주가 된 계단은 발주할 수 없으니 떨어낸다 —
# 단계 번호(n)는 그대로 두어 "몇 단계가 빠졌는지" 가 카드에서 보이게 한다.
qtys = trailing.allocate_step_qty(qty, [s['weight'] for s in norm_steps])
trail_final = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
if not trail_final:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_QTY', f'{qty:,}주로는 계단을 나눌 수 없음 — 수량을 늘리거나 계단을 줄이세요')}
# 가장 얕은 계단이 제일 먼저 발동한다. 이게 현재가 이상이면 등록하는 순간 터진다.
# 고점=현재가로 고정된 뒤로는 최저 매도가가 현재가보다 높은 경우만 남는다
# (트레일 폭은 항상 현재가 아래로 내려감).
top = trail_final[0]
if top['cond_uv'] >= basis:
ref = '발동가' if is_arm else '현재가'
when = '발동하자마자' if is_arm else '등록 즉시'
if top['floor_applied']:
msg = (f'최저 매도가 {min_sell_price:,}원이 {ref} {basis:,}원보다 높아 '
f'{when} 발동됩니다 — {ref}보다 낮게 설정하세요')
else:
msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 {ref} {basis:,}원 이상이라 '
f'{when} 발동됩니다')
return {'ok': False,
'message': card.format_rejected(
'ARM_IMMEDIATE' if is_arm else 'TRAIL_IMMEDIATE', msg)}
if is_arm:
# 예약 단계에선 나가는 주문이 없다 — 발주가는 발동 시점에 정해진다.
price = None
stop_price = None
else:
price = top['ord_uv']
stop_price = top['cond_uv']
budget_conversion = None
if budget is not None:
conv = guards.convert_budget_to_qty(side, budget, md.get('orderbook'),
fallback_price=md.get('current_price'))
if not conv['ok']:
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'budget': budget, 'reason': conv['code'],
'message': conv['message']})
return {'ok': False, 'message': card.format_rejected(conv['code'], conv['message'])}
qty = conv['qty']
budget_conversion = {
'budget': budget,
'qty': conv['qty'],
'ref_price': conv['ref_price'],
'remainder': conv['remainder'],
'source': conv.get('source', 'orderbook'),
'bumped': conv.get('bumped', False),
}
request = {
'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
'qty': qty, 'order_type': order_type,
}
if order_type == 'LIMIT':
request['price'] = price
elif order_type in ('STOP_LIMIT', 'TRAILING_STOP'):
request['price'] = price
request['stop_price'] = stop_price
if order_type == 'TRAILING_STOP':
request['trail_steps'] = trail_final
request['min_sell_price'] = min_sell_price
request['trail_peak'] = trail_peak
elif order_type == 'TRAILING_ARM':
# price/stop_price 를 넣지 않는다 — 지금 나가는 주문이 없고, 넣으면 validate_request 의
# 가격제한폭·"조건단가 < 현재가" 검사에 걸린다(둘 다 예약엔 무의미하다).
request['trigger_price'] = trigger_price
request['trail_steps'] = trail_final
request['min_sell_price'] = min_sell_price
request['trail_peak'] = trail_peak
r = guards.validate_request(request, md)
if not r.ok:
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': r.code, 'message': r.message})
return {'ok': False, 'message': card.format_rejected(r.code, r.message)}
# 종목 상태 평가 (ka10099 캐시 기반) — 정리매매·투자위험·거래정지 사전 거부, 그 외 위험은 경고
state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
if not state_eval['result'].ok:
sr = state_eval['result']
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': sr.code,
'message': sr.message,
'stock_state': state_eval['state'],
'order_warning': state_eval['order_warning']})
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
state_warning = state_eval['warning'] # 카드에 표시할 경고 (None이면 정상)
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)
estimate = None
final_price = price
if order_type == 'MARKET':
if md.get('orderbook'):
estimate = guards.estimate_market_fill(side, qty, md['orderbook'])
elif order_type == 'AGGRESSIVE_LIMIT':
agg_res = guards.aggressive_limit_price(side, md.get('orderbook'),
fallback_price=md.get('current_price'))
if not agg_res['ok']:
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': agg_res['code'],
'message': agg_res['message']})
return {'ok': False, 'message': card.format_rejected(agg_res['code'], agg_res['message'])}
estimate = {'aggressive_price': agg_res['price'],
'source': agg_res['source'],
'ref_price': agg_res['ref_price']}
final_price = agg_res['price']
payload = {
'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
'qty': qty, 'price': final_price, 'order_type': order_type, 'routing_suffix': suffix,
}
if order_type == 'STOP_LIMIT':
payload['stop_price'] = stop_price
elif order_type == 'TRAILING_STOP':
# 승인 시점에 확정되는 것: 계좌·종목·계단별 수량·하락률·최저 매도가.
# 이후 감시 루프는 계단별 조건단가만 올린다 (수량·계단 구성은 불변).
payload['stop_price'] = stop_price
payload['trail_steps'] = trail_final
payload['trail_peak'] = trail_peak
payload['min_sell_price'] = min_sell_price
elif order_type == 'TRAILING_ARM':
# 승인 시점에 확정: 계좌·종목·수량·발동가·계단 정의·최저 매도가.
# 발동 시점에 정해짐: 고점(그때의 현재가)·계단별 조건단가/지정가/주수·라우팅.
# ⚠️ trail_steps 에는 레벨 없는 정규화 계단만 넣는다 — 발동 경로가 미리보기 가격을
# 실수로 재사용하지 못하게 구조적으로 막는다. 미리보기는 별도 키(trail_preview).
payload['trigger_price'] = trigger_price
payload['trail_steps'] = norm_steps
payload['trail_preview'] = trail_final
payload['min_sell_price'] = min_sell_price
try:
pending = _pin_store.issue(account, payload)
except RuntimeError:
active = _pin_store.peek()
info = _active_card_info(active) if active else None
return {'ok': False, 'message': card.format_card_locked(info)}
card_msg = card.format_card(request, md, pending.card_id, estimate, budget_conversion,
state_warning)
pin_msg = card.format_pin_message(pending.pin)
ledger.append('card_issued', {'card_id': pending.card_id, **payload})
ledger.append('pin_issued', {'card_id': pending.card_id, 'account': account,
'pin_length': len(pending.pin)})
return {
'ok': True,
'card_id': pending.card_id,
'card_message': card_msg,
'pin_message': pin_msg,
'expiry_seconds': pending.expiry_seconds,
'is_spouse': pending.account_label in _limits()['spouse_accounts'],
}
def propose_trade_multi(
legs: list,
side: str,
symbol: str,
symbol_name: str,
order_type: str,
price: Optional[int] = None,
) -> dict:
"""복수 계좌 동시 주문 — 같은 종목·방향·가격을 여러 계좌에서 카드 1장 + PIN 1개로.
legs: [{'account': str, 'qty': int}, ...] (2개 이상)
웹 거래 모달 '두 계좌 모두 매도' 진입점. SELL + LIMIT/MARKET 만 지원.
레그별 검증은 propose_trade 와 동일 경로(collect_market_data + validate_request),
하나라도 거부면 전체 거부. PIN 형식 일관성을 위해 같은 소유자 그룹 계좌만 허용.
"""
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected('INVALID_SIDE', '복수 계좌 동시 주문은 매도만 지원')}
if order_type not in ('LIMIT', 'MARKET'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
if not legs or len(legs) < 2:
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '계좌 2개 이상 필요')}
accounts = [str(l.get('account') or '') for l in legs]
if len(set(accounts)) != len(accounts):
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '계좌 중복')}
limits = _limits()
owners, spouses = set(limits['owner_accounts']), set(limits['spouse_accounts'])
if not (all(a in owners for a in accounts) or all(a in spouses for a in accounts)):
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '같은 소유자 그룹 계좌만 동시 주문 가능')}
validated = []
state_warning = None
suffix = None
for leg in legs:
account = leg['account']
try:
qty = int(leg.get('qty') or 0)
except (TypeError, ValueError):
qty = 0
if qty <= 0:
return {'ok': False,
'message': card.format_rejected('MISSING_QTY', f'[{account}] 수량 필요')}
md = datasource.collect_market_data(account, symbol, side, qty)
request = {'account': account, 'side': side, 'symbol': symbol,
'symbol_name': symbol_name, 'qty': qty, 'order_type': order_type}
if order_type == 'LIMIT':
request['price'] = price
r = guards.validate_request(request, md)
if not r.ok:
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': r.code,
'message': r.message, 'multi': True})
return {'ok': False, 'message': card.format_rejected(r.code, f'[{account}] {r.message}')}
state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
if not state_eval['result'].ok:
sr = state_eval['result']
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': sr.code,
'message': sr.message, 'multi': True,
'stock_state': state_eval['state'],
'order_warning': state_eval['order_warning']})
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
if state_eval['warning']:
state_warning = state_eval['warning']
if suffix is None:
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], None)
validated.append({'account': account, 'qty': qty,
'position_qty': md.get('position_qty', 0)})
payload = {
'multi': True,
'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
'order_type': order_type, 'price': price, 'routing_suffix': suffix,
'legs': [{'account': v['account'], 'qty': v['qty']} for v in validated],
# 단일 payload 필드(account/qty)를 읽는 경로(format_card_locked 등) 호환용 요약
'account': '+'.join(accounts),
'qty': sum(v['qty'] for v in validated),
}
try:
pending = _pin_store.issue(accounts[0], payload)
except RuntimeError:
active = _pin_store.peek()
info = _active_card_info(active) if active else None
return {'ok': False, 'message': card.format_card_locked(info)}
card_msg = card.format_card_multi(validated, side, symbol, symbol_name, order_type,
price, pending.card_id, state_warning)
pin_msg = card.format_pin_message(pending.pin)
ledger.append('card_issued', {'card_id': pending.card_id, **payload})
ledger.append('pin_issued', {'card_id': pending.card_id, 'account': accounts[0],
'pin_length': len(pending.pin)})
return {
'ok': True,
'card_id': pending.card_id,
'card_message': card_msg,
'pin_message': pin_msg,
'expiry_seconds': pending.expiry_seconds,
'is_spouse': pending.account_label in spouses,
}
def propose_and_send(account, side, symbol, symbol_name, qty, order_type,
price=None, routing_force=None, budget=None) -> dict:
"""propose_trade 후 카드+PIN 을 텔레그램에 분리 발송. skill / launchd 진입점용."""
res = propose_trade(account, side, symbol, symbol_name, qty, order_type, price, routing_force,
budget=budget)
if not res['ok']:
send_telegram(res['message'], parse_mode=None)
return res
send_telegram(res['card_message'], parse_mode='Markdown')
send_telegram(res['pin_message'], parse_mode=None)
return res
def submit_with_pin_and_send(pin_input: str, dry_run: bool = False) -> dict:
"""submit_with_pin 후 결과 메시지를 텔레그램에 발송."""
res = submit_with_pin(pin_input, dry_run=dry_run)
msg = res.get('message')
if msg:
send_telegram(msg, parse_mode='Markdown' if res['ok'] and not dry_run else None)
return res
def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
sidecar.guard_or_raise()
_sweep_expired_and_notify()
# PIN 자리에 "0" 단독 → 취소로 해석 (amend 의 0=취소와 일관성).
# 본인 PIN 4자리 / 가희 8자리라 "0" 한 자리는 어차피 PIN 길이 미달.
if (pin_input or '').strip() == '0':
return cancel_active_card()
ok, reason, card_obj = _pin_store.verify(pin_input)
if not ok:
if card_obj is None:
return {'ok': False, 'message': '활성 카드 없음'}
if reason == '만료':
ledger.append('expired', {'card_id': card_obj.card_id, **card_obj.payload})
return {'ok': False,
'message': card.format_expired(card_obj.card_id, card_obj.payload['side'])}
if 'PIN 불일치' in reason:
ledger.append('rejected', {'card_id': card_obj.card_id, 'reason': 'PIN_MISMATCH'})
return {'ok': False, 'message': card.format_pin_mismatch(card_obj.card_id)}
return {'ok': False, 'message': reason}
p = card_obj.payload
ledger.append('approved', {'card_id': card_obj.card_id, **p})
if p.get('multi'):
return _submit_multi_legs(card_obj, dry_run)
if p['order_type'] == 'TRAILING_STOP':
return _submit_trailing_steps(card_obj, dry_run)
if p['order_type'] == 'TRAILING_ARM':
return _register_trailing_arm(card_obj, dry_run)
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
res = kiwoom_order.submit(
account_label=p['account'],
side=p['side'],
symbol=p['symbol'],
qty=p['qty'],
price=p.get('price'),
order_type=submit_order_type,
routing_suffix=p['routing_suffix'],
dry_run=dry_run,
card_id=card_obj.card_id,
stop_price=p.get('stop_price'),
)
if res['ok']:
if dry_run:
msg = card.format_dryrun(res.get('payload', {}))
else:
ord_no = res.get('ord_no', '')
msg = card.format_submitted(card_obj.card_id, p['side'],
p.get('symbol_name', p['symbol']),
p['qty'], p.get('price'), ord_no)
if ord_no:
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
fill_watcher.watch(
ord_no=ord_no, account=p['account'], side=p['side'],
symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
order_qty=p['qty'], price=p.get('price'),
order_type=p['order_type'], card_id=card_obj.card_id,
)
return {'ok': True, 'message': msg, 'detail': res}
return {'ok': False,
'message': card.format_rejected(res.get('reason', 'UNKNOWN'), str(res.get('error', ''))),
'detail': res}
def _register_trailing_arm(card_obj, dry_run: bool) -> dict:
"""예약 트레일링 카드 — 키움 호출 0. 발동가 대기 예약 1건을 파일에 쓰고 끝난다.
PIN 은 여기서 소비된다. 발동 시점엔 PIN 이 없다(관리자님 명시 승인) — 그래서 승인 대상에
발동가까지 포함되고, 감시 루프는 그 확정된 내용을 조건 도달 시 집행만 한다.
"""
p = card_obj.payload
name = p.get('symbol_name', p['symbol'])
info = {'card_id': card_obj.card_id, 'account': p['account'], 'symbol': p['symbol'],
'symbol_name': name, 'qty': p['qty'], 'trigger_price': p['trigger_price'],
'min_sell_price': p.get('min_sell_price'), 'steps': p.get('trail_steps') or [],
'dry_run': dry_run, 'arm_id': None}
if dry_run:
return {'ok': True, 'message': card.format_arm_registered(info)}
try:
arm = trailing.register_arm(
account=p['account'], symbol=p['symbol'], symbol_name=name,
qty=p['qty'], trigger_price=p['trigger_price'],
steps=p.get('trail_steps') or [],
min_sell_price=p.get('min_sell_price'), card_id=card_obj.card_id)
except Exception as e:
# 주문이 나간 게 없으므로 여기선 그냥 거부로 끝내면 된다 (트레일링 등록 실패와 달리
# 되돌릴 외부 효과가 없다).
ledger.append('arm_fire_failed', {'card_id': card_obj.card_id,
'error': repr(e), 'stage': 'register', **p})
return {'ok': False,
'message': card.format_rejected('ARM_REGISTER_FAILED',
f'{name} 예약 등록 실패 — {e}')}
info['arm_id'] = arm['id']
ledger.append('arm_registered', {'card_id': card_obj.card_id, 'arm_id': arm['id'], **p})
return {'ok': True, 'message': card.format_arm_registered(info), 'arm_id': arm['id']}
def cancel_trailing_arm(arm_id: str) -> dict:
"""대기 예약 취소. PIN 없음 — 취소는 안전한 방향이다(cancel_open_order 와 같은 판례).
⚠️ firing 마킹이 걸린 예약은 이미 키움에 스톱주문이 나갔을 수 있다. 그 주문까지 자동으로
취소하지는 않는다 — 승인 범위를 넘는 매매 API 자동 호출이 된다. 대신 접수된 주문번호를
응답에 실어 관리자님이 미체결 목록에서 직접 처리하게 한다.
"""
sidecar.guard_or_raise()
arm = trailing.get_arm(arm_id)
if arm is None:
return {'ok': False, 'message': f'대기 예약 없음 ({arm_id})'}
fired = arm.get('fired_legs') or []
gone = trailing.remove_arm(arm_id, 'manual_cancel')
if gone is None:
return {'ok': False, 'message': f'대기 예약 없음 ({arm_id})'}
ledger.append('arm_canceled', {'arm_id': arm_id, 'account': arm['account'],
'symbol': arm['symbol'], 'qty': arm['qty'],
'trigger_price': arm['trigger_price']})
msg = (f'🗑 예약 트레일링 취소 ({arm_id}) — {arm["symbol_name"]} '
f'{arm["qty"]:,}주 · 발동가 {arm["trigger_price"]:,}원')
if fired:
nos = ', '.join(s.get('ord_no', '') for s in fired if s.get('ord_no'))
msg += (f'\n⚠️ 발동 처리 중 접수된 주문이 있습니다: {nos}\n'
f'미체결 목록에서 별도로 취소해 주세요 (자동 취소하지 않습니다).')
return {'ok': True, 'message': msg, 'placed_orders': fired}
def place_trailing_legs(account: str, symbol: str, steps: list, routing_suffix: str,
card_id: Optional[str], dry_run: bool,
on_placed=None) -> tuple:
"""계단별 스톱지정가 접수. 반환 (placed, failed, details).
얕은 계단(먼저 발동할 것)부터 순서대로 낸다 — 중간에 막혀도 가장 가까운 방어선이 먼저 걸린다.
한 레그가 실패해도 나머지는 계속 시도한다. 이미 나간 주문을 되돌리지 않는 이유는 취소 자체도
실패할 수 있어 상태가 더 불분명해지기 때문이다 — 대신 성공·실패를 그대로 호출측에 돌려준다.
`on_placed(leg)` 는 레그 하나가 접수될 때마다 즉시 호출된다. 예약 트레일링 발동 경로가
"어느 레그까지 실제로 나갔는지" 를 디스크에 미리 적는 데 쓴다(중간에 죽었을 때의 복구 단서).
실주문이 나가는 유일한 계단 발주 경로다 — 등록 즉시 트레일링과 예약 발동이 이 함수를
공유해야 두 벌로 갈라지지 않는다.
"""
placed, failed, details = [], [], []
for s in steps:
res = kiwoom_order.submit(
account_label=account,
side='SELL',
symbol=symbol,
qty=s['qty'],
price=s['ord_uv'],
order_type='STOP_LIMIT',
routing_suffix=routing_suffix,
dry_run=dry_run,
card_id=card_id,
stop_price=s['cond_uv'],
)
details.append(res)
if res['ok']:
# dry_run 은 주문번호가 없다 — 예약 등록 없이 접수될 내용만 확인한다.
leg = dict(s, ord_no=res.get('ord_no', ''))
placed.append(leg)
if on_placed is not None:
on_placed(leg)
else:
failed.append(dict(s, reason=res.get('reason', 'UNKNOWN'),
error=str(res.get('error', ''))))
return placed, failed, details
def _submit_trailing_steps(card_obj, dry_run: bool) -> dict:
"""트레일링 계단 카드 — 계단마다 스톱주문을 내고 예약 1건으로 묶어 등록한다.
키움엔 스톱지정가(trde_tp=28)로 나간다 — 트레일링은 우리 쪽 개념이다.
얕은 계단(먼저 발동할 것)부터 접수해, 중간에 막히더라도 가장 가까운 방어선이 먼저 걸리게 한다.
한 레그가 실패해도 나머지는 계속 시도하고, 접수된 레그만 예약에 등록한다. 이미 나간 주문을
되돌리지 않는 이유는 취소 자체도 실패할 수 있어 상태가 더 불분명해지기 때문이다 —
대신 몇 단이 걸리고 몇 단이 실패했는지를 메시지에 그대로 드러낸다.
fill_watcher 는 걸지 않는다. 스톱 예약은 조건 도달까지 미체결로 남는 게 정상이라
30분 미체결 알림이 오탐이 된다 — 생존·체결 감시는 trailing_monitor 가 담당한다.
"""
p = card_obj.payload
steps = p.get('trail_steps') or []
name = p.get('symbol_name', p['symbol'])
placed, failed, details = place_trailing_legs(
account=p['account'], symbol=p['symbol'], steps=steps,
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id, dry_run=dry_run)
if not placed:
return {'ok': False,
'message': card.format_rejected(
(failed[0]['reason'] if failed else 'UNKNOWN'),
f'{name} 계단 {len(steps)}단 전부 접수 실패 — '
+ (failed[0]['error'] if failed else '')),
'detail': details}
info = {'card_id': card_obj.card_id, 'symbol_name': name, 'account': p['account'],
'total_qty': p['qty'], 'peak': p['trail_peak'],
'min_sell_price': p.get('min_sell_price'),
'placed': placed, 'failed': failed, 'dry_run': dry_run,
'res_id': None, 'register_error': None}
if not dry_run:
registerable = [s for s in placed if s['ord_no']]
if not registerable:
# 접수는 됐는데 주문번호를 못 받았다 — 감시가 붙을 수 없으니 반드시 알린다.
ledger.append('trailing_register_failed',
{'card_id': card_obj.card_id, 'error': 'no ord_no', **p})
info['register_error'] = '주문번호를 못 받아 감시를 걸지 못했습니다'
else:
try:
res_trl = trailing.register_steps(
account=p['account'], symbol=p['symbol'], symbol_name=name,
total_qty=p['qty'], peak=p['trail_peak'], steps=registerable,
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id,
min_sell_price=p.get('min_sell_price'),
)
info['res_id'] = res_trl['id']
except Exception as e:
# 주문은 이미 접수됨 — 등록 실패를 숨기면 트레일링이 안 도는 걸 모른다.
ledger.append('trailing_register_failed',
{'card_id': card_obj.card_id, 'error': repr(e), **p})
info['register_error'] = str(e)
return {'ok': True, 'message': card.format_trailing_submitted(info), 'detail': details}
def _submit_multi_legs(card_obj, dry_run: bool) -> dict:
"""multi 카드(propose_trade_multi)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.
한 레그가 실패해도 나머지 레그는 계속 시도. ok = 한 레그라도 접수 성공.
접수된 레그는 각각 fill_watcher 추적 (큐 기반이라 동시 다건 OK).
"""
p = card_obj.payload
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
msgs = []
details = []
any_ok = False
for leg in p.get('legs') or []:
res = kiwoom_order.submit(
account_label=leg['account'],
side=p['side'],
symbol=p['symbol'],
qty=leg['qty'],
price=p.get('price'),
order_type=submit_order_type,
routing_suffix=p['routing_suffix'],
dry_run=dry_run,
card_id=card_obj.card_id,
)
details.append(res)
acc_disp = card.ACCOUNT_DISPLAY.get(leg['account'], leg['account'])
if res['ok']:
any_ok = True
if dry_run:
msgs.append(f'[{acc_disp}] ' + card.format_dryrun(res.get('payload', {})))
continue
ord_no = res.get('ord_no', '')
msgs.append(f'[{acc_disp}] ' + card.format_submitted(
card_obj.card_id, p['side'], p.get('symbol_name', p['symbol']),
leg['qty'], p.get('price'), ord_no))
if ord_no:
fill_watcher.watch(
ord_no=ord_no, account=leg['account'], side=p['side'],
symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
order_qty=leg['qty'], price=p.get('price'),
order_type=p['order_type'], card_id=card_obj.card_id,
)
else:
msgs.append(f'[{acc_disp}] ' + card.format_rejected(
res.get('reason', 'UNKNOWN'), str(res.get('error', ''))))
return {'ok': any_ok, 'message': '\n'.join(msgs), 'detail': details}
def cancel_active_card() -> dict:
sidecar.guard_or_raise()
card_obj = _pin_store.cancel()
if not card_obj:
return {'ok': False, 'message': '활성 카드 없음'}
ledger.append('canceled', {'card_id': card_obj.card_id, **card_obj.payload, 'reason': 'user_cancel'})
return {'ok': True, 'message': card.format_canceled(card_obj.card_id, card_obj.payload['side'])}
def cancel_active_card_and_send() -> dict:
res = cancel_active_card()
if res.get('message'):
send_telegram(res['message'], parse_mode=None)
return res
# ---- 키움 접수 후 주문 정정·취소 (kt10002/kt10003) ----
def _list_open_orders(account: Optional[str] = None,
ord_no: Optional[str] = None) -> list[dict]:
"""ka10075 미체결 조회 — account 명시 시 그 계좌만, 아니면 전체.
ord_no 명시 시 해당 ord_no 만 필터링.
"""
if account:
rows = kc.get_open_orders(account)
else:
rows = kc.get_open_orders_all()
if ord_no:
rows = [r for r in rows if r.get('ord_no') == ord_no]
return rows
def _format_open_order_list(rows: list[dict]) -> str:
lines = []
for r in rows:
side_word = '매수' if r['side'] == 'BUY' else ('매도' if r['side'] == 'SELL' else r['side'])
price = f"{r['order_price']:,}원" if r['order_price'] else r['order_type']
lines.append(
f"ord_no={r['ord_no']} · [{r['account']}] {r['name']} ({r['code']}) "
f"{side_word} {r['order_qty']}주 @ {price} · 미체결 {r['unfilled_qty']}주 · "
f"{r['exchange']} · {r['status']} · {r['order_time']}"
)
return '\n'.join(lines) if lines else '(미체결 없음)'
def _resolve_open_order(ord_no: Optional[str], account: Optional[str]) -> dict:
"""미체결 주문 1건을 확정. 명시·자동 선택·모호 케이스를 단일 흐름으로.
반환:
{ok: True, row: {...}} — 1건 확정
{ok: False, code: NO_OPEN_ORDERS, message: ...} — 미체결 없음
{ok: False, code: NOT_FOUND, message: ...} — ord_no 매칭 실패
{ok: False, code: AMBIGUOUS, message: ...} — ord_no 미명시 + 다수 후보
"""
rows = _list_open_orders(account=account, ord_no=ord_no)
if not rows:
if ord_no:
return {'ok': False, 'code': 'NOT_FOUND',
'message': f'⛔ 미체결 주문 ord_no={ord_no} 을(를) 찾을 수 없습니다'
+ (f' (계좌: {account})' if account else ' (전 계좌)')}
return {'ok': False, 'code': 'NO_OPEN_ORDERS',
'message': '⛔ 미체결 주문이 없습니다'}
if len(rows) > 1:
listing = _format_open_order_list(rows)
hint = ('대상 ord_no 를 명시해 주세요:\n' + listing) if not ord_no \
else f'⛔ 동일 ord_no 가 여러 건입니다 (예외 상황):\n{listing}'
return {'ok': False, 'code': 'AMBIGUOUS', 'message': hint}
return {'ok': True, 'row': rows[0]}
def cancel_open_order(ord_no: Optional[str] = None,
account: Optional[str] = None,
dry_run: bool = False) -> dict:
"""키움에 접수된 미체결 주문 취소 (kt10003). PIN 게이트 없음 (취소는 안전 방향).
ord_no 명시 시 즉시 취소. 미명시 + 미체결 1건이면 자동, 2건 이상이면 모호 응답.
cancel_qty=0 — 잔량 전부 취소.
"""
sidecar.guard_or_raise()
resolved = _resolve_open_order(ord_no, account)
if not resolved['ok']:
return {'ok': False, 'message': resolved['message'], 'code': resolved['code']}
row = resolved['row']
res = kiwoom_order.cancel_order(
account_label=row['account'],
orig_ord_no=row['ord_no'],
symbol=row['code'],
cancel_qty=0,
routing_suffix=row.get('routing_suffix', ''),
dry_run=dry_run,
)
side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side'])
if not res['ok']:
reason = res.get('reason', 'UNKNOWN')
detail = ''
if reason == 'BROKER_REJECT':
detail = str(res.get('response', {}).get('return_msg') or '')
elif reason == 'NETWORK':
detail = str(res.get('error', ''))
msg = (f"⛔ 취소 실패 [{reason}] · ord_no={row['ord_no']} · "
f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주"
+ (f"\n사유: {detail}" if detail else ''))
return {'ok': False, 'message': msg, 'detail': res}
if res.get('dry_run'):
msg = (f"🧪 [DRY-RUN] 취소 시뮬레이션 · ord_no={row['ord_no']} · "
f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주 "
f"({row['exchange']})")
else:
new_ord = res.get('new_ord_no', '')
msg = (f"✅ 취소 접수 · 원주문 ord_no={row['ord_no']} → 취소 ord_no={new_ord} · "
f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주 "
f"({row['exchange']})")
# 키움이 취소 ord_no 발급하면 ka10075 폴링으로 broker 확정 추적 — 확정/30분 타임아웃 시 텔레그램 알림
if new_ord:
fill_watcher.watch_cancel(
new_ord_no=new_ord,
orig_ord_no=row['ord_no'],
account=row['account'],
side=row['side'],
symbol=row['code'],
symbol_name=row['name'],
cancel_qty=row['unfilled_qty'],
)
return {'ok': True, 'message': msg, 'detail': res}
def cancel_open_order_and_send(ord_no: Optional[str] = None,
account: Optional[str] = None,
dry_run: bool = False) -> dict:
res = cancel_open_order(ord_no=ord_no, account=account, dry_run=dry_run)
if res.get('message'):
send_telegram(res['message'], parse_mode=None)
return res
def modify_open_order(ord_no: Optional[str] = None,
account: Optional[str] = None,
qty: Optional[int] = None,
price: Optional[int] = None,
dry_run: bool = False) -> dict:
"""키움에 접수된 미체결 주문 정정 (kt10002). PIN 게이트 없음.
qty/price 둘 다 키움 명세상 Required. 일부만 주면 기존 값 그대로 유지.
시장가 미체결은 정정 불가 (가격 0 차단) — 취소 후 신규 발주 안내.
"""
sidecar.guard_or_raise()
resolved = _resolve_open_order(ord_no, account)
if not resolved['ok']:
return {'ok': False, 'message': resolved['message'], 'code': resolved['code']}
row = resolved['row']
# 스톱지정가(trde_tp=28) 계열은 정정 대상에서 뺀다. 두 가지가 동시에 깨진다:
# ① 여기선 mdfy_cond_uv 를 안 넘겨 조건단가가 빈 값으로 나간다(조건 소실 위험).
# ② 정정 응답 ord_no 가 신규 번호라, trailing_stops.json 의 레그 ord_no 가 어긋나
# 그 레그의 자동 손절선 상향이 이후 전부 실패한다.
# 바꾸고 싶으면 취소 후 재등록이 맞다.
if row.get('stop_price'):
return {'ok': False, 'code': 'STOP_ORDER',
'message': f"⛔ 스톱지정가·트레일링 주문은 정정 불가 (ord_no={row['ord_no']}) — "
'취소 후 재등록하세요'}
if qty is None and price is None:
return {'ok': False,
'message': '⛔ 정정 항목 누락 — qty/price 중 최소 하나는 필요합니다'}
new_qty = qty if qty is not None else row['unfilled_qty']
new_price = price if price is not None else row['order_price']
if new_qty <= 0:
return {'ok': False,
'message': f'⛔ 정정수량 {new_qty} — 0 이하 정정은 취소를 사용하세요'}
if new_price <= 0:
return {'ok': False,
'message': '⛔ 시장가 미체결은 정정 불가 (가격 0). 취소 후 신규 발주하세요.'}
# 가격제한폭·예수금 — 신규 주문과 같은 가드를 태운다. 정정도 결국 새 가격으로 주문이 나가고,
# propose 만 막아두면 정정이 그 검증을 우회하는 뒷문이 된다.
# 매도 보유수량은 태우지 않는다 — 이미 접수된 주문의 수량을 그대로 두거나 줄이는 경로라
# (웹은 price 만 보낸다) 새로 필요한 보유분이 없다.
md = datasource.collect_market_data(row['account'], row['code'], row['side'], new_qty)
r = guards.validate_price_band(row['side'], new_price,
md['upper_limit'], md['lower_limit'])
if not r.ok:
return {'ok': False, 'code': r.code,
'message': f"⛔ 정정 거부 [{r.code}] · ord_no={row['ord_no']} · {r.message}"}
if row['side'] == 'BUY':
# ⚠️ md['balance_d2'] 는 미체결 매수로 묶인 돈을 **정정 대상 주문까지 포함해** 뺀 값이다.
# 그 주문은 지금 가격을 바꾸는 당사자라, 되돌려 더하지 않으면 제 주문에 제가 막힌다
# (149,000×2 로 묶인 상태에서 149,100 으로 올리는 것조차 '예수금 부족'이 된다).
# 되더하는 식은 datasource 의 locked 계산(order_price × unfilled_qty)과 같아야 정확히 상쇄된다.
avail = md['balance_d2'] + row['order_price'] * row['unfilled_qty']
r = guards.validate_balance_for_buy(new_qty, new_price, avail)
if not r.ok:
return {'ok': False, 'code': r.code,
'message': f"⛔ 정정 거부 [{r.code}] · ord_no={row['ord_no']} · {r.message}"}
side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side'])
res = kiwoom_order.modify_order(
account_label=row['account'],
orig_ord_no=row['ord_no'],
symbol=row['code'],
modify_qty=new_qty,
modify_price=new_price,
routing_suffix=row.get('routing_suffix', ''),
dry_run=dry_run,
)
if not res['ok']:
reason = res.get('reason', 'UNKNOWN')
detail = ''
if reason == 'BROKER_REJECT':
detail = str(res.get('response', {}).get('return_msg') or '')
elif reason == 'NETWORK':
detail = str(res.get('error', ''))
msg = (f"⛔ 정정 실패 [{reason}] · ord_no={row['ord_no']} · "
f"[{row['account']}] {row['name']} {side_word} {new_qty}주 @ {new_price:,}원"
+ (f"\n사유: {detail}" if detail else ''))
return {'ok': False, 'message': msg, 'detail': res}
if res.get('dry_run'):
msg = (f"🧪 [DRY-RUN] 정정 시뮬레이션 · ord_no={row['ord_no']} · "
f"[{row['account']}] {row['name']} {side_word} "
f"{row['order_qty']}주 @ {row['order_price']:,}원 "
f"→ {new_qty}주 @ {new_price:,}원 ({row['exchange']})")
else:
new_ord = res.get('new_ord_no', '')
msg = (f"✏️ 정정 접수 · 원주문 ord_no={row['ord_no']} → 정정 ord_no={new_ord} · "
f"[{row['account']}] {row['name']} {side_word} "
f"{row['order_qty']}주 @ {row['order_price']:,}원 "
f"→ {new_qty}주 @ {new_price:,}원 ({row['exchange']})")
return {'ok': True, 'message': msg, 'detail': res}
def modify_open_order_and_send(ord_no: Optional[str] = None,
account: Optional[str] = None,
qty: Optional[int] = None,
price: Optional[int] = None,
dry_run: bool = False) -> dict:
res = modify_open_order(ord_no=ord_no, account=account, qty=qty, price=price,
dry_run=dry_run)
if res.get('message'):
send_telegram(res['message'], parse_mode=None)
return res
def list_open_orders_text(account: Optional[str] = None) -> dict:
"""미체결 주문 리스트 — 단순 조회. 텔레그램 응답용."""
sidecar.guard_or_raise()
rows = _list_open_orders(account=account)
if not rows:
return {'ok': True, 'message': '미체결 주문 없음', 'rows': rows}
return {'ok': True, 'message': _format_open_order_list(rows), 'rows': rows}
def amend_trade(qty: Optional[int] = None, price: Optional[int] = None,
order_type: Optional[str] = None, budget: Optional[int] = None,
routing_force: Optional[str] = None,
symbol: Optional[str] = None, symbol_name: Optional[str] = None,
account: Optional[str] = None) -> dict:
"""활성 카드의 수량/가격/주문방식/예산/라우팅/종목/계좌를 수정.
수정 가능: qty, price, order_type, budget, routing_force, symbol, symbol_name, account.
수정 불가 (새 카드로): side (매수↔매도).
qty/price/budget 중 하나라도 0 이면 → 카드 취소 위임 (다른 비0 값과 동시 입력 시 거부).
symbol 변경 시 symbol_name 도 같이 입력 필요. account 변경 시 본인↔가희 PIN 길이도 재조정.
가드 12개 전부 재실행. 통과 시 PIN 재발급 + 만료 리셋, card_id 유지.
"""
sidecar.guard_or_raise()
_sweep_expired_and_notify()
active = _pin_store.peek()
if active is None or active.is_expired() or active.consumed:
return {'ok': False,
'message': card.format_rejected('NO_ACTIVE_CARD', '수정할 활성 카드가 없습니다')}
# 0 입력 → 취소. 0 + non-zero 동시 입력은 거부 (의도 모호)
numeric_inputs = {'qty': qty, 'price': price, 'budget': budget}
has_zero = any(v == 0 for v in numeric_inputs.values() if v is not None)
has_nonzero = any(v is not None and v != 0 for v in numeric_inputs.values())
other_amend = (order_type is not None or routing_force is not None
or symbol is not None or symbol_name is not None or account is not None)
if has_zero and (has_nonzero or other_amend):
return {'ok': False,
'message': card.format_rejected('AMBIGUOUS_AMEND',
'0(취소)과 다른 수정값을 동시에 줄 수 없습니다')}
if has_zero:
return cancel_active_card()
if budget is not None and qty is not None:
return {'ok': False,
'message': card.format_rejected('AMBIGUOUS_INPUT',
'qty와 budget을 동시에 지정할 수 없음 — 하나만 입력')}
p = active.payload
new_account = account if account is not None else p['account']
new_symbol = symbol if symbol is not None else p['symbol']
new_symbol_name = (symbol_name if symbol_name is not None
else p.get('symbol_name', new_symbol))
# account whitelist 검증
whitelist = _limits()['accounts_whitelist']
if new_account not in whitelist:
return {'ok': False,
'message': card.format_rejected('INVALID_ACCOUNT',
f'허용되지 않은 계좌: {new_account}. 가능: {whitelist}')}
# symbol 6자리 숫자 형식 검증
if not (isinstance(new_symbol, str) and len(new_symbol) == 6 and new_symbol.isdigit()):
return {'ok': False,
'message': card.format_rejected('INVALID_SYMBOL',
f'종목코드는 6자리 숫자여야 합니다 (입력: {new_symbol!r})')}
# symbol 변경 시 symbol_name 동반 필수
if symbol is not None and symbol != p['symbol'] and symbol_name is None:
return {'ok': False,
'message': card.format_rejected('MISSING_SYMBOL_NAME',
'종목 변경 시 symbol_name 도 같이 입력해야 합니다')}
# 반대로 symbol_name 단독 변경 금지 — 카드 표시(이름)와 발주(코드) 불일치 차단
if symbol_name is not None and symbol is None and symbol_name != p.get('symbol_name'):
return {'ok': False,
'message': card.format_rejected('ORPHAN_SYMBOL_NAME',
'종목명만 단독으로 바꿀 수 없습니다 — symbol(6자리 코드) 도 같이 입력')}
new_qty = qty if qty is not None else p.get('qty')
new_price = price if price is not None else p.get('price')
new_order_type = order_type if order_type is not None else p.get('order_type')
md = datasource.collect_market_data(new_account, new_symbol, p['side'], new_qty or 0)
budget_conversion = None
if budget is not None:
conv = guards.convert_budget_to_qty(p['side'], budget, md.get('orderbook'),
fallback_price=md.get('current_price'))
if not conv['ok']:
ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
'budget': budget, 'reason': conv['code'],
'message': conv['message']})
return {'ok': False, 'message': card.format_rejected(conv['code'], conv['message'])}
new_qty = conv['qty']
new_order_type = 'MARKET'
new_price = None
budget_conversion = {
'budget': budget, 'qty': conv['qty'], 'ref_price': conv['ref_price'],
'remainder': conv['remainder'], 'source': conv.get('source', 'orderbook'),
'bumped': conv.get('bumped', False),
}
if new_order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE',
f'잘못된 주문방식: {new_order_type}')}
# LIMIT 으로 전환할 땐 price 필수 (MARKET→LIMIT 또는 AGG→LIMIT 시 누락 차단)
if new_order_type == 'LIMIT' and not new_price:
return {'ok': False,
'message': card.format_rejected('MISSING_LIMIT_PRICE',
'LIMIT 으로 변경하려면 price 도 같이 입력해야 합니다')}
request = {
'account': new_account, 'side': p['side'], 'symbol': new_symbol,
'symbol_name': new_symbol_name,
'qty': new_qty, 'order_type': new_order_type,
}
if new_order_type == 'LIMIT':
request['price'] = new_price
r = guards.validate_request(request, md)
if not r.ok:
ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
'account': new_account, 'symbol': new_symbol,
'qty': new_qty, 'price': new_price,
'reason': r.code, 'message': r.message})
return {'ok': False, 'message': card.format_rejected(r.code, r.message)}
state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
if not state_eval['result'].ok:
sr = state_eval['result']
ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
'qty': new_qty, 'price': new_price, 'reason': sr.code,
'message': sr.message})
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
state_warning = state_eval['warning']
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)
estimate = None
final_price = new_price
if new_order_type == 'MARKET':
if md.get('orderbook'):
estimate = guards.estimate_market_fill(p['side'], new_qty, md['orderbook'])
elif new_order_type == 'AGGRESSIVE_LIMIT':
agg_res = guards.aggressive_limit_price(p['side'], md.get('orderbook'),
fallback_price=md.get('current_price'))
if not agg_res['ok']:
ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
'qty': new_qty, 'reason': agg_res['code'],
'message': agg_res['message']})
return {'ok': False, 'message': card.format_rejected(agg_res['code'], agg_res['message'])}
estimate = {'aggressive_price': agg_res['price'],
'source': agg_res['source'], 'ref_price': agg_res['ref_price']}
final_price = agg_res['price']
new_payload = {
'account': new_account, 'side': p['side'], 'symbol': new_symbol,
'symbol_name': new_symbol_name,
'qty': new_qty, 'price': final_price, 'order_type': new_order_type,
'routing_suffix': suffix,
}
pending = _pin_store.amend(new_payload, account_label=new_account)
if pending is None:
return {'ok': False,
'message': card.format_rejected('NO_ACTIVE_CARD',
'수정할 활성 카드가 없습니다 (만료/소비)')}
card_msg = card.format_card(request, md, pending.card_id, estimate, budget_conversion,
state_warning, amended=True)
pin_msg = card.format_pin_message(pending.pin)
ledger.append('amended', {'card_id': pending.card_id, **new_payload})
return {'ok': True, 'card_message': card_msg, 'pin_message': pin_msg,
'card_id': pending.card_id, 'expiry_seconds': pending.expiry_seconds}
def amend_and_send(qty: Optional[int] = None, price: Optional[int] = None,
order_type: Optional[str] = None, budget: Optional[int] = None,
routing_force: Optional[str] = None,
symbol: Optional[str] = None, symbol_name: Optional[str] = None,
account: Optional[str] = None) -> dict:
"""amend_trade 후 카드+PIN 텔레그램 분리 발송. 0 입력 시 cancel + 텔레그램 발송."""
res = amend_trade(qty, price, order_type, budget, routing_force,
symbol=symbol, symbol_name=symbol_name, account=account)
if not res['ok']:
send_telegram(res['message'], parse_mode=None)
return res
if 'card_message' in res:
send_telegram(res['card_message'], parse_mode='Markdown')
send_telegram(res['pin_message'], parse_mode=None)
elif res.get('message'):
# cancel 위임 결과 (card_message 없음)
send_telegram(res['message'], parse_mode=None)
return res
def expire_check() -> Optional[dict]:
pending = _pin_store.peek()
if pending is None:
return None
if pending.is_expired():
cancelled = _pin_store.cancel()
if cancelled:
ledger.append('expired', {'card_id': cancelled.card_id, **cancelled.payload})
return {'card_id': cancelled.card_id,
'message': card.format_expired(cancelled.card_id, cancelled.payload['side'])}
return None
def start_expiry_watcher(send_func=None) -> None:
"""별도 스레드 — 1초마다 expire_check + 만료 시 send_func 으로 알림."""
global _expiry_thread, _expiry_send
_expiry_send = send_func or send_telegram
if _expiry_thread and _expiry_thread.is_alive():
return
def _loop():
while True:
try:
exp = expire_check()
if exp and _expiry_send:
_expiry_send(exp['message'])
except Exception as e:
print(f'[expiry_watcher] {e}', file=sys.stderr)
time.sleep(1)
_expiry_thread = threading.Thread(target=_loop, name='order-expiry', daemon=True)
_expiry_thread.start()
# ---- 단축어 / 명령 ----
def handle_command(command: str) -> dict:
parts = command.strip().split()
head = parts[0] if parts else ''
if head == '/orders_off':
sidecar.disable(' '.join(parts[1:]) or 'user command')
return {'ok': True, 'message': '🚫 매매 비활성화. /orders_on 으로 재개.'}
if head == '/orders_on':
ok = sidecar.enable()
return {'ok': True, 'message': '✅ 매매 활성화' if ok else '⚠️ 이미 활성화 상태'}
if head == '/cancel':
return cancel_active_card()
if head == '/orders_status':
return {'ok': True, 'message': json.dumps(status_text(), ensure_ascii=False, indent=2)}
if head == '/positions':
return {'ok': False,
'message': '⚙️ /positions 는 stock_portfolio_report 모듈로 통합 예정'}
if head == '/balance':
return {'ok': False,
'message': '⚙️ /balance 는 stock_portfolio_report 모듈로 통합 예정'}
return {'ok': False, 'message': f'알 수 없는 명령: {head}'}
def _active_card_info(pending) -> dict:
return {
'card_id': pending.card_id,
'account': pending.account_label,
'side': pending.payload.get('side'),
'symbol': pending.payload.get('symbol'),
'symbol_name': pending.payload.get('symbol_name'),
'qty': pending.payload.get('qty'),
'price': pending.payload.get('price'),
'order_type': pending.payload.get('order_type'),
'expires_in': max(0, pending.expiry_seconds - int(time.time() - pending.issued_at)),
}
def status_text() -> dict:
sc = sidecar.status()
pending = _pin_store.peek()
out = {
'sidecar_disabled': sc.get('disabled'),
'sidecar_path': sc.get('path'),
'agent_env_blocked': sc.get('agent_env_blocked'),
'active_card': None,
}
if pending:
out['active_card'] = _active_card_info(pending)
return out
# ---- CLI ----
def _main(argv: list[str]) -> int:
if not argv or argv[0] == 'status':
print(json.dumps(status_text(), ensure_ascii=False, indent=2))
return 0
cmd = argv[0]
if cmd == 'propose':
if len(argv) < 7:
print('usage: propose <account> <side> <symbol> <name> <qty|-> <type> [price] [routing] [--budget <won>] [--send]')
return 2
account, side, symbol, name, qty_s, otype = argv[1:7]
price = None
routing = None
budget = None
send = '--send' in argv[7:]
extras = argv[7:]
i = 0
while i < len(extras):
extra = extras[i]
if extra == '--send':
i += 1
continue
if extra == '--budget':
if i + 1 >= len(extras):
print('--budget requires a value', file=sys.stderr)
return 2
budget = int(extras[i + 1])
i += 2
continue
if extra.isdigit():
price = int(extra)
else:
routing = extra
i += 1
qty = None if qty_s == '-' else int(qty_s)
fn = propose_and_send if send else propose_trade
res = fn(account, side, symbol, name, qty, otype, price, routing, budget=budget)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'pin':
if len(argv) < 2:
print('usage: pin <input> [--live] [--send]')
return 2
dry_run = '--live' not in argv[2:]
send = '--send' in argv[2:]
fn = submit_with_pin_and_send if send else submit_with_pin
res = fn(argv[1], dry_run=dry_run)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'cancel':
send = '--send' in argv[1:]
fn = cancel_active_card_and_send if send else cancel_active_card
res = fn()
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'amend':
qty = None
price = None
order_type = None
budget = None
routing = None
symbol = None
symbol_name = None
account = None
send = '--send' in argv[1:]
extras = argv[1:]
i = 0
while i < len(extras):
extra = extras[i]
if extra == '--send':
i += 1; continue
if extra == '--qty':
qty = int(extras[i + 1]); i += 2; continue
if extra == '--price':
price = int(extras[i + 1]); i += 2; continue
if extra == '--order-type':
order_type = extras[i + 1]; i += 2; continue
if extra == '--budget':
budget = int(extras[i + 1]); i += 2; continue
if extra == '--routing':
routing = extras[i + 1]; i += 2; continue
if extra == '--symbol':
symbol = extras[i + 1]; i += 2; continue
if extra == '--symbol-name':
symbol_name = extras[i + 1]; i += 2; continue
if extra == '--account':
account = extras[i + 1]; i += 2; continue
print(f'unknown amend arg: {extra}', file=sys.stderr)
return 2
if all(v is None for v in (qty, price, order_type, budget, routing,
symbol, symbol_name, account)):
print('usage: amend [--qty N] [--price W] [--order-type LIMIT|MARKET|AGGRESSIVE_LIMIT] [--budget W] [--routing AL|NX|KRX] [--symbol CODE] [--symbol-name NAME] [--account 일반|ISA|가희_일반|가희_ISA] [--send]\n qty/price/budget 중 하나라도 0 → 취소', file=sys.stderr)
return 2
fn = amend_and_send if send else amend_trade
res = fn(qty=qty, price=price, order_type=order_type, budget=budget,
routing_force=routing, symbol=symbol, symbol_name=symbol_name,
account=account)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'cmd':
if len(argv) < 2:
print('usage: cmd </command>')
return 2
res = handle_command(' '.join(argv[1:]))
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'open-orders':
# open-orders [--account A]
account = None
extras = argv[1:]
i = 0
while i < len(extras):
if extras[i] == '--account':
account = extras[i + 1]; i += 2; continue
print(f'unknown open-orders arg: {extras[i]}', file=sys.stderr)
return 2
res = list_open_orders_text(account=account)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'cancel-order':
# cancel-order [--ord-no N] [--account A] [--live] [--send]
ord_no = None
account = None
send = '--send' in argv[1:]
live = '--live' in argv[1:]
extras = argv[1:]
i = 0
while i < len(extras):
if extras[i] in ('--send', '--live'):
i += 1; continue
if extras[i] == '--ord-no':
ord_no = extras[i + 1]; i += 2; continue
if extras[i] == '--account':
account = extras[i + 1]; i += 2; continue
print(f'unknown cancel-order arg: {extras[i]}', file=sys.stderr)
return 2
fn = cancel_open_order_and_send if send else cancel_open_order
res = fn(ord_no=ord_no, account=account, dry_run=not live)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
if cmd == 'modify-order':
# modify-order [--ord-no N] [--account A] [--qty Q] [--price P] [--live] [--send]
ord_no = None
account = None
qty = None
price = None
send = '--send' in argv[1:]
live = '--live' in argv[1:]
extras = argv[1:]
i = 0
while i < len(extras):
if extras[i] in ('--send', '--live'):
i += 1; continue
if extras[i] == '--ord-no':
ord_no = extras[i + 1]; i += 2; continue
if extras[i] == '--account':
account = extras[i + 1]; i += 2; continue
if extras[i] == '--qty':
qty = int(extras[i + 1]); i += 2; continue
if extras[i] == '--price':
price = int(extras[i + 1]); i += 2; continue
print(f'unknown modify-order arg: {extras[i]}', file=sys.stderr)
return 2
if qty is None and price is None:
print('usage: modify-order [--ord-no N] [--account A] --qty Q | --price P [--live] [--send]', file=sys.stderr)
return 2
fn = modify_open_order_and_send if send else modify_open_order
res = fn(ord_no=ord_no, account=account, qty=qty, price=price, dry_run=not live)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res['ok'] else 1
print(f'unknown CLI command: {cmd}', file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(_main(sys.argv[1:]))