auto: 일일 백업 2026-08-26 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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