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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-28 02:00:06 +09:00
parent 5eb280201d
commit 9e5b1c211e
119 changed files with 2074 additions and 1331 deletions
@@ -35,12 +35,18 @@
바뀌었다. 거래소가 지워주는 스톱주문과 달리 arm 은 우리 파일에만 있으니 우리가 지운다:
`is_arm_expired` 판정 + 감시 루프의 다음 거래일 첫 사이클 정리.
여기에 세 번째 키 `recent` 가 더 있다 — 장 마감 소멸·만료된 예약의 설정을 짧게 보관해
자산웹이 "같은 설정으로 다시 등록" 버튼을 띄울 수 있게 하는 **UI 편의용 링버퍼**다.
감사 기록이 아니다(그쪽은 state/order_log.jsonl, 무한 append).
⚠️ **두 갈래를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적
전이여야 해서다.** promote_arm 이 락 하나 안에서 os.replace 1회로 끝낸다. 파일을 쪼개면
락을 중첩해야 하고(데드락), 중간에 죽으면 둘 다 존재하거나 둘 다 사라진다.
⚠️ 그 대가로 **_write 는 None 인 쪽을 디스크에서 다시 읽어 보존**한다. 이 계약이 깨지면
reservations 쪽 쓰기 6곳이 arms 를 조용히 전멸시킨다 (테스트로 고정해 둠).
한쪽 쓰기가 나머지 두 갈래를 조용히 전멸시킨다 — reservations 쪽 쓰기만 6곳이다.
`_read_doc` 이 세 키를 하드 정규화하므로 **키를 늘릴 때 _read_doc 과 _write 를 반드시
같이** 고쳐야 한다 (테스트로 고정해 둠).
⚠️ arm 의 steps 에는 **가격·주수를 저장하지 않는다**(정의 n/pct/cum/weight 만). routing_suffix
도 저장하지 않는다. 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 무기한 대기라 등록 시점
@@ -253,16 +259,18 @@ def _read_doc() -> dict:
때문이다. 파일이 둘이면 락을 중첩해야 하고, 중간에 죽으면 둘 다 존재하거나 둘 다
사라진다.
"""
empty = {'reservations': [], 'arms': [], 'recent': []}
if not STATE_FILE.exists():
return {'reservations': [], 'arms': []}
return empty
try:
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return {'reservations': [], 'arms': []}
return empty
if not isinstance(data, dict):
return {'reservations': [], 'arms': []}
return empty
return {'reservations': data.get('reservations') or [],
'arms': data.get('arms') or []}
'arms': data.get('arms') or [],
'recent': data.get('recent') or []}
def _read() -> list:
@@ -273,10 +281,15 @@ def _read_arms() -> list:
return _read_doc()['arms']
def _write(reservations: Optional[list] = None, arms: Optional[list] = None) -> None:
def _read_recent() -> list:
return _read_doc()['recent']
def _write(reservations: Optional[list] = None, arms: Optional[list] = None,
recent: Optional[list] = None) -> None:
"""None 인 쪽은 디스크의 현재 값을 유지한다.
⚠️ 두 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
⚠️ 세 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
반드시 _FileLock 안에서 부를 것 — 여기서 다시 읽어 병합한다.
"""
doc = _read_doc()
@@ -284,6 +297,8 @@ def _write(reservations: Optional[list] = None, arms: Optional[list] = None) ->
doc['reservations'] = reservations
if arms is not None:
doc['arms'] = arms
if recent is not None:
doc['recent'] = recent
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + '.tmp')
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding='utf-8')
@@ -334,6 +349,10 @@ def _build_reservation(account: str, symbol: str, symbol_name: str, total_qty: i
'qty': s['qty'], 'ord_no': s['ord_no'],
'cond_uv': s['cond_uv'], 'ord_uv': s['ord_uv'],
'entry_cond_uv': s['cond_uv'], 'modify_count': 0} for s in steps],
# 등록 시점 계단 **정의**(가격·주수 없음). steps 는 레그가 체결·소멸할 때마다 줄어들고
# 마지막 레그가 빠지면 비어버려서, 재등록용 원본이 여기 없으면 계단을 복원할 수 없다.
'entry_steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
for s in steps],
'routing_suffix': routing_suffix,
'card_id': card_id,
'created_at': _now_iso(),
@@ -582,6 +601,73 @@ def mark_arm_firing(arm_id: str) -> bool:
return False
# ---------------------------------------------------------------------------
# 최근 종료 (recent) — 재등록 버튼용 단기 캐시
#
# 트레일링 스톱은 장 마감에 거래소가 지우고, 대기 예약은 등록일 당일로 우리가 지운다. 둘 다
# 사라지는 순간 계좌·수량·계단이 함께 없어져서, 다시 걸려면 처음부터 입력해야 했다.
# 종료 시점의 설정을 짧게 보관해 자산웹이 "이전 설정 그대로 채워진 거래 모달" 을 열 수 있게 한다.
#
# ⚠️ 이건 **UI 편의용 단기 캐시이지 감사 기록이 아니다.** 감사 원장은 state/order_log.jsonl 이고
# 그쪽은 무한 append 로 그대로 둔다. 여기는 링버퍼라 오래된 건 지워진다.
# ⚠️ 재등록은 **사람이 버튼을 누르고 PIN 을 다시 거친다** — 감시 루프가 스스로 되살리는 경로는
# 없다. "자동 재등록 안 함"(2026-07-30 결정)은 그대로 유효하다.
# ---------------------------------------------------------------------------
RECENT_MAX = 20
RECENT_DAYS = 7
def list_recent() -> list:
"""최근 종료 목록 (최신이 앞). 락 없이 읽기 — 웹 렌더용."""
return _read_recent()
def get_recent(key: str) -> Optional[dict]:
for e in _read_recent():
if e.get('key') == key:
return e
return None
def push_recent(entry: dict) -> dict:
"""종료된 예약 1건을 최근 목록 맨 앞에 넣는다 (링버퍼).
⚠️ 제거(remove_step/remove_arm) 와 **같은 락에 묶지 않는다.** 레그 소멸은 레그마다 제거가
끝난 **뒤에야** "예약이 죽었다" 를 알 수 있어 애초에 한 락에 못 넣기 때문이다. 그 사이에
죽으면 항목 하나를 잃는데, 순서를 뒤집으면(기록 먼저) 같은 자리에서 중복이 남는다 —
편의 기능이라 유실이 중복보다 낫다.
"""
entry = dict(entry)
entry.setdefault('ended_at', _now_iso())
entry['key'] = _new_id('RC-')
cutoff = (datetime.now(KST) - timedelta(days=RECENT_DAYS)).isoformat(timespec='seconds')
with _FileLock(_LOCK_FILE):
recent = [e for e in _read_recent() if (e.get('ended_at') or '') >= cutoff]
recent.insert(0, entry)
_write(recent=recent[:RECENT_MAX])
return entry
def make_recent_entry(kind: str, reason: str, src: dict, steps: list) -> dict:
"""예약/대기예약 스냅샷 → 재등록에 필요한 것만 뽑은 항목.
steps 는 정의만(n/pct/cum/weight) — 가격·주수는 재등록 시점에 다시 계산된다.
"""
return {
'kind': kind, # 'trailing' | 'arm' → 재등록 order_type 을 가른다
'reason': reason,
'account': src['account'],
'symbol': src['symbol'],
'symbol_name': src.get('symbol_name') or src['symbol'],
'qty': src.get('qty') or 0,
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s.get('weight')}
for s in steps],
'min_sell_price': src.get('min_sell_price'),
'trigger_price': src.get('trigger_price'), # arm 만. trailing 은 None
}
def is_arm_expired(arm: dict, now: Optional[datetime] = None) -> bool:
"""등록일의 거래 세션이 끝났으면 만료 (2026-08-25 관리자님 결정으로 무기한 → 당일).