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
@@ -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