auto: 일일 백업 2026-08-05 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
|
||||
CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json'
|
||||
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
|
||||
HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.json'
|
||||
SURGE_TOGGLES = WORKSPACE / 'state' / 'behive_surge_toggles.json'
|
||||
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
||||
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
|
||||
@@ -2209,6 +2210,93 @@ def _note_button_html(code: str, name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _surge_toggles_lock():
|
||||
"""SURGE_TOGGLES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
from contextlib import contextmanager as _cm
|
||||
|
||||
@_cm
|
||||
def _ctx():
|
||||
lock_path = SURGE_TOGGLES.with_suffix(SURGE_TOGGLES.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
return _ctx()
|
||||
|
||||
|
||||
def _load_surge_toggles() -> dict:
|
||||
"""{'by_code': {code: {'name': str}}}. 급등락 알림 감시 대상 (surge_monitor.py 가 읽는다).
|
||||
|
||||
⚠️ 키는 종목코드 전용 — 메모(by_name fallback 있음)와 달리 코드 없는 종목은 감시할 수 없다
|
||||
(모니터가 시세 조회에 코드를 써야 하므로).
|
||||
"""
|
||||
if not SURGE_TOGGLES.exists():
|
||||
return {'by_code': {}}
|
||||
try:
|
||||
d = json.loads(SURGE_TOGGLES.read_text())
|
||||
if not isinstance(d.get('by_code'), dict):
|
||||
d['by_code'] = {}
|
||||
return d
|
||||
except Exception:
|
||||
return {'by_code': {}}
|
||||
|
||||
|
||||
def _is_surge_on(code: str | None) -> bool:
|
||||
code = (code or '').strip()
|
||||
if not code:
|
||||
return False
|
||||
return bool(_load_surge_toggles().get('by_code', {}).get(code))
|
||||
|
||||
|
||||
def _set_surge_toggle(code: str, name: str, on: bool) -> bool:
|
||||
"""급등락 알림 on/off. 반환값은 적용 후 상태."""
|
||||
code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
|
||||
name = (name or '').strip()[:40]
|
||||
if not code:
|
||||
raise ValueError('종목코드가 필요합니다 (급등락 감시는 코드 기준)')
|
||||
SURGE_TOGGLES.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _surge_toggles_lock():
|
||||
d = _load_surge_toggles()
|
||||
if on:
|
||||
d['by_code'][code] = {'name': name}
|
||||
else:
|
||||
d['by_code'].pop(code, None)
|
||||
tmp = SURGE_TOGGLES.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
tmp.replace(SURGE_TOGGLES)
|
||||
return on
|
||||
|
||||
|
||||
def _surge_toggle_button_html(code: str, name: str) -> str:
|
||||
"""detail-name 줄(종목명 옆, `+ 태그` 왼쪽)의 급등락 알림 토글. 켜짐이면 🔔 + has-surge 강조.
|
||||
|
||||
문턱은 종목별 ATR14 × K 로 surge_monitor 가 계산한다 (고정 % 아님) — 여기선 on/off만.
|
||||
⚠️ 아이콘 하나뿐이라(관리자님 요청, 2026-08-04) 무슨 버튼인지는 title 툴팁이 유일한 설명이다.
|
||||
"""
|
||||
code_s = (code or '').strip()
|
||||
if not code_s:
|
||||
return ''
|
||||
name_s = (name or '').strip()
|
||||
on = _is_surge_on(code_s)
|
||||
cls = 'btn-surge has-surge' if on else 'btn-surge'
|
||||
label = '🔔' if on else '🔕'
|
||||
title = '급등락 알림 켜짐 — 누르면 끕니다' if on else '급등락 알림 꺼짐 — 누르면 켭니다'
|
||||
return (
|
||||
f'<button type="button" class="{cls}" data-surge-toggle="1"'
|
||||
f' data-surge-code="{html.escape(code_s, quote=True)}"'
|
||||
f' data-surge-name="{html.escape(name_s, quote=True)}"'
|
||||
f' data-surge-on="{"1" if on else "0"}"'
|
||||
f' aria-label="급등락 알림" title="{title}">{label}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _chart_hlines_lock():
|
||||
"""CHART_HLINES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
@@ -2918,10 +3006,12 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
# 관심종목은 그룹 버튼도 +태그 옆에 (actions 아님).
|
||||
group_add_btn = _interest_group_button_html(c.get('stock') or '') if source == 'interests' else ''
|
||||
# 급등락 알림 토글은 +태그 왼쪽 (관리자님 지정 위치).
|
||||
surge_btn = _surge_toggle_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
detail_name_html = ''
|
||||
if not is_pending:
|
||||
code_span = f'<span class="detail-code">{code}</span>' if code else ''
|
||||
detail_name_html = f'<div class="detail-name">{stock}{code_span}{tag_add_btn}{group_add_btn}</div>'
|
||||
detail_name_html = f'<div class="detail-name">{stock}{code_span}{surge_btn}{tag_add_btn}{group_add_btn}</div>'
|
||||
# 현재가 아래 메타 라인 — 관심·감시는 비중·계좌 없어 시장(KOSPI/KOSDAQ)만.
|
||||
_mkt_label = _market_label_html(code)
|
||||
market_meta_html = f'<div class="detail-meta">{_mkt_label}</div>' if _mkt_label else ''
|
||||
@@ -4056,6 +4146,8 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
|
||||
row_key = (code or stock) + key_suffix
|
||||
tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
# 급등락 알림 토글 — +태그 왼쪽 (관심·감시 행과 같은 위치).
|
||||
surge_btn = _surge_toggle_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
trade_btn = (
|
||||
f'<div class="actions">'
|
||||
@@ -4081,7 +4173,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
{candle_summary}
|
||||
</summary>
|
||||
<div class="detail">
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}<span class="detail-owner owner-{owner_cls}">{owner_disp}</span>{tag_add_btn}</div>
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}<span class="detail-owner owner-{owner_cls}">{owner_disp}</span>{surge_btn}{tag_add_btn}</div>
|
||||
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
|
||||
{f'<div class="detail-simple"><span class="ds-lbl">현재가</span><span class="ds-val">{price_html}</span></div>' if price else ''}
|
||||
<div class="detail-meta"><span class="dm-item">비중 {weight:.2f}%</span><span class="dm-item">{acct_label}</span>{_market_label_html(code)}</div>
|
||||
@@ -5835,6 +5927,12 @@ details.row[open] > .detail {
|
||||
.actions .btn-note:hover { background: rgba(201,178,125,0.14); border-color: #c9b27d; }
|
||||
.actions .btn-note:active { transform: translateY(1px); }
|
||||
.actions .btn-note.has-note { color: #f0c95a; border-color: rgba(240,201,90,0.5); background: rgba(240,201,90,0.12); }
|
||||
/* 급등락 알림 토글 — detail-name 줄(종목명 옆, +태그 왼쪽). 아이콘 하나뿐이라 테두리 없이 둔다.
|
||||
꺼짐은 흐리게(opacity), 켜짐은 선명 + 초록 배경으로 한눈에 구분. 터치 타깃은 24px 확보. */
|
||||
.detail-name .btn-surge { align-self: center; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; font-size: 13px; line-height: 1; cursor: pointer; font-family: inherit; opacity: 0.45; filter: grayscale(1); transition: opacity .12s, background .12s; }
|
||||
.detail-name .btn-surge:hover { opacity: 0.8; background: rgba(143,168,201,0.12); }
|
||||
.detail-name .btn-surge:active { transform: translateY(1px); }
|
||||
.detail-name .btn-surge.has-surge { opacity: 1; filter: none; background: rgba(111,211,163,0.16); }
|
||||
.note-head-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.note-del-wrap { position: relative; display: inline-flex; }
|
||||
.note-confirm { position: absolute; top: calc(100% + 6px); left: 0; display: flex; align-items: center; gap: 6px; background: #1b1f2a; border: 1px solid #3a2030; border-radius: 8px; padding: 6px 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.4); white-space: nowrap; z-index: 5; }
|
||||
@@ -10894,6 +10992,16 @@ def render_html() -> str:
|
||||
'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
|
||||
'});'
|
||||
'}'
|
||||
# 급등락 토글 아이콘 즉시 반영. 같은 종목이 여러 패널에 중복 노출되므로 code로 전 인스턴스를 칠한다.
|
||||
'function paintSurge(code,on){'
|
||||
'if(!code)return;'
|
||||
'document.querySelectorAll(\'[data-surge-toggle][data-surge-code="\'+code+\'"]\').forEach(function(b){'
|
||||
'b.dataset.surgeOn=on?"1":"0";'
|
||||
'b.classList.toggle("has-surge",!!on);'
|
||||
'b.textContent=on?"\\uD83D\\uDD14":"\\uD83D\\uDD15";'
|
||||
'b.title=on?"급등락 알림 켜짐 — 누르면 끕니다":"급등락 알림 꺼짐 — 누르면 켭니다";'
|
||||
'});'
|
||||
'}'
|
||||
'function populateNoteForm(trigger){'
|
||||
'var nm=document.getElementById("note-modal");if(!nm)return;'
|
||||
'var name=trigger.getAttribute("data-note-stock")||"";'
|
||||
@@ -11100,6 +11208,38 @@ def render_html() -> str:
|
||||
'openModal("note-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
# 급등락 알림 토글 — 낙관적 갱신(즉시 반영) 후 POST. 실패하면 되돌린다.
|
||||
# ⚠️ 패널 전체 새로고침(__behive_load)을 부르지 않는다 — 키움 조회까지 다시 돌아
|
||||
# 버튼이 몇 초 뒤에야 바뀐다. 토글 말고 달라지는 게 없어서 DOM만 고치면 충분하고,
|
||||
# 서버는 이미 _invalidate_panels_cache 했으니 다음 자동 새로고침이 알아서 일치시킨다.
|
||||
'var surgeBtn=t.closest&&t.closest("[data-surge-toggle]");'
|
||||
'if(surgeBtn){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'if(surgeBtn.dataset.busy==="1")return;'
|
||||
'surgeBtn.dataset.busy="1";'
|
||||
'var sCode=surgeBtn.dataset.surgeCode||"";'
|
||||
'var sName=surgeBtn.dataset.surgeName||"";'
|
||||
'var wasOn=surgeBtn.dataset.surgeOn==="1";'
|
||||
'paintSurge(sCode,!wasOn);'
|
||||
'var body=new URLSearchParams();'
|
||||
'body.append("code",sCode);'
|
||||
'body.append("stock",sName);'
|
||||
'body.append("on",wasOn?"0":"1");'
|
||||
'fetch("/surge/toggle",{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:body.toString(),credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
|
||||
'.then(function(j){'
|
||||
'if(j&&j.ok){'
|
||||
'paintSurge(sCode,!!j.on);'
|
||||
'showToast((sName||"종목")+" 급등락 알림 "+(j.on?"켰습니다":"껐습니다"));'
|
||||
'}else{'
|
||||
'paintSurge(sCode,wasOn);'
|
||||
'showToast("급등락 알림 변경 실패: "+((j&&j.error)||"오류"));'
|
||||
'}'
|
||||
'})'
|
||||
'.catch(function(){paintSurge(sCode,wasOn);showToast("네트워크 오류");})'
|
||||
'.finally(function(){surgeBtn.dataset.busy="";});'
|
||||
'return;'
|
||||
'}'
|
||||
'var gAssign=t.closest&&t.closest("[data-group-assign]");'
|
||||
'if(gAssign){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
@@ -15090,6 +15230,44 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/surge/toggle':
|
||||
# 급등락 알림 on/off. code 필수 (모니터가 코드로 시세를 조회하므로 name fallback 없음).
|
||||
# on 파라미터 없으면 현재 상태를 뒤집는다.
|
||||
code = (params.get('code') or [''])[0].strip()
|
||||
on_raw = (params.get('on') or [''])[0].strip()
|
||||
if not code:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': '종목코드 없음'})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain='종목코드 없음')
|
||||
return
|
||||
try:
|
||||
on = (on_raw in ('1', 'true', 'on')) if on_raw else (not _is_surge_on(code))
|
||||
on = _set_surge_toggle(code, stock, on)
|
||||
_invalidate_panels_cache()
|
||||
except ValueError as e:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain=str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
if wants_json:
|
||||
self._send_json(500, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_error(500, 'Internal Error', explain=str(e))
|
||||
return
|
||||
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} stock={stock} on={on} → ok\n')
|
||||
sys.stdout.flush()
|
||||
if wants_json:
|
||||
self._send_json(200, {'ok': True, 'on': on})
|
||||
else:
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# /stock/<code>/generate — 새 보고서 큐 등록
|
||||
if self.path.startswith('/stock/') and self.path.endswith('/generate'):
|
||||
tail = self.path[len('/stock/'):-len('/generate')]
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""토글 켜진 종목의 급등락 감시 → 레이 텔레그램 알림.
|
||||
|
||||
LLM을 깨우지 않음. 두 기준을 병행한다:
|
||||
1. 거래소 VI 발동 — "이건 급등락이다"를 거래소가 공식 판정. 문턱을 우리가 정할 필요가 없다.
|
||||
2. 자기 이력 분위수 — 장중 이탈폭(전일종가 대비)이 그 종목 과거 이탈폭의 p90 을 넘으면 알림.
|
||||
|
||||
고정 %를 쓰지 않는 이유 (실측 관심·감시 65종목 × 280거래일, 2026-08-04):
|
||||
- 장중 이탈폭 중간값이 3.8% → ±3%는 급등락이 아니라 평범한 날
|
||||
- 종목별 변동성이 22배 차(ATR14 1.0%~22.3%). ±5% 문턱이면 KODEX 미국S&P500 은 발생 0회,
|
||||
SK이터닉스는 75회. 같은 5%가 한쪽엔 도달 불가, 한쪽엔 노이즈다.
|
||||
|
||||
⚠️ ATR 배수(ATR% × K)를 쓰지 않는 이유 — 2026-08-04 관리자님 지적으로 교체:
|
||||
국내 주식 하루 가격제한폭이 ±30% 인데 ATR 배수는 상한이 없어 변동성 큰 종목의 문턱이
|
||||
제한폭 밖으로 밀려난다. 실측: 1차(ATR×1.5)가 5/65 종목, 확대(ATR×3.0)가 31/65 종목(48%)에서
|
||||
30% 초과 = **영원히 발동 불가**였다(ATR% 중간값이 10%라 확대는 절반이 죽는다).
|
||||
분위수는 실제로 관측된 이탈폭이라 구조적으로 제한폭을 넘을 수 없다(p90~p99 전부 30% 초과 0종목).
|
||||
덤으로 알림량이 정의상 (1-p) 비율로 확정된다 — p90 = 종목당 연 25회.
|
||||
⚠️ 신규상장 첫날·정리매매는 제한폭 예외지만, 그런 종목은 이력이 없어 애초에 VI만 감시한다.
|
||||
|
||||
데이터원은 behive_web 의 /api/realtime/quotes — 현재가와 VI를 한 번에 주고 키움 호출이 0이다.
|
||||
VI(1h)는 시장 전역 broadcast라 구독이 필요 없고, 현재가(0B)는 구독이 필요한데 behive_web 의
|
||||
_rt_gather_codes() 가 이미 보유+관심+감시를 구독하므로 토글 종목은 그 부분집합이다.
|
||||
구독 상한에 걸려 빠진 종목만 ka10095 배치 1콜로 폴백한다.
|
||||
|
||||
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회
|
||||
|
||||
Usage:
|
||||
python3 surge_monitor.py check # 1회 감시 (launchd 용)
|
||||
python3 surge_monitor.py check --force # 장외에도 실행 (테스트용)
|
||||
python3 surge_monitor.py dry-run # 판정만 출력, 텔레그램 발송 없음
|
||||
python3 surge_monitor.py list # 토글 종목의 문턱(%)·발동가 출력
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
sys.path.insert(0, str(WORKSPACE))
|
||||
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'
|
||||
ALERTS_STATE = STATE_DIR / 'surge_alerts.json'
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
|
||||
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
|
||||
|
||||
# 1차 문턱 = 그 종목 과거 장중 이탈폭의 이 분위수. 통수를 정하는 유일한 손잡이 —
|
||||
# 분위수라 알림량이 정의상 (1-p) 비율로 확정된다. 0.90 = 종목당 연 25회(≈2주 1번).
|
||||
SURGE_PCTL = 0.90
|
||||
# 확대 단계 — 1차 알림 후 여기까지 더 벌어지면 한 번 더. 종목당 하루 최대 2통.
|
||||
SURGE_PCTL_BIG = 0.98
|
||||
# 분위수 산출에 필요한 최소 관측일. 미달(신규상장 등)이면 VI만 감시한다.
|
||||
MIN_HISTORY = 60
|
||||
# 이력 조회 봉 수 — sqlite 캐시에서 읽는 양만 늘린다(추가 API 콜 없음).
|
||||
HISTORY_COUNT = 400
|
||||
# 문턱 산출 페이싱 — ka10081 유량이 초당 5건이라 캐시가 stale 한 종목이 많으면 429가 쏟아진다.
|
||||
MAX_COMPUTE_PER_CYCLE = 12
|
||||
COMPUTE_PACE_SEC = 0.35
|
||||
|
||||
# behive_web 실시간 엔드포인트. BIND_HOST='' 라 localhost 로 도달한다.
|
||||
RT_URL = 'http://127.0.0.1:18790/api/realtime/quotes'
|
||||
RT_TIMEOUT = 5
|
||||
|
||||
MARKET_OPEN = (9, 0)
|
||||
MARKET_CLOSE = (15, 35) # 15:30 마감 + 최종 체결 버퍼
|
||||
|
||||
|
||||
def load_json(path: Path, default):
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def alerts_lock():
|
||||
"""surge_alerts.json 동시 쓰기 직렬화. watchlist_monitor 와 동일 패턴, 별도 lock 파일."""
|
||||
lock_path = ALERTS_STATE.with_suffix(ALERTS_STATE.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def is_market_hours() -> bool:
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
# KRX 휴장일도 거래 없음. 데이터 파일 누락이나 import 실패 시엔 평소대로 진행.
|
||||
try:
|
||||
from holiday_sync import is_holiday_today
|
||||
if is_holiday_today():
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
mins = now.hour * 60 + now.minute
|
||||
return (MARKET_OPEN[0] * 60 + MARKET_OPEN[1]) <= mins <= (MARKET_CLOSE[0] * 60 + MARKET_CLOSE[1])
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
if not chat_ids:
|
||||
print('no telegram chat_ids', file=sys.stderr)
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
data = urllib.parse.urlencode({
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
print(f'telegram HTTP {r.status}', file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f'telegram error: {e}', file=sys.stderr)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
# ---------------- 감시 대상 ----------------
|
||||
def load_toggles() -> dict[str, str]:
|
||||
"""{code: name} — behive_web 의 🔔 토글이 켠 종목만."""
|
||||
raw = load_json(TOGGLES, {})
|
||||
by_code = raw.get('by_code') if isinstance(raw, dict) else None
|
||||
if not isinstance(by_code, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for code, v in by_code.items():
|
||||
cc = kc._clean_code(code)
|
||||
if not cc or not v:
|
||||
continue
|
||||
# v 는 True(레거시) 또는 {'name': ...}
|
||||
out[cc] = (v.get('name') or '') if isinstance(v, dict) else ''
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- 데이터원 ----------------
|
||||
def fetch_realtime(codes: list[str]) -> tuple[dict, dict, bool]:
|
||||
"""behive_web 실시간 허브에서 (quotes, vi, ok). 키움 호출 0.
|
||||
|
||||
허브가 콜드하거나 behive_web 이 죽어 있으면 ok=False — 호출측이 폴백/경고를 결정한다.
|
||||
"""
|
||||
if not codes:
|
||||
return {}, {}, False
|
||||
url = f'{RT_URL}?codes={",".join(codes)}'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=RT_TIMEOUT) as r:
|
||||
d = json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
print(f'[warn] behive_web 실시간 조회 실패 ({e}) — VI 감시 불가, ATR만 진행', file=sys.stderr)
|
||||
return {}, {}, False
|
||||
return (d.get('quotes') or {}), (d.get('vi') or {}), bool(d.get('connected'))
|
||||
|
||||
|
||||
def fetch_quotes_fallback(codes: list[str]) -> dict:
|
||||
"""구독 상한에 걸려 허브에 없는 종목만 ka10095 배치 1콜로 보충."""
|
||||
if not codes:
|
||||
return {}
|
||||
try:
|
||||
return kc.get_watchlist_quotes(codes) or {}
|
||||
except Exception as e:
|
||||
print(f'[warn] ka10095 배치 실패: {e}', file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------- 문턱 (자기 이력 분위수) ----------------
|
||||
def _quantile(sorted_vals: list[float], p: float) -> float:
|
||||
"""오름차순 리스트의 p 분위수. 실측 스크립트와 같은 식(nearest-rank)을 써야 값이 일치한다."""
|
||||
return sorted_vals[min(len(sorted_vals) - 1, int(len(sorted_vals) * p))]
|
||||
|
||||
|
||||
def _compute_thresholds(code: str) -> tuple[dict | None, bool]:
|
||||
"""(문턱 dict 또는 None, 재시도해야 하는가).
|
||||
|
||||
장중 이탈폭 = max(|고가−전일종가|, |저가−전일종가|) / 전일종가 × 100.
|
||||
종가 대비가 아니라 **전일종가 대비 장중 최대 이탈폭**을 쓰는 이유 — 실시간 감시가 보는 값이
|
||||
`현재가 vs 전일종가`(키움 pct)라서 같은 축으로 비교해야 한다. 종가기준은 장중 움직임을
|
||||
과소평가해(중간값 3.8% vs 종가기준 1.x%) 문턱이 너무 낮게 잡힌다.
|
||||
|
||||
⚠️ 두 번째 반환값이 필요한 이유 — 조회 실패(429·네트워크)를 이력부족과 같이 취급해
|
||||
빈 dict 로 캐시하면 그 종목이 **하루 내내 조용히 VI만 감시**하게 된다. 실패는 재시도 대상.
|
||||
"""
|
||||
try:
|
||||
import daily_candles_cache as dcc
|
||||
candles = dcc.get_candles(code, HISTORY_COUNT)
|
||||
except Exception as e:
|
||||
print(f'[{code}] 일봉 조회 실패 (다음 사이클 재시도): {e}', file=sys.stderr)
|
||||
return None, True
|
||||
if not candles or len(candles) < MIN_HISTORY:
|
||||
return None, False
|
||||
exc: list[float] = []
|
||||
for i in range(1, len(candles)):
|
||||
pc = candles[i - 1]['close']
|
||||
if not pc:
|
||||
continue
|
||||
exc.append(max(abs(candles[i]['high'] - pc), abs(candles[i]['low'] - pc)) / pc * 100)
|
||||
if len(exc) < MIN_HISTORY:
|
||||
return None
|
||||
exc.sort()
|
||||
prev_close = candles[-1]['close']
|
||||
if not prev_close:
|
||||
return None, False
|
||||
return {
|
||||
'thr': round(_quantile(exc, SURGE_PCTL), 2),
|
||||
'thr_big': round(_quantile(exc, SURGE_PCTL_BIG), 2),
|
||||
'prev_close': prev_close,
|
||||
'n': len(exc),
|
||||
}, False
|
||||
|
||||
|
||||
def get_threshold_map(codes: list[str]) -> dict[str, dict]:
|
||||
"""종목별 문턱 맵. 하루 1회만 계산하고 캐시한다.
|
||||
|
||||
⚠️ daily_candles_cache.get_candles 는 캐시 최신봉이 어제보다 오래되면 ka10081 을 때린다.
|
||||
매 사이클(1분) × 종목수만큼 호출하면 폭주하므로 날짜가 바뀔 때와 새 토글이 생길 때만 계산.
|
||||
⚠️ 분위수 파라미터를 바꿨으면 캐시가 옛 값을 들고 있으니 `pctl` 서명이 다르면 재계산한다.
|
||||
⚠️ **ka10081 유량 제한이 초당 5건**이라 종목을 한꺼번에 돌리면 429가 쏟아진다(2026-08-04 실측:
|
||||
70종목 일괄 산출 시 26종목 실패). 그래서 사이클당 `MAX_COMPUTE_PER_CYCLE` 개까지만,
|
||||
`COMPUTE_PACE_SEC` 간격으로 계산한다. 남은 종목은 다음 사이클이 이어받는다(1분 간격 × 396회라
|
||||
장 시작 몇 분 안에 전부 채워진다). 조회 실패는 캐시하지 않아 다음 사이클에 재시도된다.
|
||||
"""
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
sig = f'{SURGE_PCTL}/{SURGE_PCTL_BIG}'
|
||||
cache = load_json(THR_CACHE, {})
|
||||
fresh = cache.get('date') == today and cache.get('pctl') == sig
|
||||
by_code = cache.get('by_code') if fresh else {}
|
||||
if not isinstance(by_code, dict):
|
||||
by_code = {}
|
||||
missing = [c for c in codes if c not in by_code][:MAX_COMPUTE_PER_CYCLE]
|
||||
if missing:
|
||||
for i, c in enumerate(missing):
|
||||
if i:
|
||||
time.sleep(COMPUTE_PACE_SEC)
|
||||
r, retry = _compute_thresholds(c)
|
||||
if retry:
|
||||
continue # 실패는 기록하지 않는다 — 다음 사이클에 다시 시도
|
||||
by_code[c] = r if r else {}
|
||||
save_json(THR_CACHE, {'date': today, 'pctl': sig, 'by_code': by_code})
|
||||
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]]:
|
||||
"""충족된 (트리거키, 사유라벨) 목록.
|
||||
|
||||
VI 발동 중이면 분위수 트리거는 생략한다 — 같은 사건을 두 번 알리지 않기 위해서.
|
||||
"""
|
||||
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 thr or not thr.get('thr'):
|
||||
return out
|
||||
direction = 'up' if pct > 0 else 'down'
|
||||
t1, t2 = thr['thr'], thr.get('thr_big') or 0
|
||||
top1 = round((1 - SURGE_PCTL) * 100)
|
||||
top2 = round((1 - SURGE_PCTL_BIG) * 100)
|
||||
if t2 and abs(pct) >= t2:
|
||||
out.append((f'{direction}2', f'{thr["n"]}일 중 상위 {top2}% 움직임 (문턱 {t2:.1f}%)'))
|
||||
if abs(pct) >= t1:
|
||||
out.append((f'{direction}', f'{thr["n"]}일 중 상위 {top1}% 움직임 (문턱 {t1:.1f}%)'))
|
||||
return out
|
||||
|
||||
|
||||
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 '급락'
|
||||
label = r['name'] or r['code']
|
||||
lines.append('')
|
||||
lines.append(f'{icon} #{label} {word} ({pct:+.2f}%)')
|
||||
lines.append(f'• 현재가: {r["price"]:,}원')
|
||||
lines.append(f'• 사유: {r["reason"]}')
|
||||
if r.get('vi_price'):
|
||||
lines.append(f'• VI 발동가: {r["vi_price"]:,}원')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ---------------- 실행 ----------------
|
||||
def _prune(state: dict, today: str) -> dict:
|
||||
"""오늘·어제만 남긴다 (무한 증식 방지)."""
|
||||
keep = {today, (datetime.now(KST) - timedelta(days=1)).strftime('%Y-%m-%d')}
|
||||
return {k: v for k, v in state.items() if k in keep}
|
||||
|
||||
|
||||
def run(dry: bool = False, force: bool = False) -> int:
|
||||
if not force and not is_market_hours():
|
||||
print(f'장외 시간 — skip ({datetime.now(KST).strftime("%Y-%m-%d %H:%M")})')
|
||||
return 0
|
||||
toggles = load_toggles()
|
||||
if not toggles:
|
||||
print('감시 대상 없음 — 자산웹에서 🔔 토글을 켜주세요')
|
||||
return 0
|
||||
codes = sorted(toggles)
|
||||
quotes, vi_map, connected = fetch_realtime(codes)
|
||||
missing = [c for c in codes if not (quotes.get(c) or {}).get('price')]
|
||||
if missing:
|
||||
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)
|
||||
|
||||
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}
|
||||
|
||||
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:
|
||||
continue
|
||||
vi = vi_map.get(code)
|
||||
for key, reason in evaluate(float(pct), thr_map.get(code), vi):
|
||||
if f'{code}:{key}' in already:
|
||||
continue
|
||||
rec = {
|
||||
'code': code,
|
||||
'name': toggles.get(code) or '',
|
||||
'price': int(price),
|
||||
'pct': float(pct),
|
||||
'key': key,
|
||||
'reason': reason,
|
||||
}
|
||||
if vi and vi.get('active'):
|
||||
rec['vi_price'] = vi.get('trigger_price') or 0
|
||||
pending.append(rec)
|
||||
already.add(f'{code}:{key}')
|
||||
|
||||
if dry:
|
||||
print(f'감시 {len(codes)}종목 / 허브연결 {connected} / 문턱 산출 {len(thr_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:
|
||||
print('--- DRY ---')
|
||||
print(build_message(pending))
|
||||
print(f'done. watched={len(codes)} triggered={len(pending)}')
|
||||
return 0
|
||||
|
||||
if not pending:
|
||||
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:
|
||||
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)}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
toggles = load_toggles()
|
||||
if not toggles:
|
||||
print('감시 대상 없음 — 자산웹에서 🔔 토글을 켜주세요')
|
||||
return 0
|
||||
codes = sorted(toggles)
|
||||
thr_map = get_threshold_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}')
|
||||
for c in codes:
|
||||
t = thr_map.get(c) or {}
|
||||
name = (toggles[c] or c)[:15]
|
||||
if not t.get('thr'):
|
||||
print(f'{name:<16}{"이력 부족 — VI만 감시":>30}')
|
||||
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'\n※ VI 발동은 이 문턱과 별개로 먼저 알림 (정적VI 전일종가 ±10% 부근)')
|
||||
print(f'※ 문턱은 하루 가격제한폭 ±30% 안에 있음이 보장됨 (실제 관측된 이탈폭의 분위수)')
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else 'help'
|
||||
force = '--force' in sys.argv[2:]
|
||||
try:
|
||||
if cmd == 'check':
|
||||
return run(dry=False, force=force)
|
||||
if cmd == 'dry-run':
|
||||
return run(dry=True, force=True)
|
||||
if cmd == 'list':
|
||||
return cmd_list()
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
print(f'[fatal] {e}\n{tb}', file=sys.stderr)
|
||||
try:
|
||||
send_telegram(f'⚠️ [surge_monitor] 실행 실패\n{type(e).__name__}: {e}')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user