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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-26 02:00:02 +09:00
parent 600be2ee96
commit 992b85005d
162 changed files with 3704 additions and 1825 deletions
+192 -41
View File
@@ -167,11 +167,13 @@ def propose_trade(
stop_price: Optional[int] = None,
trail_steps: Optional[list] = None,
min_sell_price: Optional[int] = None,
trigger_price: Optional[int] = None,
) -> dict:
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT', 'TRAILING_STOP'):
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT',
'TRAILING_STOP', 'TRAILING_ARM'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
if side not in ('BUY', 'SELL'):
@@ -186,12 +188,19 @@ def propose_trade(
'message': card.format_rejected('STOP_INPUT',
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
norm_steps = None
if order_type == 'TRAILING_STOP':
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
# 트레일링은 계단 정의(하락률·누적 비중)만 입력받고, 계단별 조건단가·지정가·주수는
# 현재가와 보유수량 기준으로 자동 계산한다 (md 수집 후).
#
# 예약 트레일링(TRAILING_ARM)은 같은 입력에 발동가가 하나 더 붙고, 계단 계산의 기준을
# 현재가 대신 발동가로 쓴다. 그 외 검증은 전부 공유한다 — 발동하면 결국 같은 물건이
# 되므로 여기서 갈라놓으면 한쪽만 고쳐지는 사고가 난다.
is_arm = order_type == 'TRAILING_ARM'
kind = '예약 트레일링' if is_arm else '트레일링 스톱'
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected('STOP_SELL_ONLY', '트레일링 스톱은 매도 전용')}
'message': card.format_rejected(
'ARM_SELL_ONLY' if is_arm else 'STOP_SELL_ONLY', f'{kind}은 매도 전용')}
try:
norm_steps = trailing.normalize_steps(trail_steps or [])
except (ValueError, KeyError, TypeError) as e:
@@ -201,11 +210,15 @@ def propose_trade(
# 계단별 주수는 보유수량을 나눠 만든다 — 예산 환산이 끼면 기준이 두 개가 된다.
return {'ok': False,
'message': card.format_rejected('TRAIL_BUDGET',
'트레일링은 수량으로만 지정 (예산 환산 불가)')}
f'{kind}은 수량으로만 지정 (예산 환산 불가)')}
if min_sell_price is not None and min_sell_price <= 0:
return {'ok': False,
'message': card.format_rejected('TRAIL_MIN_PRICE',
'최저 매도가는 0보다 커야 함 (제한 없으면 비우세요)')}
if is_arm and (not trigger_price or trigger_price <= 0):
return {'ok': False,
'message': card.format_rejected('ARM_TRIGGER', '발동가를 입력하세요')}
# 같은 계좌·종목에 활성 트레일링과 대기 예약이 함께 있으면 안 된다 — 어느 쪽이든 먼저 취소.
dup = trailing.find_by_symbol(account, symbol)
if dup:
dup_steps = dup.get('steps') or []
@@ -216,6 +229,14 @@ def propose_trade(
f'{symbol_name or symbol} 트레일링 예약이 이미 있음 '
f'({dup["id"]} · {len(dup_steps)}단 · 1단계 손절 {top_uv:,}원) '
f'— 먼저 취소하세요')}
dup_arm = trailing.find_arm_by_symbol(account, symbol)
if dup_arm:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_DUPLICATE',
f'{symbol_name or symbol} 예약 트레일링이 이미 있음 '
f'({dup_arm["id"]} · 발동가 {dup_arm["trigger_price"]:,}원 · '
f'{len(dup_arm.get("steps") or [])}단) — 먼저 취소하세요')}
if budget is not None and qty is not None:
return {'ok': False,
'message': card.format_rejected('AMBIGUOUS_INPUT',
@@ -230,17 +251,37 @@ def propose_trade(
trail_final = None
trail_peak = None
if order_type == 'TRAILING_STOP':
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
is_arm = order_type == 'TRAILING_ARM'
# 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 fallback 금지 — 기준이 흔들림).
cur = md.get('current_price') or 0
if cur <= 0:
return {'ok': False,
'message': card.format_rejected('TRAIL_NO_PRICE',
'현재가를 못 받아 트레일링 손절선을 정할 수 없음')}
# 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
# 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
# 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
trail_peak = cur
'message': card.format_rejected(
'TRAIL_NO_PRICE',
'현재가를 못 받아 발동가 도달 여부를 판정할 수 없음' if is_arm
else '현재가를 못 받아 트레일링 손절선을 정할 수 없음')}
if is_arm:
# 이미 닿아 있으면 '기다렸다가'라는 이 주문유형의 존재 이유가 사라진다 —
# 등록하는 순간 다음 감시(1분 내)에 바로 발동한다. 그럴 거면 일반 트레일링이 맞다.
if cur >= trigger_price:
return {'ok': False,
'message': card.format_rejected(
'ARM_ALREADY_REACHED',
f'현재가 {cur:,}원이 이미 발동가 {trigger_price:,}원 이상입니다 — '
f'발동가를 올리시거나 일반 트레일링 스톱으로 등록하세요')}
# 계단 계산 기준 = 발동가. 실제 발주는 발동 시점 현재가로 다시 계산하지만,
# 여기서 미리 재보는 이유는 ①카드에 예상 손절선·주수를 보이고 ②발동해도 못 쓰는
# 조합(수량 부족·즉시발동)을 지금 걸러내기 위해서다.
# 발동 시점엔 현재가 >= 발동가 이므로 실제 손절선은 이 미리보기 이상이다
# — 관리자님이 승인한 것보다 불리해지는 방향으로는 움직이지 않는다.
basis = trigger_price
else:
# 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
# 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
# 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
basis = cur
trail_peak = basis
try:
leveled = trailing.compute_step_levels(trail_peak, norm_steps, min_sell_price)
except ValueError as e:
@@ -257,16 +298,25 @@ def propose_trade(
# 고점=현재가로 고정된 뒤로는 최저 매도가가 현재가보다 높은 경우만 남는다
# (트레일 폭은 항상 현재가 아래로 내려감).
top = trail_final[0]
if top['cond_uv'] >= cur:
if top['cond_uv'] >= basis:
ref = '발동가' if is_arm else '현재가'
when = '발동하자마자' if is_arm else '등록 즉시'
if top['floor_applied']:
msg = (f'최저 매도가 {min_sell_price:,}원이 현재가 {cur:,}원보다 높아 '
f'등록 즉시 발동됩니다 — 현재가보다 낮게 설정하세요')
msg = (f'최저 매도가 {min_sell_price:,}원이 {ref} {basis:,}원보다 높아 '
f'{when} 발동됩니다 — {ref}보다 낮게 설정하세요')
else:
msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 현재가 {cur:,}원 이상이라 '
f'등록 즉시 발동됩니다')
return {'ok': False, 'message': card.format_rejected('TRAIL_IMMEDIATE', msg)}
price = top['ord_uv']
stop_price = top['cond_uv']
msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 {ref} {basis:,}원 이상이라 '
f'{when} 발동됩니다')
return {'ok': False,
'message': card.format_rejected(
'ARM_IMMEDIATE' if is_arm else 'TRAIL_IMMEDIATE', msg)}
if is_arm:
# 예약 단계에선 나가는 주문이 없다 — 발주가는 발동 시점에 정해진다.
price = None
stop_price = None
else:
price = top['ord_uv']
stop_price = top['cond_uv']
budget_conversion = None
if budget is not None:
@@ -300,6 +350,13 @@ def propose_trade(
request['trail_steps'] = trail_final
request['min_sell_price'] = min_sell_price
request['trail_peak'] = trail_peak
elif order_type == 'TRAILING_ARM':
# price/stop_price 를 넣지 않는다 — 지금 나가는 주문이 없고, 넣으면 validate_request 의
# 가격제한폭·"조건단가 < 현재가" 검사에 걸린다(둘 다 예약엔 무의미하다).
request['trigger_price'] = trigger_price
request['trail_steps'] = trail_final
request['min_sell_price'] = min_sell_price
request['trail_peak'] = trail_peak
r = guards.validate_request(request, md)
if not r.ok:
@@ -352,6 +409,15 @@ def propose_trade(
payload['trail_steps'] = trail_final
payload['trail_peak'] = trail_peak
payload['min_sell_price'] = min_sell_price
elif order_type == 'TRAILING_ARM':
# 승인 시점에 확정: 계좌·종목·수량·발동가·계단 정의·최저 매도가.
# 발동 시점에 정해짐: 고점(그때의 현재가)·계단별 조건단가/지정가/주수·라우팅.
# ⚠️ trail_steps 에는 레벨 없는 정규화 계단만 넣는다 — 발동 경로가 미리보기 가격을
# 실수로 재사용하지 못하게 구조적으로 막는다. 미리보기는 별도 키(trail_preview).
payload['trigger_price'] = trigger_price
payload['trail_steps'] = norm_steps
payload['trail_preview'] = trail_final
payload['min_sell_price'] = min_sell_price
try:
pending = _pin_store.issue(account, payload)
@@ -538,6 +604,8 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
return _submit_multi_legs(card_obj, dry_run)
if p['order_type'] == 'TRAILING_STOP':
return _submit_trailing_steps(card_obj, dry_run)
if p['order_type'] == 'TRAILING_ARM':
return _register_trailing_arm(card_obj, dry_run)
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
res = kiwoom_order.submit(
@@ -576,6 +644,108 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
'detail': res}
def _register_trailing_arm(card_obj, dry_run: bool) -> dict:
"""예약 트레일링 카드 — 키움 호출 0. 발동가 대기 예약 1건을 파일에 쓰고 끝난다.
PIN 은 여기서 소비된다. 발동 시점엔 PIN 이 없다(관리자님 명시 승인) — 그래서 승인 대상에
발동가까지 포함되고, 감시 루프는 그 확정된 내용을 조건 도달 시 집행만 한다.
"""
p = card_obj.payload
name = p.get('symbol_name', p['symbol'])
info = {'card_id': card_obj.card_id, 'account': p['account'], 'symbol': p['symbol'],
'symbol_name': name, 'qty': p['qty'], 'trigger_price': p['trigger_price'],
'min_sell_price': p.get('min_sell_price'), 'steps': p.get('trail_steps') or [],
'dry_run': dry_run, 'arm_id': None}
if dry_run:
return {'ok': True, 'message': card.format_arm_registered(info)}
try:
arm = trailing.register_arm(
account=p['account'], symbol=p['symbol'], symbol_name=name,
qty=p['qty'], trigger_price=p['trigger_price'],
steps=p.get('trail_steps') or [],
min_sell_price=p.get('min_sell_price'), card_id=card_obj.card_id)
except Exception as e:
# 주문이 나간 게 없으므로 여기선 그냥 거부로 끝내면 된다 (트레일링 등록 실패와 달리
# 되돌릴 외부 효과가 없다).
ledger.append('arm_fire_failed', {'card_id': card_obj.card_id,
'error': repr(e), 'stage': 'register', **p})
return {'ok': False,
'message': card.format_rejected('ARM_REGISTER_FAILED',
f'{name} 예약 등록 실패 — {e}')}
info['arm_id'] = arm['id']
ledger.append('arm_registered', {'card_id': card_obj.card_id, 'arm_id': arm['id'], **p})
return {'ok': True, 'message': card.format_arm_registered(info), 'arm_id': arm['id']}
def cancel_trailing_arm(arm_id: str) -> dict:
"""대기 예약 취소. PIN 없음 — 취소는 안전한 방향이다(cancel_open_order 와 같은 판례).
⚠️ firing 마킹이 걸린 예약은 이미 키움에 스톱주문이 나갔을 수 있다. 그 주문까지 자동으로
취소하지는 않는다 — 승인 범위를 넘는 매매 API 자동 호출이 된다. 대신 접수된 주문번호를
응답에 실어 관리자님이 미체결 목록에서 직접 처리하게 한다.
"""
sidecar.guard_or_raise()
arm = trailing.get_arm(arm_id)
if arm is None:
return {'ok': False, 'message': f'대기 예약 없음 ({arm_id})'}
fired = arm.get('fired_legs') or []
gone = trailing.remove_arm(arm_id, 'manual_cancel')
if gone is None:
return {'ok': False, 'message': f'대기 예약 없음 ({arm_id})'}
ledger.append('arm_canceled', {'arm_id': arm_id, 'account': arm['account'],
'symbol': arm['symbol'], 'qty': arm['qty'],
'trigger_price': arm['trigger_price']})
msg = (f'🗑 예약 트레일링 취소 ({arm_id}) — {arm["symbol_name"]} '
f'{arm["qty"]:,}주 · 발동가 {arm["trigger_price"]:,}원')
if fired:
nos = ', '.join(s.get('ord_no', '') for s in fired if s.get('ord_no'))
msg += (f'\n⚠️ 발동 처리 중 접수된 주문이 있습니다: {nos}\n'
f'미체결 목록에서 별도로 취소해 주세요 (자동 취소하지 않습니다).')
return {'ok': True, 'message': msg, 'placed_orders': fired}
def place_trailing_legs(account: str, symbol: str, steps: list, routing_suffix: str,
card_id: Optional[str], dry_run: bool,
on_placed=None) -> tuple:
"""계단별 스톱지정가 접수. 반환 (placed, failed, details).
얕은 계단(먼저 발동할 것)부터 순서대로 낸다 — 중간에 막혀도 가장 가까운 방어선이 먼저 걸린다.
한 레그가 실패해도 나머지는 계속 시도한다. 이미 나간 주문을 되돌리지 않는 이유는 취소 자체도
실패할 수 있어 상태가 더 불분명해지기 때문이다 — 대신 성공·실패를 그대로 호출측에 돌려준다.
`on_placed(leg)` 는 레그 하나가 접수될 때마다 즉시 호출된다. 예약 트레일링 발동 경로가
"어느 레그까지 실제로 나갔는지" 를 디스크에 미리 적는 데 쓴다(중간에 죽었을 때의 복구 단서).
실주문이 나가는 유일한 계단 발주 경로다 — 등록 즉시 트레일링과 예약 발동이 이 함수를
공유해야 두 벌로 갈라지지 않는다.
"""
placed, failed, details = [], [], []
for s in steps:
res = kiwoom_order.submit(
account_label=account,
side='SELL',
symbol=symbol,
qty=s['qty'],
price=s['ord_uv'],
order_type='STOP_LIMIT',
routing_suffix=routing_suffix,
dry_run=dry_run,
card_id=card_id,
stop_price=s['cond_uv'],
)
details.append(res)
if res['ok']:
# dry_run 은 주문번호가 없다 — 예약 등록 없이 접수될 내용만 확인한다.
leg = dict(s, ord_no=res.get('ord_no', ''))
placed.append(leg)
if on_placed is not None:
on_placed(leg)
else:
failed.append(dict(s, reason=res.get('reason', 'UNKNOWN'),
error=str(res.get('error', ''))))
return placed, failed, details
def _submit_trailing_steps(card_obj, dry_run: bool) -> dict:
"""트레일링 계단 카드 — 계단마다 스톱주문을 내고 예약 1건으로 묶어 등록한다.
@@ -592,28 +762,9 @@ def _submit_trailing_steps(card_obj, dry_run: bool) -> dict:
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', ''))))
placed, failed, details = place_trailing_legs(
account=p['account'], symbol=p['symbol'], steps=steps,
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id, dry_run=dry_run)
if not placed:
return {'ok': False,