auto: 일일 백업 2026-08-01 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -165,7 +165,7 @@ def propose_trade(
|
||||
routing_force: Optional[str] = None,
|
||||
budget: Optional[int] = None,
|
||||
stop_price: Optional[int] = None,
|
||||
trail_pct: Optional[float] = None,
|
||||
trail_steps: Optional[list] = None,
|
||||
min_sell_price: Optional[int] = None,
|
||||
) -> dict:
|
||||
sidecar.guard_or_raise()
|
||||
@@ -185,27 +185,37 @@ def propose_trade(
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('STOP_INPUT',
|
||||
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
|
||||
norm_steps = None
|
||||
if order_type == 'TRAILING_STOP':
|
||||
# 트레일링은 트레일 폭(%)만 입력받고 조건단가·지정가는 현재가 기준 자동 계산 (md 수집 후).
|
||||
# 트레일링은 계단 정의(하락률·누적 비중)만 입력받고, 계단별 조건단가·지정가·주수는
|
||||
# 현재가와 보유수량 기준으로 자동 계산한다 (md 수집 후).
|
||||
if side != 'SELL':
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('STOP_SELL_ONLY', '트레일링 스톱은 매도 전용')}
|
||||
if trail_pct is None or not (trailing.MIN_TRAIL_PCT <= trail_pct <= trailing.MAX_TRAIL_PCT):
|
||||
try:
|
||||
norm_steps = trailing.normalize_steps(trail_steps or [])
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'TRAIL_PCT_RANGE',
|
||||
f'트레일 폭은 {trailing.MIN_TRAIL_PCT}~{trailing.MAX_TRAIL_PCT}% 범위')}
|
||||
'message': card.format_rejected('TRAIL_STEPS', f'계단 정의 오류: {e}')}
|
||||
if budget is not None:
|
||||
# 계단별 주수는 보유수량을 나눠 만든다 — 예산 환산이 끼면 기준이 두 개가 된다.
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('TRAIL_BUDGET',
|
||||
'트레일링은 수량으로만 지정 (예산 환산 불가)')}
|
||||
if min_sell_price is not None and min_sell_price <= 0:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('TRAIL_MIN_PRICE',
|
||||
'최저 매도가는 0보다 커야 함 (제한 없으면 비우세요)')}
|
||||
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"]} · 손절 {dup["cond_uv"]:,}원) — 먼저 취소하세요')}
|
||||
f'({dup["id"]} · {len(dup_steps)}단 · 1단계 손절 {top_uv:,}원) '
|
||||
f'— 먼저 취소하세요')}
|
||||
if budget is not None and qty is not None:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('AMBIGUOUS_INPUT',
|
||||
@@ -218,7 +228,8 @@ def propose_trade(
|
||||
# collect_market_data 의 잔고/보유 조회는 qty 와 무관.
|
||||
md = datasource.collect_market_data(account, symbol, side, qty or 0)
|
||||
|
||||
trail_levels = None
|
||||
trail_final = None
|
||||
trail_peak = None
|
||||
if order_type == 'TRAILING_STOP':
|
||||
# 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 fallback 금지 — 기준이 흔들림).
|
||||
cur = md.get('current_price') or 0
|
||||
@@ -229,24 +240,33 @@ def propose_trade(
|
||||
# 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
|
||||
# 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
|
||||
# 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
|
||||
peak = cur
|
||||
trail_peak = cur
|
||||
try:
|
||||
trail_levels = trailing.compute_levels(peak, trail_pct, min_sell_price)
|
||||
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))}
|
||||
# 손절선이 현재가 이상이면 등록하는 순간 발동한다. 고점=현재가로 고정된 뒤로는
|
||||
# 최저 매도가가 현재가보다 높은 경우만 남는다(트레일 폭은 항상 현재가 아래로 내려감).
|
||||
if trail_levels['cond_uv'] >= cur:
|
||||
if trail_levels['floor_applied']:
|
||||
# 계단별 주수 배분. 수량이 적어 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'] >= cur:
|
||||
if top['floor_applied']:
|
||||
msg = (f'최저 매도가 {min_sell_price:,}원이 현재가 {cur:,}원보다 높아 '
|
||||
f'등록 즉시 발동됩니다 — 현재가보다 낮게 설정하세요')
|
||||
else:
|
||||
msg = (f'손절선 {trail_levels["cond_uv"]:,}원이 현재가 {cur:,}원 이상이라 '
|
||||
msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 현재가 {cur:,}원 이상이라 '
|
||||
f'등록 즉시 발동됩니다')
|
||||
return {'ok': False, 'message': card.format_rejected('TRAIL_IMMEDIATE', msg)}
|
||||
price = trail_levels['ord_uv']
|
||||
stop_price = trail_levels['cond_uv']
|
||||
trail_levels['peak'] = peak
|
||||
price = top['ord_uv']
|
||||
stop_price = top['cond_uv']
|
||||
|
||||
budget_conversion = None
|
||||
if budget is not None:
|
||||
@@ -277,9 +297,9 @@ def propose_trade(
|
||||
request['price'] = price
|
||||
request['stop_price'] = stop_price
|
||||
if order_type == 'TRAILING_STOP':
|
||||
request['trail_pct'] = trail_pct
|
||||
request['trail_steps'] = trail_final
|
||||
request['min_sell_price'] = min_sell_price
|
||||
request['trail_peak'] = trail_levels['peak']
|
||||
request['trail_peak'] = trail_peak
|
||||
|
||||
r = guards.validate_request(request, md)
|
||||
if not r.ok:
|
||||
@@ -326,11 +346,11 @@ def propose_trade(
|
||||
if order_type == 'STOP_LIMIT':
|
||||
payload['stop_price'] = stop_price
|
||||
elif order_type == 'TRAILING_STOP':
|
||||
# 승인 시점에 확정되는 것: 계좌·종목·수량·트레일 폭·최저 매도가.
|
||||
# 이후 감시 루프는 조건단가만 올린다.
|
||||
# 승인 시점에 확정되는 것: 계좌·종목·계단별 수량·하락률·최저 매도가.
|
||||
# 이후 감시 루프는 계단별 조건단가만 올린다 (수량·계단 구성은 불변).
|
||||
payload['stop_price'] = stop_price
|
||||
payload['trail_pct'] = trail_pct
|
||||
payload['trail_peak'] = trail_levels['peak']
|
||||
payload['trail_steps'] = trail_final
|
||||
payload['trail_peak'] = trail_peak
|
||||
payload['min_sell_price'] = min_sell_price
|
||||
|
||||
try:
|
||||
@@ -516,14 +536,10 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
|
||||
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)
|
||||
|
||||
# 트레일링은 키움에 스톱지정가(trde_tp=28)로 나간다 — 트레일링은 우리 쪽 개념.
|
||||
if p['order_type'] == 'AGGRESSIVE_LIMIT':
|
||||
submit_order_type = 'LIMIT'
|
||||
elif p['order_type'] == 'TRAILING_STOP':
|
||||
submit_order_type = 'STOP_LIMIT'
|
||||
else:
|
||||
submit_order_type = p['order_type']
|
||||
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'],
|
||||
@@ -545,30 +561,7 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
msg = card.format_submitted(card_obj.card_id, p['side'],
|
||||
p.get('symbol_name', p['symbol']),
|
||||
p['qty'], p.get('price'), ord_no)
|
||||
if p['order_type'] == 'TRAILING_STOP':
|
||||
# 트레일링은 조건 도달까지 며칠 미체결로 남는 게 정상 — fill_watcher 를 걸면
|
||||
# 30분 미체결 알림이 오탐으로 간다. 생존·체결 감시는 trailing_monitor 가 담당.
|
||||
if ord_no:
|
||||
try:
|
||||
res_trl = trailing.register(
|
||||
ord_no=ord_no, account=p['account'], symbol=p['symbol'],
|
||||
symbol_name=p.get('symbol_name', p['symbol']), qty=p['qty'],
|
||||
trail_pct=p['trail_pct'], peak=p['trail_peak'],
|
||||
cond_uv=p['stop_price'], ord_uv=p['price'],
|
||||
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id,
|
||||
min_sell_price=p.get('min_sell_price'),
|
||||
)
|
||||
msg += (f'\n트레일링 감시 시작 ({res_trl["id"]}) — '
|
||||
f'고점 {p["trail_peak"]:,}원 기준 {p["trail_pct"]}% 아래 '
|
||||
f'{p["stop_price"]:,}원')
|
||||
except Exception as e:
|
||||
# 주문은 이미 접수됨 — 등록 실패를 숨기면 트레일링이 안 도는 걸 모른다.
|
||||
ledger.append('trailing_register_failed',
|
||||
{'card_id': card_obj.card_id, 'ord_no': ord_no,
|
||||
'error': repr(e), **p})
|
||||
msg += ('\n⚠️ 주문은 접수됐지만 트레일링 등록 실패 — '
|
||||
'손절선이 따라 올라가지 않습니다. 확인 필요.')
|
||||
elif ord_no:
|
||||
if ord_no:
|
||||
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
|
||||
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
|
||||
fill_watcher.watch(
|
||||
@@ -583,6 +576,84 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
'detail': res}
|
||||
|
||||
|
||||
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 = [], [], []
|
||||
|
||||
for s in steps:
|
||||
res = kiwoom_order.submit(
|
||||
account_label=p['account'],
|
||||
side='SELL',
|
||||
symbol=p['symbol'],
|
||||
qty=s['qty'],
|
||||
price=s['ord_uv'],
|
||||
order_type='STOP_LIMIT',
|
||||
routing_suffix=p['routing_suffix'],
|
||||
dry_run=dry_run,
|
||||
card_id=card_obj.card_id,
|
||||
stop_price=s['cond_uv'],
|
||||
)
|
||||
details.append(res)
|
||||
if res['ok']:
|
||||
# dry_run 은 주문번호가 없다 — 예약 등록 없이 접수될 내용만 확인한다.
|
||||
placed.append(dict(s, ord_no=res.get('ord_no', '')))
|
||||
else:
|
||||
failed.append(dict(s, reason=res.get('reason', 'UNKNOWN'),
|
||||
error=str(res.get('error', ''))))
|
||||
|
||||
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)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user