Files
hyowons fed3526b20 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:39:41 +09:00

1044 lines
45 KiB
Python

"""
매매 흐름 핸들러.
자연어/단축어 → 카드+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 # 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,
) -> dict:
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
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 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)
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
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,
}
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_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})
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,
)
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)
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
if ord_no:
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 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']
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). 취소 후 신규 발주하세요.'}
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:]))