auto: 일일 백업 2026-08-26 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,8 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
type_marker = f' {cfg["warning_emoji"]} 스톱지정가(예약)'
|
||||
elif request['order_type'] == 'TRAILING_STOP':
|
||||
type_marker = f' {cfg["warning_emoji"]} 트레일링 스톱(예약)'
|
||||
elif request['order_type'] == 'TRAILING_ARM':
|
||||
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}', '']
|
||||
@@ -98,15 +100,29 @@ 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':
|
||||
elif request['order_type'] in ('TRAILING_STOP', 'TRAILING_ARM'):
|
||||
# 승인 대상은 "계단 정의" — 각 계단의 조건단가는 고점이 오르면 제 하락률을 유지하며
|
||||
# 함께 따라 올라간다. 계단이 1개면 종전의 단일 트레일링과 같다.
|
||||
is_arm = request['order_type'] == 'TRAILING_ARM'
|
||||
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
|
||||
steps = request.get('trail_steps') or []
|
||||
lines.append(f'{marker} 시작 고점: {_md_bold(_money(peak_v))} (등록 시점){cur_part}')
|
||||
if is_arm:
|
||||
gap = ''
|
||||
if cur:
|
||||
gap = f' — 발동까지 {_pct((peak_v - cur) / cur * 100)}'
|
||||
lines.append(f'{marker} 발동가: {_md_bold(_money(peak_v) + " 도달 시 시작")}{cur_part}{gap}')
|
||||
# 대기 예약이 당일 유효로 바뀐 뒤로는 상한가 초과 발동가가 '오늘 도달 불가' 확정이다.
|
||||
# 거부하지는 않는다(관리자님 지시) — 무의미하다는 사실만 승인 전에 알린다.
|
||||
up_lim = market_data.get('upper_limit') or 0
|
||||
if up_lim and peak_v > up_lim:
|
||||
lines.append(f'{cfg["warning_emoji"]} 오늘 상한가 {_money(up_lim)} 초과 — '
|
||||
f'오늘은 도달 불가, 장 마감에 만료됩니다')
|
||||
else:
|
||||
lines.append(f'{marker} 시작 고점: {_md_bold(_money(peak_v))} (등록 시점){cur_part}')
|
||||
min_sell = request.get('min_sell_price')
|
||||
if min_sell:
|
||||
# 최저 매도가가 손절선을 결정했는지(=트레일보다 높았는지) 명시 — 둘 중 어느 게
|
||||
@@ -115,7 +131,10 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
who = f'{tied}개 계단 적용' if tied else '미적용 (트레일이 더 높음)'
|
||||
lines.append(f'{marker} 최저 매도가: {_md_bold(_money(min_sell))} ({who})')
|
||||
lines.append('')
|
||||
lines.append(f'{marker} 매도 계단 {len(steps)}단')
|
||||
steps_head = f'{marker} 매도 계단 {len(steps)}단'
|
||||
if is_arm:
|
||||
steps_head += ' (발동가 기준 예상)'
|
||||
lines.append(steps_head)
|
||||
for s in steps:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
|
||||
@@ -127,6 +146,14 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
lines.append(f' ↳ 나머지 {rest:,}주는 스톱 없음 (계속 보유)')
|
||||
lines.append('')
|
||||
lines.append(f'{marker} 고점이 오르면 계단이 통째로 따라 올라갑니다 (내려가지 않음)')
|
||||
if is_arm:
|
||||
# 이 기능의 유일한 위험(발동 시 PIN 재확인 없음)을 승인 화면에서 고지하는 자리다.
|
||||
# 실제 손절선이 미리보기 이상인 이유: 발동 시점 현재가 ≥ 발동가.
|
||||
lines.append(f'{marker} 지금은 주문이 나가지 않습니다 — '
|
||||
f'발동가에 닿는 순간 PIN 재확인 없이 위 계단이 자동 발주됩니다')
|
||||
lines.append(f'{marker} {_md_bold("오늘 장 마감까지만 유효")} — '
|
||||
f'미발동이면 자동 취소됩니다 (스톱주문과 같은 당일 유효)')
|
||||
lines.append(f'{marker} 실제 손절선은 발동 시점 현재가 기준이라 위 값 이상입니다')
|
||||
elif request['order_type'] == 'MARKET':
|
||||
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
|
||||
else:
|
||||
@@ -293,6 +320,45 @@ def format_trailing_submitted(info: dict) -> str:
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_arm_registered(info: dict) -> str:
|
||||
"""예약 트레일링 등록 완료 — 아직 키움에 주문이 없다는 사실을 분명히 적는다."""
|
||||
acct = _account_display(info.get('account', ''))
|
||||
head = '🧪 [모의] 예약 트레일링' if info.get('dry_run') else '⏳ 예약 트레일링'
|
||||
steps = info.get('steps') or []
|
||||
lines = [f'{head} 등록: {info["symbol_name"]} ({info.get("symbol", "")})',
|
||||
f'[{acct}] {info["qty"]:,}주 · 발동가 {_money(info["trigger_price"])} 도달 시 시작']
|
||||
if info.get('cur_price'):
|
||||
cur = info['cur_price']
|
||||
gap = (info['trigger_price'] - cur) / cur * 100
|
||||
lines.append(f'현재가 {_money(cur)} · 발동까지 {_pct(gap)}')
|
||||
if info.get('min_sell_price'):
|
||||
lines.append(f'최저 매도가 {_money(info["min_sell_price"])}')
|
||||
step_parts = []
|
||||
for s in steps:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
|
||||
step_parts.append(f'−{_num(s["pct"])}% {cum_part}')
|
||||
lines.append(f'매도 계단 {len(steps)}단: ' + ' · '.join(step_parts))
|
||||
if info.get('arm_id'):
|
||||
lines.append(f'대기 시작 ({info["arm_id"]}) — **오늘 장 마감까지** 유효')
|
||||
lines.append('지금은 키움에 주문이 없습니다. 발동가에 닿으면 PIN 재확인 없이 자동 발주됩니다.')
|
||||
lines.append('미발동으로 장이 끝나면 자동 취소됩니다 (스톱주문과 같은 당일 유효).')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_arm_fired(info: dict) -> str:
|
||||
"""예약 트레일링 발동 — 계단 접수 결과는 format_trailing_submitted 와 같은 모양이라 재사용하고,
|
||||
발동 경위(발동가·실제 발동가·수량 축소)만 앞뒤에 덧붙인다."""
|
||||
head = [f'🎯 예약 트레일링 발동 — {info["symbol_name"]} ({info.get("symbol", "")})',
|
||||
f'발동가 {_money(info["trigger_price"])} 도달 (현재가 {_money(info["peak"])})']
|
||||
if info.get('qty_reduced'):
|
||||
head.append(f'⚠️ 매도가능 {info["total_qty"]:,}주 — '
|
||||
f'예약 {info["arm_qty"]:,}주에서 줄여 재배분했습니다')
|
||||
body = format_trailing_submitted(info)
|
||||
tail = ['※ 스톱주문은 장 마감 시 소멸합니다 (자동 재등록 안 함)']
|
||||
return '\n'.join(head + ['', body] + tail)
|
||||
|
||||
|
||||
def format_expired(card_id: str, side: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
return f'⏱️ [#{card_id}] 승인 만료. {side_word}가 취소되었습니다.'
|
||||
|
||||
@@ -130,6 +130,15 @@ def _safe_broker_executions(account_label: str, now: datetime) -> tuple[Optional
|
||||
return executions, None
|
||||
|
||||
|
||||
def nxt_eligible(symbol: str) -> bool:
|
||||
"""NXT 거래가능 여부 (종목코드만으로). 캐시 읽기라 API 콜 0.
|
||||
|
||||
collect_market_data 를 거치지 않는 호출자용 — 트레일링 감시 루프가 대기 예약을
|
||||
발동시킬 때 라우팅(_AL/_KRX)을 정하는 데 쓴다.
|
||||
"""
|
||||
return _nxt_eligible(symbol, {})
|
||||
|
||||
|
||||
def collect_market_data(account_label: str, symbol: str, side: str, qty: int) -> dict:
|
||||
now = datetime.now(KST)
|
||||
quote = _safe_quote(symbol, account_label)
|
||||
|
||||
@@ -89,6 +89,17 @@ def session_at(now: datetime) -> str:
|
||||
return 'CLOSED'
|
||||
|
||||
|
||||
def after_trading_day(now: datetime) -> bool:
|
||||
"""그날 거래 세션이 전부 끝났는가 (NXT 애프터 마감 = 20:00 이후).
|
||||
|
||||
⚠️ `session_at(now) == 'CLOSED'` 로 대신하면 안 된다 — 그 판정은 **장 시작 전 새벽**과
|
||||
**09:00:00~09:00:30 틈**(NXT 프리 종료와 정규장 시작 사이)에도 CLOSED 를 돌려주므로,
|
||||
당일 유효기간을 재는 데 쓰면 아침에 멀쩡한 예약이 만료로 잡힌다.
|
||||
"""
|
||||
th = _limits()['trading_hours']
|
||||
return now.replace(tzinfo=None).time() >= _parse_hms(th['nxt_after_end'])
|
||||
|
||||
|
||||
def is_today_holiday(now: datetime) -> bool:
|
||||
th = _limits()['trading_hours']
|
||||
rel = th.get('holiday_state_file')
|
||||
@@ -103,6 +114,11 @@ def is_today_holiday(now: datetime) -> bool:
|
||||
return False
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
holidays = data.get('holidays') if isinstance(data, dict) else data
|
||||
# holiday_sync.py 가 쓰는 실제 형태는 {날짜: 이름} dict 다. list 만 받던 시절의
|
||||
# 코드가 남아 있어 dict 를 만나면 조용히 False 를 반환했고(= 휴장일 가드가 통째로
|
||||
# 무력화), 2026-08-25 예약 트레일링 작업에서 발견해 dict 를 먼저 처리하게 했다.
|
||||
if isinstance(holidays, dict):
|
||||
return today in holidays
|
||||
if not isinstance(holidays, list):
|
||||
return False
|
||||
for h in holidays:
|
||||
@@ -504,7 +520,7 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
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',
|
||||
'TRAILING_STOP'):
|
||||
'TRAILING_STOP', 'TRAILING_ARM'):
|
||||
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))
|
||||
@@ -548,6 +564,20 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
return Result.REJECT('STOP_TRIGGER_NOT_BELOW',
|
||||
f'조건단가 {stop_price:,}원 ≥ 현재가 {cur:,}원 — '
|
||||
f'하락 시 매도(스톱)는 현재가보다 낮게 설정해야 함')
|
||||
if request['order_type'] == 'TRAILING_ARM':
|
||||
# 예약 트레일링은 지금 나가는 주문이 아니라 "발동가에 닿으면 시작" 하는 예약이다.
|
||||
# 그래서 스톱지정가와 두 가지 검증을 일부러 건너뛴다:
|
||||
# · 가격제한폭 — 발동가는 오늘 상한가 위여도 통과시킨다. 실제 발주는 발동 시점의
|
||||
# 현재가로 계산되므로 그때 검증된다. arm 이 당일 유효가 된 뒤로는 상한가 초과가
|
||||
# "오늘 도달 불가 = 그냥 만료" 를 뜻하지만, **막지 않고 경고만** 한다
|
||||
# (2026-08-25 관리자님 결정). 경고는 승인 카드(card.format_card)와 웹
|
||||
# 미리보기·확인 팝업 3곳에 있다 — 여기서 거부로 바꾸지 말 것.
|
||||
# · 조건단가 < 현재가 — 발동가는 정의상 현재가보다 위다. 이 검사를 태우면 전부 거부된다.
|
||||
# 아래 매도 보유수량 검증은 그대로 받는다 — 지금 없는 종목을 예약해봐야 무의미하다.
|
||||
if request['side'] != 'SELL':
|
||||
return Result.REJECT('ARM_SELL_ONLY', '예약 트레일링은 매도 전용')
|
||||
if not request.get('trigger_price'):
|
||||
return Result.REJECT('ARM_TRIGGER', '발동가를 입력하세요')
|
||||
if request['side'] == 'BUY':
|
||||
basis = None
|
||||
if request['order_type'] == 'MARKET':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
"ledger": {
|
||||
"log_file": "state/order_log.jsonl",
|
||||
"events": ["card_issued", "pin_issued", "amended", "approved", "rejected", "expired", "canceled", "submitted", "filled", "partial", "failed", "dryrun", "cancel_submitted", "cancel_rejected", "cancel_confirmed", "cancel_unconfirmed_timeout", "modify_submitted", "modify_rejected"],
|
||||
"events": ["card_issued", "pin_issued", "amended", "approved", "rejected", "expired", "canceled", "submitted", "filled", "partial", "failed", "dryrun", "cancel_submitted", "cancel_rejected", "cancel_confirmed", "cancel_unconfirmed_timeout", "modify_submitted", "modify_rejected", "trailing_register_failed", "arm_registered", "arm_fired", "arm_fire_failed", "arm_canceled"],
|
||||
"mask_token_in_logs": true
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""예약 트레일링(TRAILING_ARM) propose·승인 경로 회귀 테스트 (2026-08-25).
|
||||
|
||||
datasource·sidecar·pin_store·ledger 를 mock 해서 키움을 전혀 건드리지 않는다.
|
||||
가장 중요한 확인은 **거부 케이스에서 카드가 발행되지 않는 것** — 카드가 나가면 PIN 한 번으로
|
||||
발동 조건까지 확정되므로, 걸러야 할 입력이 카드 단계를 통과하면 안 된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from orders import handler, ledger
|
||||
from orders import trailing as t
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
STEPS = [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, {'pct': 30, 'cum': 100}]
|
||||
|
||||
|
||||
def _md(cur=70000):
|
||||
return {
|
||||
'now': datetime(2026, 8, 25, 10, 0, tzinfo=KST),
|
||||
'is_holiday': False,
|
||||
'nxt_eligible': True,
|
||||
'current_price': cur,
|
||||
'prev_close': cur,
|
||||
'upper_limit': int(cur * 1.3),
|
||||
'lower_limit': int(cur * 0.7),
|
||||
'halt': False,
|
||||
'vi': False,
|
||||
'orderbook': {'asks': [{'price': cur + 100, 'qty': 200}],
|
||||
'bids': [{'price': cur, 'qty': 200}]},
|
||||
'broker_executions': [],
|
||||
'broker_query_error': None,
|
||||
'position_qty': 100,
|
||||
'balance_d2': 5_000_000,
|
||||
'stock_meta': None,
|
||||
}
|
||||
|
||||
|
||||
class ArmProposeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
|
||||
self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
|
||||
return_value=_md()).start()
|
||||
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
|
||||
fake = mock.MagicMock()
|
||||
fake.card_id, fake.pin, fake.expiry_seconds, fake.account_label = 'TEST', '1234', 120, '일반'
|
||||
self.issue = mock.patch.object(handler._pin_store, 'issue', return_value=fake).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def tearDown(self):
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def _propose(self, **kw):
|
||||
base = dict(account='일반', side='SELL', symbol='005930', symbol_name='삼성전자',
|
||||
qty=100, order_type='TRAILING_ARM', trail_steps=STEPS,
|
||||
trigger_price=80000)
|
||||
base.update(kw)
|
||||
return handler.propose_trade(**base)
|
||||
|
||||
def _assert_rejected(self, res, code):
|
||||
self.assertFalse(res['ok'], msg=res.get('message'))
|
||||
self.assertIn(code, res['message'])
|
||||
self.issue.assert_not_called() # 거부는 카드를 남기지 않아야 한다
|
||||
|
||||
# ── 통과 경로 ──
|
||||
def test_valid_arm_issues_card_with_definition_only(self):
|
||||
res = self._propose(min_sell_price=60000)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = self.issue.call_args[0][1]
|
||||
self.assertEqual(payload['order_type'], 'TRAILING_ARM')
|
||||
self.assertEqual(payload['trigger_price'], 80000)
|
||||
self.assertIsNone(payload['price']) # 나가는 주문이 없다
|
||||
# trail_steps 는 정의만, 미리보기는 별도 키 — 발동 경로가 옛 가격을 재사용하지 못하게.
|
||||
self.assertNotIn('cond_uv', payload['trail_steps'][0])
|
||||
self.assertIn('cond_uv', payload['trail_preview'][0])
|
||||
|
||||
def test_preview_uses_trigger_not_current_price(self):
|
||||
res = self._propose()
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = self.issue.call_args[0][1]
|
||||
expect = t.compute_step_levels(80000, t.normalize_steps(STEPS), None)
|
||||
self.assertEqual([s['cond_uv'] for s in payload['trail_preview']],
|
||||
[s['cond_uv'] for s in expect])
|
||||
|
||||
def test_trigger_above_todays_upper_limit_is_allowed(self):
|
||||
"""발동가는 가격제한폭 검증을 받지 않는다 — 실제 발주는 발동 시점 현재가로 계산된다.
|
||||
|
||||
arm 이 당일 유효가 된 뒤로는 '오늘 상한가 초과 = 오늘 도달 불가'지만 **막지 않고
|
||||
경고만** 한다(2026-08-25 관리자님 결정). 통과가 의도된 동작이라 고정해 둔다.
|
||||
"""
|
||||
res = self._propose(trigger_price=200000) # 현재가 70,000 · 상한가 91,000
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
|
||||
def test_trigger_above_upper_limit_warns_on_card(self):
|
||||
"""막지는 않되 승인 전에 '오늘 도달 불가'를 반드시 알린다."""
|
||||
res = self._propose(trigger_price=200000)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
self.assertIn('오늘 상한가', res['card_message'])
|
||||
self.assertIn('만료', res['card_message'])
|
||||
# 상한가 이내면 경고가 없어야 한다 (남발 금지)
|
||||
res2 = self._propose(trigger_price=85000) # 상한가 91,000 이내
|
||||
self.assertTrue(res2['ok'], msg=res2.get('message'))
|
||||
self.assertNotIn('오늘 상한가', res2['card_message'])
|
||||
|
||||
# ── 거부 경로 ──
|
||||
def test_buy_rejected(self):
|
||||
self._assert_rejected(self._propose(side='BUY'), 'ARM_SELL_ONLY')
|
||||
|
||||
def test_missing_trigger_rejected(self):
|
||||
self._assert_rejected(self._propose(trigger_price=None), 'ARM_TRIGGER')
|
||||
|
||||
def test_zero_trigger_rejected(self):
|
||||
self._assert_rejected(self._propose(trigger_price=0), 'ARM_TRIGGER')
|
||||
|
||||
def test_already_reached_rejected(self):
|
||||
self._assert_rejected(self._propose(trigger_price=70000), 'ARM_ALREADY_REACHED')
|
||||
self._assert_rejected(self._propose(trigger_price=65000), 'ARM_ALREADY_REACHED')
|
||||
|
||||
def test_floor_above_trigger_rejected(self):
|
||||
self._assert_rejected(self._propose(min_sell_price=85000), 'ARM_IMMEDIATE')
|
||||
|
||||
def test_budget_rejected(self):
|
||||
self._assert_rejected(self._propose(qty=None, budget=1_000_000), 'TRAIL_BUDGET')
|
||||
|
||||
def test_bad_steps_rejected(self):
|
||||
self._assert_rejected(self._propose(trail_steps=[{'pct': 20, 'cum': 20},
|
||||
{'pct': 10, 'cum': 50}]), 'TRAIL_STEPS')
|
||||
|
||||
def test_qty_too_small_rejected(self):
|
||||
# qty=0 은 '미입력'(None)이 아니라 '값 0' 이라 MISSING_QTY 를 지나 계단 배분에서 걸린다.
|
||||
self._assert_rejected(self._propose(qty=0), 'TRAIL_QTY')
|
||||
|
||||
def test_no_qty_rejected(self):
|
||||
self._assert_rejected(self._propose(qty=None), 'MISSING_QTY')
|
||||
|
||||
def test_duplicate_arm_rejected(self):
|
||||
t.register_arm(account='일반', symbol='005930', symbol_name='삼성전자', qty=10,
|
||||
trigger_price=90000, steps=t.normalize_steps(STEPS))
|
||||
self._assert_rejected(self._propose(), 'TRAIL_DUPLICATE')
|
||||
|
||||
def test_existing_trailing_blocks_arm(self):
|
||||
"""같은 계좌·종목에 활성 트레일링이 있으면 예약도 막는다 — 같은 주식을 두 번 팔 수 없다."""
|
||||
st = t.normalize_steps(STEPS)
|
||||
lv = t.compute_step_levels(70000, st, None)
|
||||
qtys = t.allocate_step_qty(100, [s['weight'] for s in st])
|
||||
legs = [dict(s, qty=q, ord_no=f'O{s["n"]}') for s, q in zip(lv, qtys)]
|
||||
t.register_steps(account='일반', symbol='005930', symbol_name='삼성전자',
|
||||
total_qty=100, peak=70000, steps=legs)
|
||||
self._assert_rejected(self._propose(), 'TRAIL_DUPLICATE')
|
||||
|
||||
def test_arm_blocks_plain_trailing_too(self):
|
||||
"""반대 방향도 막혀야 한다 — 대기 예약이 있으면 일반 트레일링을 새로 걸 수 없다."""
|
||||
t.register_arm(account='일반', symbol='005930', symbol_name='삼성전자', qty=10,
|
||||
trigger_price=90000, steps=t.normalize_steps(STEPS))
|
||||
res = handler.propose_trade(account='일반', side='SELL', symbol='005930',
|
||||
symbol_name='삼성전자', qty=100,
|
||||
order_type='TRAILING_STOP', trail_steps=STEPS)
|
||||
self._assert_rejected(res, 'TRAIL_DUPLICATE')
|
||||
|
||||
def test_holiday_now_actually_blocks(self):
|
||||
"""guards.is_today_holiday 의 dict 미인식 버그를 고친 뒤라야 이게 막힌다."""
|
||||
self.collect.return_value = dict(_md(), is_holiday=True)
|
||||
res = self._propose()
|
||||
self.assertFalse(res['ok'], msg=res.get('message'))
|
||||
self.assertIn('HOLIDAY', res['message'])
|
||||
|
||||
|
||||
class ArmRegisterTests(unittest.TestCase):
|
||||
"""PIN 승인 → 대기 예약 등록. 키움 호출이 0건이어야 한다."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
self.submit = mock.patch.object(handler.kiwoom_order, 'submit').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
self.card = mock.MagicMock()
|
||||
self.card.card_id = 'AB3D'
|
||||
self.card.payload = {
|
||||
'account': '일반', 'side': 'SELL', 'symbol': '005930', 'symbol_name': '삼성전자',
|
||||
'qty': 100, 'price': None, 'order_type': 'TRAILING_ARM', 'routing_suffix': '_AL',
|
||||
'trigger_price': 80000, 'trail_steps': t.normalize_steps(STEPS),
|
||||
'trail_preview': [], 'min_sell_price': 60000,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def test_register_places_no_order(self):
|
||||
res = handler._register_trailing_arm(self.card, dry_run=False)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
self.submit.assert_not_called()
|
||||
arms = t.list_arms()
|
||||
self.assertEqual(len(arms), 1)
|
||||
self.assertEqual((arms[0]['trigger_price'], arms[0]['card_id']), (80000, 'AB3D'))
|
||||
self.assertIn('발동가에 닿으면 PIN 재확인 없이', res['message'])
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
res = handler._register_trailing_arm(self.card, dry_run=True)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.submit.assert_not_called()
|
||||
|
||||
def test_cancel_removes_arm(self):
|
||||
handler._register_trailing_arm(self.card, dry_run=False)
|
||||
arm_id = t.list_arms()[0]['id']
|
||||
with mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None):
|
||||
res = handler.cancel_trailing_arm(arm_id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
|
||||
def test_cancel_warns_about_orphan_orders(self):
|
||||
"""발동 중 접수된 주문이 있으면 자동 취소하지 않고 주문번호를 알려준다."""
|
||||
handler._register_trailing_arm(self.card, dry_run=False)
|
||||
arm_id = t.list_arms()[0]['id']
|
||||
t.mark_arm_firing(arm_id)
|
||||
t.record_arm_leg(arm_id, {'n': 1, 'qty': 20, 'cond_uv': 72000, 'ord_uv': 71900,
|
||||
'ord_no': '0374065'})
|
||||
with mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None):
|
||||
res = handler.cancel_trailing_arm(arm_id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertIn('0374065', res['message'])
|
||||
self.assertIn('별도로 취소', res['message'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
unittest.main(verbosity=2)
|
||||
@@ -19,8 +19,12 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from orders import trailing as t
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
class NormalizeStepsTests(unittest.TestCase):
|
||||
"""입력한 누적 비중 → 내부 추가 비중."""
|
||||
@@ -358,6 +362,376 @@ console.log(JSON.stringify(out));
|
||||
f'비중환산 {cums}')
|
||||
|
||||
|
||||
class ArmPlanTests(unittest.TestCase):
|
||||
"""예약 트레일링 발동 계획 — 순수 함수라 키움 없이 전부 검증된다."""
|
||||
|
||||
def setUp(self):
|
||||
self.steps = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
|
||||
{'pct': 30, 'cum': 100}])
|
||||
|
||||
def _arm(self, qty=100, trigger=80000, floor=None):
|
||||
return {'id': 'ARM-TEST', 'account': '일반', 'symbol': '005930',
|
||||
'symbol_name': '삼성전자', 'qty': qty, 'trigger_price': trigger,
|
||||
'min_sell_price': floor, 'steps': self.steps}
|
||||
|
||||
def test_reach_is_upward_only(self):
|
||||
self.assertEqual(t.plan_arm_fire(self._arm(), 79999, 100)['reason'], 'not_reached')
|
||||
# 발동가 정확히 일치도 발동 — 판정이 >= 라서.
|
||||
self.assertEqual(t.plan_arm_fire(self._arm(), 80000, 100)['action'], 'fire')
|
||||
|
||||
def test_no_price_is_not_a_reach(self):
|
||||
self.assertEqual(t.plan_arm_fire(self._arm(), 0, 100)['reason'], 'no_price')
|
||||
|
||||
def test_peak_is_current_price_not_trigger(self):
|
||||
"""갭 상승으로 발동가를 훌쩍 넘겨 열리면 고점은 현재가여야 한다."""
|
||||
r = t.plan_arm_fire(self._arm(trigger=80000), 95000, 100)
|
||||
self.assertEqual(r['peak'], 95000)
|
||||
|
||||
def test_qty_shrinks_to_sellable(self):
|
||||
r = t.plan_arm_fire(self._arm(qty=100), 80000, 40)
|
||||
self.assertEqual((r['qty_used'], r['reduced']), (40, True))
|
||||
self.assertEqual(sum(s['qty'] for s in r['legs']), 40)
|
||||
|
||||
def test_no_sellable_drops_the_arm(self):
|
||||
self.assertEqual(t.plan_arm_fire(self._arm(), 80000, 0)['action'], 'drop')
|
||||
|
||||
def test_one_share_keeps_a_step_alive(self):
|
||||
r = t.plan_arm_fire(self._arm(qty=100), 80000, 1)
|
||||
self.assertEqual(r['action'], 'fire')
|
||||
self.assertEqual(sum(s['qty'] for s in r['legs']), 1)
|
||||
|
||||
def test_actual_stops_never_worse_than_preview(self):
|
||||
"""승인 화면은 발동가 기준으로 보여준다 — 실제 손절선이 그보다 낮으면 안 된다."""
|
||||
preview = t.compute_step_levels(80000, self.steps, None)
|
||||
for cur in (80000, 82000, 90000, 120000):
|
||||
legs = t.plan_arm_fire(self._arm(trigger=80000), cur, 100)['legs']
|
||||
for actual, shown in zip(legs, preview):
|
||||
self.assertGreaterEqual(actual['cond_uv'], shown['cond_uv'], f'현재가 {cur}')
|
||||
|
||||
def test_first_stop_always_below_current(self):
|
||||
"""발동 즉시 체결되면 트레일링이 아니다 — 무작위 시세로 불변식 확인."""
|
||||
rnd = random.Random(20260825)
|
||||
for _ in range(300):
|
||||
trigger = rnd.randrange(1000, 300000)
|
||||
cur = trigger + rnd.randrange(0, 50000)
|
||||
floor = rnd.choice([None, int(trigger * 0.7), int(trigger * 0.95)])
|
||||
r = t.plan_arm_fire(self._arm(trigger=trigger, floor=floor), cur, 100)
|
||||
if r['action'] == 'fire':
|
||||
self.assertLess(r['legs'][0]['cond_uv'], cur,
|
||||
f'trigger={trigger} cur={cur} floor={floor}')
|
||||
|
||||
def test_floor_ties_steps_without_merging(self):
|
||||
r = t.plan_arm_fire(self._arm(trigger=80000, floor=75000), 80000, 100)
|
||||
self.assertEqual(len(r['legs']), 3) # 병합하지 않는다
|
||||
self.assertEqual({s['cond_uv'] for s in r['legs']}, {75000})
|
||||
|
||||
|
||||
class ArmStateTests(unittest.TestCase):
|
||||
"""대기 예약 상태 — 예약과 한 파일을 공유하므로 서로를 지우지 않는지가 핵심."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
self.steps = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
|
||||
{'pct': 30, 'cum': 100}])
|
||||
self.arm = t.register_arm(account='일반', symbol='005930', symbol_name='삼성전자',
|
||||
qty=100, trigger_price=80000, steps=self.steps,
|
||||
min_sell_price=70000, card_id='AB3D')
|
||||
|
||||
def tearDown(self):
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def _legs(self, peak=82000, qty=100):
|
||||
lv = t.compute_step_levels(peak, self.steps, 70000)
|
||||
qtys = t.allocate_step_qty(qty, [s['weight'] for s in self.steps])
|
||||
return [dict(s, qty=q, ord_no=f'O{s["n"]:04d}') for s, q in zip(lv, qtys) if q > 0]
|
||||
|
||||
def test_arm_stores_definition_only(self):
|
||||
"""가격·라우팅을 굳혀두면 발동 시점 시세와 어긋난다."""
|
||||
a = t.get_arm(self.arm['id'])
|
||||
self.assertNotIn('cond_uv', a['steps'][0])
|
||||
self.assertNotIn('routing_suffix', a)
|
||||
self.assertIsNone(a['firing_at'])
|
||||
|
||||
def test_reservation_writes_do_not_wipe_arms(self):
|
||||
"""레거시 6개 호출부가 _write(reservations) 로 arms 를 지우면 안 된다."""
|
||||
res = t.register_steps(account='ISA', symbol='000660', symbol_name='SK하이닉스',
|
||||
total_qty=100, peak=500000, steps=self._legs(500000))
|
||||
for fn in (lambda: t.commit_peak(res['id'], 510000),
|
||||
lambda: t.commit_step_modify(res['id'], 1, 'X0001', 510000, 460000, 459000),
|
||||
lambda: t.should_notify_failure(res['id'], 9e9, 1800),
|
||||
lambda: t.remove_step(res['id'], 3, 'test')):
|
||||
fn()
|
||||
self.assertEqual(len(t.list_arms()), 1, '대기 예약이 사라졌다')
|
||||
|
||||
def test_firing_marker_blocks_refire(self):
|
||||
self.assertTrue(t.mark_arm_firing(self.arm['id']))
|
||||
self.assertFalse(t.mark_arm_firing(self.arm['id']))
|
||||
|
||||
def test_promote_is_atomic(self):
|
||||
legs = self._legs()
|
||||
out = t.promote_arm(self.arm['id'], 82000, legs, '_AL')
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
r = t.get(out['reservation']['id'])
|
||||
self.assertEqual((r['peak'], r['entry_peak'], r['routing_suffix']), (82000, 82000, '_AL'))
|
||||
self.assertEqual((r['min_sell_price'], r['card_id']), (70000, 'AB3D'))
|
||||
self.assertEqual(r['qty'], sum(s['qty'] for s in legs))
|
||||
|
||||
def test_promote_unknown_arm_is_noop(self):
|
||||
self.assertIsNone(t.promote_arm('ARM-NOPE', 82000, self._legs()))
|
||||
self.assertEqual(len(t.list_arms()), 1)
|
||||
|
||||
def test_record_leg_survives_for_crash_diagnosis(self):
|
||||
t.mark_arm_firing(self.arm['id'])
|
||||
t.record_arm_leg(self.arm['id'], self._legs()[0])
|
||||
self.assertEqual(t.get_arm(self.arm['id'])['fired_legs'][0]['ord_no'], 'O0001')
|
||||
|
||||
def test_duplicate_lookup_covers_both_kinds(self):
|
||||
self.assertIsNotNone(t.find_arm_by_symbol('일반', '005930'))
|
||||
self.assertIsNone(t.find_arm_by_symbol('ISA', '005930'))
|
||||
|
||||
def test_remove_arm(self):
|
||||
self.assertEqual(t.remove_arm(self.arm['id'], 'manual')['removed_reason'], 'manual')
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.assertIsNone(t.remove_arm(self.arm['id']))
|
||||
|
||||
|
||||
class ArmExpiryTests(unittest.TestCase):
|
||||
"""대기 예약 수명 = 등록일 당일 (2026-08-25 무기한에서 변경).
|
||||
|
||||
거래소가 지워주는 스톱주문과 달리 우리 파일에만 있으므로 우리가 만료를 재야 한다.
|
||||
"""
|
||||
|
||||
def _arm(self, created):
|
||||
return {'id': 'ARM-T', 'created_at': created, 'trigger_price': 80000}
|
||||
|
||||
def test_same_day_during_session_is_alive(self):
|
||||
for hh, mm in ((8, 0), (9, 0), (9, 0), (12, 30), (15, 25), (19, 59)):
|
||||
now = datetime(2026, 8, 25, hh, mm, tzinfo=KST)
|
||||
self.assertFalse(t.is_arm_expired(self._arm('2026-08-25T09:30:00+09:00'), now),
|
||||
f'{hh:02d}:{mm:02d} 에 만료로 잡혔다')
|
||||
|
||||
def test_opening_gap_is_not_expiry(self):
|
||||
"""09:00:00~09:00:30 은 session_at 이 CLOSED 를 주는 틈 — 여기서 만료되면 안 된다."""
|
||||
now = datetime(2026, 8, 25, 9, 0, 10, tzinfo=KST)
|
||||
self.assertFalse(t.is_arm_expired(self._arm('2026-08-25T08:30:00+09:00'), now))
|
||||
|
||||
def test_after_session_end_same_day_expires(self):
|
||||
now = datetime(2026, 8, 25, 20, 0, tzinfo=KST)
|
||||
self.assertTrue(t.is_arm_expired(self._arm('2026-08-25T09:30:00+09:00'), now))
|
||||
|
||||
def test_next_day_expires(self):
|
||||
now = datetime(2026, 8, 26, 8, 0, tzinfo=KST)
|
||||
self.assertTrue(t.is_arm_expired(self._arm('2026-08-25T09:30:00+09:00'), now))
|
||||
|
||||
def test_missing_created_at_is_kept(self):
|
||||
"""날짜를 모르면 지우지 않는다 — 조용히 사라지는 쪽이 더 위험하다."""
|
||||
self.assertFalse(t.is_arm_expired({'id': 'ARM-X', 'trigger_price': 1},
|
||||
datetime(2026, 8, 26, 9, 0, tzinfo=KST)))
|
||||
|
||||
|
||||
class ArmMonitorTests(unittest.TestCase):
|
||||
"""감시 루프의 발동 단계 — 실주문 경로라 stub 으로 전부 막고 호출만 관찰한다."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
|
||||
import trailing_monitor as tm
|
||||
self.tm = tm
|
||||
self.sent, self.submits = [], []
|
||||
self.state = {'quote': 70000, 'able': 100, 'disabled': False}
|
||||
self._orig_fns = (tm.kc.get_open_orders, tm.kc.get_watchlist_quotes, tm.kc.get_positions,
|
||||
tm.handler.place_trailing_legs, tm.send_telegram,
|
||||
tm.sidecar.is_disabled, tm.ledger.append,
|
||||
tm.datasource.nxt_eligible, tm.guards.determine_routing)
|
||||
tm.kc.get_open_orders = lambda acct, side=None: []
|
||||
tm.kc.get_watchlist_quotes = lambda codes, exchange='AL': {
|
||||
c: {'price': self.state['quote']} for c in codes}
|
||||
tm.kc.get_positions = lambda acct, exchange='KRX': [
|
||||
{'code': '005930', 'qty': self.state['able'], 'trde_able_qty': self.state['able']}]
|
||||
tm.handler.place_trailing_legs = self._fake_place
|
||||
tm.send_telegram = lambda msg, parse_mode=None: self.sent.append(msg)
|
||||
tm.sidecar.is_disabled = lambda: self.state['disabled']
|
||||
tm.ledger.append = lambda ev, payload: None
|
||||
tm.datasource.nxt_eligible = lambda code: True
|
||||
tm.guards.determine_routing = lambda now, nxt, force: '_AL'
|
||||
|
||||
self.steps = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
|
||||
{'pct': 30, 'cum': 100}])
|
||||
self.arm = t.register_arm(account='일반', symbol='005930', symbol_name='삼성전자',
|
||||
qty=100, trigger_price=80000, steps=self.steps,
|
||||
card_id='AB3D')
|
||||
|
||||
def tearDown(self):
|
||||
(self.tm.kc.get_open_orders, self.tm.kc.get_watchlist_quotes, self.tm.kc.get_positions,
|
||||
self.tm.handler.place_trailing_legs, self.tm.send_telegram,
|
||||
self.tm.sidecar.is_disabled, self.tm.ledger.append,
|
||||
self.tm.datasource.nxt_eligible, self.tm.guards.determine_routing) = self._orig_fns
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def _fake_place(self, account, symbol, steps, routing_suffix, card_id, dry_run,
|
||||
on_placed=None):
|
||||
placed = []
|
||||
for s in steps:
|
||||
self.submits.append({'n': s['n'], 'qty': s['qty'], 'cond': s['cond_uv'],
|
||||
'routing': routing_suffix})
|
||||
leg = dict(s, ord_no=f'O{s["n"]:04d}')
|
||||
placed.append(leg)
|
||||
if on_placed:
|
||||
on_placed(leg)
|
||||
return placed, [], []
|
||||
|
||||
def test_below_trigger_places_nothing(self):
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [])
|
||||
self.assertEqual(len(t.list_arms()), 1)
|
||||
|
||||
def test_reach_fires_and_promotes(self):
|
||||
self.state['quote'] = 81000
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(len(self.submits), 3)
|
||||
self.assertEqual([s['routing'] for s in self.submits], ['_AL'] * 3)
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.assertEqual(len(t.list_active()), 1)
|
||||
self.assertEqual(t.list_active()[0]['peak'], 81000) # 고점 = 발동 시점 현재가
|
||||
self.assertIn('발동', self.sent[0])
|
||||
|
||||
def test_sidecar_blocks_before_marking(self):
|
||||
"""마킹 후 막히면 예약이 갇힌다 — 사이드카는 마킹 전에 봐야 한다."""
|
||||
self.state['quote'] = 81000
|
||||
self.state['disabled'] = True
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [])
|
||||
self.assertIsNone(t.get_arm(self.arm['id'])['firing_at'])
|
||||
self.assertEqual(len(t.list_arms()), 1)
|
||||
|
||||
def test_stuck_arm_is_not_refired(self):
|
||||
t.mark_arm_firing(self.arm['id'])
|
||||
self.state['quote'] = 81000
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [], '발동 처리 중단 예약을 재발주했다')
|
||||
self.assertEqual(len(t.list_arms()), 1)
|
||||
|
||||
def _age_arm(self, days=1):
|
||||
"""등록일을 과거로 밀어 만료 상태를 만든다."""
|
||||
past = (datetime.now(KST) - timedelta(days=days)).isoformat(timespec='seconds')
|
||||
arms = t.list_arms()
|
||||
arms[0]['created_at'] = past
|
||||
with t._FileLock(t._LOCK_FILE):
|
||||
t._write(arms=arms)
|
||||
|
||||
def test_expired_arm_is_purged_not_fired(self):
|
||||
"""어제 예약이 오늘 시세로 발동하면 안 된다 — 지우고 알린다."""
|
||||
self._age_arm()
|
||||
self.state['quote'] = 81000 # 발동가 도달 상태로 만들어도
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [], '만료 예약을 발주했다')
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.assertEqual(len(t.list_active()), 0)
|
||||
self.assertTrue(any('만료' in m for m in self.sent), self.sent)
|
||||
|
||||
def test_expiry_purge_survives_quote_failure(self):
|
||||
"""시세 조회가 실패해도 만료 정리는 돼야 한다 (정리가 시세보다 앞이라서)."""
|
||||
self._age_arm()
|
||||
self.tm.kc.get_watchlist_quotes = lambda codes, exchange='AL': (
|
||||
_ for _ in ()).throw(RuntimeError('시세 실패'))
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
|
||||
def test_expiry_notice_is_batched(self):
|
||||
"""여러 건이 같이 만료돼도 텔레그램은 한 통."""
|
||||
t.register_arm(account='일반', symbol='000660', symbol_name='SK하이닉스', qty=10,
|
||||
trigger_price=500000, steps=self.steps)
|
||||
past = (datetime.now(KST) - timedelta(days=3)).isoformat(timespec='seconds')
|
||||
arms = t.list_arms()
|
||||
for a in arms:
|
||||
a['created_at'] = past
|
||||
with t._FileLock(t._LOCK_FILE):
|
||||
t._write(arms=arms)
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.assertEqual(len([m for m in self.sent if '만료' in m]), 1, self.sent)
|
||||
|
||||
def test_dry_run_does_not_purge(self):
|
||||
self._age_arm()
|
||||
self.tm.check(force=True, dry_run=True)
|
||||
self.assertEqual(len(t.list_arms()), 1)
|
||||
self.assertEqual(self.sent, [])
|
||||
|
||||
def test_open_order_query_failure_holds_the_arm(self):
|
||||
"""미체결 매도 수량을 모르면 발주하지 않는다 — 0 으로 단정하면 과다 매도가 된다."""
|
||||
self.state['quote'] = 81000
|
||||
self.tm.kc.get_open_orders = lambda acct, side=None: (_ for _ in ()).throw(
|
||||
RuntimeError('조회 실패'))
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [])
|
||||
self.assertEqual(len(t.list_arms()), 1)
|
||||
self.assertIsNone(t.get_arm(self.arm['id'])['firing_at'])
|
||||
|
||||
def test_pending_sell_reduces_sellable(self):
|
||||
"""같은 종목에 미체결 매도가 있으면 그만큼 빼고 발주한다."""
|
||||
self.state['quote'] = 81000
|
||||
self.tm.kc.get_open_orders = lambda acct, side=None: [
|
||||
{'code': '005930', 'side': 'SELL', 'unfilled_qty': 60, 'ord_no': 'X1',
|
||||
'routing_suffix': ''}]
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(sum(s['qty'] for s in self.submits), 40)
|
||||
|
||||
def test_position_query_failure_holds_instead_of_dropping(self):
|
||||
self.state['quote'] = 81000
|
||||
self.tm.kc.get_positions = lambda acct, exchange='KRX': (_ for _ in ()).throw(
|
||||
RuntimeError('조회 실패'))
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [])
|
||||
self.assertEqual(len(t.list_arms()), 1, '조회 실패로 예약을 지웠다')
|
||||
|
||||
def test_sold_out_position_drops_the_arm(self):
|
||||
self.state['quote'] = 81000
|
||||
self.state['able'] = 0
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(self.submits, [])
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.assertIn('삭제', self.sent[0])
|
||||
|
||||
def test_partial_holding_shrinks_the_order(self):
|
||||
self.state['quote'] = 81000
|
||||
self.state['able'] = 40
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(sum(s['qty'] for s in self.submits), 40)
|
||||
self.assertIn('줄여', self.sent[0])
|
||||
|
||||
def test_arm_failure_does_not_break_stop_raising(self):
|
||||
"""arm 코드가 터져도 실주문을 지키는 손절선 상향은 완료돼야 한다."""
|
||||
lv = t.compute_step_levels(5500, self.steps, None)
|
||||
qtys = t.allocate_step_qty(300, [s['weight'] for s in self.steps])
|
||||
legs = [dict(s, qty=q, ord_no=f'A{s["n"]:04d}') for s, q in zip(lv, qtys)]
|
||||
res = t.register_steps(account='일반', symbol='381620', symbol_name='제닉스로보틱스',
|
||||
total_qty=300, peak=5500, steps=legs)
|
||||
self.tm.kc.get_open_orders = lambda acct, side=None: [
|
||||
{'ord_no': s['ord_no'], 'unfilled_qty': s['qty'], 'routing_suffix': ''} for s in legs]
|
||||
modified = []
|
||||
self.tm.kiwoom_order.modify_order = lambda **kw: (
|
||||
modified.append(kw['orig_ord_no']) or {'ok': True, 'new_ord_no': 'N1'})
|
||||
self.state['quote'] = 81000 # 두 종목 모두 고점 갱신 + arm 도달
|
||||
self.tm.handler.place_trailing_legs = lambda **kw: (_ for _ in ()).throw(
|
||||
RuntimeError('arm 폭발'))
|
||||
try:
|
||||
rc = self.tm.check(force=True)
|
||||
finally:
|
||||
self.tm.kiwoom_order.modify_order = self._orig_fns[3] and __import__(
|
||||
'orders.kiwoom_order', fromlist=['modify_order']).modify_order
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(len(modified), 3, '손절선 상향이 arm 예외에 휩쓸렸다')
|
||||
self.assertEqual(t.get(res['id'])['peak'], 81000)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -22,6 +22,30 @@
|
||||
|
||||
상태는 파일(state/trailing_stops.json)에 저장 — 웹·감시 루프가 각각 다른 프로세스라
|
||||
같은 예약 목록을 봐야 한다. 동시성은 fcntl flock 으로 직렬화 (pin.py 와 같은 패턴).
|
||||
|
||||
── 대기 예약 (arms) — 예약 트레일링, 2026-08-25 ──────────────────────────────
|
||||
|
||||
같은 파일에 `arms` 키가 하나 더 있다. **발동가에 닿으면 그때 트레일링을 시작하는 예약**으로,
|
||||
`reservations`(이미 키움에 주문이 나간 것)와 성질이 정반대다:
|
||||
|
||||
reservations : 주문이 키움에 있음 → 감시 루프가 죽어도 손절선은 살아있음, 장 마감에 소멸
|
||||
arms : 주문이 없고 이 파일에만 있음 → 감시가 죽으면 발동 안 함, **당일만 유효**
|
||||
|
||||
⚠️ **arm 도 스톱주문과 같은 수명(등록일 당일)이다** — 2026-08-25 관리자님 결정으로 무기한에서
|
||||
바뀌었다. 거래소가 지워주는 스톱주문과 달리 arm 은 우리 파일에만 있으니 우리가 지운다:
|
||||
`is_arm_expired` 판정 + 감시 루프의 다음 거래일 첫 사이클 정리.
|
||||
|
||||
⚠️ **두 갈래를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적
|
||||
전이여야 해서다.** promote_arm 이 락 하나 안에서 os.replace 1회로 끝낸다. 파일을 쪼개면
|
||||
락을 중첩해야 하고(데드락), 중간에 죽으면 둘 다 존재하거나 둘 다 사라진다.
|
||||
|
||||
⚠️ 그 대가로 **_write 는 None 인 쪽을 디스크에서 다시 읽어 보존**한다. 이 계약이 깨지면
|
||||
reservations 쪽 쓰기 6곳이 arms 를 조용히 전멸시킨다 (테스트로 고정해 둠).
|
||||
|
||||
⚠️ arm 의 steps 에는 **가격·주수를 저장하지 않는다**(정의 n/pct/cum/weight 만). routing_suffix
|
||||
도 저장하지 않는다. 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 무기한 대기라 등록 시점
|
||||
세션(KRX)과 발동 시점 세션(NXT)이 달라 옛 suffix 로 발주하면 거부된다. 둘 다 발동 시점에
|
||||
plan_arm_fire / guards.determine_routing 으로 새로 계산한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,7 +58,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .guards import tick_size
|
||||
from .guards import after_trading_day, tick_size
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
STATE_FILE = WORKSPACE_ROOT / 'state' / 'trailing_stops.json'
|
||||
@@ -58,10 +82,18 @@ ORD_UV_GAP_TICKS = 2
|
||||
MAX_STEPS = 5
|
||||
|
||||
|
||||
# 혼동되는 글자(0/O, 1/I/l)를 뺀 영숫자 — 사람이 눈으로 읽고 옮겨 적는 id 라서.
|
||||
_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(KST).isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def _new_id(prefix: str) -> str:
|
||||
return prefix + ''.join(secrets.choice(_ID_CHARS) for _ in range(4))
|
||||
|
||||
|
||||
def floor_to_tick(price: int) -> int:
|
||||
"""호가단위로 내림. 매도 조건단가·지정가는 내림이 안전(더 빨리 발동/체결)."""
|
||||
t = tick_size(price)
|
||||
@@ -213,21 +245,48 @@ class _FileLock:
|
||||
_LOCK_FILE = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock')
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
def _read_doc() -> dict:
|
||||
"""상태파일 전체. `reservations`(키움에 주문이 나가 있는 예약) 와
|
||||
`arms`(발동가 도달을 기다리는 대기 예약) 두 갈래가 한 파일에 들어 있다.
|
||||
|
||||
한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록" 이라는 하나의 원자적 전이이기
|
||||
때문이다. 파일이 둘이면 락을 중첩해야 하고, 중간에 죽으면 둘 다 존재하거나 둘 다
|
||||
사라진다.
|
||||
"""
|
||||
if not STATE_FILE.exists():
|
||||
return []
|
||||
return {'reservations': [], 'arms': []}
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return data.get('reservations') or []
|
||||
return {'reservations': [], 'arms': []}
|
||||
if not isinstance(data, dict):
|
||||
return {'reservations': [], 'arms': []}
|
||||
return {'reservations': data.get('reservations') or [],
|
||||
'arms': data.get('arms') or []}
|
||||
|
||||
|
||||
def _write(reservations: list) -> None:
|
||||
def _read() -> list:
|
||||
return _read_doc()['reservations']
|
||||
|
||||
|
||||
def _read_arms() -> list:
|
||||
return _read_doc()['arms']
|
||||
|
||||
|
||||
def _write(reservations: Optional[list] = None, arms: Optional[list] = None) -> None:
|
||||
"""None 인 쪽은 디스크의 현재 값을 유지한다.
|
||||
|
||||
⚠️ 두 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
|
||||
반드시 _FileLock 안에서 부를 것 — 여기서 다시 읽어 병합한다.
|
||||
"""
|
||||
doc = _read_doc()
|
||||
if reservations is not None:
|
||||
doc['reservations'] = reservations
|
||||
if arms is not None:
|
||||
doc['arms'] = arms
|
||||
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')
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
os.replace(tmp, STATE_FILE)
|
||||
|
||||
|
||||
@@ -251,28 +310,20 @@ def find_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def register_steps(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
peak: int, steps: list, routing_suffix: str = '',
|
||||
card_id: Optional[str] = None,
|
||||
min_sell_price: Optional[int] = None) -> dict:
|
||||
"""키움에 계단별 스톱주문이 접수된 직후 호출 — 레그 목록을 예약 1건으로 등록.
|
||||
def _build_reservation(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
peak: int, steps: list, routing_suffix: str,
|
||||
card_id: Optional[str], min_sell_price: Optional[int]) -> dict:
|
||||
"""접수된 레그 목록 → 예약 dict (파일 접근 없는 순수 조립).
|
||||
|
||||
steps 각 항목은 접수 결과까지 채워져 있어야 한다:
|
||||
{'n', 'pct', 'cum', 'weight', 'qty', 'ord_no', 'cond_uv', 'ord_uv'}
|
||||
|
||||
접수에 실패한 레그는 호출측이 빼고 넘긴다 — 여기 등록된 것은 전부 키움에 살아있는
|
||||
주문이어야 감시 루프의 생존 판정이 성립한다.
|
||||
|
||||
초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이
|
||||
현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다.
|
||||
register_steps(등록 즉시 트레일링)와 promote_arm(대기 예약 발동)이 공유한다.
|
||||
"""
|
||||
if not steps:
|
||||
raise ValueError('등록할 계단이 없음 — 접수 성공한 레그가 하나도 없다')
|
||||
for s in steps:
|
||||
if not s.get('ord_no'):
|
||||
raise ValueError(f'{s.get("n")}단계 ord_no 없음 — 접수 확인된 주문만 등록')
|
||||
res = {
|
||||
'id': 'TRL-' + ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4)),
|
||||
return {
|
||||
'id': _new_id('TRL-'),
|
||||
'account': account,
|
||||
'symbol': symbol,
|
||||
'symbol_name': symbol_name,
|
||||
@@ -289,6 +340,25 @@ def register_steps(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
'updated_at': _now_iso(),
|
||||
'entry_peak': peak,
|
||||
}
|
||||
|
||||
|
||||
def register_steps(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
peak: int, steps: list, routing_suffix: str = '',
|
||||
card_id: Optional[str] = None,
|
||||
min_sell_price: Optional[int] = None) -> dict:
|
||||
"""키움에 계단별 스톱주문이 접수된 직후 호출 — 레그 목록을 예약 1건으로 등록.
|
||||
|
||||
steps 각 항목은 접수 결과까지 채워져 있어야 한다:
|
||||
{'n', 'pct', 'cum', 'weight', 'qty', 'ord_no', 'cond_uv', 'ord_uv'}
|
||||
|
||||
접수에 실패한 레그는 호출측이 빼고 넘긴다 — 여기 등록된 것은 전부 키움에 살아있는
|
||||
주문이어야 감시 루프의 생존 판정이 성립한다.
|
||||
|
||||
초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이
|
||||
현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다.
|
||||
"""
|
||||
res = _build_reservation(account, symbol, symbol_name, total_qty, peak, steps,
|
||||
routing_suffix, card_id, min_sell_price)
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
reservations.append(res)
|
||||
@@ -399,3 +469,249 @@ def remove(res_id: str, reason: str = '') -> Optional[dict]:
|
||||
gone['removed_reason'] = reason
|
||||
return gone
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 대기 예약 (arm) — 발동가에 닿으면 그때 트레일링을 등록한다
|
||||
#
|
||||
# 트레일링 스톱은 등록 즉시 계단별 스톱주문이 키움에 나가고 그 순간의 현재가가 고점이
|
||||
# 된다. 아직 오르지 않은 종목엔 그게 불리해서, 발동가를 미리 정해두고 현재가가 거기에
|
||||
# 올라와 닿는 순간 그때의 현재가를 고점으로 트레일링을 시작하는 갈래를 둔다.
|
||||
#
|
||||
# 대기 중인 예약은 키움이 아니라 이 파일에만 있다 — 거래소가 지워주지 않으므로 **우리가**
|
||||
# 스톱주문과 같은 수명(등록일 당일)을 강제한다(is_arm_expired). 2026-08-25 관리자님 결정.
|
||||
# 대신 감시 프로세스가 죽어 있으면 발동하지 않는다 — 이건 스톱주문과 정반대 성질이다.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_arms() -> list:
|
||||
"""대기 예약 전체 (락 없이 읽기 — 감시 루프의 스냅샷용)."""
|
||||
return _read_arms()
|
||||
|
||||
|
||||
def get_arm(arm_id: str) -> Optional[dict]:
|
||||
for a in _read_arms():
|
||||
if a['id'] == arm_id:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def find_arm_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||||
"""같은 계좌·종목의 대기 예약. 중복 등록 차단용."""
|
||||
for a in _read_arms():
|
||||
if a['account'] == account and a['symbol'] == symbol:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def register_arm(account: str, symbol: str, symbol_name: str, qty: int,
|
||||
trigger_price: int, steps: list,
|
||||
min_sell_price: Optional[int] = None,
|
||||
card_id: Optional[str] = None) -> dict:
|
||||
"""PIN 승인 직후 호출 — 발동가 도달을 기다리는 예약 1건 등록. 키움 주문은 아직 없다.
|
||||
|
||||
steps 는 normalize_steps 결과, 즉 **정의만**(n/pct/cum/weight)이다. 조건단가·지정가·
|
||||
계단별 주수는 저장하지 않는다 — 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고 그 사이
|
||||
보유수량이 줄었을 수도 있어서, 발동 시점의 현재가와 매도가능 수량으로 다시 계산해야
|
||||
실제 시세와 맞는다.
|
||||
|
||||
routing_suffix 도 저장하지 않는다. 당일 안에서도 등록 시점 세션(정규장)과 발동 시점
|
||||
세션(NXT 애프터)이 다를 수 있고, 옛 suffix 로 발주하면 거부된다.
|
||||
"""
|
||||
if not steps:
|
||||
raise ValueError('계단이 비어 있음')
|
||||
if trigger_price <= 0:
|
||||
raise ValueError('발동가가 0 이하')
|
||||
arm = {
|
||||
'id': _new_id('ARM-'),
|
||||
'account': account,
|
||||
'symbol': symbol,
|
||||
'symbol_name': symbol_name,
|
||||
'qty': qty,
|
||||
'trigger_price': trigger_price,
|
||||
'min_sell_price': min_sell_price,
|
||||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
|
||||
for s in steps],
|
||||
'card_id': card_id,
|
||||
'created_at': _now_iso(),
|
||||
'updated_at': _now_iso(),
|
||||
'firing_at': None,
|
||||
}
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
arms.append(arm)
|
||||
_write(arms=arms)
|
||||
return arm
|
||||
|
||||
|
||||
def remove_arm(arm_id: str, reason: str = '') -> Optional[dict]:
|
||||
"""대기 예약 제거 (취소·수량 소멸·계단 소멸). 반환값은 제거된 arm — 알림 메시지용."""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for i, a in enumerate(arms):
|
||||
if a['id'] == arm_id:
|
||||
gone = arms.pop(i)
|
||||
_write(arms=arms)
|
||||
gone['removed_reason'] = reason
|
||||
return gone
|
||||
return None
|
||||
|
||||
|
||||
def mark_arm_firing(arm_id: str) -> bool:
|
||||
"""발주 직전 마킹. 이미 마킹돼 있으면 False (재발동 차단).
|
||||
|
||||
키움 발주가 성공한 뒤 promote_arm 전에 프로세스가 죽으면 다음 사이클이 같은 종목을
|
||||
또 판다. 창은 1초 남짓이지만 결과가 이중 매도라, 발주를 시작했다는 사실을 먼저
|
||||
디스크에 남긴다.
|
||||
|
||||
⚠️ 마킹이 남아있는 arm 은 **자동으로 재시도하지 않는다** — 주문이 나갔는지 알 수 없는
|
||||
상태에서 다시 내는 게 바로 이중 매도다. 감시 루프는 알림만 보내고, 관리자님이 실제
|
||||
미체결을 확인한 뒤 자산웹에서 취소한다.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for a in arms:
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
if a.get('firing_at'):
|
||||
return False
|
||||
a['firing_at'] = _now_iso()
|
||||
a['updated_at'] = _now_iso()
|
||||
_write(arms=arms)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_arm_expired(arm: dict, now: Optional[datetime] = None) -> bool:
|
||||
"""등록일의 거래 세션이 끝났으면 만료 (2026-08-25 관리자님 결정으로 무기한 → 당일).
|
||||
|
||||
트레일링 스톱주문이 장 마감에 소멸하는 것과 **같은 수명**을 갖게 한 것이다. 대기 예약은
|
||||
키움이 아니라 우리 파일에만 있어 거래소가 지워주지 않으므로, 우리가 지워야 한다.
|
||||
|
||||
판정은 두 갈래:
|
||||
· 등록일이 오늘보다 이전 → 만료 (밤새 넘긴 것·주말 넘긴 것)
|
||||
· 등록일이 오늘이고 이미 20:00 지남 → 만료
|
||||
|
||||
⚠️ 감시 루프는 08:00~19:59 에만 도므로 **20:00 직후에 지워지지는 않는다.** 실제 정리는
|
||||
다음 거래일 첫 사이클이다. 그 사이(장외)에 자산웹을 열면 만료 상태로 표시된다 —
|
||||
화면이 거짓말하지 않게 웹도 같은 판정을 쓴다.
|
||||
"""
|
||||
now = now or datetime.now(KST)
|
||||
created = (arm.get('created_at') or '')[:10]
|
||||
if not created:
|
||||
return False # 날짜를 모르면 함부로 지우지 않는다
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
if created < today:
|
||||
return True
|
||||
return created == today and after_trading_day(now)
|
||||
|
||||
|
||||
def record_arm_leg(arm_id: str, leg: dict) -> bool:
|
||||
"""발동 중 레그 하나가 접수될 때마다 즉시 기록 (write-ahead).
|
||||
|
||||
firing_at 만으로는 중간에 죽었을 때 "몇 단이 실제로 나갔는지" 를 알 수 없다. 키움엔 우리
|
||||
주문을 식별하는 client order id 가 없어 미체결 목록만으로는 관리자님이 직접 낸 스톱주문과
|
||||
구분되지 않는다 — 그래서 주문번호를 우리가 적어둔다.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for a in arms:
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
a.setdefault('fired_legs', []).append(
|
||||
{'n': leg['n'], 'qty': leg['qty'], 'cond_uv': leg['cond_uv'],
|
||||
'ord_uv': leg['ord_uv'], 'ord_no': leg.get('ord_no', '')})
|
||||
a['updated_at'] = _now_iso()
|
||||
_write(arms=arms)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def plan_arm_fire(arm: dict, cur_price: int, sellable_qty: int) -> dict:
|
||||
"""대기 예약 + 현재가 + 매도가능 수량 → 발주 계획 (순수 함수 — 단위테스트 대상).
|
||||
|
||||
반환 {'action', 'reason', 'peak', 'qty_used', 'reduced', 'legs'}
|
||||
fire : legs 를 그대로 발주한다
|
||||
skip : 아직 아니다 (미도달·시세 없음) — 아무것도 하지 않는다
|
||||
drop : 발동할 수 없다 (매도가능 0주·수량 부족) → 예약 삭제 + 알림
|
||||
|
||||
고점(peak)은 발동 시점 현재가다. 발동가가 아니라 현재가를 쓰는 이유는 갭 상승으로 발동가를
|
||||
훌쩍 넘겨 열릴 수 있어서다 — 현재가 >= 발동가 이므로 손절선은 승인 시점 미리보기보다 항상
|
||||
같거나 높다(관리자님에게 불리해지지 않는 방향).
|
||||
|
||||
수량은 min(예약 수량, 매도가능)이다. 대기 중 관리자님이 직접 팔았으면 그만큼 줄여 재배분한다
|
||||
— 대기 예약이 보유분을 잠그지 않기로 한 결정의 뒷수습이 여기다.
|
||||
"""
|
||||
def _out(action, reason):
|
||||
return {'action': action, 'reason': reason, 'peak': cur_price,
|
||||
'qty_used': 0, 'reduced': False, 'legs': []}
|
||||
|
||||
if cur_price <= 0:
|
||||
return _out('skip', 'no_price')
|
||||
if cur_price < arm['trigger_price']:
|
||||
return _out('skip', 'not_reached')
|
||||
if sellable_qty <= 0:
|
||||
return _out('drop', 'no_sellable')
|
||||
|
||||
qty_used = min(arm['qty'], sellable_qty)
|
||||
steps = arm.get('steps') or []
|
||||
leveled = compute_step_levels(cur_price, steps, arm.get('min_sell_price'))
|
||||
qtys = allocate_step_qty(qty_used, [s['weight'] for s in steps])
|
||||
# 0주가 된 계단은 발주할 수 없으니 떨어낸다. 단계 번호(n)는 유지해 몇 단계가 빠졌는지 보인다.
|
||||
legs = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
|
||||
if not legs:
|
||||
return _out('drop', 'qty_too_small')
|
||||
# 실주문 직전 마지막 안전망. propose 가 발동가 기준으로 이미 걸렀고 현재가 >= 발동가,
|
||||
# 최저 매도가는 승인 후 불변이라 정상 경로에선 도달하지 않는다. 그래도 남기는 이유는
|
||||
# 여기를 지나면 되돌릴 수 없는 매도 주문이 나가기 때문이다.
|
||||
if legs[0]['cond_uv'] >= cur_price:
|
||||
return _out('skip', 'immediate')
|
||||
return {'action': 'fire', 'reason': '', 'peak': cur_price, 'qty_used': qty_used,
|
||||
'reduced': qty_used < arm['qty'], 'legs': legs}
|
||||
|
||||
|
||||
def promote_arm(arm_id: str, peak: int, steps: list,
|
||||
routing_suffix: str = '') -> Optional[dict]:
|
||||
"""발동 완료 — 대기 예약을 지우고 같은 내용의 트레일링 예약을 등록 (한 락 안에서 원자적).
|
||||
|
||||
steps 는 접수 결과까지 채워진 레그 목록(register_steps 와 같은 형식).
|
||||
둘을 따로 하면 중간에 죽었을 때 arm 과 예약이 동시에 존재하거나 둘 다 사라진다.
|
||||
|
||||
반환 {'arm': 제거된 arm, 'reservation': 새 예약} — 알림 메시지 조립용.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
doc = _read_doc()
|
||||
arms = doc['arms']
|
||||
for i, a in enumerate(arms):
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
res = _build_reservation(
|
||||
a['account'], a['symbol'], a['symbol_name'],
|
||||
sum(s['qty'] for s in steps), peak, steps,
|
||||
routing_suffix, a.get('card_id'), a.get('min_sell_price'))
|
||||
arms.pop(i)
|
||||
reservations = doc['reservations']
|
||||
reservations.append(res)
|
||||
_write(reservations=reservations, arms=arms)
|
||||
return {'arm': a, 'reservation': res}
|
||||
return None
|
||||
|
||||
|
||||
def should_notify_arm_failure(arm_id: str, now_ts: float, cooldown_sec: int) -> bool:
|
||||
"""발동 실패 알림을 보낼지. 보낸다고 판단하면 그 시점을 기록한다 (락 안에서 원자적).
|
||||
|
||||
firing_at 이 걸린 채 남은 arm 이나 사이드카 ON 상태는 매 사이클 같은 판정이 반복된다 —
|
||||
쿨다운 없이 알리면 19:59까지 매분 텔레그램이 쏟아진다. should_notify_failure 와 같은 패턴.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for a in arms:
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
last = a.get('last_fail_notice_ts') or 0
|
||||
if now_ts - last < cooldown_sec:
|
||||
return False
|
||||
a['last_fail_notice_ts'] = now_ts
|
||||
_write(arms=arms)
|
||||
return True
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user