auto: 일일 백업 2026-07-22 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,8 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
type_marker = f' {cfg["warning_emoji"]} 시장가'
|
||||
elif request['order_type'] == 'AGGRESSIVE_LIMIT':
|
||||
type_marker = f' {cfg["warning_emoji"]} 공격적 지정가'
|
||||
elif request['order_type'] == 'STOP_LIMIT':
|
||||
type_marker = f' {cfg["warning_emoji"]} 스톱지정가(예약)'
|
||||
amend_marker = ' ✏️ 수정됨' if amended else ''
|
||||
|
||||
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}{amend_marker}', '']
|
||||
@@ -83,6 +85,12 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
ratio = (request['price'] - market_data['prev_close']) / market_data['prev_close'] * 100
|
||||
price_part += f' (전일종가 {_pct(ratio)})'
|
||||
lines.append(f'{marker} 가격: {price_part}')
|
||||
elif request['order_type'] == 'STOP_LIMIT':
|
||||
stop_p = request.get('stop_price') or 0
|
||||
cur = market_data.get('current_price')
|
||||
cur_part = f' (현재가 {_money(cur)})' if cur else ''
|
||||
lines.append(f'{marker} 조건: {_md_bold(_money(stop_p) + " 도달 시")}{cur_part}')
|
||||
lines.append(f'{marker} 매도가격: {_md_bold(_money(request["price"]))} (지정가)')
|
||||
elif request['order_type'] == 'MARKET':
|
||||
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
|
||||
else:
|
||||
|
||||
@@ -156,21 +156,37 @@ def collect_market_data(account_label: str, symbol: str, side: str, qty: int) ->
|
||||
'stock_meta': stock_meta,
|
||||
}
|
||||
|
||||
try:
|
||||
open_orders = kc.get_open_orders(account_label)
|
||||
except Exception:
|
||||
open_orders = []
|
||||
|
||||
if side == 'BUY':
|
||||
try:
|
||||
bal = kc.get_balance(account_label)
|
||||
md['balance_d2'] = _to_int(bal.get('d2_entra'))
|
||||
d2 = _to_int(bal.get('d2_entra'))
|
||||
# d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감.
|
||||
locked = sum(_to_int(o.get('order_price')) * _to_int(o.get('unfilled_qty'))
|
||||
for o in open_orders
|
||||
if o.get('side') == 'BUY' and _to_int(o.get('order_price')) > 0)
|
||||
md['balance_d2'] = max(0, d2 - locked)
|
||||
except Exception:
|
||||
md['balance_d2'] = 0
|
||||
else:
|
||||
try:
|
||||
positions = kc.get_positions(account_label)
|
||||
md['position_qty'] = next(
|
||||
(_to_int(p.get('trde_able_qty') or p.get('qty') or p.get('hold_qty') or p.get('rmnd_qty'))
|
||||
for p in positions
|
||||
if (p.get('code') == symbol) or (p.get('symbol') == symbol) or (p.get('stk_cd') == symbol)),
|
||||
0,
|
||||
)
|
||||
pos = next((p for p in positions
|
||||
if (p.get('code') == symbol) or (p.get('symbol') == symbol) or (p.get('stk_cd') == symbol)),
|
||||
None)
|
||||
if pos:
|
||||
hold = _to_int(pos.get('qty') or pos.get('hold_qty') or pos.get('rmnd_qty'))
|
||||
trde_able = _to_int(pos.get('trde_able_qty') or hold)
|
||||
pend_sell = sum(_to_int(o.get('unfilled_qty')) for o in open_orders
|
||||
if o.get('side') == 'SELL' and o.get('code') == symbol)
|
||||
# trde_able이 미체결 매도 제외 여부 불확실 → min으로 이중차감 방지 (check 핸들러와 동일).
|
||||
md['position_qty'] = max(0, min(trde_able, hold - pend_sell))
|
||||
else:
|
||||
md['position_qty'] = 0
|
||||
except Exception:
|
||||
md['position_qty'] = 0
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
return r
|
||||
if request['side'] not in ('BUY', 'SELL'):
|
||||
return Result.REJECT('INVALID_SIDE', f'잘못된 방향: {request["side"]}')
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
|
||||
return Result.REJECT('INVALID_ORDER_TYPE', f'잘못된 주문방식: {request["order_type"]}')
|
||||
r = validate_trading_hours(market_data['now'], market_data.get('is_holiday', False),
|
||||
market_data.get('nxt_eligible', False))
|
||||
@@ -525,6 +525,27 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
if request['order_type'] == 'STOP_LIMIT':
|
||||
# 스톱지정가: 지정가(체결가)·조건단가(트리거) 둘 다 가격제한 이내 + 매도 전용.
|
||||
# 보호매도(하락 시 매도)라 트리거는 현재가보다 낮아야 함 — 아니면 즉시 발동돼 스톱 의미 없음.
|
||||
if request['side'] != 'SELL':
|
||||
return Result.REJECT('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')
|
||||
stop_price = request.get('stop_price')
|
||||
if not stop_price or stop_price <= 0:
|
||||
return Result.REJECT('STOP_PRICE_REQUIRED', '조건단가(트리거 가격)를 입력하세요')
|
||||
r = validate_price_band(request['side'], request['price'],
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
r = validate_price_band(request['side'], stop_price,
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
cur = market_data.get('current_price') or 0
|
||||
if cur and stop_price >= cur:
|
||||
return Result.REJECT('STOP_TRIGGER_NOT_BELOW',
|
||||
f'조건단가 {stop_price:,}원 ≥ 현재가 {cur:,}원 — '
|
||||
f'하락 시 매도(스톱)는 현재가보다 낮게 설정해야 함')
|
||||
if request['side'] == 'BUY':
|
||||
basis = None
|
||||
if request['order_type'] == 'MARKET':
|
||||
|
||||
@@ -164,16 +164,25 @@ def propose_trade(
|
||||
price: Optional[int] = None,
|
||||
routing_force: Optional[str] = None,
|
||||
budget: Optional[int] = None,
|
||||
stop_price: Optional[int] = None,
|
||||
) -> dict:
|
||||
sidecar.guard_or_raise()
|
||||
_sweep_expired_and_notify()
|
||||
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_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 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',
|
||||
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
|
||||
if budget is not None and qty is not None:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('AMBIGUOUS_INPUT',
|
||||
@@ -211,6 +220,9 @@ def propose_trade(
|
||||
}
|
||||
if order_type == 'LIMIT':
|
||||
request['price'] = price
|
||||
elif order_type == 'STOP_LIMIT':
|
||||
request['price'] = price
|
||||
request['stop_price'] = stop_price
|
||||
|
||||
r = guards.validate_request(request, md)
|
||||
if not r.ok:
|
||||
@@ -254,6 +266,8 @@ def propose_trade(
|
||||
'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
|
||||
|
||||
try:
|
||||
pending = _pin_store.issue(account, payload)
|
||||
@@ -450,6 +464,7 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
routing_suffix=p['routing_suffix'],
|
||||
dry_run=dry_run,
|
||||
card_id=card_obj.card_id,
|
||||
stop_price=p.get('stop_price'),
|
||||
)
|
||||
|
||||
if res['ok']:
|
||||
|
||||
@@ -31,6 +31,7 @@ TR_CANCEL = 'kt10003'
|
||||
# 매매구분 코드
|
||||
TRDE_TP_LIMIT = '0' # 보통가 (지정가)
|
||||
TRDE_TP_MARKET = '3' # 시장가
|
||||
TRDE_TP_STOP = '28' # 스톱지정가 — cond_uv(조건단가) 도달 시 ord_uv(지정가)로 발주
|
||||
|
||||
# 거래소 코드 (라우팅 suffix → API 거래소 구분)
|
||||
_EXCHANGE_BY_SUFFIX = {
|
||||
@@ -52,27 +53,38 @@ def _exchange_for(suffix: str) -> str:
|
||||
|
||||
def submit(account_label: str, side: str, symbol: str, qty: int,
|
||||
price: Optional[int], order_type: str, routing_suffix: str,
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
dry_run: bool = True, card_id: Optional[str] = None,
|
||||
stop_price: Optional[int] = None) -> dict:
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
if side not in ('BUY', 'SELL'):
|
||||
raise ValueError(f'invalid side: {side}')
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
|
||||
raise ValueError(f'invalid order_type: {order_type}')
|
||||
if order_type in ('LIMIT', 'AGGRESSIVE_LIMIT') and not price:
|
||||
raise ValueError(f'{order_type} requires price')
|
||||
if order_type == 'STOP_LIMIT' and (not price or not stop_price):
|
||||
raise ValueError('STOP_LIMIT requires price(지정가) and stop_price(조건단가)')
|
||||
|
||||
tr_id = TR_BUY if side == 'BUY' else TR_SELL
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
is_market = order_type == 'MARKET'
|
||||
is_stop = order_type == 'STOP_LIMIT'
|
||||
|
||||
if is_market:
|
||||
trde_tp = TRDE_TP_MARKET
|
||||
elif is_stop:
|
||||
trde_tp = TRDE_TP_STOP
|
||||
else:
|
||||
trde_tp = TRDE_TP_LIMIT
|
||||
|
||||
body = {
|
||||
'dmst_stex_tp': exchange,
|
||||
'stk_cd': symbol,
|
||||
'ord_qty': str(qty),
|
||||
'ord_uv': '' if is_market else str(price),
|
||||
'trde_tp': TRDE_TP_MARKET if is_market else TRDE_TP_LIMIT,
|
||||
'cond_uv': '',
|
||||
'trde_tp': trde_tp,
|
||||
'cond_uv': str(stop_price) if is_stop else '',
|
||||
}
|
||||
|
||||
payload = {
|
||||
@@ -82,6 +94,7 @@ def submit(account_label: str, side: str, symbol: str, qty: int,
|
||||
'symbol': symbol,
|
||||
'qty': qty,
|
||||
'price': price,
|
||||
'stop_price': stop_price if is_stop else None,
|
||||
'order_type': order_type,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
|
||||
Reference in New Issue
Block a user