auto: 일일 백업 2026-07-31 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-31 02:00:02 +09:00
parent a5f6b0ecd8
commit d7042430c3
183 changed files with 7898 additions and 2204 deletions
@@ -35,7 +35,7 @@ if str(_PARENT) not in sys.path:
import kiwoom_client as kc # noqa: E402
from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar # noqa: E402
from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar, trailing # noqa: E402
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
OPENCLAW_CONFIG = Path.home() / '.openclaw' / 'openclaw.json'
@@ -165,11 +165,13 @@ def propose_trade(
routing_force: Optional[str] = None,
budget: Optional[int] = None,
stop_price: Optional[int] = None,
trail_pct: Optional[float] = None,
min_sell_price: Optional[int] = None,
) -> dict:
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT', 'TRAILING_STOP'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
if side not in ('BUY', 'SELL'):
@@ -183,6 +185,27 @@ def propose_trade(
return {'ok': False,
'message': card.format_rejected('STOP_INPUT',
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
if order_type == 'TRAILING_STOP':
# 트레일링은 트레일 폭(%)만 입력받고 조건단가·지정가는 현재가 기준 자동 계산 (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):
return {'ok': False,
'message': card.format_rejected(
'TRAIL_PCT_RANGE',
f'트레일 폭은 {trailing.MIN_TRAIL_PCT}~{trailing.MAX_TRAIL_PCT}% 범위')}
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:
return {'ok': False,
'message': card.format_rejected(
'TRAIL_DUPLICATE',
f'{symbol_name or symbol} 트레일링 예약이 이미 있음 '
f'({dup["id"]} · 손절 {dup["cond_uv"]:,}원) — 먼저 취소하세요')}
if budget is not None and qty is not None:
return {'ok': False,
'message': card.format_rejected('AMBIGUOUS_INPUT',
@@ -195,6 +218,36 @@ def propose_trade(
# collect_market_data 의 잔고/보유 조회는 qty 와 무관.
md = datasource.collect_market_data(account, symbol, side, qty or 0)
trail_levels = None
if order_type == 'TRAILING_STOP':
# 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 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 실측 근거로 관리자님 결정).
peak = cur
try:
trail_levels = trailing.compute_levels(peak, trail_pct, 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']:
msg = (f'최저 매도가 {min_sell_price:,}원이 현재가 {cur:,}원보다 높아 '
f'등록 즉시 발동됩니다 — 현재가보다 낮게 설정하세요')
else:
msg = (f'손절선 {trail_levels["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
budget_conversion = None
if budget is not None:
conv = guards.convert_budget_to_qty(side, budget, md.get('orderbook'),
@@ -220,9 +273,13 @@ def propose_trade(
}
if order_type == 'LIMIT':
request['price'] = price
elif order_type == 'STOP_LIMIT':
elif order_type in ('STOP_LIMIT', 'TRAILING_STOP'):
request['price'] = price
request['stop_price'] = stop_price
if order_type == 'TRAILING_STOP':
request['trail_pct'] = trail_pct
request['min_sell_price'] = min_sell_price
request['trail_peak'] = trail_levels['peak']
r = guards.validate_request(request, md)
if not r.ok:
@@ -268,6 +325,13 @@ 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['min_sell_price'] = min_sell_price
try:
pending = _pin_store.issue(account, payload)
@@ -453,7 +517,13 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
if p.get('multi'):
return _submit_multi_legs(card_obj, dry_run)
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
# 트레일링은 키움에 스톱지정가(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']
res = kiwoom_order.submit(
account_label=p['account'],
side=p['side'],
@@ -475,9 +545,32 @@ 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)
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
if 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:
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
fill_watcher.watch(
ord_no=ord_no, account=p['account'], side=p['side'],
symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),