auto: 일일 백업 2026-08-01 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-01 02:00:02 +09:00
parent d7042430c3
commit 30be97ab2f
177 changed files with 4124 additions and 3489 deletions
+126 -55
View File
@@ -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)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.