549545bde6
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1044 lines
45 KiB
Python
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:]))
|