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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-07 02:00:02 +09:00
parent 307f7844c4
commit f38a3878b1
175 changed files with 3594 additions and 2983 deletions
+191 -55
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""토글 켜진 종목의 급등락 감시 → 레이 텔레그램 알림.
LLM을 깨우지 않음. 기준을 병행한다:
1. 거래소 VI 발동 — "이건 급등락이다"를 거래소가 공식 판정. 문턱을 우리가 정할 필요가 없다.
2. 자기 이력 분위수 — 장중 이탈폭(전일종가 대비)이 그 종목 과거 이탈폭의 p90 을 넘으면 알림.
LLM을 깨우지 않음. 기준을 병행한다:
1. 거래소 상·하한가 도달 — 그날 갈 수 있는 끝까지 간 것. 가장 강한 신호라 무엇에도 가리지 않는다.
2. 거래소 VI 발동 — "이건 급등락이다"를 거래소가 공식 판정. 문턱을 우리가 정할 필요가 없다.
3. 자기 이력 분위수 — 장중 이탈폭(전일종가 대비)이 그 종목 과거 이탈폭의 p90 을 넘으면 알림.
고정 %를 쓰지 않는 이유 (실측 관심·감시 65종목 × 280거래일, 2026-08-04):
- 장중 이탈폭 중간값이 3.8% → ±3%는 급등락이 아니라 평범한 날
@@ -23,10 +24,16 @@ VI(1h)는 시장 전역 broadcast라 구독이 필요 없고, 현재가(0B)는
_rt_gather_codes() 가 이미 보유+관심+감시를 구독하므로 토글 종목은 그 부분집합이다.
구독 상한에 걸려 빠진 종목만 ka10095 배치 1콜로 폴백한다.
⚠️ 상·하한가·VI 가 걸려 있는 동안 분위수 트리거는 **보류**했다가 풀린 뒤에 내보낸다
(2026-08-06 관리자님 요청). 그전엔 조용히 버려져서, VI 중에 문턱을 넘었다가 해제 시점에
되밀린 움직임은 알림이 아예 없었다. 같은 사건을 두 번 알리지 않으면서 사실은 잃지 않기 위함.
state:
behive_surge_toggles.json — 감시 대상 (behive_web 의 🔔 토글이 씀)
surge_thresholds.json — {date, by_code: {code: {thr, thr_big, prev_close, n}}}. 하루 1회만 계산
surge_alerts.json — {date: {code: [트리거키]}}. 방향별 1회 + 확대단계 1
surge_limits.json — {date, by_code: {code: {upl, lst}}}. 거래소 상·하한가, 하루 1
surge_alerts.json — {date: {code: [트리거키], __pending__: {code: {키: 보류정보}}}}
방향별 1회 + 확대단계 1회 + 상·하한가 1회, VI 는 발동 건마다 1회
Usage:
python3 surge_monitor.py check # 1회 감시 (launchd 용)
@@ -55,7 +62,10 @@ import kiwoom_client as kc # noqa: E402
STATE_DIR = WORKSPACE / 'state'
TOGGLES = STATE_DIR / 'behive_surge_toggles.json'
THR_CACHE = STATE_DIR / 'surge_thresholds.json'
LIMITS_CACHE = STATE_DIR / 'surge_limits.json'
ALERTS_STATE = STATE_DIR / 'surge_alerts.json'
# 보류 트리거를 담는 예약 키. 종목코드는 6자리라 이 이름과 충돌하지 않는다.
PENDING_KEY = '__pending__'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
@@ -234,7 +244,7 @@ def _compute_thresholds(code: str) -> tuple[dict | None, bool]:
continue
exc.append(max(abs(candles[i]['high'] - pc), abs(candles[i]['low'] - pc)) / pc * 100)
if len(exc) < MIN_HISTORY:
return None
return None, False
exc.sort()
prev_close = candles[-1]['close']
if not prev_close:
@@ -278,38 +288,108 @@ def get_threshold_map(codes: list[str]) -> dict[str, dict]:
return {c: v for c, v in by_code.items() if v.get('thr')}
# ---------------- 판정 ----------------
def evaluate(pct: float, thr: dict | None, vi: dict | None) -> list[tuple[str, str]]:
"""충족된 (트리거키, 사유라벨) 목록.
# ---------------- 상·하한가 (거래소 계산값) ----------------
def get_limit_map(codes: list[str]) -> dict[str, dict]:
"""{code: {'upl', 'lst'}} — 거래소가 계산한 가격제한폭. 하루 1콜(ka10095 배치).
VI 발동 중이면 분위수 트리거는 생략한다 — 같은 사건을 두 번 알리지 않기 위해서.
기준가(전일종가)로 정해져 장중 불변이라 하루 1회면 충분하다. 분위수 문턱과 달리
일봉 이력이 필요 없어서 신규상장 등 이력 부족 종목도 이 기준으론 감시된다.
⚠️ 배치 호출 자체가 실패하면 캐시하지 않는다 — 문턱 캐시와 같은 이유로, 실패를
'제한가 없음'으로 굳히면 그 종목이 하루 내내 조용히 빠진다. 응답에 값이 없는
종목만 빈 dict 로 굳혀 매 사이클 재조회를 막는다.
"""
out: list[tuple[str, str]] = []
if vi and vi.get('active'):
t = (vi.get('trigger_time') or '').strip() or 'na'
kind = (vi.get('apply_kind') or '').strip() or 'VI'
out.append((f'vi:{t}', f'거래소 VI 발동 ({kind})'))
return out
if not codes:
return {}
today = datetime.now(KST).strftime('%Y-%m-%d')
cache = load_json(LIMITS_CACHE, {})
by_code = cache.get('by_code') if cache.get('date') == today else {}
if not isinstance(by_code, dict):
by_code = {}
missing = [c for c in codes if c not in by_code]
if missing:
try:
rows = kc.get_watchlist_quotes(missing) or {}
except Exception as e:
print(f'[warn] 상하한가 조회 실패 (다음 사이클 재시도): {e}', file=sys.stderr)
return {c: v for c, v in by_code.items() if v.get('upl')}
for c in missing:
r = rows.get(c) or {}
upl, lst = r.get('upper_limit') or 0, r.get('lower_limit') or 0
by_code[c] = {'upl': upl, 'lst': lst} if (upl and lst) else {}
save_json(LIMITS_CACHE, {'date': today, 'by_code': by_code})
return {c: v for c, v in by_code.items() if v.get('upl')}
# ---------------- 판정 ----------------
def _limit_hit(price: int, limits: dict | None) -> str | None:
"""'up'(상한가) | 'down'(하한가) | None. 거래소 제한가와 현재가 비교."""
if not limits or not price:
return None
upl, lst = limits.get('upl') or 0, limits.get('lst') or 0
if upl and price >= upl:
return 'up'
if lst and price <= lst:
return 'down'
return None
def _quantile_triggers(pct: float, thr: dict | None) -> list[dict]:
"""분위수 트리거만 — [{key, reason, word}]."""
if not thr or not thr.get('thr'):
return out
return []
direction = 'up' if pct > 0 else 'down'
word = '급등' if pct > 0 else '급락'
t1, t2 = thr['thr'], thr.get('thr_big') or 0
top1 = round((1 - SURGE_PCTL) * 100)
top2 = round((1 - SURGE_PCTL_BIG) * 100)
out: list[dict] = []
if t2 and abs(pct) >= t2:
out.append((f'{direction}2', f'{thr["n"]}일 중 상위 {top2}% 움직임 (문턱 {t2:.1f}%)'))
out.append({'key': f'{direction}2', 'word': word,
'reason': f'{thr["n"]}일 중 상위 {top2}% 움직임 (문턱 {t2:.1f}%)'})
if abs(pct) >= t1:
out.append((f'{direction}', f'{thr["n"]}일 중 상위 {top1}% 움직임 (문턱 {t1:.1f}%)'))
out.append({'key': direction, 'word': word,
'reason': f'{thr["n"]}일 중 상위 {top1}% 움직임 (문턱 {t1:.1f}%)'})
return out
def evaluate(pct: float, price: int, thr: dict | None, vi: dict | None,
limits: dict | None) -> tuple[list[dict], list[dict], str]:
"""(즉시 알릴 트리거, 보류할 트리거, 가림사유).
거래소 판정(상·하한가·VI)이 걸려 있는 동안 분위수 트리거는 **보류**한다. 같은 사건을
두 번 알리지 않으면서도, 그 사이 문턱을 넘은 사실은 버리지 않고 풀린 뒤에 내보내기 위해서다.
가림사유가 빈 문자열이면 가린 것이 없다는 뜻 — 호출측은 이때 보류분을 방출한다.
⚠️ 가림 여부는 `defer` 가 비었는지로 판단하면 안 된다. VI 중에 주가가 문턱 아래로
되밀리면 defer 도 비는데, 그걸 '가림 해제'로 읽으면 VI 도중에 보류분이 새어나간다.
"""
now: list[dict] = []
hit = _limit_hit(price, limits)
if hit == 'up':
now.append({'key': 'limit_up', 'word': '상한가',
'reason': f'거래소 상한가 도달 ({(limits or {}).get("upl", 0):,}원)'})
elif hit == 'down':
now.append({'key': 'limit_down', 'word': '하한가',
'reason': f'거래소 하한가 도달 ({(limits or {}).get("lst", 0):,}원)'})
vi_on = bool(vi and vi.get('active'))
if vi_on:
t = (vi.get('trigger_time') or '').strip() or 'na'
kind = (vi.get('apply_kind') or '').strip() or 'VI'
now.append({'key': f'vi:{t}', 'word': None, 'reason': f'거래소 VI 발동 ({kind})'})
quantile = _quantile_triggers(pct, thr)
if hit or vi_on:
return now, quantile, ({'up': '상한가', 'down': '하한가'}.get(hit) or 'VI')
return now + quantile, [], ''
def build_message(records: list[dict]) -> str:
ts = datetime.now(KST).strftime('%m/%d %H:%M')
lines = [f'[급등락] {ts}', f'{len(records)}건 감지']
for r in records:
pct = r['pct']
icon = '🚀' if pct > 0 else '🔻'
word = '급등' if pct > 0 else '급락'
# ⚠️ 아이콘은 pct 가 아니라 방향어에서 파생한다. 보류 방출 건은 돌파 시점 방향(급락)과
# 방출 시점 현재가 부호(+)가 어긋날 수 있어, pct 로 고르면 '🚀 급락'이 찍힌다.
word = r.get('word') or ('급등' if pct > 0 else '급락')
icon = {'상한가': '', '하한가': '', '급등': '🚀', '급락': '🔻'}.get(word, '🚀')
label = r['name'] or r['code']
lines.append('')
lines.append(f'{icon} #{label} {word} ({pct:+.2f}%)')
@@ -342,64 +422,114 @@ def run(dry: bool = False, force: bool = False) -> int:
for c, q in fetch_quotes_fallback(missing).items():
quotes[kc._clean_code(c)] = {'price': q.get('price'), 'pct': q.get('change_pct'), 'change': q.get('change')}
thr_map = get_threshold_map(codes)
limit_map = get_limit_map(codes)
today = datetime.now(KST).strftime('%Y-%m-%d')
day_state = load_json(ALERTS_STATE, {}).get(today) or {}
already = {f'{c}:{k}' for c, ks in day_state.items() if isinstance(ks, list) for k in ks}
# 보류분 — 감시에서 빠진 종목 것은 버린다(토글이 꺼졌으면 방출할 이유가 없다).
held = day_state.get(PENDING_KEY) or {}
pending = {c: v for c, v in held.items() if c in toggles and isinstance(v, dict)} if isinstance(held, dict) else {}
pending_sig = json.dumps(pending, sort_keys=True)
to_send: list[dict] = []
flushed: list[tuple[str, str]] = [] # (code, key) — 발송 성공 시에만 보류에서 지운다
def _rec(code: str, price: int, pct: float, trig: dict, vi: dict | None) -> dict:
r = {
'code': code,
'name': toggles.get(code) or '',
'price': price,
'pct': pct,
'key': trig['key'],
'word': trig.get('word'),
'reason': trig['reason'],
}
if vi and vi.get('active'):
r['vi_price'] = vi.get('trigger_price') or 0
return r
pending: list[dict] = []
for code in codes:
q = quotes.get(code) or {}
price = q.get('price')
pct = q.get('pct')
if not price or pct is None:
if not q.get('price') or q.get('pct') is None:
continue
price, pct = int(q['price']), float(q['pct'])
vi = vi_map.get(code)
for key, reason in evaluate(float(pct), thr_map.get(code), vi):
if f'{code}:{key}' in already:
now_trigs, defer_trigs, blocked_by = evaluate(
pct, price, thr_map.get(code), vi, limit_map.get(code))
for trig in now_trigs:
if f'{code}:{trig["key"]}' in already:
continue
rec = {
'code': code,
'name': toggles.get(code) or '',
'price': int(price),
'pct': float(pct),
'key': key,
'reason': reason,
to_send.append(_rec(code, price, pct, trig, vi))
already.add(f'{code}:{trig["key"]}')
if blocked_by:
# 가림 중 — 문턱 돌파 사실만 적어둔다. 이탈폭이 가장 컸던 시점을 남긴다.
for trig in defer_trigs:
if f'{code}:{trig["key"]}' in already:
continue
cur = (pending.get(code) or {}).get(trig['key']) or {}
if abs(pct) > abs(cur.get('pct') or 0):
pending.setdefault(code, {})[trig['key']] = {
'pct': pct, 'word': trig.get('word'),
'reason': trig['reason'], 'via': blocked_by,
}
continue
# 가림 해제 — 보류분 방출. 같은 키를 위에서 이미 즉시 알렸으면 already 가 걸러낸다.
for key, d in sorted((pending.get(code) or {}).items()):
if f'{code}:{key}' in already:
flushed.append((code, key))
continue
trig = {
'key': key, 'word': d.get('word'),
'reason': f'{d.get("reason") or "문턱 돌파"}{d.get("via") or "VI"} 중 최대 {d.get("pct", 0):+.2f}% 도달',
}
if vi and vi.get('active'):
rec['vi_price'] = vi.get('trigger_price') or 0
pending.append(rec)
to_send.append(_rec(code, price, pct, trig, vi))
already.add(f'{code}:{key}')
flushed.append((code, key))
if dry:
print(f'감시 {len(codes)}종목 / 허브연결 {connected} / 문턱 산출 {len(thr_map)}종목')
print(f'감시 {len(codes)}종목 / 허브연결 {connected} / 문턱 {len(thr_map)}종목 / 제한가 {len(limit_map)}종목')
for code in codes:
q = quotes.get(code) or {}
t = thr_map.get(code) or {}
thr = f'{t["thr"]:.1f}% / 확대 {t["thr_big"]:.1f}%' if t.get('thr') else '— (이력부족, VI만)'
print(f' {code} {toggles.get(code) or "":<12} 등락 {q.get("pct")}% / 문턱 {thr}'
f'{" / VI" if (vi_map.get(code) or {}).get("active") else ""}')
if pending:
lm = limit_map.get(code) or {}
thr = f'{t["thr"]:.1f}% / 확대 {t["thr_big"]:.1f}%' if t.get('thr') else '— (이력부족, VI·제한가만)'
lim = f'{lm["upl"]:,}/{lm["lst"]:,}' if lm.get('upl') else ''
print(f' {code} {toggles.get(code) or "":<12} 등락 {q.get("pct")}% / 문턱 {thr} / 상하한 {lim}'
f'{" / VI" if (vi_map.get(code) or {}).get("active") else ""}'
f'{" / 보류 " + ",".join(sorted(pending[code])) if pending.get(code) else ""}')
if to_send:
print('--- DRY ---')
print(build_message(pending))
print(f'done. watched={len(codes)} triggered={len(pending)}')
print(build_message(to_send))
print(f'done. watched={len(codes)} triggered={len(to_send)} pending={sum(len(v) for v in pending.values())}')
return 0
if not pending:
ok = send_telegram(build_message(to_send)) if to_send else True
if ok:
for code, key in flushed:
bucket = pending.get(code) or {}
bucket.pop(key, None)
if not bucket:
pending.pop(code, None)
if not to_send and json.dumps(pending, sort_keys=True) == pending_sig:
print(f'done. watched={len(codes)} triggered=0')
return 0
if send_telegram(build_message(pending)):
with alerts_lock():
latest = _prune(load_json(ALERTS_STATE, {}), today)
day = latest.setdefault(today, {})
for r in pending:
with alerts_lock():
latest = _prune(load_json(ALERTS_STATE, {}), today)
day = latest.setdefault(today, {})
if ok:
for r in to_send:
bucket = day.setdefault(r['code'], [])
if r['key'] not in bucket:
bucket.append(r['key'])
print(f'alerted {r["code"]} {r["name"]}:{r["key"]} {r["pct"]:+.2f}% @ {r["price"]:,}')
save_json(ALERTS_STATE, latest)
print(f'done. watched={len(codes)} triggered={len(pending)}')
if pending:
day[PENDING_KEY] = pending
else:
day.pop(PENDING_KEY, None)
save_json(ALERTS_STATE, latest)
print(f'done. watched={len(codes)} triggered={len(to_send)} pending={sum(len(v) for v in pending.values())}')
return 0
@@ -410,20 +540,26 @@ def cmd_list() -> int:
return 0
codes = sorted(toggles)
thr_map = get_threshold_map(codes)
limit_map = get_limit_map(codes)
top1 = round((1 - SURGE_PCTL) * 100)
top2 = round((1 - SURGE_PCTL_BIG) * 100)
print(f'감시 {len(codes)}종목 — 1차=자기이력 상위 {top1}% / 확대=상위 {top2}%')
print(f'{"종목":<16}{"전일종가":>10}{"1차":>7}{"급등가":>10}{"급락가":>10}{"확대":>7}')
print(f'{"종목":<16}{"전일종가":>10}{"1차":>7}{"급등가":>10}{"급락가":>10}{"확대":>7}'
f'{"상한가":>10}{"하한가":>10}')
for c in codes:
t = thr_map.get(c) or {}
lm = limit_map.get(c) or {}
name = (toggles[c] or c)[:15]
lim = f'{lm["upl"]:>10,}{lm["lst"]:>10,}' if lm.get('upl') else f'{"":>10}{"":>10}'
if not t.get('thr'):
print(f'{name:<16}{"이력 부족 — VI만 감시":>30}')
print(f'{name:<16}{"이력 부족 — VI·제한가":>30}{lim}')
continue
pv, t1, t2 = t['prev_close'], t['thr'], t['thr_big']
print(f'{name:<16}{pv:>10,}{t1:>6.1f}%{round(pv * (1 + t1 / 100)):>10,}'
f'{round(pv * (1 - t1 / 100)):>10,}{t2:>6.1f}%')
print(f'\nVI 발동은 이 문턱과 별개로 먼저 알림 (정적VI 전일종가 ±10% 부근)')
f'{round(pv * (1 - t1 / 100)):>10,}{t2:>6.1f}%{lim}')
print(f'\n상·하한가 도달은 문턱과 별개로 항상 알림 (거래소 계산값, 하루 1콜 캐시)')
print(f'※ VI 발동은 이 문턱과 별개로 먼저 알림 (정적VI 전일종가 ±10% 부근)')
print(f'※ 상·하한가·VI 중 문턱을 넘으면 보류했다가 풀린 뒤 알림 (사실을 버리지 않음)')
print(f'※ 문턱은 하루 가격제한폭 ±30% 안에 있음이 보장됨 (실제 관측된 이탈폭의 분위수)')
return 0