auto: 일일 백업 2026-08-26 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user