auto: 일일 백업 2026-07-31 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,8 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
type_marker = f' {cfg["warning_emoji"]} 공격적 지정가'
|
||||
elif request['order_type'] == 'STOP_LIMIT':
|
||||
type_marker = f' {cfg["warning_emoji"]} 스톱지정가(예약)'
|
||||
elif request['order_type'] == 'TRAILING_STOP':
|
||||
type_marker = f' {cfg["warning_emoji"]} 트레일링 스톱(예약)'
|
||||
amend_marker = ' ✏️ 수정됨' if amended else ''
|
||||
|
||||
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}{amend_marker}', '']
|
||||
@@ -91,6 +93,26 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
cur_part = f' (현재가 {_money(cur)})' if cur else ''
|
||||
lines.append(f'{marker} 조건: {_md_bold(_money(stop_p) + " 도달 시")}{cur_part}')
|
||||
lines.append(f'{marker} 매도가격: {_md_bold(_money(request["price"]))} (지정가)')
|
||||
elif request['order_type'] == 'TRAILING_STOP':
|
||||
# 승인 대상은 "트레일 폭" — 조건단가는 고점이 오르면 이 폭을 유지하며 따라 올라간다.
|
||||
stop_p = request.get('stop_price') or 0
|
||||
cur = market_data.get('current_price')
|
||||
cur_part = f' (현재가 {_money(cur)})' if cur else ''
|
||||
# 초기 고점은 항상 등록 시점 현재가 — 과거 고점 기준 옵션은 2026-07-30 제거됨.
|
||||
peak_v = request.get('trail_peak') or 0
|
||||
trail_pct_part = f'고점 대비 −{request.get("trail_pct")}%'
|
||||
lines.append(f'{marker} 시작 고점: {_md_bold(_money(peak_v))} (등록 시점)')
|
||||
lines.append(f'{marker} 트레일 폭: {_md_bold(trail_pct_part)}')
|
||||
min_sell = request.get('min_sell_price')
|
||||
if min_sell:
|
||||
# 최저 매도가가 손절선을 결정했는지(=트레일보다 높았는지) 명시 — 둘 중 어느 게
|
||||
# 지배했는지 안 보이면 관리자님이 값을 잘못 읽는다.
|
||||
raw_trail = int(peak_v * (1 - float(request['trail_pct']) / 100)) if peak_v else 0
|
||||
who = '최저가 적용' if stop_p >= raw_trail else '트레일 적용'
|
||||
lines.append(f'{marker} 최저 매도가: {_md_bold(_money(min_sell))} ({who})')
|
||||
lines.append(f'{marker} 현재 손절선: {_md_bold(_money(stop_p) + " 도달 시")}{cur_part}')
|
||||
lines.append(f'{marker} 매도가격: {_md_bold(_money(request["price"]))} (지정가)')
|
||||
lines.append(f'{marker} 고점이 오르면 손절선도 따라 올라갑니다 (내려가지 않음)')
|
||||
elif request['order_type'] == 'MARKET':
|
||||
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
|
||||
else:
|
||||
@@ -300,6 +322,8 @@ def format_card_locked(active: Optional[dict] = None) -> str:
|
||||
price_str = '시장가'
|
||||
elif order_type == 'AGGRESSIVE_LIMIT':
|
||||
price_str = f'공격적 지정가 {_money(price)}' if price else '공격적 지정가'
|
||||
elif order_type == 'TRAILING_STOP':
|
||||
price_str = f'트레일링 −{active.get("trail_pct")}% (손절 {_money(active.get("stop_price"))})'
|
||||
else:
|
||||
price_str = _money(price) if price is not None else '지정가'
|
||||
|
||||
|
||||
@@ -503,7 +503,8 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
return r
|
||||
if request['side'] not in ('BUY', 'SELL'):
|
||||
return Result.REJECT('INVALID_SIDE', f'잘못된 방향: {request["side"]}')
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT',
|
||||
'TRAILING_STOP'):
|
||||
return Result.REJECT('INVALID_ORDER_TYPE', f'잘못된 주문방식: {request["order_type"]}')
|
||||
r = validate_trading_hours(market_data['now'], market_data.get('is_holiday', False),
|
||||
market_data.get('nxt_eligible', False))
|
||||
@@ -525,9 +526,10 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
if request['order_type'] == 'STOP_LIMIT':
|
||||
if request['order_type'] in ('STOP_LIMIT', 'TRAILING_STOP'):
|
||||
# 스톱지정가: 지정가(체결가)·조건단가(트리거) 둘 다 가격제한 이내 + 매도 전용.
|
||||
# 보호매도(하락 시 매도)라 트리거는 현재가보다 낮아야 함 — 아니면 즉시 발동돼 스톱 의미 없음.
|
||||
# 트레일링도 키움엔 스톱지정가로 나가므로 같은 검증을 받는다 (조건단가는 자동 계산값).
|
||||
if request['side'] != 'SELL':
|
||||
return Result.REJECT('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')
|
||||
stop_price = request.get('stop_price')
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -212,13 +212,18 @@ def cancel_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
modify_qty: int, modify_price: int,
|
||||
routing_suffix: str = '',
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
dry_run: bool = True, card_id: Optional[str] = None,
|
||||
modify_cond_price: Optional[int] = None) -> dict:
|
||||
"""미체결 주문 정정 (kt10002).
|
||||
|
||||
modify_qty / modify_price 둘 다 필수 — 키움 명세상 mdfy_qty/mdfy_uv 모두 Required.
|
||||
수량만 바꿀 땐 기존 가격, 가격만 바꿀 땐 기존 수량을 그대로 전달.
|
||||
시장가 주문은 정정 불가 (mdfy_uv 가 가격이라 0 불가) — 호출 전에 차단.
|
||||
routing_suffix 는 원주문의 거래소와 동일해야 함.
|
||||
|
||||
modify_cond_price: 스톱지정가(trde_tp=28) 주문의 조건단가 정정용 (mdfy_cond_uv).
|
||||
트레일링 스톱이 고점 갱신 시 조건단가를 올리는 데 사용. 일반 주문은 None.
|
||||
⚠️ 정정 응답의 ord_no 는 신규 주문번호 — 다음 정정은 그 번호를 orig_ord_no 로 써야 함.
|
||||
"""
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
@@ -230,6 +235,8 @@ def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
raise ValueError(f'modify_qty must be > 0 (got {modify_qty})')
|
||||
if modify_price <= 0:
|
||||
raise ValueError(f'modify_price must be > 0 — 시장가 주문은 정정 불가, 취소 후 신규 발주')
|
||||
if modify_cond_price is not None and modify_cond_price <= 0:
|
||||
raise ValueError(f'modify_cond_price must be > 0 (got {modify_cond_price})')
|
||||
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
body = {
|
||||
@@ -238,7 +245,7 @@ def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
'stk_cd': symbol,
|
||||
'mdfy_qty': str(modify_qty),
|
||||
'mdfy_uv': str(modify_price),
|
||||
'mdfy_cond_uv': '',
|
||||
'mdfy_cond_uv': str(modify_cond_price) if modify_cond_price else '',
|
||||
}
|
||||
|
||||
payload = {
|
||||
@@ -248,6 +255,7 @@ def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
'orig_ord_no': orig_ord_no,
|
||||
'modify_qty': modify_qty,
|
||||
'modify_price': modify_price,
|
||||
'modify_cond_price': modify_cond_price,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
'tr_id': TR_MODIFY,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""트레일링 스톱 예약 상태 관리.
|
||||
|
||||
키움에 REST 트레일링 주문 TR 이 없어서, 스톱지정가(trde_tp=28) 주문을 실제로 걸어두고
|
||||
고점이 갱신될 때마다 조건단가를 kt10002 정정으로 올려 트레일링을 구현한다.
|
||||
|
||||
주문이 키움 서버에 있으므로 감시 루프가 죽어도 마지막 손절선은 살아있다.
|
||||
감시 루프가 하는 일은 "이미 승인된 주문의 조건단가 상향"뿐 — 신규 발주·수량 변경 없음.
|
||||
|
||||
⚠️ kt10002 정정 응답의 ord_no 는 신규 주문번호다. 정정할 때마다 ord_no 를 갱신하지 않으면
|
||||
두 번째 정정부터 orig_ord_no 가 틀려서 전부 실패한다. commit_modify 가 이걸 담당.
|
||||
|
||||
상태는 파일(state/trailing_stops.json)에 저장 — 웹·감시 루프가 각각 다른 프로세스라
|
||||
같은 예약 목록을 봐야 한다. 동시성은 fcntl flock 으로 직렬화 (pin.py 와 같은 패턴).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .guards import tick_size
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
STATE_FILE = WORKSPACE_ROOT / 'state' / 'trailing_stops.json'
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
# 트레일 폭 허용 범위 (%). 너무 좁으면 정상 출렁임에 즉시 털리고, 너무 넓으면 스톱 의미가 없다.
|
||||
# ⚠️ 상한을 바꾸면 **세 곳**이 같이 움직여야 한다 — 여기(서버 검증) /
|
||||
# behive_web 트레일 폭 드롭다운 옵션 / behive_web `trailBlockReason` 의 범위 체크.
|
||||
# 하나만 고치면 드롭다운에서 고를 수 있는데 서버가 거부하거나 그 반대가 된다.
|
||||
MIN_TRAIL_PCT = 0.5
|
||||
MAX_TRAIL_PCT = 30.0
|
||||
|
||||
# 지정가(ord_uv) = 조건단가(cond_uv) 보다 이 틱수만큼 아래.
|
||||
# 조건단가와 같게 두면 발동 후 그 가격 아래로는 안 팔려 미체결로 남는다.
|
||||
# 반대로 너무 벌리면 급락 시 헐값 매도가 되니 2틱.
|
||||
ORD_UV_GAP_TICKS = 2
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(KST).isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def floor_to_tick(price: int) -> int:
|
||||
"""호가단위로 내림. 매도 조건단가·지정가는 내림이 안전(더 빨리 발동/체결)."""
|
||||
t = tick_size(price)
|
||||
return price - (price % t)
|
||||
|
||||
|
||||
def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] = None) -> dict:
|
||||
"""고점·트레일 폭(·최저 매도가) → 조건단가(트리거)·지정가(체결가).
|
||||
|
||||
cond_uv = max(peak × (1 − pct/100), min_sell_price) 를 호가단위 내림
|
||||
ord_uv = cond_uv − ORD_UV_GAP_TICKS 틱
|
||||
|
||||
min_sell_price(최저 매도가) = 손절선의 하한. 트레일 폭을 넓게 잡아도 이 가격 아래로는
|
||||
손절선이 내려가지 않는다 — 초기 구간 손실 제한용. 고점이 올라 트레일 손절선이 이 값을
|
||||
추월하면 그 뒤로는 트레일이 지배한다(최저가는 자연히 무의미해짐).
|
||||
"""
|
||||
if peak <= 0:
|
||||
raise ValueError(f'peak must be > 0 (got {peak})')
|
||||
if not (MIN_TRAIL_PCT <= trail_pct <= MAX_TRAIL_PCT):
|
||||
raise ValueError(f'trail_pct out of range: {trail_pct}')
|
||||
cond_uv = floor_to_tick(int(peak * (1 - trail_pct / 100)))
|
||||
floor_applied = False
|
||||
if min_sell_price:
|
||||
if min_sell_price <= 0:
|
||||
raise ValueError(f'min_sell_price must be > 0 (got {min_sell_price})')
|
||||
floor_uv = floor_to_tick(int(min_sell_price))
|
||||
if floor_uv > cond_uv:
|
||||
cond_uv = floor_uv
|
||||
floor_applied = True
|
||||
ord_uv = floor_to_tick(cond_uv - ORD_UV_GAP_TICKS * tick_size(cond_uv))
|
||||
if cond_uv <= 0 or ord_uv <= 0:
|
||||
raise ValueError(f'computed level <= 0 (peak={peak}, pct={trail_pct})')
|
||||
return {'cond_uv': cond_uv, 'ord_uv': ord_uv, 'floor_applied': floor_applied}
|
||||
|
||||
|
||||
def next_levels(res: dict, cur_price: int) -> Optional[dict]:
|
||||
"""현재가를 보고 올릴 값이 있으면 반환, 없으면 None (순수 함수 — 단위테스트 대상).
|
||||
|
||||
상향 전용: 고점이 갱신되고 그 결과 조건단가가 실제로 올라갈 때만 정정 대상.
|
||||
고점이 올라도 호가단위 내림 때문에 조건단가가 그대로면 정정하지 않는다(불필요한 API 콜 차단).
|
||||
최저 매도가가 아직 지배 중이면 트레일 손절선이 올라도 조건단가가 안 변해 None 이 된다.
|
||||
"""
|
||||
if cur_price <= 0:
|
||||
return None
|
||||
if cur_price <= res['peak']:
|
||||
return None
|
||||
lv = compute_levels(cur_price, res['trail_pct'], res.get('min_sell_price'))
|
||||
if lv['cond_uv'] <= res['cond_uv']:
|
||||
return None
|
||||
return {'peak': cur_price, 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv']}
|
||||
|
||||
|
||||
class _FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fp = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fp = open(self.path, 'w')
|
||||
fcntl.flock(self._fp, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
try:
|
||||
fcntl.flock(self._fp, fcntl.LOCK_UN)
|
||||
finally:
|
||||
self._fp.close()
|
||||
|
||||
|
||||
_LOCK_FILE = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock')
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
if not STATE_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return data.get('reservations') or []
|
||||
|
||||
|
||||
def _write(reservations: list) -> None:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps({'reservations': reservations}, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8')
|
||||
os.replace(tmp, STATE_FILE)
|
||||
|
||||
|
||||
def list_active() -> list:
|
||||
"""등록된 예약 전체 (락 없이 읽기 — 감시 루프의 스냅샷용)."""
|
||||
return _read()
|
||||
|
||||
|
||||
def get(res_id: str) -> Optional[dict]:
|
||||
for r in _read():
|
||||
if r['id'] == res_id:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def find_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||||
"""같은 계좌·종목의 기존 예약. 중복 등록 차단용."""
|
||||
for r in _read():
|
||||
if r['account'] == account and r['symbol'] == symbol:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def register(ord_no: str, account: str, symbol: str, symbol_name: str, qty: int,
|
||||
trail_pct: float, peak: int, cond_uv: int, ord_uv: int,
|
||||
routing_suffix: str = '', card_id: Optional[str] = None,
|
||||
min_sell_price: Optional[int] = None) -> dict:
|
||||
"""키움에 스톱주문이 접수된 직후 호출 — ord_no 를 받아 예약으로 등록.
|
||||
|
||||
초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이
|
||||
현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다.
|
||||
"""
|
||||
if not ord_no:
|
||||
raise ValueError('ord_no required — 접수 확인된 주문만 등록')
|
||||
res = {
|
||||
'id': 'TRL-' + ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4)),
|
||||
'ord_no': ord_no,
|
||||
'account': account,
|
||||
'symbol': symbol,
|
||||
'symbol_name': symbol_name,
|
||||
'qty': qty,
|
||||
'trail_pct': trail_pct,
|
||||
'min_sell_price': min_sell_price,
|
||||
'peak': peak,
|
||||
'cond_uv': cond_uv,
|
||||
'ord_uv': ord_uv,
|
||||
'routing_suffix': routing_suffix,
|
||||
'card_id': card_id,
|
||||
'created_at': _now_iso(),
|
||||
'updated_at': _now_iso(),
|
||||
'modify_count': 0,
|
||||
'entry_peak': peak,
|
||||
'entry_cond_uv': cond_uv,
|
||||
}
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
reservations.append(res)
|
||||
_write(reservations)
|
||||
return res
|
||||
|
||||
|
||||
def commit_modify(res_id: str, new_ord_no: str, peak: int, cond_uv: int, ord_uv: int) -> Optional[dict]:
|
||||
"""정정 성공 후 상태 갱신. new_ord_no 는 kt10002 응답의 신규 주문번호.
|
||||
|
||||
⚠️ ord_no 갱신이 이 함수의 핵심 — 안 하면 다음 정정이 실패한다.
|
||||
"""
|
||||
if not new_ord_no:
|
||||
raise ValueError('new_ord_no required')
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] == res_id:
|
||||
r['ord_no'] = new_ord_no
|
||||
r['peak'] = peak
|
||||
r['cond_uv'] = cond_uv
|
||||
r['ord_uv'] = ord_uv
|
||||
r['modify_count'] = r.get('modify_count', 0) + 1
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def should_notify_failure(res_id: str, now_ts: float, cooldown_sec: int) -> bool:
|
||||
"""정정 실패 알림을 보낼지. 보낸다고 판단하면 그 시점을 기록한다 (락 안에서 원자적).
|
||||
|
||||
NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 실패가 반복된다 —
|
||||
쿨다운 없이 알리면 20:00까지 매분 텔레그램이 쏟아진다.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] != res_id:
|
||||
continue
|
||||
last = r.get('last_fail_notice_ts') or 0
|
||||
if now_ts - last < cooldown_sec:
|
||||
return False
|
||||
r['last_fail_notice_ts'] = now_ts
|
||||
_write(reservations)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove(res_id: str, reason: str = '') -> Optional[dict]:
|
||||
"""예약 종료 (체결·취소·소멸). 반환값은 제거된 예약 — 알림 메시지용."""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for i, r in enumerate(reservations):
|
||||
if r['id'] == res_id:
|
||||
gone = reservations.pop(i)
|
||||
_write(reservations)
|
||||
gone['removed_reason'] = reason
|
||||
return gone
|
||||
return None
|
||||
Reference in New Issue
Block a user