auto: 일일 백업 2026-07-03 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,59 @@ def now_kst() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def session_vol_frac(candles_asc: list[dict]) -> float | None:
|
||||
"""마지막 봉이 오늘·정규장(09:00~15:30) 진행형이면 세션경과 비율(0.15~1.0), 아니면 None.
|
||||
장중 누적 거래량을 하루치로 환산(RVOL·회전율)하는 분모. 휴장/주말은 오늘 봉이 없어 자연 배제.
|
||||
NXT(15:30~) 시간대는 KRX 마감 후라 완성봉 취급(None)."""
|
||||
if not candles_asc:
|
||||
return None
|
||||
now = now_kst()
|
||||
if candles_asc[-1].get('date') != now.strftime('%Y%m%d'):
|
||||
return None
|
||||
hm = now.hour * 60 + now.minute
|
||||
if not (9 * 60 <= hm < 15 * 60 + 30):
|
||||
return None
|
||||
return min(1.0, max(0.15, (hm - 9 * 60) / 390.0)) # 09:00~15:30 = 390분
|
||||
|
||||
|
||||
def turnover_today(candles_asc: list[dict], vol_time_frac: float | None = None) -> tuple[float | None, bool]:
|
||||
"""오늘 회전율(%)과 장중환산 여부. 마지막 봉 turnover_rate 우선, 없으면 최근 완성봉에서
|
||||
상장주식수 역산 → 오늘 거래량으로 산출. vol_time_frac 있으면 오늘 누적을 하루치로 투영('예상').
|
||||
데이터 없으면 (None, scaled)."""
|
||||
if not candles_asc:
|
||||
return None, False
|
||||
scaled = bool(vol_time_frac and 0 < vol_time_frac < 1.0)
|
||||
vols = [c.get('volume') or 0 for c in candles_asc]
|
||||
today_v = vols[-1] if vols else 0
|
||||
proj_v = (today_v / vol_time_frac) if scaled and today_v else today_v
|
||||
last_c = candles_asc[-1]
|
||||
if last_c.get('turnover_rate') and not scaled:
|
||||
return last_c['turnover_rate'], scaled
|
||||
for c in reversed(candles_asc[:-1]):
|
||||
tr, cv = c.get('turnover_rate'), c.get('volume')
|
||||
if tr and cv:
|
||||
listed = cv / (tr / 100.0)
|
||||
return (proj_v / listed * 100.0, scaled) if listed > 0 else (None, scaled)
|
||||
return None, scaled
|
||||
|
||||
|
||||
def rvol_today(candles_asc: list[dict], vol_time_frac: float | None = None) -> tuple[float | None, bool]:
|
||||
"""상대거래량(RVOL) = 오늘 거래량 ÷ 직전 20거래일 평균, 장중환산 여부.
|
||||
vol_time_frac 있으면 오늘 누적을 하루치로 투영('예상'). 데이터<21봉이면 (None, scaled)."""
|
||||
if not candles_asc:
|
||||
return None, False
|
||||
scaled = bool(vol_time_frac and 0 < vol_time_frac < 1.0)
|
||||
vols = [c.get('volume') or 0 for c in candles_asc]
|
||||
if len(vols) < 21:
|
||||
return None, scaled
|
||||
avg20 = sum(vols[-21:-1]) / 20.0
|
||||
if avg20 <= 0:
|
||||
return None, scaled
|
||||
today_v = vols[-1]
|
||||
proj_v = (today_v / vol_time_frac) if scaled and today_v else today_v
|
||||
return proj_v / avg20, scaled
|
||||
|
||||
|
||||
def ts_for_filename() -> str:
|
||||
return now_kst().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
|
||||
@@ -547,11 +600,13 @@ VOL_BOT = VOL_TOP + VOL_PANEL_H
|
||||
CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '3M': 63, '1M': 21}
|
||||
|
||||
|
||||
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None, intraday: bool = False) -> str:
|
||||
"""캔들 + SMA 3선 + 거래량 막대 인라인 SVG.
|
||||
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None,
|
||||
intraday: bool = False) -> str:
|
||||
"""캔들 + SMA 3선 + 거래량 막대(+일봉 20일 거래량 이동평균선) 인라인 SVG.
|
||||
|
||||
range_key: '1Y' (250일) | '6M' (120일) | '3M' (63일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
|
||||
ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
|
||||
회전율 값은 차트에 그리지 않고 caller가 turnover_today()로 별도 표시 (차트 아래).
|
||||
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
|
||||
외부 JS 의존성 0. CSS는 inline.
|
||||
"""
|
||||
@@ -747,12 +802,30 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
f'<rect x="{x-body_w/2:.2f}" y="{bar_top:.2f}" width="{body_w:.2f}" height="{bar_h:.2f}" '
|
||||
f'fill="{color}" opacity="0.7"/>'
|
||||
)
|
||||
# 거래량 20일 이동평균선 — 일봉에서만 (분봉은 '20분 평균'이 돼 의미 없음)
|
||||
vols_full = [c['volume'] for c in candles_full]
|
||||
if not intraday:
|
||||
vma = (sma(vols_full, 20))[-n_take:]
|
||||
vma_pts = [f'{slot_x(i):.2f},{vol_y(v):.2f}' for i, v in enumerate(vma) if v is not None]
|
||||
if len(vma_pts) >= 2:
|
||||
parts.append(
|
||||
f'<polyline points="{" ".join(vma_pts)}" fill="none" stroke="#f5a623" '
|
||||
f'stroke-width="2" opacity="0.9"/>'
|
||||
)
|
||||
|
||||
# 거래량 Y 레이블 (max 만) — 우측
|
||||
parts.append(
|
||||
f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{VOL_TOP+22}" text-anchor="start" fill="#7a8493" font-size="28">'
|
||||
f'{_fmt_vol(v_max)}</text>'
|
||||
)
|
||||
|
||||
# 20MA 범례 (거래량 패널 하단 좌측) — 일봉 전용. RVOL·회전율 텍스트는 차트에 안 그림(차트 아래 표시).
|
||||
if not intraday:
|
||||
parts.append(
|
||||
f'<text x="{CHART_PAD_L+8}" y="{VOL_BOT-8}" text-anchor="start" fill="#f5a623" font-size="24">'
|
||||
f'— 거래량 20일평균</text>'
|
||||
)
|
||||
|
||||
# ---- X축 날짜 (시작·중간·끝) ----
|
||||
for i in (0, n // 2, n - 1):
|
||||
x = slot_x(i)
|
||||
@@ -1597,6 +1670,20 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
chart_1y = render_svg_chart(snap, '1Y')
|
||||
chart_6m = render_svg_chart(snap, '6M')
|
||||
chart_1m = render_svg_chart(snap, '1M')
|
||||
# 오늘 회전율·RVOL 값 — 차트 아래 표시. 장중 생성 시 종가 기준 환산('예상').
|
||||
_vfrac = session_vol_frac(snap.get('candles_asc') or [])
|
||||
_cands = snap.get('candles_asc') or []
|
||||
_turn, _tscaled = turnover_today(_cands, _vfrac)
|
||||
_rvol, _rscaled = rvol_today(_cands, _vfrac)
|
||||
_cap_bits = []
|
||||
if _rvol is not None:
|
||||
_cap_bits.append(f'<b>RVOL</b> x{_rvol:.1f}')
|
||||
if _turn is not None:
|
||||
_cap_bits.append(f'<b>회전율</b> {_turn:.2f}%')
|
||||
_turn_cap = (
|
||||
f'<div class="chart-cap">{" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}</div>'
|
||||
if _cap_bits else ''
|
||||
)
|
||||
chart_block = (
|
||||
'<div class="chart-tabs">'
|
||||
'<button type="button" data-range="1Y" class="active">1년</button>'
|
||||
@@ -1606,6 +1693,7 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
f'<div class="chart-panel active" data-range="1Y">{chart_1y}</div>'
|
||||
f'<div class="chart-panel" data-range="6M">{chart_6m}</div>'
|
||||
f'<div class="chart-panel" data-range="1M">{chart_1m}</div>'
|
||||
+ _turn_cap
|
||||
)
|
||||
|
||||
# ---- 외국인/기관 수급 표 (최근 20일) ----
|
||||
@@ -1854,6 +1942,8 @@ body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFo
|
||||
.chart-tabs button.active{background:rgba(251,191,36,0.12);color:#fbbf24;border-color:#fbbf24}
|
||||
.chart-panel{display:none}
|
||||
.chart-panel.active{display:block}
|
||||
.chart-cap{margin:8px 0 4px;padding:8px 10px 0;font-size:9px;line-height:1.5;color:#7a8493;border-top:1px solid #1d222b}
|
||||
.chart-cap b{color:#9aa4b2;font-weight:600}
|
||||
.kvtable th{vertical-align:top}
|
||||
.info-label{cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;border-bottom:1px dotted #5b6473;display:inline-flex;align-items:center;gap:4px;color:#7a8493;padding:1px 2px}
|
||||
.info-label::after{content:'ⓘ';font-size:11px;color:#5b6473;font-weight:normal}
|
||||
|
||||
Reference in New Issue
Block a user