auto: 일일 백업 2026-08-26 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-26 02:00:02 +09:00
parent 600be2ee96
commit 992b85005d
162 changed files with 3704 additions and 1825 deletions
+339 -23
View File
@@ -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