#!/usr/bin/env python3
"""비하이브 워치리스트 실시간 웹 뷰. 페이지 열기·새로고침 시점에 키움 ka10095 batch 호출.
GET / → watchlist 카드형 HTML 한 페이지로 응답. JS·외부 리소스 없음 (인라인 CSS만).
GET /api/panels[?owner=self|gahee] → 패널 JSON (자동 갱신·partial swap용).
POST /{delete,restore,purge} → 감시종목 lifecycle (watchlist).
POST /interests/{add,update,delete} → 관심종목 수동 관리. JSON 응답 (Accept: application/json).
서버 바인드는 Tailscale IP(`100.75.148.12`)로 제한 — 외부망 직접 도달 차단.
외부 노출은 NAS reverse proxy(`stock.hyowons.net` → mac:18790) 경유.
write 엔드포인트는 인증·CSRF 가드 없음 — Tailnet 내부망 한정 운영을 전제.
CLI:
python3 behive_web.py serve # 서버 기동 (launchd가 호출)
python3 behive_web.py render [--out FILE] # HTML 한 번 렌더 후 출력 (디버깅용)
"""
from __future__ import annotations
import argparse
import gzip
import html
import json
import sys
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from zoneinfo import ZoneInfo
WORKSPACE = Path(__file__).resolve().parent.parent
# 빌드 식별자 — 파일 mtime 기반, import 시 1회 계산. 코드 수정 후 데몬 재시작하면 값이 바뀐다.
# 클라이언트가 shell 로드 시 저장한 __build 와 /api/panels 응답의 build 가 다르면 1회 자동 리로드
# → SPA식 fetch 갱신(location.reload 없음)이 옛 인라인 JS/CSS를 계속 쓰던 문제 해소.
try:
BUILD = str(int(Path(__file__).resolve().stat().st_mtime))
except Exception:
BUILD = '0'
WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
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'
TRADE_JOURNAL_FILE = WORKSPACE / 'state' / 'trade_journal.jsonl'
MARKET_HISTORY_FILE = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
ADR_TREND_DAYS = 30 # 시장정보 sub-tab ADR 추세 차트 default 기간 (영업일 기준 최근 N행)
# (label, days) — days=-1 전체. UI 버튼·서버 sanity check·localStorage 허용값 모두 단일 진실 출처.
ADR_TREND_PRESETS = [('1주', 7), ('1달', 30), ('3달', 90), ('6달', 180), ('1년', 365), ('전체', -1)]
ADR_MA_WINDOW = 20 # ADR 이동평균 윈도우. 카드 수치·차트 모두 이 값 사용. 가용 데이터 < window 면 평균 산출 안 함
NET_WORTH_CHART_DAYS = -2 # default = 이번달. 클라이언트가 ?chart_days로 override
# (label, days) — 클라이언트 UI 옵션. days=-1 전체, -2 이번달(KST 기준), -3 이번주(이번 ISO 월요일~오늘).
NET_WORTH_CHART_PRESETS = [('전체보기', -1), ('올해', -4), ('이번달', -2), ('이번주', -3)]
# 차트 단위 토글 — 각 구간 마지막 영업일 net_worth만 표시. 'day'=기본 일별.
NET_WORTH_UNIT_PRESETS = [('일별', 'day'), ('주별', 'week'), ('월별', 'month'), ('연별', 'year')]
NET_WORTH_CHART_UNIT = 'day'
# 단위 → tooltip 손익 라벨 매핑
_NET_WORTH_DELTA_LABEL = {'day': '그날 손익', 'week': '주간 손익', 'month': '월간 손익', 'year': '연간 손익'}
# 기간별 허용 단위 — 의미없는 조합(이번주+월별 등) 비활성용. 기간이 단위 너비보다 좁으면 비허용.
NET_WORTH_ALLOWED_UNITS_BY_DAYS: dict[int, tuple[str, ...]] = {
-3: ('day',), # 이번주 — day 만 (주별은 한 점이라 의미 없음)
-2: ('day', 'week'), # 이번달 — month 빼고 (월별은 한 점이라 의미 없음)
-4: ('day', 'week', 'month'), # 올해 — year 빼고 (연별은 한 점이라 의미 없음)
-1: ('day', 'week', 'month', 'year'), # 전체 — 모두
}
# 차트 표시 모드 — 'pnl'=시작점 baseline=0 누적손익(기본), 'net'=순자산 시계열.
# 'pnl'=시작점 baseline=0 누적손익(전부 누적), 'period'=각 점이 그 기간(일/주/월/연) 자체 손익, 'net'=순자산 시계열.
NET_WORTH_MODE_PRESETS = [('총자산', 'net'), ('손익누적', 'pnl'), ('기간손익', 'period')]
NET_WORTH_CHART_MODE = 'pnl'
KST = ZoneInfo('Asia/Seoul')
BIND_HOST = '' # 모든 인터페이스 (Tailscale + LAN). 외부망 직접 차단은 공유기 NAT가 담당.
BIND_PORT = 18790
QUOTE_WORKERS = 8
QUOTE_CACHE_TTL = 30.0 # 첫 cold 8s 동안 분산 박힌 캐시가 다음 RENDER 만료(10s) 시점에 살아있도록. 가격 stale 최대 30s.
sys.path.insert(0, str(WORKSPACE / 'scripts'))
sys.path.insert(0, str(WORKSPACE)) # sim 패키지(from sim import signals, config) import 용
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
_holding_since_cache: dict = {'mtime': None, 'map': {}}
_quote_cache: dict[str, tuple[dict, float]] = {}
_quote_cache_lock = threading.Lock()
# 계좌별 미체결(ka10075) 캐시 — 거래 모달 max_qty 계산용. 미체결은 가격과 무관하므로
# 단가 keystroke마다 재조회하지 않도록 짧은 TTL로 묶는다. 주문 접수 후 프론트가 재조회하면
# 최대 TTL 만큼 stale할 수 있으나, propose/키움 서버가 최종 검증하므로 표시용으론 충분.
OPEN_ORDERS_CACHE_TTL = 15.0
_open_orders_cache: dict[str, tuple[list, float]] = {}
_open_orders_cache_lock = threading.Lock()
def _cached_open_orders(account_label: str) -> list:
"""계좌 미체결 주문 목록 (ka10075) — TTL 캐시. 실패 시 빈 리스트."""
now = time.time()
with _open_orders_cache_lock:
hit = _open_orders_cache.get(account_label)
if hit and (now - hit[1]) < OPEN_ORDERS_CACHE_TTL:
return hit[0]
try:
import kiwoom_client as kc
rows = kc.get_open_orders(account_label)
except Exception:
return []
with _open_orders_cache_lock:
_open_orders_cache[account_label] = (rows, now)
return rows
def _pending_buy_lock(account_label: str) -> int:
"""계좌의 미체결 매수 지정가로 묶인 예수금 합계. d2_entra는 이 금액을 아직 포함하므로
실제 매수가능액 = d2_entra − 이 값. 시장가(price=0) 미체결은 금액 미상이라 제외(render 경로와 동일)."""
total = 0
for o in _cached_open_orders(account_label):
if o.get('side') == 'BUY':
px = o.get('order_price') or 0
if px > 0:
total += px * (o.get('unfilled_qty') or 0)
return total
def _pending_sell_qty(account_label: str, code: str) -> int:
"""계좌·종목의 미체결 매도 수량 합계."""
total = 0
for o in _cached_open_orders(account_label):
if o.get('side') == 'SELL' and o.get('code') == code:
total += o.get('unfilled_qty') or 0
return total
# 기술적 권장가(매수/매도 판단 보조) 캐시 — 완성된 일봉 기반이라 장중 안정적.
# 키: f'{code}:{side}:{avg_price}'. 현재가 대비 비교는 클라이언트가 실시간 시세로 처리.
ADVICE_CACHE_TTL = 120.0
_advice_cache: dict[str, tuple[dict, float]] = {}
_advice_cache_lock = threading.Lock()
def _price_advice(code: str, side: str, avg_price: int | None = None) -> dict | None:
"""일봉 기반 기술적 권장 매수가/매도가. 근거는 이평·ATR만(sim.signals 규칙 재사용).
반환: {'levels': [{label, price, hint, tap}], 'atr','sma20','rsi','price','recent_low','recent_high'}
데이터 부족(신규주)·ETF 등으로 지표 산출 불가 시 None (예외 raise 안 함).
⚠️ 상수는 활성 sim.config 사용 — params.json 튜닝이 걸려있으면 그 값이 반영됨(현재는 문서화된 기본값).
"""
side = (side or '').upper()
cache_key = f'{code}:{side}:{avg_price or 0}'
now = time.time()
with _advice_cache_lock:
hit = _advice_cache.get(cache_key)
if hit and (now - hit[1]) < ADVICE_CACHE_TTL:
return hit[0]
try:
from sim import signals, config
import daily_candles_cache as dcc
candles = dcc.get_candles(code, count=250)
ind = signals.compute_indicators(candles) if candles else None
except Exception:
traceback.print_exc()
ind = None
if not ind:
with _advice_cache_lock:
_advice_cache[cache_key] = (None, now)
return None
price = ind['price']
atr = ind['atr']
sma20 = ind['sma_long']
prev_close = ind['prev_close']
recent_high = ind['recent_high']
recent_low = ind['recent_low']
rsi = ind.get('rsi')
levels: list[dict] = []
if side == 'BUY':
pullback = int(round(max(sma20, prev_close - atr * config.PULLBACK_ATR_MULT)))
p_stop, p_target = signals.compute_stop_target(pullback, atr, recent_low)
levels.append({
'label': '눌림목 매수가', 'price': pullback, 'tap': True,
'hint': f'20일선({sma20:,.0f})과 전일종가−ATR({prev_close - atr * config.PULLBACK_ATR_MULT:,.0f}) 중 높은 값. '
f'여기까지 눌리면 매수 검토(지정가로 걸어둠).',
})
if recent_high:
levels.append({
'label': '돌파 기준가', 'price': int(recent_high), 'tap': True,
'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 이 위로 거래량 실려 뚫으면 돌파 매수 신호.',
})
levels.append({
'label': '(눌림목 매수 시) 예상 손절', 'price': p_stop, 'tap': False,
'hint': f'매수가−ATR×{config.STOP_ATR_MULT:g}와 최근 {config.SWING_LOW_LOOKBACK}일 저점 중 위쪽. 여기 깨지면 손절.',
})
levels.append({
'label': '(눌림목 매수 시) 목표가', 'price': p_target, 'tap': False,
'hint': f'손익비 {config.RR_RATIO:g}:1 — 손절폭의 {config.RR_RATIO:g}배 위. 1차 익절 목표.',
})
else: # SELL — 보유 청산. 평단 고정은 고수익주에서 붕괴 → 지지·저항·트레일링으로.
trail = int(round(price - atr * config.TRAIL_ATR_MULT))
levels.append({
'label': '트레일링 손절', 'price': trail, 'tap': True,
'hint': f'현재가−ATR×{config.TRAIL_ATR_MULT:g}. 고점 대비 이만큼 되돌리면 익절/이탈(수익 보호).',
})
levels.append({
'label': '20일선 지지', 'price': int(round(sma20)), 'tap': True,
'hint': '20일 이동평균선. 추세 지지선 — 종가로 깨지면 상승추세 이탈 신호.',
})
if recent_low:
levels.append({
'label': f'{config.SWING_LOW_LOOKBACK}일 스윙저점', 'price': int(recent_low), 'tap': True,
'hint': f'최근 {config.SWING_LOW_LOOKBACK}일 최저가. 이 아래로 밀리면 하락 전환 경계.',
})
if recent_high:
levels.append({
'label': '전고점(저항/목표)', 'price': int(recent_high), 'tap': True,
'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 1차 저항 — 도달 시 분할 익절 고려.',
})
# 평단 근처(±ATR)일 때만 평단 기준 손절선 추가 — 그 밖(고수익/큰손실)에선 무의미해 생략.
if avg_price and abs(price - avg_price) <= atr:
a_stop, _ = signals.compute_stop_target(int(avg_price), atr, recent_low)
levels.append({
'label': '평단 기준 손절', 'price': a_stop, 'tap': True,
'hint': f'평단({avg_price:,})−ATR×{config.STOP_ATR_MULT:g}와 스윙저점 중 위쪽. 본전 근처에서 손실 방어선.',
})
result = {
'levels': levels, 'price': int(price), 'atr': int(round(atr)),
'sma20': int(round(sma20)), 'rsi': (round(rsi) if rsi is not None else None),
'recent_low': int(recent_low) if recent_low else None,
'recent_high': int(recent_high) if recent_high else None,
}
with _advice_cache_lock:
_advice_cache[cache_key] = (result, now)
return result
INDICES_CACHE_TTL = 30.0
_indices_cache: dict = {'data': None, 'expires_at': 0.0}
_indices_lock = threading.Lock()
_indices_refresh_in_flight = False
# ticker 회전 순서: 국내 → 미국 → 환율/원자재. ex.map으로 입력 순서 보존.
_INDEX_SOURCES = (
('naver_kr', 'KOSPI', 'KOSPI', None),
('naver_kr', 'KOSDAQ', 'KOSDAQ', None),
('naver_gl', '.INX', 'S&P500', None),
('naver_gl', '.IXIC', 'NASDAQ', None),
('naver_gl', '.N225', '닛케이225', None),
('yahoo', 'KRW=X', 'USD/KRW', 2),
('yahoo', 'CL=F', 'WTI', 2),
('yahoo', 'GC=F', '금', 2),
('yahoo', 'SI=F', '은', 2),
('yahoo', 'HG=F', '동', 3),
)
def _fmt_quote(label: str, price: float, change: float, pct: float, decimals: int = 2) -> dict:
direction = 'up' if change > 0 else ('down' if change < 0 else 'flat')
return {
'kind': 'index',
'label': label,
'price': f'{price:,.{decimals}f}',
'change': f'{abs(change):,.{decimals}f}',
'pct': f'{abs(pct):.2f}',
'direction': direction,
}
def _fetch_naver_index(code: str, label: str, base: str) -> dict | None:
"""네이버 stock API로 지수 1개 fetch. base는 국내(`m.stock.naver.com/api`) 또는 해외(`api.stock.naver.com`)."""
import urllib.request
try:
url = f'{base}/index/{code}/basic'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
raw = resp.read()
data = json.loads(raw.decode('utf-8', 'ignore'))
price_s = str(data.get('closePrice') or '').replace(',', '')
change_s = str(data.get('compareToPreviousClosePrice') or '').replace(',', '')
pct_s = str(data.get('fluctuationsRatio') or '').replace(',', '')
if not price_s:
return None
return _fmt_quote(label, float(price_s), float(change_s or 0), float(pct_s or 0), 2)
except Exception as e:
sys.stderr.write(f'naver index fetch {code} failed: {e}\n')
return None
def _fetch_yahoo_quote(symbol: str, label: str, decimals: int = 2) -> dict | None:
"""Yahoo Finance chart API로 종목·환율·원자재 1개 fetch. 마지막 두 close 비교로 등락."""
import urllib.request
try:
url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=2d&interval=1d'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
raw = resp.read()
data = json.loads(raw.decode('utf-8', 'ignore'))
closes = [x for x in data['chart']['result'][0]['indicators']['quote'][0]['close'] if x is not None]
if not closes:
return None
cur = closes[-1]
prev = closes[-2] if len(closes) >= 2 else cur
change = cur - prev
pct = (change / prev * 100) if prev else 0.0
return _fmt_quote(label, cur, change, pct, decimals)
except Exception as e:
sys.stderr.write(f'yahoo fetch {symbol} failed: {e}\n')
return None
def _fetch_one_index(spec) -> dict | None:
kind, code, label, decimals = spec
if kind == 'naver_kr':
return _fetch_naver_index(code, label, 'https://m.stock.naver.com/api')
if kind == 'naver_gl':
return _fetch_naver_index(code, label, 'https://api.stock.naver.com')
if kind == 'yahoo':
return _fetch_yahoo_quote(code, label, decimals)
return None
def _fetch_indices_now() -> list[dict]:
"""10개 source 병렬 fetch (ex.map으로 순서 보존). 각 source는 3s timeout."""
with ThreadPoolExecutor(max_workers=len(_INDEX_SOURCES)) as ex:
results = list(ex.map(_fetch_one_index, _INDEX_SOURCES))
return [r for r in results if r]
def _refresh_indices_async() -> None:
"""SWR 백그라운드 갱신. single-flight 가드로 다중 fetch 차단."""
global _indices_refresh_in_flight
with _indices_lock:
if _indices_refresh_in_flight:
return
_indices_refresh_in_flight = True
def _run():
global _indices_refresh_in_flight
try:
data = _fetch_indices_now()
if data:
with _indices_lock:
_indices_cache['data'] = data
_indices_cache['expires_at'] = time.time() + INDICES_CACHE_TTL
except Exception as e:
sys.stderr.write(f'indices background refresh failed: {e}\n')
finally:
with _indices_lock:
_indices_refresh_in_flight = False
threading.Thread(target=_run, daemon=True, name='indices-swr').start()
def _fetch_indices() -> list[dict]:
"""ticker 회전용 시장 지표 — KOSPI/KOSDAQ + 미국 3대 지수 + USD/KRW + WTI/금.
TTL 30s + stale-while-revalidate: 캐시 있으면 즉시 반환(만료여도 stale 반환 + 백그라운드 갱신).
캐시 비었을 때만 동기 병렬 fetch. 첫 cold ~300ms, 이후 사용자는 indices 대기 없음."""
now = time.time()
with _indices_lock:
cached = _indices_cache['data']
fresh = cached is not None and _indices_cache['expires_at'] > now
if cached:
if not fresh:
_refresh_indices_async()
return cached
data = _fetch_indices_now()
if data:
with _indices_lock:
_indices_cache['data'] = data
_indices_cache['expires_at'] = now + INDICES_CACHE_TTL
return data
return []
# 시장 상황 카드 — ADR(상승/하락 종목수)과 투자자별 매매(개인/외국인/기관) 데이터.
# 네이버 m.stock 비공식 API 한 콜로 KOSPI/KOSDAQ 둘 다 fetch.
# 분당 변화 폭이 작아 5분 TTL + stale-while-revalidate.
MARKET_INDICATORS_TTL = 300.0
_market_indicators_cache: dict = {'data': None, 'expires_at': 0.0}
_market_indicators_lock = threading.Lock()
_market_indicators_refresh_in_flight = False
_MARKET_INDICATOR_SYMBOLS = (('KOSPI', '코스피'), ('KOSDAQ', '코스닥'))
def _parse_signed_int(s) -> int | None:
"""네이버 응답은 '+13,930' / '-25,461' / '0' 형태. 콤마·부호 제거 후 정수."""
if s is None:
return None
try:
return int(str(s).replace(',', '').replace('+', '').strip())
except (ValueError, TypeError):
return None
def _parse_count(s) -> int:
"""upDownStockInfo 값은 '4' / '1,450' / '839' — 콤마 포함 가능. 실패 시 0."""
if s is None:
return 0
try:
return int(str(s).replace(',', '').strip())
except (ValueError, TypeError):
return 0
def _fetch_one_market_indicator(symbol_label: tuple[str, str]) -> dict | None:
"""네이버 m.stock integration API 한 콜로 시장 1개의 dealTrendInfo + upDownStockInfo 수집."""
import urllib.request
symbol, label = symbol_label
try:
url = f'https://m.stock.naver.com/api/index/{symbol}/integration'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
raw = resp.read()
data = json.loads(raw.decode('utf-8', 'ignore'))
except Exception as e:
sys.stderr.write(f'market indicator fetch {symbol} failed: {e}\n')
return None
up_down = data.get('upDownStockInfo') or {}
rise = _parse_count(up_down.get('riseCount'))
upper = _parse_count(up_down.get('upperCount'))
fall = _parse_count(up_down.get('fallCount'))
lower = _parse_count(up_down.get('lowerCount'))
steady = _parse_count(up_down.get('steadyCount'))
adv = rise + upper
dec = fall + lower
adr = (adv / dec * 100.0) if dec else None
deal = data.get('dealTrendInfo') or {}
personal = _parse_signed_int(deal.get('personalValue'))
foreign = _parse_signed_int(deal.get('foreignValue'))
institutional = _parse_signed_int(deal.get('institutionalValue'))
return {
'symbol': symbol,
'label': label,
'rise': rise,
'upper': upper,
'fall': fall,
'lower': lower,
'steady': steady,
'adr': adr,
'bizdate': str(deal.get('bizdate') or '').strip() or None,
'personal': personal,
'foreign': foreign,
'institutional': institutional,
}
def _fetch_market_indicators_now() -> list[dict]:
with ThreadPoolExecutor(max_workers=len(_MARKET_INDICATOR_SYMBOLS)) as ex:
results = list(ex.map(_fetch_one_market_indicator, _MARKET_INDICATOR_SYMBOLS))
return [r for r in results if r]
# 분기 영업이익 캐시 — (value_eok, period, fetched_at). 분기 데이터는 자주 안 변해 6h TTL.
_QUARTER_OP_CACHE: dict[str, tuple] = {}
_QUARTER_OP_TTL = 6 * 3600
def _fetch_quarter_op_profit(code: str) -> dict | None:
"""네이버 m.stock 분기 재무에서 '최근 확정 분기' 영업이익(억원) + 분기 라벨 반환.
네이버는 응답에 미래 분기 컨센서스(증권사 추정)를 isConsensus='Y'로 섞어 준다.
그 추정치만 제외하고, 확정(N) 분기 중 가장 최근 것을 채택한다.
(값 자체에 대한 임계 가드는 두지 않는다 — 네이버의 확정 분류를 신뢰. 호황기 급증
실적을 오탐 차단하던 과거 가드를 제거함.)
반환: {'value': 억원(int), 'period': 'YYYY.MM'} 또는 None.
"""
import time as _t
now = _t.time()
hit = _QUARTER_OP_CACHE.get(code)
if hit and (now - hit[2]) < _QUARTER_OP_TTL:
return {'value': hit[0], 'period': hit[1]} if hit[0] is not None else None
import urllib.request
try:
url = f'https://m.stock.naver.com/api/stock/{code}/finance/quarter'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
data = json.loads(resp.read().decode('utf-8', 'ignore'))
fi = data.get('financeInfo') or {}
titles = fi.get('trTitleList') or []
op_cols = {}
for r in (fi.get('rowList') or []):
if r.get('title') == '영업이익':
op_cols = r.get('columns') or {}
break
# 컨센서스(추정) 제외, 확정 분기 key 내림차순(최근 우선)
cand = [t for t in titles if t.get('isConsensus') != 'Y' and t.get('key')]
cand.sort(key=lambda t: t['key'], reverse=True)
chosen = None
for t in cand:
raw = (op_cols.get(t['key']) or {}).get('value')
if raw in (None, '', '-'):
continue
try:
val = int(str(raw).replace(',', ''))
except ValueError:
continue
chosen = {'value': val, 'period': (t.get('title') or '').rstrip('.')}
break
_QUARTER_OP_CACHE[code] = (chosen['value'] if chosen else None,
chosen['period'] if chosen else None, now)
return chosen
except Exception as e:
sys.stderr.write(f'quarter op_profit fetch {code} failed: {e}\n')
return None
def _refresh_market_indicators_async() -> None:
global _market_indicators_refresh_in_flight
with _market_indicators_lock:
if _market_indicators_refresh_in_flight:
return
_market_indicators_refresh_in_flight = True
def _run():
global _market_indicators_refresh_in_flight
try:
data = _fetch_market_indicators_now()
if data:
with _market_indicators_lock:
_market_indicators_cache['data'] = data
_market_indicators_cache['expires_at'] = time.time() + MARKET_INDICATORS_TTL
except Exception as e:
sys.stderr.write(f'market indicators background refresh failed: {e}\n')
finally:
with _market_indicators_lock:
_market_indicators_refresh_in_flight = False
threading.Thread(target=_run, daemon=True, name='market-indicators-swr').start()
def _fetch_market_indicators() -> list[dict]:
"""KOSPI/KOSDAQ의 ADR·투자자별 매매. 5분 TTL + stale-while-revalidate.
실패시 빈 리스트 — caller(_render_market_panel)가 graceful degrade 처리."""
now = time.time()
with _market_indicators_lock:
cached = _market_indicators_cache['data']
fresh = cached is not None and _market_indicators_cache['expires_at'] > now
if cached:
if not fresh:
_refresh_market_indicators_async()
return cached
data = _fetch_market_indicators_now()
if data:
with _market_indicators_lock:
_market_indicators_cache['data'] = data
_market_indicators_cache['expires_at'] = now + MARKET_INDICATORS_TTL
return data
return []
def _load_holidays() -> set[str]:
"""state/market_holidays.json에서 KRX 휴장일 set(YYYY-MM-DD) 로드. 파일 없거나 깨지면 빈 set.
파일 mtime 기반 lazy reload — holiday_sync가 갱신하면 다음 호출에서 자동 반영."""
if not HOLIDAYS_FILE.exists():
return set()
try:
mtime = HOLIDAYS_FILE.stat().st_mtime
except OSError:
return _holidays_cache.get('set', set())
if mtime != _holidays_cache.get('mtime'):
try:
data = json.loads(HOLIDAYS_FILE.read_text())
_holidays_cache['set'] = set((data.get('holidays') or {}).keys())
_holidays_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'holidays load failed: {e}\n')
return _holidays_cache.get('set', set())
def _load_traded_codes() -> set[str]:
"""trade_journal.jsonl 에서 distinct 종목코드 set 로드. mtime 기반 lazy reload.
워치리스트·관심종목 행에서 '거래내역' 버튼 노출 여부 판정용."""
if not TRADE_JOURNAL_FILE.exists():
return set()
try:
mtime = TRADE_JOURNAL_FILE.stat().st_mtime
except OSError:
return _traded_codes_cache.get('set', set())
if mtime != _traded_codes_cache.get('mtime'):
codes: set[str] = set()
try:
for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
d = json.loads(ln)
c = d.get('code')
if c:
codes.add(c)
except json.JSONDecodeError:
continue
_traded_codes_cache['set'] = codes
_traded_codes_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'trade codes load failed: {e}\n')
return _traded_codes_cache.get('set', set())
def _holding_since_map() -> dict:
"""trade_journal.jsonl 재구성 → {code: 'YYYYMMDD'} 현재 연속 보유 시작일.
보유종목 '매수후 고점대비' 낙폭 계산용. mtime 기반 lazy reload.
계좌·owner 무관 code 단위 순매수 누적 — 보유수량이 0으로 떨어지면 streak 리셋,
재진입 시 마지막 streak 시작일. (드물게 본인·가희 동시보유 시 더 이른 날로 근사)."""
if not TRADE_JOURNAL_FILE.exists():
return {}
try:
mtime = TRADE_JOURNAL_FILE.stat().st_mtime
except OSError:
return _holding_since_cache.get('map', {})
if mtime != _holding_since_cache.get('mtime'):
try:
events: dict[str, list[tuple[str, int]]] = {}
for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
d = json.loads(ln)
except json.JSONDecodeError:
continue
c = d.get('code')
if not c:
continue
day = (d.get('date') or '').replace('-', '')
net = int(d.get('buy_qty') or 0) - int(d.get('sell_qty') or 0)
events.setdefault(c, []).append((day, net))
out: dict[str, str] = {}
for c, evs in events.items():
evs.sort(key=lambda x: x[0])
run = 0
start = None
for day, net in evs:
if run <= 0 and net > 0:
start = day
run += net
if run <= 0:
run = 0
start = None
if run > 0 and start:
out[c] = start
_holding_since_cache['map'] = out
_holding_since_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'holding_since load failed: {e}\n')
return _holding_since_cache.get('map', {})
_US_EASTERN = ZoneInfo('America/New_York')
def _us_market_open() -> bool:
"""미국 야간/새벽 sentiment 블록 표시 기간 — KST 22:30 ~ 다음날 10:00.
미국 정규장(ET 09:30~16:00 = KST 22:30~05:00 DST) 시간 + 미국장 마감 후 한국 정규장 개장 1시간까지.
한국 정규장 시작 후 10:00 이전엔 야간 미국 마감 데이터가 여전히 sentiment 참조용으로 유효."""
now_kst = datetime.now(KST)
hm = now_kst.hour * 60 + now_kst.minute
return hm >= 22 * 60 + 30 or hm < 10 * 60
# 미국 장 시간에 시장 카드 KOSPI/KOSDAQ 자리를 채울 KOSPI 200 + MSCI Korea(EWY) quote 캐시.
# 5분 TTL. _fetch_yahoo_quote 재사용. 한국 정규장 시간엔 fetch X (캐시도 만료 → 빈 결과).
_EXTRA_MARKET_QUOTES_TTL = 300.0
_extra_market_quotes_cache: dict = {'data': None, 'expires_at': 0.0}
_extra_market_quotes_lock = threading.Lock()
# 지수 차트 sparkline 캐시 — 모달의 lazy fetch. 5분 TTL. label → Yahoo symbol 매핑은 _IDX_CHART_SYMBOL.
_IDX_CHART_SYMBOL = {
'KOSPI': '^KS11',
'KOSDAQ': '^KQ11',
'S&P500': '^GSPC',
'필라델피아 반도체': '^SOX',
'MSCI Korea (EWY)': 'EWY',
'VIX (공포지수)': '^VIX',
'미국채 10년 (%)': '^TNX',
}
_IDX_CHART_TTL = 300.0
_idx_chart_cache: dict = {} # symbol -> {'data': list[float], 'expires_at': float}
_idx_chart_lock = threading.Lock()
def _fetch_idx_chart_series(symbol: str) -> list[dict] | None:
"""Yahoo chart API 로 최근 1개월 일별 close 시리즈. 5분 TTL per-symbol. 각 점은 {'d': iso_date, 'v': close}."""
now = time.time()
with _idx_chart_lock:
c = _idx_chart_cache.get(symbol)
if c and c.get('expires_at', 0) > now:
return c['data']
import urllib.request
try:
url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=1mo&interval=1d'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=4.0) as resp:
data = json.loads(resp.read().decode('utf-8', 'ignore'))
result = data.get('chart', {}).get('result') or []
if not result:
return None
timestamps = result[0].get('timestamp') or []
closes = result[0]['indicators']['quote'][0].get('close') or []
series: list[dict] = []
for ts, c in zip(timestamps, closes):
if c is None:
continue
try:
d_iso = datetime.fromtimestamp(int(ts), tz=KST).strftime('%Y-%m-%d')
except Exception:
continue
series.append({'d': d_iso, 'v': float(c)})
if len(series) < 2:
return None
with _idx_chart_lock:
_idx_chart_cache[symbol] = {'data': series, 'expires_at': now + _IDX_CHART_TTL}
return series
except Exception as e:
sys.stderr.write(f'idx chart fetch {symbol} failed: {e}\n')
return None
def _fetch_extra_market_quotes() -> list[dict] | None:
"""미국 장 시간 전용: KOSPI 200 + MSCI Korea(EWY) 두 quote. 5분 캐시.
fetch 실패하거나 부분 실패면 빈 quote 자리 None 으로. 모두 실패면 None 리턴."""
now = time.time()
with _extra_market_quotes_lock:
cached = _extra_market_quotes_cache['data']
fresh = cached is not None and _extra_market_quotes_cache['expires_at'] > now
if cached and fresh:
return cached
results = []
for sym, lbl, dec in (
('EWY', 'MSCI Korea (EWY)', 2),
('^SOX', '필라델피아 반도체', 2),
('^VIX', 'VIX (공포지수)', 2),
('^TNX', '미국채 10년 (%)', 3),
):
q = _fetch_yahoo_quote(sym, lbl, dec)
results.append(q)
if not any(results):
return None
with _extra_market_quotes_lock:
_extra_market_quotes_cache['data'] = results
_extra_market_quotes_cache['expires_at'] = now + _EXTRA_MARKET_QUOTES_TTL
return results
def _market_active_now() -> bool:
"""NXT 운영 시간대(평일 08:00~20:00 KST) 안 + KRX 휴장일 아님.
이 조건 밖에선 자동 갱신 토글이 비활성화된다.
휴장일 데이터는 holiday_sync가 주 1회 investing.com에서 갱신."""
now = datetime.now(KST)
if now.weekday() >= 5: # 토(5)·일(6)
return False
if now.hour < 8 or now.hour >= 20:
return False
if now.strftime('%Y-%m-%d') in _load_holidays():
return False
return True
def _show_day_change_now() -> bool:
"""종목 등락률 표시 시간대 — 평일 08:00 ~ 다음날 06:00 (KST).
NXT 마감(20:00) 이후에도 다음날 새벽 06:00까지 직전 거래일 등락률을 계속 표시.
주말·휴장일은 표시 X (단, 직전 거래일이 평일 거래일이면 그 새벽 시간대까지는 ON)."""
now = datetime.now(KST)
today_str = now.strftime('%Y-%m-%d')
holidays = _load_holidays()
wd = now.weekday()
hour = now.hour
# 평일 08:00 이후 — 오늘 거래일 진행 (휴장 아닐 때)
if wd < 5 and hour >= 8 and today_str not in holidays:
return True
# 평일 또는 토요일 00:00~06:00 — 직전 거래일이 평일·비휴장이면 그 결과 표시
if hour < 6 and wd <= 5:
prev = now - timedelta(days=1)
if prev.weekday() < 5 and prev.strftime('%Y-%m-%d') not in holidays:
return True
return False
def _market_phase_state() -> dict:
"""현재 시각 기준 시장 phase 판정. 거래 모달 시간 가드용.
- regular(정규장): 평일 09:00~15:30 — KRX+NXT 거래 가능
- nxt(NXT): 평일 08:00~09:00 + 15:30~20:00 — NXT만 거래 가능
- closed(장외): 평일 그 외 — 매매 불가
- holiday(휴장): KRX 휴장일 — 매매 불가
- weekend(주말): 토·일 — 매매 불가
"""
now = datetime.now(KST)
time_str = now.strftime('%H:%M')
if now.weekday() >= 5:
return {'phase': 'weekend', 'label': '주말 휴장', 'active': False, 'time': time_str}
if now.strftime('%Y-%m-%d') in _load_holidays():
return {'phase': 'holiday', 'label': '휴장일', 'active': False, 'time': time_str}
hm = now.hour * 60 + now.minute
if 9 * 60 <= hm < 15 * 60 + 30:
return {'phase': 'regular', 'label': '정규장', 'active': True, 'time': time_str}
if 8 * 60 <= hm < 20 * 60:
return {'phase': 'nxt', 'label': 'NXT 시장', 'active': True, 'time': time_str}
return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str}
def _bump_panel_signal() -> None:
"""주문 접수/취소 등 이벤트 발생 시 state/fill_signal 갱신 → 브라우저 1초 틱(realtime)이
fill_seq 변화를 감지해 패널을 1회 갱신. 3초 주기 전면 swap 폐지(2026-07-22, 이벤트 기반 전환)
후 미체결·PIN 배지 신선도 유지용. fill_watcher 가 체결 시 쓰는 같은 파일. 실패 무시."""
try:
(WORKSPACE / 'state' / 'fill_signal').write_text(str(time.time()), encoding='utf-8')
except Exception:
pass
def _is_preopen_market_day() -> bool:
"""거래일 08:00 전. 장전 가격으로 당일 손익을 만들지 않는다."""
now = datetime.now(KST)
return (
now.weekday() < 5
and now.hour < 8
and now.strftime('%Y-%m-%d') not in _load_holidays()
)
def _load_watchlist() -> list[dict]:
if not WATCHLIST.exists():
return []
data = json.loads(WATCHLIST.read_text())
return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
def _load_interests() -> list[dict]:
if not INTERESTS.exists():
return []
data = json.loads(INTERESTS.read_text())
return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
"""ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s.
캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함.
batch 실패·응답 누락 종목은 _fetch_quote 단건(ka10001) fallback."""
import kiwoom_client as kc
now = time.time()
cards: list[dict] = []
miss_codes: list[str] = []
miss_indices: list[int] = []
for e in entries:
out = dict(e)
code = (e.get('code') or '').strip()
name = e.get('stock', '')
token = code or name
out['quote_error'] = None
out['price'] = None
out['day_change'] = None
out['day_change_pct'] = None
out['open'] = None
out['high'] = None
out['low'] = None
cards.append(out)
if not token:
out['quote_error'] = '종목 식별 실패'
continue
with _quote_cache_lock:
cached = _quote_cache.get(token)
if cached and cached[1] > now:
snap = cached[0]
out['price'] = snap['price']
out['day_change'] = snap['day_change']
out['day_change_pct'] = snap['day_change_pct']
out['quote_error'] = snap['quote_error']
out['open'] = snap.get('open')
out['high'] = snap.get('high')
out['low'] = snap.get('low')
out['volume'] = snap.get('volume')
out['_nxt_volume'] = snap.get('_nxt_volume', 0)
out['_krx_price'] = snap.get('_krx_price', 0)
out['_nxt_price'] = snap.get('_nxt_price', 0)
continue
if code:
miss_codes.append(code)
miss_indices.append(len(cards) - 1)
else:
# 코드 없는 entry는 resolve 거쳐야 해서 단건 호출 — 거의 없음.
cards[-1] = _fetch_quote(e)
if not miss_codes:
return cards
nxt_probe_codes = list(miss_codes)
nxt_probe_indices = list(miss_indices)
# WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당.
# 서버가 동일 렌더러로 OHLC를 그린다. 단, NXT 거래가능 초록점은 허브 exchange가 아니라
# 아래 ka10095 NX 단독 응답 가능 여부로만 판정한다.
# 허브에 없는(아직 미체결) 종목만 ka10095 AL 폴백으로 남긴다. 캐시엔 안 넣음 — 매 페치 라이브 허브 사용.
hub = _RT_HUB
if hub is not None and hub.is_connected():
hub_q = hub.get_quotes(miss_codes)
rest_codes: list[str] = []
rest_indices: list[int] = []
for idx, code in zip(miss_indices, miss_codes):
q = hub_q.get(kc._clean_code(code))
price = (q or {}).get('price') or 0
if not price:
rest_codes.append(code)
rest_indices.append(idx)
continue
out = cards[idx]
out['price'] = price
out['day_change'] = q.get('change')
out['day_change_pct'] = q.get('pct')
out['open'] = q.get('open') or None
out['high'] = q.get('high') or None
out['low'] = q.get('low') or None
out['volume'] = q.get('volume')
out['_krx_price'] = price
out['_nxt_price'] = 0
miss_codes, miss_indices = rest_codes, rest_indices
batch: dict[str, dict] = {}
if miss_codes:
try:
batch = kc.get_watchlist_quotes(miss_codes)
except Exception as ex:
sys.stderr.write(f'ka10095 batch 실패, ka10001 fallback: {ex}\n')
for idx in miss_indices:
cards[idx] = _fetch_quote(entries[idx])
# NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리).
# get_watchlist_quotes는 NXT 단독 suffix를 'NX'로 받는다. 'NXT'는 suffix 없이 KRX/기본값으로
# 떨어져 NXT 미거래 종목도 거래 가능처럼 보이므로 쓰면 안 된다.
nxt_batch: dict[str, dict] = {}
try:
nxt_batch = kc.get_watchlist_quotes(nxt_probe_codes, exchange='NX')
except Exception as ex:
sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n')
for idx, code in zip(nxt_probe_indices, nxt_probe_codes):
nxt_q = nxt_batch.get(code) if nxt_batch else None
cards[idx]['_nxt_price'] = (nxt_q or {}).get('price', 0) if nxt_q else 0
# NXT 거래량 — RVOL은 KRX 기준(일봉 평균과 동일)이라 KRX=AL−NXT 계산에 사용.
cards[idx]['_nxt_volume'] = (nxt_q or {}).get('volume', 0) if nxt_q else 0
for idx, code in zip(miss_indices, miss_codes):
q = batch.get(code)
out = cards[idx]
if not q:
cards[idx] = _fetch_quote(entries[idx])
continue
price = q.get('price') or 0
if price:
out['price'] = price
out['day_change'] = q.get('change')
out['day_change_pct'] = q.get('change_pct')
out['open'] = q.get('open') or None
out['high'] = q.get('high') or None
out['low'] = q.get('low') or None
out['volume'] = q.get('volume')
else:
out['quote_error'] = '가격 0'
# 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
nxt_price = out.get('_nxt_price', 0) or 0
out['_krx_price'] = price
out['_nxt_price'] = nxt_price
snap = {
'price': out['price'],
'day_change': out['day_change'],
'day_change_pct': out['day_change_pct'],
'quote_error': out['quote_error'],
'open': out['open'],
'high': out['high'],
'low': out['low'],
'volume': out.get('volume'),
'_nxt_volume': out.get('_nxt_volume', 0),
'_krx_price': price,
'_nxt_price': nxt_price,
}
with _quote_cache_lock:
_quote_cache[code] = (snap, time.time() + QUOTE_CACHE_TTL)
return cards
def _fetch_quote(entry: dict) -> dict:
"""키움 ka10001 — 단일 종목 현재가 fallback. 평소 _fetch_quotes_batch가 처리.
종목별 QUOTE_CACHE_TTL 캐시로 batch 실패 시에도 rate limit 완화."""
import kiwoom_client as kc
out = dict(entry)
code = (entry.get('code') or '').strip()
name = entry.get('stock', '')
target_token = code or name
out['quote_error'] = None
out['price'] = None
out['day_change'] = None
out['day_change_pct'] = None
out['open'] = None
out['high'] = None
out['low'] = None
if not target_token:
out['quote_error'] = '종목 식별 실패'
return out
now = time.time()
with _quote_cache_lock:
cached = _quote_cache.get(target_token)
if cached and cached[1] > now:
snap = cached[0]
out['price'] = snap['price']
out['day_change'] = snap['day_change']
out['day_change_pct'] = snap['day_change_pct']
out['quote_error'] = snap['quote_error']
out['open'] = snap.get('open')
out['high'] = snap.get('high')
out['low'] = snap.get('low')
out['volume'] = snap.get('volume')
return out
try:
info = kc.resolve_stock_code(target_token) if not code else {'code': code}
q = kc.get_stock_quote(info['code'])
price = q.get('price') or 0
if not price:
out['quote_error'] = '가격 0'
else:
out['price'] = price
out['day_change'] = q.get('change')
out['day_change_pct'] = q.get('change_pct')
out['volume'] = q.get('volume')
except Exception as e:
out['quote_error'] = str(e)[:80]
snap = {
'price': out['price'],
'day_change': out['day_change'],
'day_change_pct': out['day_change_pct'],
'quote_error': out['quote_error'],
'open': out['open'],
'high': out['high'],
'low': out['low'],
'volume': out.get('volume'),
}
with _quote_cache_lock:
_quote_cache[target_token] = (snap, time.time() + QUOTE_CACHE_TTL)
return out
def _holdings_map_from_positions(positions_by_acc: dict) -> dict:
"""positions_by_acc → 종목코드 keyed dict (워치리스트 카드 annotate용)."""
agg: dict = {}
for label, positions in positions_by_acc.items():
for p in positions:
code = p.get('code') or ''
if not code:
continue
cur = agg.get(code)
if cur:
new_qty = cur['qty'] + p['qty']
new_pur = cur['pur_amt'] + p['pur_amt']
cur['qty'] = new_qty
cur['pur_amt'] = new_pur
cur['avg_price'] = new_pur // new_qty if new_qty else 0
cur['accounts'].append(label)
else:
agg[code] = {
'qty': p['qty'],
'pur_amt': p['pur_amt'],
'avg_price': p['avg_price'],
'name': p['name'],
'accounts': [label],
}
return agg
def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label: dict, prev_snap_all: dict | None = None, cash_flow_by_label: dict | None = None) -> tuple[list[str], dict]:
"""계좌 데이터 → owner 그룹별 집계. stock_portfolio_report와 동일 로직.
`당일 평가손익`은 prev_snap_all(전날 stock.briefing 스냅샷) 기반 `total_net - prev_net - net_cash_flow`.
이 차이값은 보유분의 미실현 등락 + 오늘 매매의 실현손익 + 매수/매도 현금흐름까지 자연스럽게 포함하되,
외부 입출금(cash flow)은 손익이 아니므로 net_cash_flow로 분리해 제거한다.
스냅샷이 없으면 None — KPI에 '전날 스냅샷 없음'으로 표시.
개별 카드의 day_change도 동일 스냅샷의 NXT 종가를 baseline으로 재계산해 합계와 일관성 유지.
"""
if cash_flow_by_label is None:
cash_flow_by_label = {}
from stock_portfolio_report import consolidate, aggregate_journal, group_by_owner, rebase_to_nxt_close, enrich_journal_realized_pl
rows: list[dict] = []
for label, positions in positions_by_acc.items():
for p in positions:
rows.append({
'account': label,
'stock': p['name'],
'code': p['code'],
'qty': p['qty'],
'avg': p['avg_price'],
'price': p['cur_price'],
'pred_close': p.get('pred_close_pric', 0),
'day_change': p.get('day_change', 0),
'day_change_pct': p.get('day_change_pct', 0.0),
'buy_amount': p['pur_amt'],
'eval_value': p['evlt_amt'],
'profit': p['evltv_prft'],
'profit_rate': p['prft_rt'],
'tdy_buyq': p['tdy_buyq'],
'tdy_sellq': p['tdy_sellq'],
})
# A-4: prev_snap 없을 때만 ka10001 보정. brifing 과 동일 정책.
# 정상 운영(prev_snap 있음)에선 rebase_to_nxt_close 가 NXT-on-NXT 기준 더 정확하게 덮어쓰므로 불필요.
if not prev_snap_all and rows:
from stock_portfolio_report import apply_ka10001_corrections
try:
applied = apply_ka10001_corrections(rows)
sys.stderr.write(f'[ka10001-fallback] prev_snap 없음 → {applied}개 종목 보정\n')
except Exception as e:
sys.stderr.write(f'ka10001 fallback failed: {e}\n')
# 계좌 라벨 정렬 — set 변환의 비결정성 제거 + owner 안에서 일반→ISA 순서 고정.
# suffix(언더바 뒤 또는 본인 라벨 자체)를 기준으로 일반=0, ISA=1, 그 외 사전순.
def _account_label_sort_key(lb: str) -> tuple[int, str]:
suffix = lb.split('_', 1)[1] if '_' in lb else lb
return ({'일반': 0, 'ISA': 1}.get(suffix, 2), lb)
labels = sorted({r['account'] for r in rows} | set(balances.keys()), key=_account_label_sort_key)
owner_groups = group_by_owner(labels)
owner_data: dict = {}
for owner, lbls in owner_groups.items():
owner_rows = [r for r in rows if r['account'] in lbls]
consolidated = consolidate(owner_rows)
owner_journal = aggregate_journal({lb: journal_by_label.get(lb, []) for lb in lbls})
# ka10170이 어제부터 보유한 종목 매도분에 pl_amt=0 박는 케이스 보강 (스냅샷 avg_price 사용)
prev_holdings_for_owner = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
enrich_journal_realized_pl(owner_journal, prev_holdings_for_owner)
# 당일 실현손익 합계 — 매도분만 (그로스 기준, 수수료·세금 별도)
realized_pl_total = sum(j.get('pl_amt', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
realized_fees_total = sum(j.get('cmsn_tax', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
held_codes = {r['code'] for r in consolidated}
for r in consolidated:
j = owner_journal.get(r['code'])
if j:
r['journal'] = j
r['tdy_buyq'] = j['buy_qty']
r['tdy_sellq'] = j['sell_qty']
r['traded_today'] = (j['buy_qty'] + j['sell_qty']) > 0
for code, j in owner_journal.items():
if code in held_codes:
continue
consolidated.append({
'stock': j['name'],
'code': code,
'accounts': list(j['accounts']),
'qty': 0,
'avg': 0,
'price': 0,
'pred_close': 0,
'day_change': 0,
'day_change_pct': 0.0,
'buy_amount': 0,
'eval_value': 0,
'profit': 0,
'profit_rate': 0.0,
'tdy_buyq': j['buy_qty'],
'tdy_sellq': j['sell_qty'],
'traded_today': True,
'phantom': True,
'journal': j,
})
# rebase 직전 raw KRX 종가(kt00018 pred_close_pric)를 별도 키로 보존.
# 이후 ohlc-mini가 KRX/NXT 두 기준을 같이 표시할 때 사용.
# NXT 미상장 코드는 별도 마커로 식별 — KRX==NXT 우연 일치 false-positive 방지.
nxt_inactive_set = set((prev_snap_all or {}).get('_nxt_inactive') or [])
for r in consolidated:
r['pred_close_krx'] = r.get('pred_close', 0)
if r.get('code') in nxt_inactive_set:
r['_nxt_inactive'] = True
# 개별 종목의 day_change/pred_close를 어제 NXT 종가 baseline으로 재계산.
# kt00018 NXT 응답의 pred_close_pric가 KRX 종가로 채워져 raw day_change가 NXT-vs-KRX 갭으로
# 부풀려지는 문제를 해결한다. 합계 KPI는 동일 NXT 스냅샷의 prev_eval과 비교하므로
# 개별 합 ≈ 합계로 자연스럽게 일치한다.
prev_holdings = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
rebase_to_nxt_close(consolidated, prev_holdings)
total_cost = sum(r['buy_amount'] for r in consolidated)
total_value = sum(r['eval_value'] for r in consolidated)
total_profit = sum(r['profit'] for r in consolidated)
total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
# 예수금은 d2_entra(D+2 정산예수금) — 매수 즉시 차감/매도 즉시 가산되어
# 평가액 변동과 일관성 유지. entr은 D+2 결제 전까지 변하지 않아
# 신규 매수 시 손익이 매수원가만큼 부풀려지는 버그가 있다.
# 라벨별 dict 경유 — KPI 예수금 행의 [!] 버튼 노출 여부(계좌 2개 이상)를 여기서 판단한다.
deposit_by_label = {lb: (balances.get(lb, {}).get('d2_entra', 0) or 0) for lb in lbls}
deposit = sum(deposit_by_label.values())
total_net = total_value + deposit
traded_today = [r for r in consolidated if r.get('traded_today')]
# 당일 평가손익 = (total_net - prev_net) - net_cash_flow (전날 stock.briefing 20:10 스냅샷 기준).
prev_net = None
if prev_snap_all:
owner_prev = prev_snap_all.get(owner) or {}
prev_eval = sum(v.get('eval_value', 0) for v in prev_holdings.values()) if prev_holdings else None
prev_deposit = owner_prev.get('deposit')
if prev_eval is not None and prev_deposit is not None:
prev_net = prev_eval + prev_deposit
# 당일 외부 입출금 — 손익이 아니므로 day_pl_total 에서 제거.
owner_cash_flows: list[dict] = []
cash_in = 0
cash_out = 0
for lb in lbls:
for f in cash_flow_by_label.get(lb, []) or []:
owner_cash_flows.append({**f, 'label': lb})
if f['io_tp'] == 'IN':
cash_in += f['amount']
elif f['io_tp'] == 'OUT':
cash_out += f['amount']
net_cash_flow = cash_in - cash_out
day_pl_total = (total_net - prev_net - net_cash_flow) if prev_net is not None else None
day_pl_reason = None
if _is_preopen_market_day():
day_pl_total = None
day_pl_reason = 'preopen'
# 휴장일/주말은 시장 변동이 없어야 자연. 단 NXT 가격 보정으로 어제 스냅샷과 차이가 생겨
# day_pl_total이 0이 아닌 값을 갖게 됨 → 사용자 혼란 차단 위해 None으로 강제(미표시).
if _market_phase_state().get('phase') in ('holiday', 'weekend'):
day_pl_total = None
day_pl_reason = 'closed'
# 투자원금(누적 입금−출금) — 캐시 파일 읽기만 (키움 콜 0).
# principal None = 미집계(부트스트랩 전) → KPI 2행 미표시.
try:
import investment_principal as ip
pr = ip.get_principal(lbls)
except Exception as e:
sys.stderr.write(f'principal load failed: {e}\n')
pr = {'principal': None, 'cash_in': None, 'cash_out': None,
'qty_flow': False, 'by_label': {}}
owner_data[owner] = {
'labels': lbls,
'consolidated': consolidated,
'rows': owner_rows,
'total_cost': total_cost,
'total_value': total_value,
'total_profit': total_profit,
'total_profit_rate': total_profit_rate,
'deposit': deposit,
'deposit_by_label': deposit_by_label,
'total_net': total_net,
'traded_today': traded_today,
'prev_net': prev_net,
'day_pl_total': day_pl_total,
'day_pl_reason': day_pl_reason,
'realized_pl_total': realized_pl_total,
'realized_fees_total': realized_fees_total,
'cash_in': cash_in,
'cash_out': cash_out,
'net_cash_flow': net_cash_flow,
'cash_flows': owner_cash_flows,
'principal': pr['principal'],
'principal_cash_in': pr['cash_in'],
'principal_cash_out': pr['cash_out'],
'principal_qty_flow': pr['qty_flow'],
'principal_by_label': pr['by_label'],
}
# 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용.
# ka10095 batch 1콜로 처리. 실패해도 웹뷰는 정상 응답.
phantom_codes = sorted({
r['code']
for d in owner_data.values()
for r in d['consolidated']
if r.get('phantom') and r.get('code')
})
if phantom_codes:
try:
import kiwoom_client as kc
live_quotes = kc.get_watchlist_quotes(phantom_codes)
except Exception as e:
sys.stderr.write(f'[phantom-quote] fetch failed: {e}\n')
live_quotes = {}
for d in owner_data.values():
for r in d['consolidated']:
if r.get('phantom') and r.get('code'):
q = live_quotes.get(r['code'])
if q:
r['live_price'] = q.get('price', 0)
r['live_change_pct'] = q.get('change_pct', 0.0)
ordered = sorted(owner_data.keys(), key=lambda o: (o != '본인', o))
return ordered, owner_data
def _annotate_card(c: dict, holdings: dict) -> None:
"""카드에 보유 여부·기준가·diff·pct 부여. holdings는 코드 keyed dict.
ref_price/diff는 손익 계산용:
- 보유: 평단 기준 (P&L)
- 감시: 매수가 기준 (entry 거리)
"""
code = (c.get('code') or '').strip()
held = holdings.get(code) if code else None
price = c.get('price')
if held:
c['mode'] = 'held'
c['held_qty'] = held['qty']
c['held_avg_price'] = held['avg_price']
c['held_accounts'] = held['accounts']
c['ref_price'] = held['avg_price']
c['ref_label'] = '평단'
else:
c['mode'] = 'watching'
buy = c.get('buy') if isinstance(c.get('buy'), dict) else {}
c['ref_price'] = (buy or {}).get('primary')
c['ref_label'] = '매수가'
ref = c.get('ref_price')
if price is not None and isinstance(ref, (int, float)) and ref > 0:
c['diff'] = price - ref
c['pct'] = (c['diff'] / ref) * 100
else:
c['diff'] = None
c['pct'] = None
def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
"""한 페이지 로드 단위로 키움 호출을 묶는다.
병렬:
- kt00018(보유종목 전체 계좌)
- kt00001(예수금) × 계좌 수
- ka10170(당일매매일지 전체 계좌)
- ka10095(워치리스트 종목 현재가 batch 1콜, miss 시 ka10001 단건 fallback)
only_owner: '본인' / '가희' 등 owner 지정 시 해당 라벨 계좌만 호출하고 워치리스트 quotes는 skip
(owner 탭 자동 갱신용 — 보유종목 가격은 kt00018 평가가에 포함, 워치리스트와 무관).
어느 분기 실패하든 다른 분기 데이터는 계속 흐른다. 워치리스트가 비어도 holdings 분기는 그대로.
"""
import kiwoom_client as kc
from stock_portfolio_report import derive_owner
try:
accounts = kc.list_accounts()
except Exception as e:
sys.stderr.write(f'list_accounts failed: {e}\n')
accounts = []
all_labels = [a['label'] for a in accounts]
if only_owner:
labels = [lb for lb in all_labels if derive_owner(lb) == only_owner]
else:
labels = all_labels
quote_entries = [] if only_owner else entries
with ThreadPoolExecutor(max_workers=max(len(labels), 1) + 6) as ex:
# NXT 우선 가격 — 한국거래소 마감(15:30) 후에도 NXT 운영(20:00까지)으로 보유 종목 시세가 살아 움직임.
# NXT 미상장 종목은 키움이 KRX 가격으로 자동 fallback. 잔고 평가(eval_value 등)도 NXT 기준 재계산.
positions_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels if only_owner else None)
balance_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels}
journal_f = ex.submit(kc.get_trade_journal_all, None, labels if only_owner else None)
quotes_f = ex.submit(_fetch_quotes_batch, quote_entries)
# 미체결 (ka10075) — 보유/감시 종목에 매수/매도대기 배지 표시용
open_orders_f = ex.submit(kc.get_open_orders_all, labels=labels)
# 당일 입출금 (kt00015 tp='1') — 당일 평가손익 보정용
cash_flow_f = ex.submit(kc.get_cash_flow_all, None, labels if only_owner else None)
try:
positions_by_acc = positions_f.result()
except Exception as e:
sys.stderr.write(f'positions fetch failed: {e}\n')
positions_by_acc = {}
# 가격 보정 — KRX와 NXT 각각 ka10095 batch 호출.
# kiwoom_client.get_watchlist_quotes 의 exchange: 'KRX'=KRX 단독 / 'NX'=NXT 단독 / 'AL'=통합.
# ('NXT' 값은 함수에서 인식 안 되고 KRX 기본으로 떨어짐 — 사용 X)
# phase별 우선 시장:
# - regular(09:00~15:30): KRX 라이브 가격
# - nxt(08:00~09:00, 15:30~20:00): NXT 라이브 가격
# - 그 외(closed/holiday/weekend): kt00018 NXT 응답 그대로 유지
held_codes_set: set[str] = set()
for _items in positions_by_acc.values():
for _p in _items:
if _p.get('code'):
held_codes_set.add(_p['code'])
ohlc_map_krx: dict[str, dict] = {}
ohlc_map_nxt: dict[str, dict] = {}
ohlc_map_al: dict[str, dict] = {}
# KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt'])
# 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
# AL(통합) 1콜은 요약 행 단일 캔들(r['ohlc']) 전용 — 시가는 NXT 프리마켓 시가, 고저는 두 시장 극값.
if held_codes_set:
try:
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
except Exception as e:
sys.stderr.write(f'KRX positions price fetch failed: {e}\n')
try:
ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX')
except Exception as e:
sys.stderr.write(f'NXT positions price fetch failed: {e}\n')
try:
ohlc_map_al = kc.get_watchlist_quotes(list(held_codes_set), exchange='AL')
except Exception as e:
sys.stderr.write(f'AL positions price fetch failed: {e}\n')
_phase = _market_phase_state().get('phase')
active_map = ohlc_map_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None)
if active_map:
for _items in positions_by_acc.values():
for _p in _items:
q = active_map.get(_p.get('code'))
if not q:
continue
px = q.get('price', 0)
if not px or px <= 0:
continue
pcp = _p.get('pred_close_pric', 0) or 0
# 활성 시장 응답이 거래가 없는 fallback (price == pred & change == 0)이면 덮어쓰기 X
if pcp and px == pcp and (q.get('change') or 0) == 0:
continue
_p['cur_price'] = px
qty = _p.get('qty', 0)
avg = _p.get('avg_price', 0)
_p['evlt_amt'] = px * qty
if avg > 0:
_p['evltv_prft'] = (px - avg) * qty
_p['prft_rt'] = ((px - avg) / avg) * 100
ch = q.get('change')
if ch is not None:
_p['day_change'] = ch
_p['day_change_pct'] = q.get('change_pct') or 0.0
balances: dict = {}
for lb, fut in balance_fs.items():
try:
balances[lb] = fut.result()
except Exception as e:
sys.stderr.write(f'balance fetch failed [{lb}]: {e}\n')
try:
journal_by_label = journal_f.result()
except Exception as e:
sys.stderr.write(f'journal fetch failed: {e}\n')
journal_by_label = {}
# 휴장일/주말엔 ka10170이 직전 영업일 데이터를 반환 → 당일매매·당일정산·라벨별 실현손익에 어제 거래가 잘못 표시.
import trade_journal as tj
if not tj.is_market_open(datetime.now(tj.KST)):
journal_by_label = {}
# 페이지 RENDER마다 ka10170 응답 그대로 trade_journal.jsonl 적재 (추가 API 호출 X).
# 외부(HTS/MTS 등)에서 매매 후 자산웹 새로고침하면 즉시 거래내역 반영. 21:00 launchd가 최종 덮어씀.
if journal_by_label:
try:
tj.record_trades(journal_by_label, quiet=True, tag='render')
except Exception as e:
sys.stderr.write(f'[render→journal] record_trades failed: {e}\n')
# 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
# 전일 평단은 파일 이력 재생에서. raw 저장(record_trades) 직후라 prev_avg_map은 당일분 제외(date<오늘).
try:
_today_iso = datetime.now(tj.KST).strftime('%Y-%m-%d')
_prev_avgs = tj.prev_avg_map(_today_iso)
for _lb, _trs in journal_by_label.items():
for _t in _trs:
_pa = _prev_avgs.get((_lb, _t.get('code')), 0)
if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0:
tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa)
except Exception as e:
sys.stderr.write(f'[journal rebase] failed: {e}\n')
cards = quotes_f.result()
try:
open_orders = open_orders_f.result()
except Exception as e:
sys.stderr.write(f'open orders fetch failed: {e}\n')
open_orders = []
try:
cash_flow_by_label = cash_flow_f.result()
except Exception as e:
sys.stderr.write(f'cash flow fetch failed: {e}\n')
cash_flow_by_label = {}
# 투자원금 — 방금 받은 당일 입출금이 캐시에 미반영일 때만 올해 창 재조회(kt00016 라벨당 1콜).
# 입출금 없는 평시엔 캐시 읽기만 하고 호출 0. 부트스트랩 전이면 needs_refresh가 False.
try:
import investment_principal as ip
if ip.needs_refresh(labels, cash_flow_by_label):
ip.refresh_current(labels, cash_flow_by_label)
except Exception as e:
sys.stderr.write(f'principal refresh failed: {e}\n')
# 미체결 → owner별 + 전역 코드 keyed pending map
# pending_by_owner[owner][code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]}
# pending_global[code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]}
# orders 원소: {ord_no, side, qty, price, order_type, time, account, exchange}
def _empty_bucket():
return {'buy_qty': 0, 'sell_qty': 0, 'orders': []}
pending_by_owner: dict[str, dict[str, dict]] = {}
pending_global: dict[str, dict] = {}
pending_names_by_code: dict[str, str] = {} # 미보유 종목 카드 렌더용 종목명
pending_buy_lock_by_label: dict[str, int] = {} # 라벨별 매수 미체결 묶임 금액 (지정가만)
for o in open_orders:
code = o.get('code')
if not code:
continue
if o.get('name') and code not in pending_names_by_code:
pending_names_by_code[code] = o['name']
owner = derive_owner(o.get('account', ''))
side = o.get('side')
qty = o.get('unfilled_qty', 0)
if qty <= 0:
continue
ord_entry = {
'ord_no': o.get('ord_no', ''),
'side': side,
'qty': qty,
'price': o.get('order_price', 0),
'order_type': o.get('order_type', ''),
# 스톱지정가 조건단가 (ka10075 stop_pric) + 트레일링 예약이면 그 상태.
# 상세 표시가 "지정가"로 뭉개지지 않게 주문 성격을 그대로 실어 보낸다.
'stop_price': o.get('stop_price', 0),
'trailing': _trailing_info_for(o.get('ord_no', '')),
'time': o.get('order_time', ''),
'account': o.get('account', ''),
'exchange': o.get('exchange', ''),
}
bucket = pending_by_owner.setdefault(owner, {}).setdefault(code, _empty_bucket())
g = pending_global.setdefault(code, _empty_bucket())
if side == 'BUY':
bucket['buy_qty'] += qty
g['buy_qty'] += qty
px = o.get('order_price', 0)
if px > 0:
pending_buy_lock_by_label[o.get('account', '')] = (
pending_buy_lock_by_label.get(o.get('account', ''), 0) + qty * px
)
elif side == 'SELL':
bucket['sell_qty'] += qty
g['sell_qty'] += qty
bucket['orders'].append(ord_entry)
g['orders'].append(ord_entry)
holdings_map = _holdings_map_from_positions(positions_by_acc)
for c in cards:
_annotate_card(c, holdings_map)
# 워치리스트 카드는 owner 구분 없음 — pending_global 사용
p = pending_global.get(c.get('code') or '')
if p:
c['pending_buy_qty'] = p['buy_qty']
c['pending_sell_qty'] = p['sell_qty']
c['pending_orders'] = p['orders']
prev_key = None
prev_snap_all: dict = {}
try:
from stock_portfolio_report import SNAPSHOT_FILE, _normalize_prev_snap, load_json
snapshots = load_json(SNAPSHOT_FILE, {})
today_key = datetime.now(KST).strftime('%Y-%m-%d')
prev_keys = sorted(k for k in snapshots if k < today_key)
prev_key = prev_keys[-1] if prev_keys else None
if prev_key:
prev_snap_all = _normalize_prev_snap(snapshots.get(prev_key, {}))
# `_watchlist_nxt` / `_nxt_inactive` 가 어제 스냅샷에 없으면 오늘 스냅샷에서 fallback —
# 장중에 즉시 NXT 컬럼을 보기 위함. 오늘자는 "오늘 20:10 NXT 가격"이라 정확한 어제 마감가는 아니지만 NXT 흐름 확인엔 충분.
today_raw = snapshots.get(today_key) or {}
today_norm = _normalize_prev_snap(today_raw) if today_raw else {}
if not (isinstance(prev_snap_all.get('_watchlist_nxt'), dict) and prev_snap_all.get('_watchlist_nxt')):
if isinstance(today_norm.get('_watchlist_nxt'), dict) and today_norm['_watchlist_nxt']:
prev_snap_all['_watchlist_nxt'] = today_norm['_watchlist_nxt']
if not (isinstance(prev_snap_all.get('_nxt_inactive'), list) and prev_snap_all.get('_nxt_inactive')):
if isinstance(today_norm.get('_nxt_inactive'), list) and today_norm['_nxt_inactive']:
prev_snap_all['_nxt_inactive'] = today_norm['_nxt_inactive']
except Exception as e:
sys.stderr.write(f'snapshot load failed: {e}\n')
# watchlist·interests 카드에 어제 NXT 종가 주입 — ohlc-mini의 '전일(NXT)' 컬럼 baseline.
# stock.briefing 20:10 시점 NXT 가격이 _watchlist_nxt 키에 저장됨. 없는 코드는 0으로 두면 `-` placeholder.
wl_nxt_map = prev_snap_all.get('_watchlist_nxt') if isinstance(prev_snap_all.get('_watchlist_nxt'), dict) else {}
# NXT 미상장 코드 — KRX fallback 차단용. 보유 종목도 같이 표기되어 _pl_row가 lookup.
nxt_inactive_raw = prev_snap_all.get('_nxt_inactive') if isinstance(prev_snap_all.get('_nxt_inactive'), list) else []
nxt_inactive_set = set(nxt_inactive_raw)
if wl_nxt_map or nxt_inactive_set:
for c in cards:
code = c.get('code')
if not code:
continue
if code in nxt_inactive_set:
c['_nxt_inactive'] = True
elif code in wl_nxt_map:
c['_pred_close_nxt'] = wl_nxt_map[code]
# 매수 미체결 묶임 금액을 balances에 라벨별로 주입 (합산·계좌별 KPI 예수금 부연 표시용)
for lb in balances:
balances[lb]['pending_buy_locked'] = pending_buy_lock_by_label.get(lb, 0)
try:
ordered_owners, owner_data = _build_owner_data(positions_by_acc, balances, journal_by_label, prev_snap_all, cash_flow_by_label)
except Exception as e:
sys.stderr.write(f'owner aggregate failed: {e}\n')
traceback.print_exc()
ordered_owners, owner_data = [], {}
# owner별 매수 묶임 합산
for owner_name, od in owner_data.items():
od['pending_buy_locked'] = sum(
balances.get(lb, {}).get('pending_buy_locked', 0) for lb in od.get('labels', [])
)
# consolidated/phantom 행에 미체결 수량 주입 (owner 단위)
for owner_name, od in owner_data.items():
owner_pending = pending_by_owner.get(owner_name, {})
for r in od.get('consolidated', []):
p = owner_pending.get(r.get('code') or '')
if p:
r['pending_buy_qty'] = p['buy_qty']
r['pending_sell_qty'] = p['sell_qty']
r['pending_orders'] = p['orders']
for r in od.get('phantom', []) if isinstance(od.get('phantom'), list) else []:
p = owner_pending.get(r.get('code') or '')
if p:
r['pending_buy_qty'] = p['buy_qty']
r['pending_sell_qty'] = p['sell_qty']
r['pending_orders'] = p['orders']
# 미체결 매수 주문 중 owner 미보유 종목 → owner 패널 별도 섹션 노출.
# consolidated에 들어가지 않은 코드만 추림 (보유 + 당일정산 모두 제외).
unheld_codes_all: set[str] = set()
for owner_name, od in owner_data.items():
present_codes = {r.get('code') for r in od.get('consolidated', []) if r.get('code')}
owner_pending = pending_by_owner.get(owner_name, {})
unheld_rows: list[dict] = []
for code, p in owner_pending.items():
if code in present_codes or p['buy_qty'] <= 0:
continue
unheld_rows.append({
'code': code,
'stock': pending_names_by_code.get(code, ''),
'pending_buy_qty': p['buy_qty'],
'pending_sell_qty': p['sell_qty'],
'pending_orders': list(p['orders']),
})
unheld_codes_all.add(code)
unheld_rows.sort(key=lambda r: -r.get('pending_buy_qty', 0))
od['pending_unheld'] = unheld_rows
# 미보유 미체결 종목 현재가 batch (ka10095). 캐시 hit이면 즉시 반환.
if unheld_codes_all:
try:
unheld_quotes = kc.get_watchlist_quotes(sorted(unheld_codes_all))
except Exception as e:
sys.stderr.write(f'[pending-unheld] quote batch failed: {e}\n')
unheld_quotes = {}
for od in owner_data.values():
for r in od.get('pending_unheld', []):
q = unheld_quotes.get(r['code'])
if q:
r['price'] = q.get('price') or 0
r['day_change'] = q.get('change')
r['day_change_pct'] = q.get('change_pct')
r['open'] = q.get('open') # 미리보기 등락은 시가 대비 → open 필요
# 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용.
# ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용.
# 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 통합(AL) 기준.
if ohlc_map_krx or ohlc_map_nxt or ohlc_map_al:
_phase = _market_phase_state().get('phase')
for od in owner_data.values():
for r in od.get('consolidated', []):
code = r.get('code')
qk = ohlc_map_krx.get(code) if code else None
qn = ohlc_map_nxt.get(code) if code else None
# price 만 있어도 valid — 거래 없는 시간대(시가/고가/저가=0)도 종가만 표시되도록.
qk_valid = bool(qk and qk.get('price'))
qn_valid = bool(qn and qn.get('price'))
if qk_valid:
r['ohlc_krx'] = {
'open': qk['open'], 'high': qk['high'], 'low': qk['low'],
'price': qk.get('price', 0), 'change_pct': qk.get('change_pct', 0.0),
'change': qk.get('change', 0), 'volume': qk.get('volume', 0),
}
if qn_valid:
r['ohlc_nxt'] = {
'open': qn['open'], 'high': qn['high'], 'low': qn['low'],
'price': qn.get('price', 0), 'change_pct': qn.get('change_pct', 0.0),
'change': qn.get('change', 0), 'volume': qn.get('volume', 0),
}
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
# AL(통합) 우선 — 시간대에 따라 KRX↔NXT 로 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 응답이 불완전할 때만 phase 우선순위로 폴백.
qa = ohlc_map_al.get(code) if code else None
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low')
qa_full = bool(qa and qa.get('price') and qa.get('open') and qa.get('high') and qa.get('low'))
if qa_full:
primary = qa
elif _phase == 'regular':
primary = qk if qk_full else (qn if qn_full else None)
else:
primary = qn if qn_full else (qk if qk_full else None)
if primary:
r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)}
return {
'cards': cards,
'balances': balances,
'ordered_owners': ordered_owners,
'owner_data': owner_data,
'journal_by_label': journal_by_label,
'only_owner': only_owner,
}
def _apply_watchlist_action(action: str, stock: str) -> None:
"""delete = pending_delete로 표시. restore = active로 복귀. purge = 완전 삭제.
behive_youtube_digest.watchlist_lock과 동일한 lockfile을 사용해 cron save와의 race를 차단한다.
"""
import behive_youtube_digest as byd
with byd.watchlist_lock():
if not WATCHLIST.exists():
raise KeyError(stock)
data = json.loads(WATCHLIST.read_text())
if stock not in data:
raise KeyError(stock)
if action == 'delete':
data[stock]['status'] = 'pending_delete'
data[stock]['pending_delete_at'] = datetime.now(KST).isoformat()
elif action == 'restore':
data[stock].pop('status', None)
data[stock].pop('pending_delete_at', None)
elif action == 'purge':
data.pop(stock)
else:
raise ValueError(action)
tmp = WATCHLIST.with_suffix('.json.tmp')
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(WATCHLIST)
def _load_alerts_map() -> dict[str, list[str]]:
"""state/watchlist_alerts.json → {stock: [conds]}. 신규/레거시 포맷 모두 호환.
렌더링 전용 read-only — 락 안 잡음(다음 monitor가 신규 포맷으로 일관 정리)."""
if not ALERTS_STATE.exists():
return {}
try:
raw = json.loads(ALERTS_STATE.read_text())
except Exception:
return {}
if not isinstance(raw, dict):
return {}
if isinstance(raw.get('alerts'), dict):
return {k: list(v) for k, v in raw['alerts'].items() if isinstance(v, list)}
# 레거시 date-keyed: 모든 날짜 머지
merged: dict[str, list[str]] = {}
for v in raw.values():
if not isinstance(v, dict):
continue
for s, conds in v.items():
if not isinstance(conds, list):
continue
bucket = merged.setdefault(s, [])
for c in conds:
if isinstance(c, str) and c not in bucket:
bucket.append(c)
return merged
def _apply_alerts_reset(stock: str) -> None:
"""해당 종목의 알림 dedup 기록 전부 삭제 → 다음 monitor 사이클에서 재알림 가능.
watchlist_monitor.alerts_lock과 동일 lockfile 사용. 종목이 state 에 없으면 no-op
(404 아님 — 한 번도 알림 안 떴거나 이미 비어 있는 정상 상태)."""
import watchlist_monitor as wm
with wm.alerts_lock():
state = wm.load_alerts_state()
alerts = state.setdefault('alerts', {})
alerts.pop(stock, None)
tmp = ALERTS_STATE.with_suffix('.json.tmp')
ALERTS_STATE.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2))
tmp.replace(ALERTS_STATE)
class MultipleMatchError(Exception):
"""다중 매칭. do_POST에서 후보 JSON 응답으로 분기."""
def __init__(self, query: str, candidates: list[dict]):
self.query = query
self.candidates = candidates
super().__init__(query)
_MARKET_BY_CODE_CACHE = None
def _market_label_html(code: str) -> str:
"""종목코드 → 시장(코스피/코스닥) 작은 배지 HTML. 미상·ETF 등이면 ''.
stock_codes.json(name→{code,market})을 code→market로 뒤집어 프로세스 1회 캐시."""
global _MARKET_BY_CODE_CACHE
code = (code or '').strip()
if not code:
return ''
if _MARKET_BY_CODE_CACHE is None:
import kiwoom_client as kc
_MARKET_BY_CODE_CACHE = {
info['code']: info['market']
for info in kc._load_code_cache().values()
if info.get('code') and info.get('market')
}
mk = _MARKET_BY_CODE_CACHE.get(code)
if mk == 'KOSPI':
return 'KOSPI'
if mk == 'KOSDAQ':
return 'KOSDAQ'
return ''
def _resolve_stock_fuzzy(query: str) -> dict:
"""정확 매칭(case-sensitive) → substring(case-insensitive) 단일 → 다중이면 후보 응답.
'lg' 입력 시 'LG' 단독 자동 통과하지 않고 'LG전자' 등도 함께 후보로 노출되도록
case-insensitive exact 단계는 제외. 후보 정렬은 ci-exact > prefix > 나머지."""
import kiwoom_client as kc
q = (query or '').strip()
if not q:
raise ValueError('빈 입력')
cache = kc._load_code_cache()
if q in cache:
return cache[q]
if q.isdigit() and len(q) == 6:
try:
return kc.resolve_stock_code(q)
except KeyError:
raise ValueError(f'코드 "{q}" 조회 실패')
ql = q.lower()
matched = [info for name, info in cache.items() if ql in name.lower()]
if len(matched) == 1:
return matched[0]
if len(matched) > 1:
def sort_key(info: dict) -> tuple:
n = info['name'].lower()
if n == ql:
return (0, n)
if n.startswith(ql):
return (1, n)
return (2, n)
matched.sort(key=sort_key)
raise MultipleMatchError(q, matched[:30])
try:
return kc.resolve_stock_code(q)
except KeyError:
raise ValueError(f'"{q}"와 일치하는 종목을 찾을 수 없습니다. 종목명 또는 6자리 종목코드를 입력해주세요.')
_INTEREST_PRICE_LABEL = {'buy_price': '매수가', 'target_price': '목표가', 'stop_price': '손절가'}
def _interests_lock():
"""프로세스간 INTERESTS read-modify-write 직렬화. byd.watchlist_lock 패턴 동일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = INTERESTS.with_suffix(INTERESTS.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 _stock_tags_lock():
"""STOCK_TAGS 파일 직렬화 — interests_lock 와 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = STOCK_TAGS.with_suffix(STOCK_TAGS.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_stock_tags() -> dict:
"""{'by_code': {code: tag}, 'by_name': {name: tag}}. 종목 공용 태그 저장소."""
if not STOCK_TAGS.exists():
return {'by_code': {}, 'by_name': {}}
try:
d = json.loads(STOCK_TAGS.read_text())
if 'by_code' not in d:
d['by_code'] = {}
if 'by_name' not in d:
d['by_name'] = {}
return d
except Exception:
return {'by_code': {}, 'by_name': {}}
def _get_stock_tag(code: str | None, name: str | None) -> dict:
"""{'text': str, 'leader': bool} 반환. 항목 없으면 {}. code 우선 → name fallback.
구버전(문자열) 호환: 'X' → {'text': 'X', 'leader': X=='대장'} 변환."""
if not code and not name:
return {}
tags = _load_stock_tags()
for src_key, key in (('by_code', code), ('by_name', name)):
if not key:
continue
v = tags.get(src_key, {}).get(key)
if not v:
continue
if isinstance(v, str):
txt = v.strip()
return {'text': txt, 'leader': txt == '대장'}
if isinstance(v, dict):
txt = (v.get('text') or '').strip()
ldr = bool(v.get('leader'))
if txt or ldr:
return {'text': txt, 'leader': ldr}
return {}
def _tag_chip_html(code: str, name: str, interactive: bool = False) -> str:
"""종목명 옆 태그 칩 — 표시 전용 (클릭 비활성). 수정은 detail 안 '+ 태그' 버튼에서만.
- text 있을 때만 렌더 (leader 단독은 칩 미노출, 토글 상태만 보관)
- leader=True → 빨강(tag-leader), 그 외 → 노랑(tag-chip)
- interactive 인자는 후방 호환 위해 받지만 더 이상 사용 안 함 (항상 span)."""
code_s = (code or '').strip()
name_s = (name or '').strip()
if not code_s and not name_s:
return ''
t = _get_stock_tag(code_s, name_s)
text = t.get('text', '')
leader = bool(t.get('leader'))
if not text:
return ''
cls = 'tag-chip tag-leader' if leader else 'tag-chip'
chip_text = html.escape(text)
return f'{chip_text}'
def _tag_add_button_html(code: str, name: str) -> str:
"""detail 영역(자세히보기 펼침) actions의 '태그' 버튼. 칩 유무와 무관하게 항상 노출.
클릭 시 tag-modal 열리며 현재 text/leader 가 prefill 됨."""
code_s = (code or '').strip()
name_s = (name or '').strip()
if not code_s and not name_s:
return ''
t = _get_stock_tag(code_s, name_s)
text = t.get('text', '')
leader = bool(t.get('leader'))
code_attr = html.escape(code_s, quote=True)
name_attr = html.escape(name_s, quote=True)
return (
f''
)
def _info_button_html(code: str, name: str) -> str:
"""detail actions의 '상세' 버튼. 클릭 시 info-modal 열고 /api/stock_info fetch.
종목코드 없으면(키움 조회 불가) 미노출."""
code_s = (code or '').strip()
if not code_s:
return ''
return (
f''
)
def _set_stock_tag(code: str | None, name: str | None, text: str, leader: bool) -> None:
"""{text, leader} 저장. text와 leader 둘 다 빈/False면 항목 제거.
text는 # prefix 자동 strip + 20자 cap."""
text = (text or '').strip().lstrip('#').strip()[:20]
leader = bool(leader)
code = (code or '').strip() or None
name = (name or '').strip() or None
if not code and not name:
raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다')
STOCK_TAGS.parent.mkdir(parents=True, exist_ok=True)
with _stock_tags_lock():
d = _load_stock_tags()
if text or leader:
entry = {'text': text, 'leader': leader}
if code:
d['by_code'][code] = entry
if name:
d['by_name'][name] = entry
else:
if code:
d['by_code'].pop(code, None)
if name:
d['by_name'].pop(name, None)
tmp = STOCK_TAGS.with_suffix('.json.tmp')
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
tmp.replace(STOCK_TAGS)
# ---- 트레일링 계단 프리셋 (읽기 전용 · 고정 4종) ----
# 계단 = [{'pct': 고점대비 하락률, 'cum': 그때까지 팔려 있어야 할 누적 비중}]
# 누적으로 적는 이유는 관리자님이 그렇게 생각하기 때문이다("30% 빠지면 전량") —
# 주문 수량인 추가분(20/30/50)으로의 환산은 orders/trailing.normalize_steps 가 한다.
# 순서 = 기본값이 맨 앞(단일) + 트레일 폭이 좁은 것부터. 화면에서 고른 뒤 그 자리에서
# 고칠 수 있으므로 저장 기능은 두지 않는다(2026-07-31 관리자님 지시로 제거).
TRAIL_PRESETS = [
{'name': '단일', 'steps': [{'pct': 10, 'cum': 100}]},
{'name': '보수', 'steps': [{'pct': 5, 'cum': 30}, {'pct': 10, 'cum': 70},
{'pct': 15, 'cum': 100}]},
{'name': '표준', 'steps': [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}]},
{'name': '느슨', 'steps': [{'pct': 15, 'cum': 25}, {'pct': 25, 'cum': 50},
{'pct': 30, 'cum': 100}]},
]
def _stock_notes_lock():
"""STOCK_NOTES 파일 직렬화 — _stock_tags_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = STOCK_NOTES.with_suffix(STOCK_NOTES.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_stock_notes() -> dict:
"""{'by_code': {code: text}, 'by_name': {name: text}}. 종목별 자유 메모 저장소."""
if not STOCK_NOTES.exists():
return {'by_code': {}, 'by_name': {}}
try:
d = json.loads(STOCK_NOTES.read_text())
if 'by_code' not in d:
d['by_code'] = {}
if 'by_name' not in d:
d['by_name'] = {}
return d
except Exception:
return {'by_code': {}, 'by_name': {}}
def _get_stock_note(code: str | None, name: str | None) -> str:
"""메모 텍스트 반환. 없으면 ''. code 우선 → name fallback."""
if not code and not name:
return ''
notes = _load_stock_notes()
for src_key, key in (('by_code', code), ('by_name', name)):
if not key:
continue
v = notes.get(src_key, {}).get(key)
if isinstance(v, str) and v.strip():
return v.strip()
return ''
def _set_stock_note(code: str | None, name: str | None, text: str) -> None:
"""메모 저장. 빈 텍스트면 항목 제거. 1000자 cap."""
text = (text or '').strip()[:1000]
code = (code or '').strip() or None
name = (name or '').strip() or None
if not code and not name:
raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다')
STOCK_NOTES.parent.mkdir(parents=True, exist_ok=True)
with _stock_notes_lock():
d = _load_stock_notes()
if text:
if code:
d['by_code'][code] = text
if name:
d['by_name'][name] = text
else:
if code:
d['by_code'].pop(code, None)
if name:
d['by_name'].pop(name, None)
tmp = STOCK_NOTES.with_suffix('.json.tmp')
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
tmp.replace(STOCK_NOTES)
def _holdeval_lock():
"""HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = HOLDEVAL_SAVED.with_suffix(HOLDEVAL_SAVED.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_holdeval_saved() -> list:
"""보유 평가 계산기 저장 목록. [{id, owner, code, name, avg, qty}] 리스트."""
if not HOLDEVAL_SAVED.exists():
return []
try:
d = json.loads(HOLDEVAL_SAVED.read_text())
return d if isinstance(d, list) else []
except Exception:
return []
def _save_holdeval_saved(items: list) -> None:
HOLDEVAL_SAVED.parent.mkdir(parents=True, exist_ok=True)
tmp = HOLDEVAL_SAVED.with_suffix('.json.tmp')
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2))
tmp.replace(HOLDEVAL_SAVED)
def _add_holdeval_saved(owner: str, code: str, name: str, avg: float, qty: float) -> list:
"""저장 항목 추가. code·avg·qty 필수. 새 id 부여 후 갱신된 목록 반환."""
import uuid
owner = (owner or '').strip()[:40]
code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
name = (name or '').strip()[:40]
if not code:
raise ValueError('종목을 먼저 검색·선택하세요')
if avg <= 0 or qty <= 0:
raise ValueError('평단가·주수는 0보다 커야 합니다')
with _holdeval_lock():
items = _load_holdeval_saved()
items.append({'id': uuid.uuid4().hex[:8], 'owner': owner, 'code': code,
'name': name, 'avg': avg, 'qty': qty})
_save_holdeval_saved(items)
return items
def _delete_holdeval_saved(entry_id: str) -> list:
"""id로 저장 항목 삭제. 갱신된 목록 반환."""
with _holdeval_lock():
items = [it for it in _load_holdeval_saved() if it.get('id') != entry_id]
_save_holdeval_saved(items)
return items
def _update_holdeval_saved(entry_id: str, owner: str, avg: float, qty: float,
code: str = '', name: str = '') -> list:
"""id로 저장 항목 수정 (물타기·불타기 후 평단가·주수 갱신). owner·avg·qty 필수.
code·name은 넘어오면 교체, 비면 기존 유지. 갱신된 목록 반환."""
owner = (owner or '').strip()[:40]
code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
name = (name or '').strip()[:40]
if avg <= 0 or qty <= 0:
raise ValueError('평단가·주수는 0보다 커야 합니다')
with _holdeval_lock():
items = _load_holdeval_saved()
found = False
for it in items:
if it.get('id') == entry_id:
it['owner'] = owner
it['avg'] = avg
it['qty'] = qty
if code:
it['code'] = code
if name:
it['name'] = name
found = True
break
if not found:
raise ValueError('항목을 찾을 수 없습니다')
_save_holdeval_saved(items)
return items
def _note_button_html(code: str, name: str) -> str:
"""detail actions의 '메모' 버튼. 클릭 시 note-modal 열리며 현재 메모가 prefill.
메모가 있으면 ● 표시 + has-note 클래스로 강조."""
code_s = (code or '').strip()
name_s = (name or '').strip()
if not code_s and not name_s:
return ''
text = _get_stock_note(code_s, name_s)
code_attr = html.escape(code_s, quote=True)
name_attr = html.escape(name_s, quote=True)
has = bool(text)
cls = 'btn-note has-note' if has else 'btn-note'
label = '📝 메모 ●' if has else '📝 메모'
title = html.escape(text[:60], quote=True) if has else '메모 작성'
return (
f''
)
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_indicator_html(code: str) -> str:
"""detail-name 줄(종목명 옆)의 급등락 알림 on/off **표시**. 조작 불가 — 켜고 끄기는 알림 팝업에서.
문턱은 그 종목 과거 이탈폭의 분위수(p90/p98)로 surge_monitor 가 계산한다 (고정 %도, ATR 배수도 아님).
⚠️ 2026-08-06 관리자님 지시로 토글 기능을 뺐다. `data-surge-toggle` 이 없어야 위임 핸들러가
잡지 않는다 — 다시 버튼으로 만들지 말 것(아이콘 하나뿐이라 오조작 위험이 컸다).
"""
code_s = (code or '').strip()
if not code_s:
return ''
on = _is_surge_on(code_s)
cls = 'surge-ind has-surge' if on else 'surge-ind'
return (
f'{"🔔" if on else "🔕"}'
)
def _surge_info_button_html(code: str, name: str) -> str:
"""detail actions 의 '알림' 버튼 — kpi-modal 에 /api/surge 본문을 띄운다(구간 + 오늘 소진 + 토글).
켜진 종목은 has-surge 로 강조 (메모 버튼의 has-note 와 같은 패턴).
꺼진 종목에도 노출한다 — 켜는 입구가 이 팝업뿐이라서. 종목코드 없으면 미노출
(모니터가 코드로 시세를 조회하므로 `_set_surge_toggle` 이 ValueError).
"""
code_s = (code or '').strip()
if not code_s:
return ''
on = _is_surge_on(code_s)
cls = 'kpi-more-btn btn-surge-info' + (' has-surge' if on else '')
# ⚠️ name 을 함께 넘긴다 — 꺼진 종목은 토글 저장소에 이름이 없어서, 팝업에서 켜면
# 빈 이름으로 저장되고 알림에 종목코드가 찍힌다(2026-08-06 실측).
src = html.escape(f'/api/surge?code={_urlq(code_s)}&name={_urlq((name or "").strip())}', quote=True)
title = html.escape(f'{(name or "").strip()} 알림'.strip(), quote=True)
return (
f''
)
def _chart_hlines_lock():
"""CHART_HLINES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = CHART_HLINES.with_suffix(CHART_HLINES.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()
_HLINES_MAX = 30 # 종목당 수평선 개수 상한
def _load_chart_hlines() -> dict:
"""{code: [price, ...]}. 종목차트에 그릴 사용자 수평선(지지/저항 등) 저장소. 종목코드 단위 공용(계좌 무관)."""
if not CHART_HLINES.exists():
return {}
try:
d = json.loads(CHART_HLINES.read_text())
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _get_chart_hlines(code: str | None) -> list:
"""종목의 수평선 가격 리스트(정수, 내림차순). 없으면 []."""
code = (code or '').strip()
if not code:
return []
v = _load_chart_hlines().get(code)
if not isinstance(v, list):
return []
out = set()
for x in v:
try:
p = int(round(float(x)))
if p > 0:
out.add(p)
except (TypeError, ValueError):
pass
return sorted(out, reverse=True)
def _add_chart_hline(code: str | None, price) -> None:
"""수평선 가격 추가. 중복은 무시. 상한(30) 초과 시 ValueError."""
code = (code or '').strip()
if not code:
raise ValueError('종목코드가 필요합니다')
try:
p = int(round(float(price)))
except (TypeError, ValueError):
raise ValueError('가격이 올바르지 않습니다')
if p <= 0:
raise ValueError('가격은 0보다 커야 합니다')
CHART_HLINES.parent.mkdir(parents=True, exist_ok=True)
with _chart_hlines_lock():
d = _load_chart_hlines()
cur = _normalize_hline_list(d.get(code))
if p not in cur and len(cur) >= _HLINES_MAX:
raise ValueError(f'수평선은 최대 {_HLINES_MAX}개까지 등록할 수 있습니다')
cur.add(p)
d[code] = sorted(cur, reverse=True)
_write_chart_hlines(d)
def _remove_chart_hline(code: str | None, price) -> None:
"""수평선 가격 제거. 리스트가 비면 종목 키 자체 삭제."""
code = (code or '').strip()
if not code:
raise ValueError('종목코드가 필요합니다')
try:
p = int(round(float(price)))
except (TypeError, ValueError):
raise ValueError('가격이 올바르지 않습니다')
with _chart_hlines_lock():
d = _load_chart_hlines()
cur = _normalize_hline_list(d.get(code))
cur.discard(p)
if cur:
d[code] = sorted(cur, reverse=True)
else:
d.pop(code, None)
_write_chart_hlines(d)
def _normalize_hline_list(v) -> set:
out = set()
if isinstance(v, list):
for x in v:
try:
p = int(round(float(x)))
if p > 0:
out.add(p)
except (TypeError, ValueError):
pass
return out
def _write_chart_hlines(d: dict) -> None:
tmp = CHART_HLINES.with_suffix('.json.tmp')
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
tmp.replace(CHART_HLINES)
def _chart_hlines_ui(code: str, hlines: list) -> str:
"""차트 하단 수평선 UI — 컴팩트 행(등록된 선 색상 칩 + '+' 버튼).
'+' 클릭 시 추가·삭제 모달. 색상은 정렬 인덱스로 SVG와 동일 매핑.
/api/chart_svg 응답 HTML 블록에 포함돼 모든 차트(보유·관심·감시)에 동일하게 노출."""
code_s = (code or '').strip()
if not code_s:
return ''
import stock_analysis as sa
colors = getattr(sa, 'HLINE_COLORS', None) or ['#e879f9']
code_attr = html.escape(code_s, quote=True)
if hlines:
chips = ''.join(
f'{p:,}'
for i, p in enumerate(hlines)
)
manage = ''.join(
f'
'
f''
f'{p:,}'
f''
f'
'
for i, p in enumerate(hlines)
)
else:
chips = '수평선 없음'
manage = '
등록된 수평선이 없어요
'
return (
f'
'
'
'
f'
{chips}
'
''
'
'
'
'
''
'
'
'
수평선'
'
'
''
f'
{manage}
'
'
'
'
'
'
'
)
def _interest_groups_lock():
"""INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = INTEREST_GROUPS.with_suffix(INTEREST_GROUPS.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_interest_groups() -> dict:
"""관심종목 전용 그룹 레지스트리. {'groups': [순서있는 이름], 'members': {종목명: [그룹명]}}.
stock_tags 와 독립. 멤버십 키는 종목명(interests 저장소가 종목명 키라 안정적)."""
if not INTEREST_GROUPS.exists():
return {'groups': [], 'members': {}}
try:
d = json.loads(INTEREST_GROUPS.read_text())
if not isinstance(d.get('groups'), list):
d['groups'] = []
if not isinstance(d.get('members'), dict):
d['members'] = {}
return d
except Exception:
return {'groups': [], 'members': {}}
def _save_interest_groups_locked(d: dict) -> None:
"""atomic tmp write + replace. 반드시 _interest_groups_lock() 안에서 호출."""
INTEREST_GROUPS.parent.mkdir(parents=True, exist_ok=True)
tmp = INTEREST_GROUPS.with_suffix('.json.tmp')
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
tmp.replace(INTEREST_GROUPS)
def _get_interest_group_names(stock: str) -> list[str]:
"""해당 종목이 속한 그룹명 리스트. 없으면 []."""
stock = (stock or '').strip()
if not stock:
return []
members = _load_interest_groups().get('members', {})
v = members.get(stock)
return list(v) if isinstance(v, list) else []
def _create_interest_group(name: str) -> None:
"""새 그룹 등록. trim·20자 cap. 빈/중복은 무시."""
name = (name or '').strip().lstrip('#').strip()[:20]
if not name:
return
with _interest_groups_lock():
d = _load_interest_groups()
if name not in d['groups']:
d['groups'].append(name)
_save_interest_groups_locked(d)
def _rename_interest_group(old: str, new: str) -> None:
"""그룹 이름 변경. groups 위치 보존 + members 전 리스트에서 old→new 치환."""
old = (old or '').strip()
new = (new or '').strip().lstrip('#').strip()[:20]
if not old or not new or old == new:
return
with _interest_groups_lock():
d = _load_interest_groups()
if old not in d['groups']:
return
# groups: 위치 보존 교체 (new 이미 있으면 중복 제거)
d['groups'] = [new if g == old else g for g in d['groups']]
seen: list[str] = []
for g in d['groups']:
if g not in seen:
seen.append(g)
d['groups'] = seen
# members: 각 리스트 old→new, 중복 제거
for stock, names in list(d['members'].items()):
if old in names:
repl = [new if n == old else n for n in names]
dedup: list[str] = []
for n in repl:
if n not in dedup:
dedup.append(n)
d['members'][stock] = dedup
_save_interest_groups_locked(d)
def _delete_interest_group(name: str) -> None:
"""그룹 삭제. groups 에서 제거 + members 전 리스트에서 strip, 빈 리스트 키 삭제."""
name = (name or '').strip()
if not name:
return
with _interest_groups_lock():
d = _load_interest_groups()
d['groups'] = [g for g in d['groups'] if g != name]
for stock, names in list(d['members'].items()):
if name in names:
remain = [n for n in names if n != name]
if remain:
d['members'][stock] = remain
else:
d['members'].pop(stock, None)
_save_interest_groups_locked(d)
def _reorder_interest_groups(ordered: list[str]) -> None:
"""표시 순서 재배치. 기존과 동일한 이름집합만 허용(누락분은 기존 순서로 뒤에 append)."""
with _interest_groups_lock():
d = _load_interest_groups()
existing = d['groups']
new_order = [g for g in ordered if g in existing]
for g in existing:
if g not in new_order:
new_order.append(g)
d['groups'] = new_order
_save_interest_groups_locked(d)
def _set_interest_groups(stock: str, names: list[str]) -> None:
"""종목의 그룹 소속 편집. groups 에 존재하는 이름만 반영. 빈이면 members 키 삭제."""
stock = (stock or '').strip()
if not stock:
raise ValueError('종목명이 필요합니다')
with _interest_groups_lock():
d = _load_interest_groups()
valid = [n for n in names if n in d['groups']]
dedup: list[str] = []
for n in valid:
if n not in dedup:
dedup.append(n)
if dedup:
d['members'][stock] = dedup
else:
d['members'].pop(stock, None)
_save_interest_groups_locked(d)
def _interest_group_button_html(stock: str) -> str:
"""관심종목 행의 '그룹' 버튼. 클릭 시 group-assign-modal 열림.
현재 소속 + 전체 그룹 목록을 data 속성으로 실어보냄. 디자인은 +태그 버튼(btn-add-tag)과 동일."""
name_s = (stock or '').strip()
if not name_s:
return ''
current = _get_interest_group_names(name_s)
all_groups = _load_interest_groups().get('groups', [])
name_attr = html.escape(name_s, quote=True)
cur_attr = html.escape(','.join(current), quote=True)
all_attr = html.escape(','.join(all_groups), quote=True)
return (
f''
)
def _apply_interests_action(action: str, stock: str, payload: dict | None = None) -> None:
"""관심종목 add/delete. 자동 적재 없는 수동 리스트라 pending_delete trash 단계 없이 즉시 삭제."""
INTERESTS.parent.mkdir(parents=True, exist_ok=True)
with _interests_lock():
_apply_interests_action_locked(action, stock, payload)
if action == 'delete':
# 삭제 성공 후 그룹 멤버십 연쇄 정리. _interests_lock 밖에서 별도 파일 락 → 데드락 회피.
try:
_set_interest_groups(stock, [])
except Exception:
pass
def _apply_interests_action_locked(action: str, stock: str, payload: dict | None = None) -> None:
data: dict = {}
if INTERESTS.exists():
try:
data = json.loads(INTERESTS.read_text())
except Exception as e:
# 손상된 JSON은 timestamp 백업 후 빈 dict로 시작 — 다음 write로 silent 덮어쓰기 방지
backup = INTERESTS.with_name(INTERESTS.name + f'.corrupt.{int(time.time())}')
try:
INTERESTS.rename(backup)
sys.stderr.write(f'INTERESTS json 손상 — {backup}로 백업: {e}\n')
except Exception:
pass
data = {}
if action == 'add':
payload = payload or {}
code = (payload.get('code') or '').strip()
if not stock and not code:
raise ValueError('종목명 또는 종목코드 중 하나는 입력해주세요')
if code:
# 코드 우선 lookup → 정식 종목명으로 통일. stock+code 동시 입력 시 사용자 stock 오타도 교정.
import kiwoom_client as kc
try:
info = kc.resolve_stock_code(code)
stock = info.get('name') or stock or code
code = info.get('code') or code
except Exception:
if not stock:
stock = code
else:
# 종목명만 입력 — fuzzy 매칭으로 코드 lookup
info = _resolve_stock_fuzzy(stock)
stock = info.get('name') or stock
code = info.get('code', '')
if stock in data:
raise ValueError(f'이미 등록된 종목입니다: {stock} — 수정으로 변경하세요')
entry: dict = {'stock': stock, 'saved_at': datetime.now(KST).isoformat()}
if code:
entry['code'] = code
for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')):
raw = (payload.get(src_key) or '').strip()
if not raw:
continue
try:
num = float(raw.replace(',', ''))
except ValueError:
raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요')
entry[dst_key] = {sub: num, 'raw': f'{int(num):,}원' if num == int(num) else f'{num:,}원'}
memo = (payload.get('memo') or '').strip()
if memo:
entry['memo'] = memo
entry['notes'] = [memo]
data[stock] = entry
elif action == 'update':
if stock not in data:
raise KeyError(stock)
payload = payload or {}
entry = data[stock]
for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')):
if src_key not in payload:
continue
raw = (payload.get(src_key) or '').strip()
if not raw:
entry.pop(dst_key, None)
continue
try:
num = float(raw.replace(',', ''))
except ValueError:
raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요')
entry[dst_key] = {sub: num, 'raw': f'{int(num):,}원' if num == int(num) else f'{num:,}원'}
if 'memo' in payload:
memo = (payload.get('memo') or '').strip()
if memo:
entry['memo'] = memo
entry['notes'] = [memo]
else:
entry.pop('memo', None)
entry.pop('notes', None)
data[stock] = entry
elif action == 'delete':
if stock not in data:
raise KeyError(stock)
data.pop(stock)
else:
raise ValueError(f'unknown action: {action}')
tmp = INTERESTS.with_suffix('.json.tmp')
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(INTERESTS)
def _fmt_price_field(field) -> str:
if not isinstance(field, dict):
return '언급 없음'
raw = (field.get('raw') or '').strip()
return raw or '언급 없음'
def _near_buy_tier(c: dict) -> int:
"""감시 모드 매수 가능권 단계.
0 = 해당없음 (매수가 위 5% 초과)
1 = 매수가 위 3~5% (노랑, 멀리서 접근)
2 = 매수가 위 0~3% (주황, 임박)
3 = 매수가 도달/하회 (빨강, 매수권 진입)
"""
if c.get('mode') != 'watching':
return 0
pct = c.get('pct')
if not isinstance(pct, (int, float)):
return 0
if pct > 5:
return 0
if pct > 3:
return 1
if pct > 0:
return 2
return 3
def _color_class(mode: str, price, ref_price, stop_value) -> str:
"""mode 별 색상.
held(보유): 초록=수익, 빨강=손실, 회색=동가
watching(감시): 초록=매수가 도달, 빨강=손절가 하회, 회색=아직 매수가 위 대기
"""
if price is None or not isinstance(ref_price, (int, float)) or ref_price <= 0:
return 'neutral'
if mode == 'held':
if price > ref_price:
return 'up'
if price < ref_price:
return 'down'
return 'neutral'
if isinstance(stop_value, (int, float)) and stop_value > 0 and price <= stop_value:
return 'down'
if price <= ref_price:
return 'up'
return 'neutral'
def _buy_dd_html(buy_field, alerted: set[str]) -> str:
"""매수가 dd 콘텐츠 — raw 라벨 + 레벨별 알림 마커.
levels 가 없거나 raw 가 단일 가격이면 단일 줄. 다중 레벨이면 각 레벨에 마커."""
if not isinstance(buy_field, dict):
return '언급 없음'
raw = (buy_field.get('raw') or '').strip()
levels = [int(lv) for lv in (buy_field.get('levels') or []) if isinstance(lv, (int, float)) and lv > 0]
if not levels:
return html.escape(raw) if raw else '언급 없음'
norm_raw = raw.replace(',', '').replace('원', '').replace(' ', '')
single_simple = len(levels) == 1 and norm_raw.isdigit() and int(norm_raw) == levels[0]
parts: list[str] = []
if raw and not single_simple:
parts.append(f'
{html.escape(raw)}
')
for lv in levels:
mark = ' 알림됨' if f'buy@{lv}' in alerted else ''
parts.append(f'
{lv:,}원{mark}
')
return ''.join(parts)
def _vol_surge_badge(code: str, today_volume) -> str:
"""오늘 거래량이 직전 20거래일 평균 대비 1.5배 이상이면 🔥 배지(배수 표기).
- 20일 평균: dcc._select_latest(code, 21) 순수 SQLite 읽기(렌더 중 키움 호출 X).
- 장중 환산: sa.session_vol_frac로 정규장 오늘 누적 거래량을 하루치로 투영.
- 데이터 부족(차트 미조회로 캐시 없음 등)·임계 미만이면 '' 반환.
"""
if not code or not today_volume:
return ''
try:
import daily_candles_cache as dcc
import stock_analysis as sa
today = datetime.now(KST).strftime('%Y%m%d')
vols = [b['volume'] for b in dcc._select_latest(code, 21)
if str(b.get('date') or '') != today and (b.get('volume') or 0) > 0]
if len(vols) < 5:
return ''
avg20 = sum(vols[:20]) / min(len(vols), 20)
if avg20 <= 0:
return ''
frac = sa.session_vol_frac([{'date': today}])
proj = (today_volume / frac) if frac else today_volume
rvol = proj / avg20
except Exception:
return ''
if rvol < 1.5:
return ''
level = 'red' if rvol >= 3 else 'warm'
return f'🔥{rvol:.1f}'
def _render_row(c: dict, source: str = 'watchlist') -> str:
stock = html.escape(c.get('stock', ''))
code = html.escape(c.get('code', '') or '')
price = c.get('price')
err = c.get('quote_error')
mode = c.get('mode', 'watching')
ref_price = c.get('ref_price')
ref_label = c.get('ref_label', '매수가')
stop_dict = c.get('stop') if isinstance(c.get('stop'), dict) else {}
cls = _color_class(mode, price, ref_price, (stop_dict or {}).get('value'))
tier = _near_buy_tier(c)
dot_html = f'' if tier else ''
if price is None:
price_cell = '-'
diff_cell = f'{html.escape(err) if err else "조회 불가"}'
else:
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음.
# 정규장: KRX+NXT 모두 거래 → 초록점
# NXT 시간대: NXT 거래되는 종목(_nxt_price 살아있음)만 초록점
# 장마감·휴장·주말: 점 없음
nxt_p = c.get('_nxt_price', 0) or 0
_phase = _market_phase_state()['phase']
if _phase == 'regular':
_tradable = True
elif _phase == 'nxt':
_tradable = nxt_p > 0
else:
_tradable = False
mark_html = '' if _tradable else ''
# 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct가 이미 KRX 기준이라 그대로 사용.
day_pct = c.get('day_change_pct')
if c.get('_show_day_change') and isinstance(day_pct, (int, float)):
d_sign = '+' if day_pct >= 0 else ''
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
price_cell = f'{mark_html}{price:,}{d_sign}{day_pct:.2f}%'
else:
price_cell = f'{mark_html}{price:,}'
if mode == 'watching' and isinstance(ref_price, (int, float)) and ref_price > 0:
diff_cell = f'매수가 {ref_price:,.0f}'
else:
diff_cell = ''
if mode == 'held':
qty = c.get('held_qty', 0)
status_cell = f'📦 {qty:,}'
accounts = c.get('held_accounts') or []
avg = c.get('held_avg_price', 0)
held_row = (
f'매입평단'
f'{avg:,}원 · {", ".join(accounts)}'
)
else:
status_cell = ''
held_row = ''
status_cell += _pending_badges_html(c)
# 당일 OHLC mini-table — ka10095는 pred_close 필드를 안 줘서 `price - day_change` 로 KRX 종가 역산.
# NXT 종가는 stock.briefing 20:10 스냅샷의 `_watchlist_nxt`에서 주입된 `_pred_close_nxt`. 없으면 `-`.
open_ref = c.get('open')
high_ref = c.get('high')
low_ref = c.get('low')
price_ref = c.get('price')
day_chg_ref = c.get('day_change')
pred_close_krx = 0
if isinstance(price_ref, (int, float)) and price_ref > 0 and isinstance(day_chg_ref, (int, float)):
pc = int(price_ref) - int(day_chg_ref)
if pc > 0:
pred_close_krx = pc
# NXT 미상장 종목은 baseline 무효화 → NXT 컬럼 `-` placeholder.
if c.get('_nxt_inactive'):
pred_close_nxt = 0
else:
nxt_ref = c.get('_pred_close_nxt')
pred_close_nxt = int(nxt_ref) if isinstance(nxt_ref, (int, float)) and nxt_ref > 0 else 0
ohlc_mini_html = ''
if all(isinstance(v, (int, float)) and v > 0 for v in (open_ref, high_ref, low_ref, price_ref)):
ohlc_mini_html = _render_ohlc_mini(
int(open_ref), int(high_ref), int(low_ref), int(price_ref),
pred_close_krx,
pred_close_nxt,
)
alerted = set(c.get('_alerted') or []) if source == 'watchlist' else set()
unheld_done = '_unheld_done' in alerted and mode == 'watching'
# 요약 라벨: 발사된 조건 종류로 컴팩트하게. _ prefix(내부 마커) 제외.
_alert_kinds: list[str] = []
if any(a.startswith('buy@') for a in alerted) or unheld_done:
_alert_kinds.append('매수')
if 'target' in alerted:
_alert_kinds.append('목표')
if 'stop' in alerted:
_alert_kinds.append('손절')
if _alert_kinds:
alert_badge = f'알림 {"·".join(_alert_kinds)}'
else:
alert_badge = ''
upside = c.get('upside_pct')
_target_text = _fmt_price_field(c.get('target'))
if upside is not None and _target_text != '언급 없음':
sign = '+' if upside >= 0 else ''
_target_text = f'{_target_text} ({sign}{upside:g}%)'
target_raw = html.escape(_target_text)
if 'target' in alerted and _target_text != '언급 없음':
target_raw += ' 알림됨'
if source == 'watchlist':
buy_raw = _buy_dd_html(c.get('buy'), alerted)
else:
buy_raw = html.escape(_fmt_price_field(c.get('buy')))
_stop_text = _fmt_price_field(c.get('stop'))
stop_raw = html.escape(_stop_text)
if 'stop' in alerted and _stop_text != '언급 없음':
stop_raw += ' 알림됨'
chart_block = ''
flow_block = ''
if code:
chart_block = f''
# 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script)
flow_block = f''
summary_lines = c.get('summary') or []
summary_block = ''
if summary_lines:
items = ''.join(f'
{html.escape(s)}
' for s in summary_lines)
summary_block = f'
주요내용
{items}
'
notes_lines = c.get('notes') or []
notes_block = ''
if notes_lines:
items = ''.join(f'
{html.escape(n)}
' for n in notes_lines)
notes_block = f'
기타
{items}
'
video = c.get('video') or {}
video_block = ''
if video.get('url'):
title = html.escape(video.get('title', '영상'))
video_block = (
f'
'
# 현재가 아래 메타 라인 — 관심·감시는 비중·계좌 없어 시장(KOSPI/KOSDAQ)만.
_mkt_label = _market_label_html(code)
market_meta_html = f'
{_mkt_label}
' if _mkt_label else ''
if source == 'interests':
def _num_attr(field, sub):
if not isinstance(field, dict):
return ''
v = (field or {}).get(sub)
if isinstance(v, (int, float)) and v > 0:
return str(int(v)) if v == int(v) else str(v)
return ''
buy_attr_v = _num_attr(c.get('buy'), 'primary')
target_attr_v = _num_attr(c.get('target'), 'low')
stop_attr_v = _num_attr(c.get('stop'), 'value')
memo_val = c.get('memo') or ''
if not memo_val:
notes = c.get('notes') or []
if notes and isinstance(notes[0], str):
memo_val = notes[0]
edit_attrs = (
f' data-modal-open="interest-edit-modal"'
f' data-edit-stock="{stock_attr}"'
f' data-edit-code="{html.escape(c.get("code") or "", quote=True)}"'
f' data-edit-buy="{html.escape(buy_attr_v, quote=True)}"'
f' data-edit-target="{html.escape(target_attr_v, quote=True)}"'
f' data-edit-stop="{html.escape(stop_attr_v, quote=True)}"'
f' data-edit-memo="{html.escape(memo_val, quote=True)}"'
)
note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
actions_html = (
'
'
)
tag_chip = _tag_chip_html(c.get('code') or '', c.get('stock') or '', interactive=True)
row_key = code or stock
return f'''
{dot_html}{stock}{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (max(0, (c.get('volume') or 0) - (c.get('_nxt_volume') or 0)) or None))}
{price_cell}▾
{f'
{diff_cell}
' if diff_cell else ''}
{detail_name_html}
{f'
현재가{price_cell}
' if price is not None else ''}
{market_meta_html}
{'
')
head = [
srow('투자원금', f'{pr["principal"]:,}원', ' strong'),
srow('누적 입금', f'{pr["cash_in"]:,}원'),
srow('누적 출금', f'{pr["cash_out"]:,}원'),
]
if len(pr['by_label']) > 1:
head.append('
계좌별
')
head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in pr['by_label'].items()]
# 계좌 간 대체(내 일반↔ISA)는 owner 합계에선 상쇄되지만 계좌별 수치는 밀어놓는다.
# 합계는 정확하니 계좌별만 오해하지 않게 알린다.
if any('대체' in (r['rmrk'] or '') for r in fl['rows']):
head.append('
계좌 간 이동(대체)이 있어 계좌별 금액은 '
'실제 외부 입금과 다를 수 있어요. 합계는 정확합니다.
')
if pr['qty_flow']:
head.append('
⚠️ 주식 입출고(타사대체) 있음 — '
'현금 기준 원금이 실제보다 작을 수 있어요.
')
if not fl['reconciled']:
head.append('
⚠️ 분류되지 않은 새 적요가 있어요 — '
'내역 합계와 원금이 어긋날 수 있습니다.
')
if not fl['available']:
return (f'
{"".join(head)}
'
f'
입출금 내역은 아직 수집되지 않았어요.
')
n_pr = sum(1 for r in fl['rows'] if r['principal'])
n_ot = len(fl['rows']) - n_pr
items = []
for r in fl['rows']:
sign = '+' if r['io'] == 'IN' else '−'
acls = 'in' if r['io'] == 'IN' else 'out'
dt = r['date']
dsp = f'{dt[:4]}.{dt[4:6]}.{dt[6:8]}' if len(dt) == 8 else dt
items.append(
f'
'
f'{html.escape(dsp)}'
f'{html.escape(r["rmrk"] or "-")}'
f'{html.escape(_acct_short(r["label"]))}'
f'{sign}{r["amount"]:,}'
f'
'
)
other_note = ''
if fl['other_in'] or fl['other_out']:
parts = []
if fl['other_in']:
parts.append(f'입금 {fl["other_in"]:,}원')
if fl['other_out']:
parts.append(f'출금 {fl["other_out"]:,}원')
other_note = (f'
'
)
def _render_deposit_body(owner: str) -> str:
"""예수금 팝업 본문 — 계좌별 D+2 예수금. 렌더 경로가 이미 조회한 balances 를 읽어 키움 콜 0.
캐시를 쓰는 이유: 팝업 금액이 그걸 띄운 KPI 행과 항상 같고, 매수 묶임(ka10075 기반)도
거기에만 들어 있다. 캐시가 비어 있으면(서버 재시작 직후) kt00001 을 계좌당 1콜 — 이 경로엔
매수 묶임이 없다.
"""
from stock_portfolio_report import derive_owner
import kiwoom_client as kc
bal_map = (_owner_data_cache.get(owner) or {}).get('balances') or {}
if not bal_map:
labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
bal_map = {lb: kc.get_balance(lb) for lb in labels}
def srow(label: str, value: str, cls: str = '') -> str:
return (f'
{html.escape(label)}'
f'{value}
')
deposits = {lb: (b.get('d2_entra', 0) or 0) for lb, b in bal_map.items()}
head = [srow('예수금', f'{sum(deposits.values()):,}원', ' strong'),
'
계좌별
']
head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in deposits.items()]
locked = {lb: (b.get('pending_buy_locked', 0) or 0) for lb, b in bal_map.items()}
if any(locked.values()):
head.append('
매수 묶임
')
head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in locked.items() if v]
return f'
{"".join(head)}
'
def _render_cashflow_body(owner: str) -> str:
"""당일 입출금 팝업 본문 — 입금/출금 합계 + 건별 내역 (kt00015 tp='1' 당일).
렌더 경로가 이미 부르는 조회지만 팝업은 드물게 열리므로 그때 다시 부른다(계좌당 1콜).
"""
from stock_portfolio_report import derive_owner
import kiwoom_client as kc
labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
by_label = kc.get_cash_flow_all(None, labels)
rows = [{**f, 'label': lb} for lb, fl in by_label.items() for f in (fl or [])]
if not rows:
return '
오늘 입출금이 없어요.
'
rows.sort(key=lambda r: (r.get('time') or '', r['label']))
cash_in = sum(r['amount'] for r in rows if r['io_tp'] == 'IN')
cash_out = sum(r['amount'] for r in rows if r['io_tp'] == 'OUT')
def srow(label: str, value: str, cls: str = '') -> str:
return f'
{html.escape(label)}{value}
'
head = []
if cash_in:
head.append(srow('입금', f'{cash_in:,}원'))
if cash_out:
head.append(srow('출금', f'{cash_out:,}원'))
# 순액은 양방향 다 있을 때만 — 한쪽뿐이면 위 합계와 같은 값이다.
if cash_in and cash_out:
net = cash_in - cash_out
head.append(srow('순 입출금', f'{net:+,}원', ' strong'))
items = []
for r in rows:
sign = '+' if r['io_tp'] == 'IN' else '−'
acls = 'in' if r['io_tp'] == 'IN' else 'out'
tm = (r.get('time') or '')[:5]
items.append(
f'
'
f'{html.escape(tm)}'
f'{html.escape(r.get("rmrk") or "-")}'
f'{html.escape(_acct_short(r["label"]))}'
f'{sign}{r["amount"]:,}'
f'
'
)
return (
f'
{"".join(head)}
'
f'
내역 · {len(rows)}건
'
f'
{"".join(items)}
'
)
# 분위수·상하한가 트리거 키 → 사람이 읽는 이름. VI 는 발동시각이 키에 붙는다(`vi:105814`).
_SURGE_KEY_NAMES = {
'limit_up': '상한가', 'up2': '급등 확대', 'up': '급등 1차',
'down': '급락 1차', 'down2': '급락 확대', 'limit_down': '하한가',
}
def _surge_key_label(key: str) -> str:
if key.startswith('vi:'):
t = key[3:]
return f'VI {t[:2]}:{t[2:4]}' if len(t) >= 4 and t.isdigit() else 'VI'
return _SURGE_KEY_NAMES.get(key, key)
def _render_surge_body(code: str, name_hint: str = '') -> str:
"""급등락 알림 팝업 본문 — on/off 토글 + 알림 구간 + 오늘 소진 현황.
⚠️ **키움 콜 0.** surge_monitor 의 캐시 파일을 읽기만 한다. `sm.get_threshold_map()` /
`sm.get_limit_map()` 은 각각 ka10081(유량 초당 5건)·ka10095 를 때리므로 절대 부르지 않는다
— 팝업을 열 때마다 조회가 나가고, 문턱 산출은 사이클당 12종목 페이싱이 있어 여기서 부르면
그 설계가 무너진다. 캐시에 없으면 계산 대기로 표시한다.
"""
import surge_monitor as sm
code_s = (code or '').strip()
if not code_s:
return '
종목코드가 없어 알림을 설정할 수 없어요.
'
on = _is_surge_on(code_s)
# 켜진 종목은 저장된 이름, 꺼진 종목은 호출측이 넘긴 이름 — 후자가 없으면 켤 때 이름이 빈 채로 저장된다.
name = ((_load_surge_toggles().get('by_code', {}).get(code_s) or {}).get('name') or '').strip()
name = name or (name_hint or '').strip()
today = datetime.now(KST).strftime('%Y-%m-%d')
thr_cache = sm.load_json(sm.THR_CACHE, {})
lim_cache = sm.load_json(sm.LIMITS_CACHE, {})
thr_fresh = thr_cache.get('date') == today
thr_raw = (thr_cache.get('by_code') or {}) if thr_fresh else {}
lim_raw = (lim_cache.get('by_code') or {}) if lim_cache.get('date') == today else {}
thr = thr_raw.get(code_s) # None=미산출, {}=이력부족(확정), dict=정상
lim = lim_raw.get(code_s) or {}
q = None
try:
if _RT_HUB is not None:
q = _RT_HUB.get_quote(code_s)
except Exception:
q = None
# ── 토글 (기존 [data-surge-toggle] 위임 핸들러가 그대로 잡는다) ──
tog = (
f''
)
if not on:
return (
f'
{tog}'
f'
감시 중이 아니라 알림 구간이 산출되지 않았어요. '
f'켜면 다음 감시 사이클(평일 09:00~15:35, 1분 간격)에 이 종목 과거 이력으로 구간을 계산합니다.
'
f'
'
)
# ── 헤더: 전일종가 · 현재가 ──
prev_close = (thr or {}).get('prev_close') or 0
head = []
if prev_close:
head.append(f'
전일종가{prev_close:,}원
')
if q and q.get('price'):
pct = q.get('pct')
pcls = 'up' if (pct or 0) > 0 else ('down' if (pct or 0) < 0 else '')
pstr = f' ({pct:+.2f}%)' if pct is not None else ''
head.append(f'
현재가{int(q["price"]):,}원{pstr}
')
# ── 구간 사다리 (호가창처럼 위가 비쌈) ──
def band(label: str, price: int, pct: float | None, cls: str) -> str:
p = f'{pct:+.1f}%' if pct is not None else ''
return (f'
{html.escape(label)}'
f'{price:,}{p}
')
bands: list[str] = []
if lim.get('upl'):
bands.append(band('⏫ 상한가', lim['upl'], (lim['upl'] / prev_close - 1) * 100 if prev_close else None, 'lim up'))
if thr:
t1, t2 = thr['thr'], thr.get('thr_big') or 0
if t2:
bands.append(band('급등 확대', round(prev_close * (1 + t2 / 100)), t2, 'up'))
bands.append(band('급등 1차', round(prev_close * (1 + t1 / 100)), t1, 'up'))
bands.append(band('전일종가', prev_close, None, 'mid'))
bands.append(band('급락 1차', round(prev_close * (1 - t1 / 100)), -t1, 'down'))
if t2:
bands.append(band('급락 확대', round(prev_close * (1 - t2 / 100)), -t2, 'down'))
if lim.get('lst'):
bands.append(band('⏬ 하한가', lim['lst'], (lim['lst'] / prev_close - 1) * 100 if prev_close else None, 'lim down'))
notes: list[str] = []
if thr == {}:
notes.append(f'이력 {sm.MIN_HISTORY}일 미만이라 문턱을 못 냅니다 — VI·상하한가만 감시해요.')
elif not thr_fresh and thr_cache.get('date'):
# 전일종가가 바뀌면 구간이 통째로 달라진다 → 어제 값을 '알림 구간'으로 보여주지 않는다.
notes.append(f'⚠️ 마지막 산출이 {html.escape(str(thr_cache.get("date")))} 기준이라 표시하지 않아요 '
f'— 전일종가가 바뀌면 구간도 전부 달라집니다. 장 시작 후 갱신됩니다.')
elif thr is None:
# 감시 사이클과 같은 판정을 쓴다(평일 09:00~15:35 · 휴장일 제외).
notes.append('구간 계산 대기 중이에요 — 사이클당 12종목씩 채워서 몇 분 안에 나옵니다.'
if sm.is_market_hours() else '다음 장 시작 후에 계산됩니다.')
band_html = (f'
알림 구간
{"".join(bands)}
'
if bands else '')
# ── 오늘 소진 현황 ──
day = (sm.load_json(sm.ALERTS_STATE, {}).get(today) or {})
fired = [k for k in (day.get(code_s) or []) if isinstance(k, str)]
pend = list(((day.get(sm.PENDING_KEY) or {}).get(code_s) or {}))
order = list(_SURGE_KEY_NAMES)
left = [k for k in order if k not in fired]
empty = '없음'
def chips(keys: list, cls: str, blank: str = empty) -> str:
if not keys:
return blank
return ''.join(f'{html.escape(_surge_key_label(k))}' for k in keys)
def trow(label: str, inner: str) -> str:
return f'
'''
def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, journal_by_label: dict, owner_label_text: str, has_prev_snap: bool, label_disp: str | None = None) -> str:
"""단일 계좌 단위 compact KPI 카드. owner_d['rows'] 를 account==label 로 필터해 합산.
합산 뷰의 `_render_owner_kpi` 와 동일한 라벨·순서지만, 데이터 출처가 owner 합 대신 단일 라벨이라
당일 평가손익은 owner-level 전날 스냅샷(holdings는 owner 합)이라 정확한 분해가 불가 →
`(day_change × qty)` 합으로 근사하고 `근사` 표기. 합계 뷰의 prev_net 기반 값은 owner 카드에서 확인.
"""
rows = [r for r in owner_d.get('rows', []) if r.get('account') == label]
total_value = sum(r.get('eval_value', 0) for r in rows)
total_cost = sum(r.get('buy_amount', 0) for r in rows)
total_profit = sum(r.get('profit', 0) for r in rows)
total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
# 종목 단위 unique count (한 라벨 내에선 중복 없지만 안전)
held_count = len({r.get('code') for r in rows if r.get('qty', 0) > 0 and r.get('code')})
bal = balances.get(label) or {}
deposit = bal.get('d2_entra', 0) or 0
total_net = total_value + deposit
locked = bal.get('pending_buy_locked', 0) or 0
# 당일 평가손익 — 라벨별 prev_snap이 없어 근사 (day_change × qty). 실현손익·cash_flow는 포함 안 됨.
if has_prev_snap and rows:
approx_day_pl = sum(int(r.get('day_change', 0) or 0) * int(r.get('qty', 0) or 0) for r in rows)
else:
approx_day_pl = None
# 라벨별 실현손익·당일매매 — journal_by_label[label] 에서 직접 추출
label_journal = journal_by_label.get(label, []) or []
sell_entries = [j for j in label_journal if (j.get('sell_qty') or 0) > 0]
realized_pl = sum(int(j.get('pl_amt', 0) or 0) for j in sell_entries)
realized_fees = sum(int(j.get('cmsn_tax', 0) or 0) for j in sell_entries)
traded_today_codes = {j.get('code') for j in label_journal if ((j.get('buy_qty') or 0) + (j.get('sell_qty') or 0)) > 0 and j.get('code')}
sign = '+' if total_profit >= 0 else ''
pcls = _profit_class(total_profit)
if approx_day_pl is None:
day_pl_html = '전날 스냅샷 없음'
else:
dcls = _profit_class(approx_day_pl)
dsign = '+' if approx_day_pl >= 0 else ''
day_pl_html = f'{dsign}{approx_day_pl:,}원 · 근사'
deposit_pct_html = ''
if total_value:
dep_pct = (deposit / total_value) * 100
deposit_pct_html = f'({dep_pct:.1f}%) '
deposit_locked_html = (
f' · 매수 묶임 {locked:,}원' if locked > 0 else ''
)
# 라벨·순서는 _render_owner_kpi와 통일 ('총'=현금 포함 전체).
kpis = [
('총자산', f'{total_net:,}원'),
('매입금액', f'{total_cost:,}원'),
('평가금액', f'{total_value:,}원'),
('평가손익', f'{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{deposit:,}원{deposit_locked_html}'),
('보유종목/당일매매', f'{held_count}개 / {len(traded_today_codes)}개'),
]
if traded_today_codes and (realized_pl or realized_fees):
rcls = _profit_class(realized_pl)
rsign = '+' if realized_pl >= 0 else ''
fee_html = f' · 수수료·세금 {realized_fees:,}원' if realized_fees else ''
kpis.append(('당일 실현손익', f'{rsign}{realized_pl:,}원{fee_html}'))
_rows = []
for i, (k, v) in enumerate(kpis):
cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else ''
_rows.append(f'
{html.escape(k)}
{v}
')
rows_html = ''.join(_rows)
sub_text = label_disp if label_disp is not None else label
return f'''
'''
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, nxt_closed: bool = False) -> str:
"""보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.
각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
한쪽이 None이면 해당 컬럼 전체 '-' placeholder. 4행(현재가/시가/고가/저가) × 2컬럼(KRX/NXT).
active_market: 'krx' / 'nxt' / 'none'. 활성 시장 컬럼은 기본 색, 비활성은 회색 처리.
"""
# price 만 있어도 컬럼 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
has_krx = bool(krx and krx.get('price'))
has_nxt = bool(nxt and nxt.get('price'))
if not (has_krx or has_nxt):
return ''
# NXT 시가 baseline 은 호출처에서 어제 NXT 스냅샷 종가(r['pred_close'])로 미리 박아 전달.
# 자동 baseline 계산(price - change)은 호출처가 못 박은 경우만 fallback.
if has_nxt and nxt and not nxt.get('open'):
nxt_baseline = (nxt.get('price', 0) or 0) - (nxt.get('change', 0) or 0)
if nxt_baseline > 0:
nxt = dict(nxt)
nxt['open'] = nxt_baseline
def _cell(d: dict | None, key: str, dim: bool, closed: bool = False) -> str:
dim_cls = ' ohlc-dim' if dim else ''
if not d:
return f'-'
extra = '' # 현재가 셀에만 WS 라이브 갱신 마커(data-rt-ohlc-cur) + 전일종가(data-prev) 부착.
if key == 'price':
# 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리.
price = al_price or d.get('price', 0)
base = (d.get('prev_close', 0) or 0) if al_price else 0
if al_price and base:
pct = (price - base) / base * 100
else:
pct = d.get('change_pct', 0.0) or 0.0
extra = f' data-rt-ohlc-cur data-prev="{int(base) if base else 0}"'
elif key == 'close_today':
# 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·).
if not closed:
return f'·'
price = d.get('price', 0)
base = d.get('prev_close', 0) or 0
pct = ((price - base) / base * 100) if base else (d.get('change_pct', 0.0) or 0.0)
elif key == 'prev_close':
# 전 거래일 종가 — baseline 자체라 등락률 표시 없음.
price = d.get('prev_close', 0)
if not price:
return f'-'
return f'{price:,}원'
elif key == 'volume':
# 거래량 — 등락률 없이 주 수만 표시.
vol = d.get('volume', 0) or 0
if not vol:
return f'-'
return f'{vol:,}주'
else:
price = d.get(key, 0)
base = d.get('price', 0) - d.get('change', 0)
pct = ((price - base) / base * 100) if base else 0.0
if not price:
return f'-'
cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral')
sgn = '+' if pct >= 0 else ''
return (
f'{price:,}원'
f'{sgn}{pct:.2f}%'
)
krx_dim = active_market not in ('krx', 'none-active-krx') # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게
nxt_dim = active_market != 'nxt'
# 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim
if active_market == 'none':
krx_dim = True
nxt_dim = True
elif active_market == 'krx':
krx_dim = False
nxt_dim = True
elif active_market == 'nxt':
krx_dim = True
nxt_dim = False
rows = [
('전일종가', 'prev_close'),
('현재가', 'price'),
('시가', 'open'),
('고가', 'high'),
('저가', 'low'),
('당일종가', 'close_today'),
('거래량', 'volume'),
]
krx_th_dim = ' ohlc-dim' if krx_dim else ''
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
parts = [
'
',
'',
f'KRX',
f'NXT',
]
for lbl, key in rows:
parts.append(f'{lbl}')
parts.append(_cell(krx if has_krx else None, key, krx_dim, krx_closed))
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim, nxt_closed))
parts.append('
')
return ''.join(parts)
def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_nxt: int) -> str:
"""OHLC 4행 mini-table — 라벨/가격/전일(KRX)/전일(NXT) 4-col grid.
헤더 row 한 줄에 '전일(KRX)' '전일(NXT)' 컬럼명, 데이터 row는 4행(현재가/시가/고가/저가).
pred_krx/pred_nxt 중 하나가 0이면 해당 컬럼은 모두 `-` placeholder — 컬럼 레이아웃 유지.
KRX/NXT 기준이 같은 종목(NXT 미운영)에선 두 컬럼 값이 동일.
"""
if not (o > 0 and h > 0 and l > 0 and current > 0):
return ''
def _pct_cell(p: int, base: int) -> str:
if not base or not p:
return '-'
d = p - base
pct = d / base * 100
cls = 'up' if d > 0 else ('down' if d < 0 else 'neutral')
sgn = '+' if d >= 0 else ''
return f'{sgn}{pct:.2f}%'
rows = [
('현재가', current),
('시가', o),
('고가', h),
('저가', l),
]
parts = [
'
',
'',
'',
'KRX',
'NXT',
]
for lbl, p in rows:
parts.append(f'{lbl}')
parts.append(f'{p:,}원')
parts.append(_pct_cell(p, pred_krx))
parts.append(_pct_cell(p, pred_nxt))
parts.append('
')
return ''.join(parts)
def _render_single_candle(o: int, h: int, l: int, c: int, width: int = 64, height: int = 110) -> str:
"""오늘 하루 단일 캔들 SVG. 시/고/저/현 4값으로 박스+wick. 빨강=상승, 파랑=하락 (한국 관례)."""
if not (o > 0 and h > 0 and l > 0 and c > 0):
return ''
if h == l:
return ''
pad_top, pad_bot = 6, 6
plot_h = height - pad_top - pad_bot
def y(price: int) -> float:
return pad_top + (h - price) / (h - l) * plot_h
cx = width / 2
body_top = y(max(o, c))
body_bot = y(min(o, c))
body_h = max(body_bot - body_top, 1.0)
wick_top = y(h)
wick_bot = y(l)
color = '#ff4d5e' if c >= o else '#4d8cff'
body_w = max(width * 0.55, 14)
body_x = cx - body_w / 2
return (
f''
)
_EMPTY_KV_PAIR = ''
def _interleave_kv_pairs(left: list[str], right: list[str]) -> str:
"""좌·우 dt-dd 쌍 리스트를 4컬럼 row-major grid 순서로 인터리브."""
n = max(len(left), len(right))
out: list[str] = []
for i in range(n):
out.append(left[i] if i < len(left) else _EMPTY_KV_PAIR)
out.append(right[i] if i < len(right) else _EMPTY_KV_PAIR)
return ''.join(out)
def _pending_badges_html(r: dict) -> str:
"""미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분.
수량·상세 정보는 자세히보기(`_pending_detail_html`)에서.
"""
buy = r.get('pending_buy_qty', 0)
sell = r.get('pending_sell_qty', 0)
parts: list[str] = []
if buy:
parts.append(
f'💰'
)
if sell:
parts.append(
f'💰'
)
return ''.join(parts)
def _fmt_hhmmss(raw: str) -> str:
"""ka10075 'tm' 필드 'HHMMSS' → 'HH:MM:SS'. 이미 콜론이거나 비표준이면 그대로."""
s = (raw or '').strip()
if not s or ':' in s:
return s
if len(s) == 6 and s.isdigit():
return f'{s[0:2]}:{s[2:4]}:{s[4:6]}'
return s
_TRAILING_LOOKUP_TTL = 5.0
_trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}}
def _trailing_info_for(ord_no: str):
"""미체결 주문번호 → 트레일링 예약 요약 (없으면 None).
`state/trailing_stops.json` 읽기라 키움 콜 0. 렌더 경로에서 주문마다 불리므로 5초 캐시.
⚠️ 예약의 ord_no 는 정정할 때마다 바뀌므로(kt10002 가 새 번호 발급) 항상 예약 파일의
현재 ord_no 와 매칭한다 — 등록 시점 번호로 매칭하면 첫 상향 후 연결이 끊긴다.
"""
if not ord_no:
return None
now = time.time()
if now - _trailing_lookup_cache['ts'] > _TRAILING_LOOKUP_TTL:
try:
from orders import trailing as _trl
# 예약 1건이 계단(레그) N개 = 미체결 주문 N건이라, 레그마다 (예약, 레그) 로 색인한다.
by_ord = {}
for r in _trl.list_active():
for s in r.get('steps') or []:
by_ord[s['ord_no']] = (r, s)
except Exception:
by_ord = {}
_trailing_lookup_cache['by_ord'] = by_ord
_trailing_lookup_cache['ts'] = now
hit = _trailing_lookup_cache['by_ord'].get(ord_no)
if not hit:
return None
r, s = hit
steps = r.get('steps') or []
return {
'id': r['id'], 'peak': r.get('peak', 0),
'min_sell_price': r.get('min_sell_price'),
# 이 주문이 몇 단계인지 + 예약 전체가 몇 단인지 — 미체결 목록엔 레그가 따로 뜨므로
# "3단 중 2단계" 를 보여줘야 다른 행과의 관계가 읽힌다.
'step_n': s['n'], 'step_total': len(steps),
'trail_pct': s.get('pct'), 'cum': s.get('cum'),
'step_qty': s.get('qty', 0),
'modify_count': s.get('modify_count', 0),
# 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다.
'entry_cond_uv': s.get('entry_cond_uv', 0),
'entry_peak': r.get('entry_peak', 0),
# 형제 계단 — 이 예약의 전체 그림을 한 행에서 보여준다.
'siblings': [{'n': x['n'], 'pct': x.get('pct'), 'cond_uv': x.get('cond_uv', 0),
'qty': x.get('qty', 0), 'cum': x.get('cum')} for x in steps],
}
def _stop_gap_html(stop_price: int, limit_price: int) -> str:
"""스톱 주문의 조건단가−지정가 갭 표기. 호가단위로 나눠 틱수로 보여준다.
`orders/trailing.ORD_UV_GAP_TICKS` 값을 화면에 그대로 박지 않는 이유는 실제 주문에
걸린 값과 어긋날 수 있기 때문(정정 중 값 변화·키움앱 수동 개입). 실측값으로 역산한다.
틱으로 딱 나눠지지 않으면 원으로 폴백.
"""
gap = (stop_price or 0) - (limit_price or 0)
if gap <= 0:
return ''
try:
from orders.trailing import tick_size
t = tick_size(stop_price)
except Exception:
t = 0
if t > 0 and gap % t == 0:
return f' −{gap // t}틱'
return f' −{gap:,}원'
def _pending_detail_html(r: dict) -> str:
"""자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시.
`r['pending_orders']` 가 ord별 dict 리스트.
레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 muted).
"""
orders = r.get('pending_orders') or []
if not orders:
return ''
buys = [o for o in orders if o.get('side') == 'BUY']
sells = [o for o in orders if o.get('side') == 'SELL']
cur_price = r.get('price', 0) or 0 # 조건단가까지 남은 거리 계산용
def _row(o: dict, kind: str) -> str:
qty = o.get('qty', 0)
price = o.get('price', 0)
otype = o.get('order_type') or ''
stop_p = o.get('stop_price', 0) or 0
trl = o.get('trailing')
acc = html.escape(o.get('account', ''))
ord_no = html.escape(o.get('ord_no', ''))
tm = _fmt_hhmmss(html.escape(o.get('time', '')))
meta_parts = [acc]
if tm:
meta_parts.append(tm)
if ord_no:
meta_parts.append(f'주문번호 {ord_no}')
label = '미체결 매수' if kind == 'buy' else '미체결 매도'
if kind == 'sell' and trl:
label = '트레일링 매도'
elif kind == 'sell' and stop_p:
label = '스톱 매도'
# 스톱 계열은 가격이 두 개다 — 조건단가(방아쇠)와 지정가(실제 주문가).
# 첫 줄에 지정가만 '@ N원' 으로 박으면 그게 방아쇠처럼 오해되고 둘의 관계도 안 보인다.
# 그래서 트레일링은 첫 줄에 수량만 남기고 두 가격을 미니표 맨 위 단독 항으로 올린다.
if trl or stop_p:
main = f'{qty:,}주 매도 예약'
elif price:
main = f'{qty:,}주 @ {price:,}원 {otype}' if otype else f'{qty:,}주 @ {price:,}원 지정가'
else:
main = f'{qty:,}주 @ {otype or "시장가"}'
cond_line = ''
if stop_p and not trl:
# 순수 스톱지정가 — 조건단가 고정. 표 없이 한 줄이지만 순서는 트레일링과 같게
# (조건가 닿으면 → 지정가로 매도). 지정가가 먼저 오면 그게 방아쇠로 오해된다.
tail = f' {price:,}원에 매도' if price else ' 매도'
cond_line = (f'
')
if price:
# 갭은 두 값의 차를 호가단위로 나눠 틱수로 표시 — ORD_UV_GAP_TICKS 를 그대로
# 박지 않는 이유는 실제 주문 값과 어긋날 수 있어서다(정정·수동 개입).
# 틱으로 딱 안 나눠지면 원으로 폴백.
gap_part = _stop_gap_html(stop_p, price)
mini.append(f'
매도 지정가
{price:,}원{gap_part}
')
# 고점에 '등록 시점' 라벨을 붙이지 않는다 — peak 는 감시가 갱신한 현재 고점이라
# 붙여 놓으면 지금 고점이 등록 시점 값인 것처럼 읽힌다
# (실측 entry_peak 4,860 vs peak 4,950). 등록 후 얼마나 올랐는지는
# 아래 '고점 상승' 행이 실제 값으로 보여준다.
mini.append(f'
고점
{trl.get("peak", 0):,}원
')
cum_v = trl.get('cum') or 0
cum_part = '전량' if cum_v >= 100 else f'누적 {cum_v:g}%'
mini.append(f'
')
if (trl.get('step_total') or 0) > 1:
# 형제 계단 — 미체결 목록엔 레그가 각각 뜨므로, 이 예약 전체가 어떤 모양인지
# 한 행에서 보이게 한다. 지금 행에 해당하는 계단은 굵게.
sib = ' · '.join(
(f'{x["n"]}:{x["cond_uv"]:,}' if x['n'] == trl.get('step_n')
else f'{x["n"]}:{x["cond_uv"]:,}')
for x in (trl.get('siblings') or []))
mini.append(f'
전체 계단
{sib}
')
if trl.get('min_sell_price'):
mini.append(f'
최저 매도가
{trl["min_sell_price"]:,}원
')
if cur_price > 0 and stop_p:
# 손절선을 기준으로 현재가가 몇 % 위에 있는지 = 팔리기까지 남은 여유.
# 라벨이 '손절선 대비'이므로 분모도 손절선이어야 라벨과 계산이 어긋나지 않는다.
room = (cur_price - stop_p) / stop_p * 100
mini.append(f'
손절선 대비
{room:+.1f}% '
f'(현재 {cur_price:,}원)
')
# 상향 횟수는 표시하지 않는다(관리자님 지시). mc 는 상승 행들의 표시 조건으로만 쓴다.
mc = trl.get('modify_count', 0)
entry_peak = trl.get('entry_peak') or 0
peak_now = trl.get('peak', 0)
if entry_peak and peak_now > entry_peak:
mini.append(f'
'
)
# 활성 시장 판정 — OHLC 미니테이블 컬럼 강조용(active_market). cur_price 가 어느 시장 가격과 일치하는지.
nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0)
krx_p = (r.get('ohlc_krx') or {}).get('price', 0)
mark = 'NXT' if (nxt_p and nxt_p == price and nxt_p != krx_p) else 'KRX'
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
# 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래종목(ohlc_nxt 가격 살아있음)만 / 장외·휴장·주말: 점 없음
_phase = _market_phase_state()['phase']
if _phase == 'regular':
_tradable = True
elif _phase == 'nxt':
_tradable = bool(nxt_p)
else:
_tradable = False
mark_html = '' if _tradable else ''
# OHLC mini-table — ka10095 NX/NXT batch 결과를 각 컬럼에 분리 표시. 활성 시장만 밝게.
# _nxt_inactive 마커는 stock_portfolio_report의 NX 응답 기반 판정이라 ETF처럼 NXT 정상거래 종목도
# 잘못 잡힘 → 여기선 무시하고 실제 ohlc_nxt 데이터 유무로 표시 결정.
# NXT 컬럼 baseline 을 어제 NXT 스냅샷 종가(r['pred_close']) 로 통일.
# change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
# 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
ohlc_krx_for_render = r.get('ohlc_krx')
if ohlc_krx_for_render:
ohlc_krx_for_render = dict(ohlc_krx_for_render)
# KRX 전일종가 = ka10095(현재가 − 전일대비). kt00018 pred_close_krx는 장 마감 후
# 현재가와 같은 값으로 채워지는 결함이 있어 등락률이 0%로 깔린다 → ka10095 기준으로 산출.
_krx_prev = (ohlc_krx_for_render.get('price', 0) or 0) - (ohlc_krx_for_render.get('change', 0) or 0)
if _krx_prev <= 0:
_krx_prev = r.get('pred_close_krx', 0) or 0
if _krx_prev > 0:
ohlc_krx_for_render['prev_close'] = _krx_prev
ohlc_nxt_for_render = r.get('ohlc_nxt')
_snap_prev = r.get('pred_close', 0) or 0
if ohlc_nxt_for_render and _snap_prev > 0:
ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
ohlc_nxt_for_render['open'] = _snap_prev
ohlc_nxt_for_render['prev_close'] = _snap_prev
_nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
# 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
_now_ohlc = datetime.now(KST)
_hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
_trd_ohlc = _market_phase_state().get('phase') not in ('holiday', 'weekend')
ohlc_mini_html = _render_ohlc_mini_dual(
ohlc_krx_for_render,
ohlc_nxt_for_render,
active_market=mark.lower(),
al_price=price, # 현재가 행은 AL(통합) 현재가를 양 컬럼 공통 — 등락률만 각 시장 전일종가 대비.
krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30,
nxt_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
)
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
# raw pred_close_krx 는 kt00018 결함으로 정규장 시작 전·마감 후 cur_price 와 같은 값이 들어와
# 등락률이 항상 0% 으로 깔리는 문제가 있어 사용 X.
d_pct_val = r.get('day_change_pct')
# show_day_change(시간대 게이트) 동안엔 항상 rt-daypct span 을 그린다 — 값이 0/None이어도.
# span 이 없으면 WS paintDay(if(sp)) 가 실시간으로 못 채워, 한 번 0으로 렌더되면 등락률이 사라진 채 복구 안 됨.
if show_day_change:
_dp = d_pct_val if isinstance(d_pct_val, (int, float)) else 0.0
d_sign = '+' if _dp >= 0 else ''
d_cls = 'day-pct up' if _dp > 0 else ('day-pct down' if _dp < 0 else 'day-pct neutral')
price_html = f'{mark_html}{price:,}{d_sign}{_dp:.2f}%'
else:
price_html = f'{mark_html}{price:,}'
chart_block = ''
flow_block = ''
if code:
# 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움.
# data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로.
chart_block = f''
# 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script)
flow_block = f''
left_pairs = [
f'
종목코드
{code}
',
f'
현재가
{price:,}원
',
f'
평단가
{avg:,}원
',
]
right_pairs = [
f'
보유수량
{qty:,}주
',
f'
매입금액
{buy_amount:,}원
',
f'
평가금액
{eval_value:,}원
',
]
dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
pl_pairs.extend(journal_lines)
# 매수후 고점대비 — 보유 시작일 이후 일봉 고가 최댓값 대비 현재가 낙폭(%·평가액).
# 매수/매도(journal_lines) 아래에 표시. 순수 캐시 읽기(_select_latest)만 — 렌더 중 키움 호출 방지.
since = _holding_since_map().get(r.get('code') or '')
peak_high = 0
if since and code:
try:
import daily_candles_cache as dcc
for cd in dcc._select_latest(code, 250):
d = str(cd.get('date') or '')
h = cd.get('high') or 0
if d >= since and h > peak_high:
peak_high = h
except Exception:
peak_high = 0
oh_today = r.get('ohlc') or {}
if oh_today.get('h'):
peak_high = max(peak_high, oh_today['h'])
if price:
peak_high = max(peak_high, price)
if peak_high > 0 and price > 0:
dd_pct = (price - peak_high) / peak_high * 100
dd_won = (price - peak_high) * qty
ddcls = _profit_class(dd_won)
pl_pairs.append('')
pl_pairs.append(
f'
'
)
pl_pairs.append('')
pl_dl_inner = ''.join(pl_pairs)
row_key = (code or stock) + key_suffix
tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '')
# 급등락 알림 on/off 표시 — +태그 왼쪽 (관심·감시 행과 같은 위치). 조작은 actions 의 알림 버튼에서.
surge_btn = _surge_indicator_html(r.get('code') or '')
surge_info_btn = _surge_info_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'
'
f'{note_btn}'
f'{surge_info_btn}'
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
f''
f''
f'
'
) if code else ''
tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
return f'''
{_buy_rank_badge(buy_amount)}{stock}{acct_label}
📦 {qty:,}{_pending_badges_html(r)}{_vol_surge_badge(code, (r.get('ohlc_krx') or {}).get('volume'))}{weight:.2f}%{star}
{summary_journal_html}
{price_html}▾
평단 {avg:,}
{sign}{profit:,}{sign}{profit_rate:.2f}%
{candle_summary}
{stock}{f'{code}' if code else ''}{owner_disp}{surge_btn}{tag_add_btn}
'''
def _render_pending_unheld_row(r: dict) -> str:
"""미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행.
summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력."""
stock = html.escape(r.get('stock') or r.get('code') or '')
code = html.escape(r.get('code') or '')
buy_qty = r.get('pending_buy_qty', 0)
price = r.get('price')
# 미체결 매수 평균가 (가중평균) — 시장가(price=0)는 제외
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY']
priced = [o for o in orders if o.get('price', 0) > 0]
if priced:
total_qty = sum(o['qty'] for o in priced)
avg_buy = sum(o['qty'] * o['price'] for o in priced) // total_qty if total_qty else 0
else:
avg_buy = 0
accounts = sorted({o.get('account', '') for o in orders if o.get('account')})
acc_text = html.escape('+'.join(accounts))
if isinstance(price, (int, float)) and price:
# 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct 그대로.
day_pct = r.get('day_change_pct')
if isinstance(day_pct, (int, float)):
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
d_sign = '+' if day_pct >= 0 else ''
price_html = f'{price:,}{d_sign}{day_pct:.2f}%'
else:
price_html = f'{price:,}'
if avg_buy:
diff = price - avg_buy
dpct = (diff / avg_buy * 100) if avg_buy else 0.0
dcls = 'up' if diff > 0 else ('down' if diff < 0 else 'neutral')
dsign = '+' if diff >= 0 else ''
diff_html = f'{dsign}{diff:,.0f}{dsign}{dpct:.2f}%'
else:
diff_html = ''
else:
price_html = '-'
diff_html = ''
summary_line2 = f'매수 대기 {buy_qty:,}주'
if avg_buy:
summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
summary_line2 += ''
chart_block = ''
if code:
chart_block = f''
detail_pairs: list[str] = [f'
종목코드
{code}
']
if isinstance(price, (int, float)) and price:
detail_pairs.append(f'
현재가
{price:,}원
')
if avg_buy:
detail_pairs.append(f'
주문 평균가
{avg_buy:,}원
')
detail_pairs.append(f'
매수 대기
{buy_qty:,}주
')
row_key = code or stock
return f'''
{stock}매수등록{acc_text}
{summary_line2}
{price_html}▾
{f'
{diff_html}
' if diff_html else ''}
{''.join(detail_pairs)}
{_pending_detail_html(r)}
{chart_block}
'''
def _aggregate_by_unit(series: list[tuple[str, int]], unit: str) -> list[tuple[str, int]]:
"""단위(day/week/month/year)별로 각 구간의 마지막 영업일 점만 남김.
'day'는 그대로 반환. week=ISO week (월~일), month=YYYY-MM, year=YYYY."""
if unit == 'day' or not series:
return series
from datetime import date as _date
groups: dict = {} # group key → (date_str, value)
for d, v in series:
try:
dt = _date.fromisoformat(d)
except Exception:
continue
if unit == 'week':
iso = dt.isocalendar()
key = (iso[0], iso[1])
elif unit == 'month':
key = (dt.year, dt.month)
elif unit == 'year':
key = (dt.year,)
else:
return series
prev = groups.get(key)
if prev is None or d > prev[0]:
groups[key] = (d, v)
return sorted(groups.values(), key=lambda x: x[0])
def _load_cash_flow_by_date(owner: str) -> dict[str, dict]:
"""portfolio_daily_snapshot에서 owner별 일자별 입출금 dict.
{date: {'cash_in', 'cash_out'}} — 둘 다 0인 날은 제외. legacy 스냅샷(cash_in 키 없음)은 자연스럽게 0 처리.
stock_portfolio_report가 매일 20:10 스냅샷 저장 시 누적. 적재 시작 이전 과거 데이터는 비어있음.
"""
if not SNAPSHOT_FILE.exists():
return {}
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception:
return {}
out: dict[str, dict] = {}
for date_key, day_snap in snap.items():
owner_snap = (day_snap or {}).get(owner) or {}
ci = int(owner_snap.get('cash_in', 0) or 0)
co = int(owner_snap.get('cash_out', 0) or 0)
if ci or co:
out[date_key] = {'cash_in': ci, 'cash_out': co}
return out
def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: list[str], unit: str) -> dict[str, dict]:
"""unit 단위 집계 시 cash flow 를 series 점에 매핑.
aggregated_dates: 차트 series 의 날짜 리스트 (호출 시점 시리즈).
'day' 또는 daily_cf 빈 dict면 그대로 반환.
week/month/year:
- 그룹에 series 점이 1개(=대표 영업일)면 그 그룹의 daily cash flow 들을 sum 해서 대표 점에 매핑.
- 그룹에 series 점이 여러 개(=_expand_progress_group이 daily 로 펼친 진행 중 그룹)면
각 daily 점에 daily_cf 그대로 매핑 — sum 매핑 시 모든 daily 점이 동일 cf 로 박히는 버그 방지.
"""
if unit == 'day' or not daily_cf:
return dict(daily_cf) if unit == 'day' else {d: dict(cf) for d, cf in daily_cf.items() if d in set(aggregated_dates)}
from datetime import date as _date
def _key(d: str):
dt = _date.fromisoformat(d)
if unit == 'week':
iso = dt.isocalendar()
return (iso[0], iso[1])
if unit == 'month':
return (dt.year, dt.month)
if unit == 'year':
return (dt.year,)
return None
# 그룹별 daily_cf sum (대표 매핑용)
group_sum: dict = {}
for d, cf in daily_cf.items():
try:
k = _key(d)
except Exception:
continue
if k is None:
continue
s = group_sum.setdefault(k, {'cash_in': 0, 'cash_out': 0})
s['cash_in'] += cf.get('cash_in', 0)
s['cash_out'] += cf.get('cash_out', 0)
# series 의 그룹별 점 개수 — 1점=대표, 2점 이상=daily 펼쳐짐.
series_by_group: dict = {}
for d in aggregated_dates:
try:
k = _key(d)
except Exception:
continue
if k is None:
continue
series_by_group.setdefault(k, []).append(d)
out: dict[str, dict] = {}
for k, dates in series_by_group.items():
if len(dates) == 1:
s = group_sum.get(k)
if s and (s['cash_in'] or s['cash_out']):
out[dates[0]] = {'cash_in': s['cash_in'], 'cash_out': s['cash_out']}
else:
for d in dates:
cf = daily_cf.get(d)
if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)):
out[d] = {'cash_in': cf.get('cash_in', 0), 'cash_out': cf.get('cash_out', 0)}
return out
def _available_chart_periods() -> dict:
"""스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞.
반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}"""
empty = {'years': [], 'months': {}}
if not SNAPSHOT_FILE.exists():
return empty
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception:
return empty
years: set = set()
months: dict = {}
for date_key in snap.keys():
if len(date_key) < 7 or date_key[4] != '-':
continue
y, m = date_key[:4], date_key[5:7]
years.add(y)
months.setdefault(y, set()).add(m)
# 현재 연·월은 스냅샷이 아직 없어도(월초 EOD 적재 전) 드롭다운에 노출 — 지정기간 선택 가능하게.
_now = datetime.now(KST)
cy, cm = _now.strftime('%Y'), _now.strftime('%m')
years.add(cy)
months.setdefault(cy, set()).add(cm)
return {
'years': sorted(years, reverse=True),
'months': {y: sorted(ms, reverse=True) for y, ms in months.items()},
}
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''):
"""portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
각 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
unit으로 일/주/월/연 단위 집계 (각 구간 마지막 영업일 점). 기간(days) 필터는 단위 집계 후 적용.
with_prev_baseline=True 면 (series, prev_baseline) 튜플 반환. prev_baseline 은 PnL 모드용으로
series 시작점 직전의 (date, nw) 점 — series[0] 손익도 표시되도록 _to_pnl_series 에 baseline 으로 넘긴다.
series 가 이미 baseline 점을 prepend 한 케이스(기간 잘림 fallback, 진행중 그룹 fallback)에선 None.
"""
if not SNAPSHOT_FILE.exists():
return ([], None) if with_prev_baseline else []
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception as e:
sys.stderr.write(f'snapshot load failed: {e}\n')
return ([], None) if with_prev_baseline else []
daily_series: list[tuple[str, int]] = []
for date_key in sorted(snap.keys()):
owner_snap = (snap.get(date_key) or {}).get(owner) or {}
# v3/v4: {'holdings': {...}, 'deposit': int}, v1 legacy flat: {종목: {qty, ...}} (holdings/deposit 키 없음).
if 'holdings' in owner_snap:
holdings = owner_snap.get('holdings') or {}
deposit = int(owner_snap.get('deposit', 0) or 0)
else:
holdings = {k: v for k, v in owner_snap.items() if isinstance(v, dict) and 'eval_value' in v}
deposit = 0
if not holdings and not deposit:
continue
eval_total = sum(int(v.get('eval_value', 0) or 0) for v in holdings.values())
daily_series.append((date_key, eval_total + deposit))
aggregated = _aggregate_by_unit(daily_series, unit)
range_start: str | None = None # 기간 필터의 시작 경계 — fallback도 이 안에서만
# filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
baseline_prepended = False
if period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출.
range_start = (period + '-01-01') if len(period) == 4 else (period + '-01')
filtered = [(d, v) for d, v in aggregated if d.startswith(period)]
elif days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
prefix = datetime.now(KST).strftime('%Y-%m')
range_start = f'{prefix}-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
elif days == -3: # 이번주 — KST 기준 이번 ISO 월요일~오늘
today = datetime.now(KST).date()
monday = (today - timedelta(days=today.weekday())).isoformat()
range_start = monday
filtered = [(d, v) for d, v in aggregated if d >= monday]
elif days == -4: # 올해 — KST 기준 올해 1월 1일 이후
prefix = datetime.now(KST).strftime('%Y')
range_start = f'{prefix}-01-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
elif days < 0: # 전체 — 진행 그룹 daily fallback만 적용 (range 무한)
filtered = list(aggregated)
else:
filtered = list(aggregated[-days:])
# 첫날이 filter 시작점에 막 들어와 1점만 잡힌 케이스 — 직전 영업일 점을 baseline으로 prepend.
# (이번주 월요일·이번달 1일·올해 1월 1일 등에 "데이터 부족" 안내 대신 비교선이 잡히도록.)
# filtered 가 아예 빈 경우(이번주 시작이 휴장·월초 snapshot 적재 전)에도 boundary 기준 직전 점을
# 1개만 앵커(기간 시작 기준값)로 들고 온다. 라이브 current_net 과 합쳐 2점 차트가 그려진다.
# (2점 이상 당기면 6월 구간이 7월 차트에 그려져 오해 소지 — 앵커는 1점으로 제한.)
if len(filtered) < 2:
boundary = filtered[0][0] if filtered else (range_start or '9999-99-99')
baseline = [s for s in aggregated if s[0] < boundary]
if baseline:
if filtered:
filtered = [baseline[-1]] + filtered
else:
filtered = [baseline[-1]]
baseline_prepended = True
# unit이 주/월/연이고도 여전히 1점이면 진행 중 그룹의 daily 점들로 풀어줌.
# (예: 올해+월별 → 5월만 1점 → 5/01~5/18 일별 점들 + 직전 daily 점 baseline)
# range_start로 fallback 범위를 기간 필터 안으로 제한 — 이번주+월별이 5월 전체로 새지 않음.
expanded_raw = _expand_progress_group(filtered, daily_series, unit, range_start=range_start)
# day 단위 차트는 휴장일을 display 시리즈에서 제외 — 시장 무변동이라 점 표시가 무의미.
# baseline 후보(aggregated)엔 그대로 남겨 휴장일의 net_worth 가 직전 baseline 으로 활용 가능 (예: 5/1 → 5/4 손익).
holidays = _load_holidays() if unit == 'day' else set()
expanded = [s for s in expanded_raw if s[0] not in holidays] if holidays else expanded_raw
if not with_prev_baseline:
return expanded
# _expand_progress_group 가 baseline 1점 prepend 한 케이스 — expanded_raw[0] 가 filtered[0] 보다 앞이면 prepend 발생
expand_prepended = bool(expanded_raw and filtered and expanded_raw[0][0] != filtered[0][0])
if baseline_prepended or expand_prepended or not expanded:
return expanded, None
# 정상 case (기간 필터 내 2점 이상) — expanded[0] 직전의 aggregated 점이 PnL baseline
first_dt = expanded[0][0]
prev_candidates = [s for s in aggregated if s[0] < first_dt]
if prev_candidates:
return expanded, prev_candidates[-1]
# 직전 그룹 데이터 없을 때 fallback:
# - day 모드: first_dt holdings 의 buy_total + deposit (=owner 첫 적재일 누적평가손익 baseline)
# - month/year/week 그룹 1개뿐: 그 그룹 안의 첫 영업일 daily 점 (그룹 안 시작→끝 변동 표시).
# ex) 5월만 있는데 본인 4월 empty → 5월 그룹 대표 1점 + 5/1 baseline 으로 한달 추세 그림.
# 2점 이상일 땐 적용 X — series 첫 그룹 대표가 이미 baseline 역할.
if unit == 'day':
first_snap = (snap.get(first_dt) or {}).get(owner) or {}
first_holdings = first_snap.get('holdings') or {}
buy_total = sum(int(h.get('qty', 0) or 0) * int(h.get('avg_price', 0) or 0) for h in first_holdings.values())
first_deposit = int(first_snap.get('deposit', 0) or 0)
if buy_total > 0:
return expanded, (first_dt, buy_total + first_deposit)
elif len(expanded) == 1:
from datetime import date as _date
only_dt = _date.fromisoformat(first_dt)
if unit == 'month':
group_start = f'{only_dt.year:04d}-{only_dt.month:02d}-01'
elif unit == 'week':
group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat()
elif unit == 'year':
group_start = f'{only_dt.year:04d}-01-01'
else:
group_start = None
if group_start:
group_first = next((s for s in daily_series if group_start <= s[0] < first_dt), None)
if group_first:
return expanded, group_first
return expanded, None
def _to_pnl_series(series: list[tuple[str, int]], cf_by_date: dict | None, prev_baseline_nw: int | None = None) -> tuple[list[tuple[str, int]], int, int]:
"""net_worth 시계열 → '시작 baseline=0' 누적손익 시계열로 변환.
cumulative_pnl[i] = net_worth[i] - net_worth[start] - sum(net_cash_flow[after start..i]).
첫 점은 baseline 이므로 cf 제외(이미 nw[start] 에 반영). cf_by_date 는 series 와 동일 unit 으로
이미 집계된 {date: {cash_in, cash_out}}.
prev_baseline_nw 가 주어지면 series 시작점 직전 영업일의 net_worth 를 baseline 으로 잡아 series[0]
의 손익도 표시된다 (이번달/이번주 기간 필터로 첫날 손익이 baseline=0 으로 깔리는 문제 해결).
cf_by_date 도 series[0] 의 cash flow 부터 누적에 포함된다.
Returns: (pnl_series, baseline_nw, cumulative_cf_total) — current 점 변환에 baseline/cf 필요.
"""
if not series:
return [], 0, 0
cf = cf_by_date or {}
if prev_baseline_nw is not None:
baseline_nw = int(prev_baseline_nw)
cumulative_cf = 0
out: list[tuple[str, int]] = []
for d, nw in series:
delta = cf.get(d) or {}
cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0)
out.append((d, int(nw) - baseline_nw - cumulative_cf))
else:
baseline_nw = int(series[0][1])
cumulative_cf = 0
out = [(series[0][0], 0)]
for d, nw in series[1:]:
delta = cf.get(d) or {}
cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0)
out.append((d, int(nw) - baseline_nw - cumulative_cf))
return out, baseline_nw, cumulative_cf
def _expand_progress_group(filtered: list[tuple[str, int]], daily_series: list[tuple[str, int]], unit: str, *, range_start: str | None = None) -> list[tuple[str, int]]:
"""unit이 주이고 series가 1점뿐이면 그 진행 중 그룹의 daily 점들로 대체.
range_start가 주어지면 group_start를 그것보다 뒤로 제한 (기간 필터와 교집합).
그룹 직전 daily 점 1개를 baseline으로 prepend (있으면).
month/year 단위는 daily 펼침 제외 — 단위 의도와 안 맞음. 그룹 1개뿐이면 prev_baseline fallback 으로
그룹 첫 영업일을 baseline 으로 잡아 그룹 안 시작→끝 변동을 2점 차트로 표시."""
if unit in ('day', 'month', 'year') or len(filtered) >= 2 or not filtered or not daily_series:
return filtered
from datetime import date as _date
only_date = filtered[0][0]
only_dt = _date.fromisoformat(only_date)
if unit == 'week':
group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat()
else:
return filtered
if range_start and range_start > group_start:
group_start = range_start
in_group = [s for s in daily_series if group_start <= s[0] <= only_date]
if len(in_group) < 2:
return filtered
before_group = [s for s in daily_series if s[0] < group_start]
baseline = before_group[-1:] if before_group else []
return baseline + in_group
def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | None = None, selected_days: int = NET_WORTH_CHART_DAYS, selected_unit: str = NET_WORTH_CHART_UNIT, cash_flow_by_date: dict | None = None, today_cash_flow: dict | None = None, mode: str = NET_WORTH_CHART_MODE, prev_baseline_point: tuple[str, int] | None = None, rt_owner: str | None = None, rt_base_net: int | None = None) -> str:
"""꺾은선 SVG (순자산 / 손익누적 공용). 외부 의존성 없음. 데이터 < 2점이면 안내문 반환.
mode='net'(기본): series 는 raw 순자산 시계열. 입출금 마커·툴팁 행 표시.
mode='pnl': series 는 _to_pnl_series 결과(첫 점=0 누적손익). 입출금 효과는 이미 제거,
마커·툴팁 행 모두 숨김. 헤더 라벨도 '손익누적' 으로 분기.
current_net 은 mode에 맞춰 caller가 변환해서 넘긴다(net=순자산, pnl=누적손익).
selected_unit (day/week/month/year)에 따라 X축 라벨 형식과 tooltip 손익 라벨이 분기된다.
prev_baseline_point=(date, nw) 가 주어지면 차트 series 앞에 baseline 점 prepend
→ 첫 영업일 변동이 0 기점에서 분기되는 모양으로 시각화. 그 baseline ↔ 첫 영업일 구간은 점선.
"""
# pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다(cf 마커·순자산 행 숨김 공통).
# pnl=전부 누적 곡선, period=각 점이 그 기간(일/주/월/연) 자체 손익.
is_pnl = mode in ('pnl', 'period')
per_period = (mode == 'period')
cash_flow_by_date = cash_flow_by_date or {}
def _pp_key(ds: str):
from datetime import date as _pp_date
try:
dt = _pp_date.fromisoformat(ds)
except Exception:
return None
if selected_unit == 'week':
iso = dt.isocalendar()
return (iso[0], iso[1])
if selected_unit == 'month':
return (dt.year, dt.month)
if selected_unit == 'year':
return (dt.year,)
return ds # day — 각 날짜가 독립 기간
# baseline 점 prepend — PnL 모드면 baseline 값 = 0 (series 자체가 baseline 대비 누적이라).
# net 모드면 prev nw 그대로.
prev_baseline_nw = prev_baseline_point[1] if prev_baseline_point else None # day/total 계산용
baseline_prepended_pt: tuple[str, int] | None = None
if prev_baseline_point and series:
prev_dt, prev_nw = prev_baseline_point
prev_v_for_chart = 0 if is_pnl else int(prev_nw)
baseline_prepended_pt = (prev_dt, prev_v_for_chart)
# 기간·단위·모드 토글은 자산정보 탭 상단의 공통 select 컨트롤로 이동 (_render_chart_controls).
# 차트 wrapper엔 data-chart-days/unit/mode 박아 swap 후 상태 인식 가능.
if selected_unit == 'month':
empty_msg = '월별 차트는 2개월 이상의 데이터가 필요합니다.'
elif selected_unit == 'week':
empty_msg = '주별 차트는 2주 이상의 데이터가 필요합니다.'
elif selected_unit == 'year':
empty_msg = '연별 차트는 2년 이상의 데이터가 필요합니다.'
else:
_empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '총자산')
empty_msg = f'최근 {_empty_metric} 추이 데이터가 부족합니다.'
def _empty(msg: str) -> str:
return (
f'
'
f'
{msg}
'
'
'
)
if len(series) < 2 and current_net is None:
return _empty(empty_msg)
# stock.briefing 정규 적재 시각(20:10 KST) 이전이면 오늘 snapshot 행은 장중 추정치.
# series 끝 행을 제거하고 live 점으로 교체 → 마지막 구간 점선 분기 보존.
# 마감 후엔 confirmed로 간주, snapshot 오늘 행 유지 → 일반 실선.
now = datetime.now(KST)
today_kst = now.strftime('%Y-%m-%d')
before_close = (now.hour, now.minute) < (20, 10)
series = list(series) # 외부 인자 mutate 방지 + 로컬 컷
if before_close and series and series[-1][0] == today_kst:
series = series[:-1]
has_live = (
current_net is not None
and (not series or series[-1][0] != today_kst)
)
# 휴장일/주말은 시장 변동이 없어야 자연 — NXT 가격 보정으로 인한 가짜 점프(=오늘 라이브 점이 어제 스냅샷과 차이) 차단.
if has_live and _market_phase_state().get('phase') in ('holiday', 'weekend'):
has_live = False
# per_period + 라이브 점이 있으면, 라이브와 같은 기간의 확정 점들은 중복이라 제거 (라이브가 그 기간 대표).
# 예: 연별인데 2026 연 대표점(5/29) + 라이브(6/01) 둘 다 2026 → 5/29 버려 baseline→현재 2점으로 그림.
if per_period and has_live and series:
_live_key = _pp_key(today_kst)
while series and _pp_key(series[-1][0]) == _live_key:
series = series[:-1]
# baseline 점이 있으면 series 앞에 prepend (시각화용). day/total tooltip 도 자연스레 baseline 기준이 됨.
has_baseline = baseline_prepended_pt is not None
full_series_with_baseline = ([baseline_prepended_pt] if has_baseline else []) + series
full = full_series_with_baseline + ([(today_kst, int(current_net))] if has_live else [])
if len(full) < 2:
return _empty(empty_msg)
values = [v for _, v in full]
# per_period 라인 = '기간별 손익'(그 기간 자체 손익). values(누적)는 tooltip '누적 손익'·헤더 합계용 유지.
# 각 점 = 그 점 누적값 − 직전 '다른 기간' 점의 누적값 (없으면 baseline 0).
# 직전 '다른 기간' 기준이라, 같은 기간 점이 여러 개여도(올해+월별 진행 중 등) 그 기간 전체 손익이 잡힌다.
# 단일 기간(예: 데이터가 2026 한 해뿐)이면 위에서 라이브 중복점을 정리해 baseline(0)→현재 2점으로 그린다.
if per_period:
_pp_keys = [_pp_key(d) for d, _ in full]
plot_values = []
for _i in range(len(values)):
_base = 0
for _j in range(_i - 1, -1, -1):
if _pp_keys[_j] != _pp_keys[_i]:
_base = values[_j]
break
plot_values.append(int(values[_i]) - _base)
else:
plot_values = values
vmin, vmax = min(plot_values), max(plot_values)
vrange = max(vmax - vmin, 1)
pad = vrange * 0.10
y_lo = vmin - pad
y_hi = vmax + pad
span = max(y_hi - y_lo, 1)
# viewBox 800x280, padding: 좌72 우16 상14 하34 (날짜라벨 + 상단 cf 라벨 영역).
# 좌여백은 모바일 axis 폰트(22px)에서 '−350K'·'1.5M' 류 라벨이 안 잘리게 넓게 잡음.
W, H = 800, 360
L, R, T, B = 72, 16, 14, 34
iw = W - L - R
ih = H - T - B
n = len(full)
def x_at(i: int) -> float:
return L + (i / (n - 1)) * iw if n > 1 else L + iw / 2
def y_at(v: int) -> float:
return T + (1 - (v - y_lo) / span) * ih
pts = [(x_at(i), y_at(v)) for i, v in enumerate(plot_values)]
# 3-way path 분리:
# - baseline_dashed_pts: baseline → 첫 영업일 (있는 경우)
# - solid_pts: 첫 영업일 → 마지막 확정 snapshot
# - dashed_pts: 마지막 snapshot → live 점 (있는 경우)
base_offset = 1 if has_baseline else 0
solid_end = len(full) - (1 if has_live else 0)
baseline_dashed_pts = pts[:base_offset + 1] if has_baseline else []
solid_pts = pts[base_offset:solid_end]
dashed_pts = pts[solid_end - 1:] if has_live else []
# per_period면 헤더·라인색을 '마지막 기간 손익' 기준으로 (그 외 pnl/net은 시작 대비 변동).
delta = plot_values[-1] if per_period else values[-1] - values[0]
up = delta >= 0
line_color = '#ff4d5e' if up else '#4a8cf0'
fill_id = f'nw-grad-{"up" if up else "dn"}'
delta_sign = '+' if delta >= 0 else '−'
if is_pnl:
# per_period=마지막 기간 손익, 아니면 마지막 누적 손익. (pnl 첫 점=0 baseline → 비율 무의미)
_hdr_val = plot_values[-1] if per_period else values[-1]
delta_label = f'{delta_sign}{abs(_hdr_val):,}원'
else:
delta_pct = (delta / values[0] * 100) if values[0] else 0.0
delta_label = f'{delta_sign}{abs(delta):,}원 ({delta_sign}{abs(delta_pct):.2f}%)'
delta_class = 'up' if up else 'down'
# 데이터 범위(vmin~vmax)를 3구간으로 균등 분할 → 가로선 4개 (vmax / +2/3 / +1/3 / vmin).
grid_vals = [vmax - vrange * i / 3 for i in range(4)]
# 눈금 간격(step)이 단위보다 작으면 floor 나눗셈이 라벨을 뭉개므로(예: 1M 1M 1M),
# step 크기에 맞춰 소수 자릿수를 정해 인접 눈금이 항상 구별되게 한다.
step = vrange / 3
dec_m = 0 if step >= 1_000_000 else (1 if step >= 100_000 else 2)
dec_k = 0 if step >= 1000 else 1
grid_lines = []
for gv in grid_vals:
gy = y_at(int(gv))
grid_lines.append(f'')
label = f'{gv / 1_000_000:,.{dec_m}f}M' if abs(gv) >= 1_000_000 else f'{gv / 1000:,.{dec_k}f}K'
grid_lines.append(f'{html.escape(label)}')
# 단위 경계 세로선 — 일별=주 경계(ISO 주가 바뀌는 첫 점), 월별=연 경계(연도가 바뀌는 첫 점).
# 그 외 단위는 미표시. per_period 모드는 period-sep(점마다 구분선)이 이미 있어 중복이라 생략.
boundary_lines: list[str] = []
if not per_period and selected_unit in ('day', 'month'):
from datetime import date as _bd_date
def _bkey(ds: str):
if selected_unit == 'day':
try:
iso = _bd_date.fromisoformat(ds).isocalendar()
return (iso[0], iso[1]) # (ISO연, 주차)
except Exception:
return None
return ds[:4] # month → 연도
_prev_bk = _bkey(full[0][0])
for _bi in range(1, n):
_bk = _bkey(full[_bi][0])
if _bk is not None and _prev_bk is not None and _bk != _prev_bk:
_bx = x_at(_bi)
boundary_lines.append(
f''
)
if _bk is not None:
_prev_bk = _bk
# 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시.
# 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡).
zero_line = ''
if y_lo <= 0 <= y_hi:
zy = y_at(0)
zero_line = (
f''
)
label_idx = sorted({0, n // 2, n - 1})
# 점들이 모두 같은 그룹(month/year)이면 라벨 중복이라 한 단계 작은 단위로 fallback.
# 라벨 형식은 unit 그대로 — year=YYYY, month=YYYY-MM, week/day=MM-DD.
# 한 단위 안에 모든 점이 들어가는 케이스(이번달·올해 진행 중)도 unit 라벨 유지 → 중복 라벨 보일 수 있으나 사용자 의도(단위 명확 표시) 우선.
def _x_label_for(date_iso: str) -> str:
if selected_unit == 'year':
return date_iso[:4]
if selected_unit == 'month':
return date_iso[:7]
return date_iso[5:]
x_labels = []
for i in label_idx:
date_str = _x_label_for(full[i][0])
if has_live and i == n - 1:
date_str += '*' # 장중 잠정 표시
x_labels.append(
f'{html.escape(date_str)}'
)
last_x, last_y = pts[-1]
# per_period면 마지막 점 = 마지막 기간 손익(=마지막 dot 높이와 일치). 아니면 누적 마지막값.
last_v = plot_values[-1] if per_period else values[-1]
if is_pnl:
last_sign = '+' if last_v >= 0 else '−'
last_label_v = f'{last_sign}{abs(last_v):,}원'
else:
last_label_v = f'{last_v:,}원'
if has_live:
last_label_v += ' (장중)'
range_label = f'{_x_label_for(full[0][0])} ~ {_x_label_for(full[-1][0])}'
# per_period는 baseline 제외 실제 표시 기간 수(라이브 포함). 라이브 중복점 제거로 series가 비어도 정확.
if per_period:
title_n = len({k for k in _pp_keys[base_offset:] if k is not None})
else:
title_n = len(series)
_unit_word = {'week': '주', 'month': '월', 'year': '연'}.get(selected_unit, '영업일')
_title_metric = '손익' if per_period else ('손익누적' if is_pnl else '총자산')
title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '')
# 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용.
area_d = ''
if len(solid_pts) >= 2:
area_d = (
f'M {solid_pts[0][0]:.2f},{T + ih:.2f} '
+ 'L ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts)
+ f' L {solid_pts[-1][0]:.2f},{T + ih:.2f} Z'
)
solid_d = ''
if len(solid_pts) >= 2:
solid_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts)
dashed_d = ''
if len(dashed_pts) >= 2:
dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in dashed_pts)
baseline_dashed_d = ''
if len(baseline_dashed_pts) >= 2:
baseline_dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in baseline_dashed_pts)
last_dot_class = 'last-dot live' if has_live else 'last-dot'
# 호버/터치 인터랙션용 메타. 각 포인트의 viewBox 좌표·날짜·순자산·그날 손익·누적 손익·입출금.
base_v = values[0]
prev_v: int | None = None
pts_meta: list[dict] = []
today_kst_str = today_kst # 위에서 산출됨
for i, (d_str, v_i) in enumerate(full):
x_i, y_i = pts[i]
cf = cash_flow_by_date.get(d_str)
# 오늘 라이브 포인트는 별도 today_cash_flow 우선 — snapshot 적재 전이라도 KPI와 일치하게.
if d_str == today_kst_str and today_cash_flow and (today_cash_flow.get('cash_in', 0) or today_cash_flow.get('cash_out', 0)):
cf = {'cash_in': int(today_cash_flow.get('cash_in', 0) or 0),
'cash_out': int(today_cash_flow.get('cash_out', 0) or 0)}
# PnL 모드: v 자체가 이미 baseline 대비 누적손익 → total=v_i (재차감 X).
# day 는 직전 점 대비 변동분이고, 첫 점은 baseline 대비라 v_i 자체가 첫날 손익 의미 (0 아님).
# net 모드: v 가 raw 순자산. prev_baseline_nw 가 있으면 첫 점 day/total 도 그 대비 (휴장일 baseline 등).
if is_pnl:
# per_period면 '그 기간 손익' 행을 plot_values 와 일치시킨다(라이브점=이번 달 전체 손익).
# 아니면(day) 직전 점 대비 변동분. total 은 항상 누적값.
day_val = int(plot_values[i]) if per_period else (int(v_i - prev_v) if prev_v is not None else int(v_i))
total_val = int(v_i)
else:
if prev_v is not None:
day_val = int(v_i - prev_v)
elif prev_baseline_nw is not None:
day_val = int(v_i - prev_baseline_nw)
else:
day_val = 0
total_val = int(v_i - prev_baseline_nw) if prev_baseline_nw is not None else int(v_i - base_v)
meta = {
'd': d_str,
'v': int(v_i),
'day': day_val,
'total': total_val,
'x': round(x_i, 2),
'y': round(y_i, 2),
}
if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)):
ci_i = int(cf.get('cash_in', 0) or 0)
co_i = int(cf.get('cash_out', 0) or 0)
meta['cf_in'] = ci_i
meta['cf_out'] = co_i
meta['cf_net'] = ci_i - co_i
pts_meta.append(meta)
prev_v = v_i
pts_attr = html.escape(json.dumps(pts_meta, ensure_ascii=False), quote=True)
# 입출금 수직선 — 해당 포인트 x좌표에 차트 영역을 가로지르는 dashed 선.
# 색상: net > 0 입금 우세 = #0a7 / net < 0 출금 우세 = #d33 / 0 (= 입+출 동일) = #888.
# 작은 캡션 라벨(±N) 을 차트 상단에 살짝 띄움 — 텍스트로 명확히 표시.
cf_markers: list[str] = []
for meta in pts_meta:
if 'cf_net' not in meta:
continue
cf_x = meta['x']
cf_net = meta['cf_net']
cf_color = '#0a7' if cf_net > 0 else ('#d33' if cf_net < 0 else '#888')
cf_sign = '+' if cf_net >= 0 else '−'
cf_abs = abs(cf_net)
cf_label = f'{cf_sign}{cf_abs // 1_000_000}M' if cf_abs >= 1_000_000 else (
f'{cf_sign}{cf_abs // 1000}K' if cf_abs >= 1000 else f'{cf_sign}{cf_abs}'
)
cf_markers.append(
f''
)
cf_markers.append(
f'{html.escape(cf_label)}'
)
# per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게.
# (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만)
period_seps = []
point_dots = []
for _i, (px, py) in enumerate(pts):
if per_period:
# 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색).
period_seps.append(
f''
)
_pv = plot_values[_i]
_dot_color = '#ff4d5e' if _pv > 0 else ('#4a8cf0' if _pv < 0 else '#6b7280')
else:
# 순자산·손익누적은 연속 곡선 — 점만 라인색으로 (부호색은 순자산이 항상 양수라 무의미).
_dot_color = line_color
point_dots.append(f'')
svg_parts = [
f'')
_day_label = _NET_WORTH_DELTA_LABEL.get(selected_unit, '그날 손익')
# pnl 모드는 '순자산'·'입출금' 행 의미 없음 (입출금 효과 이미 제거). 서버에서 hidden 처리.
_net_row_hidden = ' hidden' if is_pnl else ''
_cf_row_hidden = ' hidden' if is_pnl else ' hidden' # cf-row 는 net 모드라도 cf_net 있을 때만 JS가 노출
tip_html = (
'
'
''
''
f'
{html.escape(_day_label)}—
'
'
누적 손익—
'
f'
총자산—
'
# 입출금 행 — 클라이언트 JS가 cf_net 있을 때만 display 토글 (pnl 모드에선 영구 hidden).
f'
'
)
def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: str, show_day_change: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT) -> str:
# 순자산 차트는 자산정보 탭(_render_summary_panel)로 통합 이동. owner 탭엔 KPI + 보유종목 흐름만.
# chart_days/chart_unit 인자는 시그니처 호환 위해 보존 (호출처 영향 최소).
# balances도 같은 이유로 보존 — 계좌별 예수금이 KPI 예수금 [!] 팝업으로 옮겨가며 여기선 미사용.
parts = [_render_owner_kpi(owner, d, owner_label_text)]
held = [r for r in d['consolidated'] if not r.get('phantom')]
traded_held = [r for r in held if r.get('traded_today')]
phantoms = [r for r in d['traded_today'] if r.get('phantom')]
if traded_held or phantoms:
parts.append(
f'
당일 매매{len(traded_held) + len(phantoms)}
'
)
for r in sorted(phantoms, key=lambda x: -((x.get('journal') or {}).get('pl_amt', 0))):
parts.append(_render_phantom_row(r))
for r in sorted(traded_held, key=lambda x: -x.get('eval_value', 0)):
parts.append(_render_holding_row(r, d['total_value'], show_day_change))
pending_unheld = d.get('pending_unheld') or []
pending_buy_held = [r for r in (d.get('consolidated') or [])
if not r.get('phantom') and r.get('pending_buy_qty', 0) > 0]
buy_total = len(pending_unheld) + len(pending_buy_held)
if buy_total:
parts.append(
f'
매수등록{buy_total}
'
)
# 보유 종목의 매수등록 카드도 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시,
# 매도등록과 동일한 이유·방식). pending_buy_held 원소가 consolidated 행 객체 자체다.
# ⚠️ pending_unheld(미보유 + 매수 미체결)는 보유 수량·평단·손익이 없어 종목카드로
# 대체할 수 없다 — 전용 카드 유지.
for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)):
parts.append(_render_holding_row(r, d['total_value'], show_day_change,
key_suffix=':pending-buy'))
for r in pending_unheld:
parts.append(_render_pending_unheld_row(r))
pending_sell_held = [r for r in (d.get('consolidated') or [])
if not r.get('phantom') and r.get('pending_sell_qty', 0) > 0]
if pending_sell_held:
parts.append(
f'
매도등록{len(pending_sell_held)}
'
)
# 매도등록 카드는 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시).
# pending_sell_held 원소가 consolidated(보유종목) 행 객체 자체라 필요한 필드가 다 있고,
# 전용 카드는 summary 값을 detail 에서 되풀이하면서 손익·버튼·태그가 없어 축소판이었다.
# 같은 종목이 자산정보 탭 보유종목과 겹치므로 key_suffix 로 row_key 를 분리한다
# (바로 아래 ':held' 와 같은 방식 — mutex/open 복원 충돌 방지).
for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)):
parts.append(_render_holding_row(r, d['total_value'], show_day_change,
key_suffix=':pending-sell'))
# 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함.
# 같은 종목이 양쪽에 들어가니 row_key에 ':held' suffix를 붙여 mutex/open 복원 충돌 방지.
# 합산 / 각계좌별도 토글:
# - 합산: consolidated 행 (현재 동작 유지)
# - 각계좌별도: raw rows를 평가금액 desc로 평탄 정렬, account 라벨은 각 행에 표시
# 토글은 자산정보 탭과 in-memory state 공유 → 한쪽 토글하면 다른 쪽도 같이 바뀜.
raw_rows = d.get('rows') or []
# ohlc 같은 owner-level annotation을 raw row에 코드 단위로 전파.
ohlc_by_code = {
c.get('code'): c.get('ohlc') for c in d.get('consolidated', [])
if c.get('code') and c.get('ohlc')
}
if held or raw_rows:
consolidated_rows_html: list[str] = []
for r in sorted(held, key=lambda x: -x.get('eval_value', 0)):
consolidated_rows_html.append(
_render_holding_row(r, d['total_value'], show_day_change, key_suffix=':held')
)
by_account_rows_html: list[str] = []
for r in sorted(raw_rows, key=lambda x: -x.get('eval_value', 0)):
if r.get('qty', 0) <= 0:
continue
synth = dict(r)
synth['accounts'] = [r.get('account', '')]
synth['traded_today'] = (r.get('tdy_buyq', 0) + r.get('tdy_sellq', 0)) > 0
if r.get('code') in ohlc_by_code:
synth['ohlc'] = ohlc_by_code[r['code']]
acc_suffix = (r.get('account', '') or '').replace(':', '_')
by_account_rows_html.append(
_render_holding_row(synth, d['total_value'], show_day_change, key_suffix=f':held:{acc_suffix}')
)
held_count = len(held)
acc_count = sum(1 for r in raw_rows if r.get('qty', 0) > 0)
# 토글 버튼은 owner KPI 카드 상단 (전체 거래내역 옆)으로 이동 — panes만 여기에 prerender.
parts.append(
'
'
'
'
f'
보유 종목{held_count}
'
+ ('\n'.join(consolidated_rows_html) if consolidated_rows_html else '
보유 종목 데이터가 없습니다.
')
+ '
'
'
'
f'
보유 종목{acc_count}
'
+ ('\n'.join(by_account_rows_html) if by_account_rows_html else '
보유 종목 데이터가 없습니다.
')
+ '
'
'
'
)
if not held and not phantoms:
parts.append('
보유 종목 데이터가 없습니다.
')
# rt-scope: 이 owner의 KPI 카드 + 보유행을 한 컨테이너로 묶음. 실시간 JS가 consolidated pane 행을
# 합산해 같은 scope 안의 data-rt-kpi(총평가금액·총평가손익·순자산·당일평가손익) td를 갱신.
# deposit은 순자산 계산용.
# 당일평가손익 = total_net − prev_net − net_cash_flow 에서 실시간 변동분은 평가액(total_value)뿐 →
# 당일평가손익 = 실시간평가액 + C, C = day_pl_total − total_value (세션 중 상수).
# prev_net은 % 계산용. day_pl_total None(전날 스냅 없음·휴장)이면 속성 미부착 → JS가 스킵.
deposit = d.get('deposit', 0) or 0
_daypl = d.get('day_pl_total')
_attrs = f'data-rt-owner="{html.escape(owner, quote=True)}" data-rt-deposit="{deposit}"'
if _daypl is not None:
_attrs += f' data-rt-daypl-c="{_daypl - d.get("total_value", 0)}" data-rt-prevnet="{d.get("prev_net", 0) or 0}"'
return f'
'
def _format_signed_billion(v: int | None) -> tuple[str, str]:
"""투자자 매매 금액 포맷 — (표시문자열, direction 클래스). 단위는 억원."""
if v is None:
return '—', 'flat'
if v > 0:
return f'+{v:,}', 'up'
if v < 0:
return f'{v:,}', 'down'
return '0', 'flat'
def _adr_class(adr: float | None) -> tuple[str, str]:
"""ADR 색상·해석 라벨. ≤80 침체(매수권), ≥120 과열(매도권). 그래프 기준선(80/100/120)과 동일."""
if adr is None:
return 'flat', ''
if adr <= 80:
return 'down', '침체'
if adr >= 120:
return 'up', '과열'
return 'flat', ''
def _market_adr_signal(market: str) -> dict | None:
"""거래 모달용 — 시장(KOSPI/KOSDAQ) 20일 정통 ADR + 과매수/과매도 판정.
_adr_class와 동일 임계(≥120 과매수·≤80 과매도). 중립·20일 미만 누적이면 None(배너 미표시).
반환 {'value': float, 'zone': '과매수'|'과매도', 'label': '코스피'|'코스닥'}."""
if market not in ('KOSPI', 'KOSDAQ'):
return None
try:
live_rows = _fetch_market_indicators() or []
today = next((r for r in live_rows if r.get('symbol') == market), None)
ma, count, _ = _compute_adr_ma_latest(market, today)
if ma is None or count < ADR_MA_WINDOW:
return None
if ma >= 120:
zone = '과매수'
elif ma <= 80:
zone = '과매도'
else:
return None
return {'value': round(ma, 1), 'zone': zone,
'label': _ADR_MARKET_LABELS.get(market, market)}
except Exception:
return None
def _build_adr_series_with_live(history_rows: list[dict], live_pt: dict | None) -> list[dict]:
"""history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,rise,fall,is_live}, ...] 반환.
rise/fall(상승·하락 종목수)은 정통 합계식 ADR 산출용. 라이브는 history 마지막 date 와 다를 때만 append."""
base: list[dict] = [
{'date': r['date'], 'adr': float(r['adr']),
'rise': r.get('rise'), 'fall': r.get('fall'), 'is_live': False}
for r in history_rows
if r.get('adr') is not None and r.get('date')
]
if live_pt and live_pt.get('adr') is not None and live_pt.get('date'):
last_date = base[-1]['date'] if base else None
if live_pt['date'] != last_date:
base.append({'date': live_pt['date'], 'adr': float(live_pt['adr']),
'rise': live_pt.get('rise'), 'fall': live_pt.get('fall'), 'is_live': True})
return base
def _adr_window_value(win: list[dict]) -> float:
"""정통 ADR = 윈도우 내 Σ상승종목 ÷ Σ하락종목 × 100. 비율의 평균(구방식)은 급등일에 왜곡되므로 합계식 사용.
윈도우 전 구간에 종목수(rise/fall)가 있고 Σ하락>0일 때만 합계식, 아니면 일별 비율 평균으로 폴백(구 데이터 graceful)."""
rises = [w['rise'] for w in win if w.get('rise') is not None]
falls = [w['fall'] for w in win if w.get('fall') is not None]
if len(rises) == len(win) and len(falls) == len(win) and sum(falls) > 0:
return sum(rises) / sum(falls) * 100
return sum(w['adr'] for w in win) / len(win)
def _compute_adr_ma_latest(symbol: str, today_row: dict | None = None,
window: int = ADR_MA_WINDOW) -> tuple[float | None, int, bool]:
"""심볼별 최근 N일 정통 ADR. (value or None, available_count, used_live).
today_row = 오늘 라이브 행(adr·rise·fall·bizdate). available_count < window 이면 '누적 중 count/window'."""
history = _load_market_history(-1).get(symbol, [])
live_pt = None
if today_row and today_row.get('adr') is not None:
bd = today_row.get('bizdate')
iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else today_row.get('date')
if iso:
live_pt = {'date': iso, 'adr': today_row['adr'],
'rise': today_row.get('rise'), 'fall': today_row.get('fall')}
base = _build_adr_series_with_live(history, live_pt)
if not base:
return (None, 0, False)
used = base[-window:]
used_live = any(r.get('is_live') for r in used)
# 확장 윈도우: window 미만이어도 가용한 만큼으로 계산(차트와 동일 정책). count로 누적 여부 표기.
return (_adr_window_value(used), len(used), used_live)
def _compute_adr_ma_series(rows: list[dict], window: int = ADR_MA_WINDOW) -> list[tuple[str, float, bool]]:
"""rows(오래된 순, {date,adr,rise,fall,is_live}) → 각 시점 trailing window 정통 ADR 시리즈.
[(date, adr, is_live), ...]. 값은 Σ상승÷Σ하락×100 (급등일 왜곡 없는 표준식).
누적 < window(20일) 인 초반 구간은 정식 지표가 아니라 아예 점을 만들지 않는다 → 그래프 미표시."""
series: list[tuple[str, float, bool]] = []
for i in range(len(rows)):
if i + 1 < window: # 20일 미만 누적 — 정식 20일 ADR 불가, 그래프에서 제외
continue
win = rows[i + 1 - window : i + 1]
val = _adr_window_value(win)
is_live = any(r.get('is_live') for r in win)
series.append((rows[i]['date'], val, is_live))
return series
def _render_adr_info_table(rows: list[dict] | None) -> str:
"""ADR 기본정보 표 — 시장(코스피/코스닥)별 20일 ADR·오늘 ADR·종목수(상승/하락/보합).
20일·오늘 각각 ≤80 침체·≥120 과열 배지. 20일은 누적 미만이면 배지 없이 (n/20). 추세 그래프 밖 상시 노출."""
if not rows:
return (
'
'
+ '
시장 지표 미수신
'
+ '
'
)
body: list[str] = []
for r in rows:
# 20일 정통 ADR (배지·색은 20일 값 기준)
ma, count, _ = _compute_adr_ma_latest(r['symbol'], r)
if ma is None:
ma_html = '—'
elif count >= ADR_MA_WINDOW:
cls, tag = _adr_class(ma)
ma_cls = cls if cls in ('up', 'down') else 'neutral'
tag_html = f'{tag}' if tag else ''
ma_html = f'{tag_html}{ma:.1f}'
else:
ma_html = f'{ma:.1f}({count}/{ADR_MA_WINDOW})'
# 오늘 raw ADR (색은 방향만: 100 초과=빨강/미만=파랑)
raw_adr = r.get('adr')
if raw_adr is None:
today_html = '—'
else:
raw_cls = 'up' if raw_adr > 100 else ('down' if raw_adr < 100 else 'neutral')
tag_cls, tag = _adr_class(raw_adr)
tag_html = f'{tag}' if tag else ''
today_html = f'{tag_html}{raw_adr:.1f}'
# 종목수 (상승↑/하락↓/보합→)
rise, fall, steady = r.get('rise'), r.get('fall'), r.get('steady')
if rise is not None and fall is not None and steady is not None:
bd_html = (
f'{rise:,}↑ / '
f'{fall:,}↓ / '
f'{steady:,}→'
)
else:
bd_html = '—'
body.append(
f'
{html.escape(r["label"])}
'
f'
{ma_html}
{today_html}
'
f'
{bd_html}
'
)
return (
'
'
'
시장
20일
오늘
'
'
종목수 ↑/↓/→
'
'' + ''.join(body) + '
'
)
def _render_market_indicators_card() -> str:
"""자산정보 탭의 시장정보 sub-tab에 들어가는 오늘의 시장 카드.
- 지수 섹션: 미국 정규장(ET 09:30~16:00) 시간이면 KOSPI 200 + MSCI Korea(EWY), 그 외 KOSPI/KOSDAQ 등락률.
- 투자자별 매매: 시장 단위 (개인/외국인/기관) — 네이버 m.stock 비공식 API.
실패시 에러 카드."""
rows = _fetch_market_indicators()
if not rows:
return '
시장 지표를 불러올 수 없습니다.
'
# bizdate는 두 시장 동일 — 첫 행 기준으로 헤더 라벨링.
bizdate = next((r['bizdate'] for r in rows if r.get('bizdate')), None)
if bizdate and len(bizdate) == 8:
date_label = f'{bizdate[4:6]}/{bizdate[6:8]}'
else:
date_label = ''
# === 지수 섹션 (시간 무관 상시 표시) ===
# KOSPI / KOSDAQ (라이브 또는 종가, _fetch_indices) + MSCI Korea(EWY) + S&P 500 + 필라델피아 반도체(SOX).
# KOSPI 200 은 라이브 데이터 부재로 KOSPI 와 거의 동일 → KOSPI 만 표시.
all_indices = _fetch_indices()
indices_by_label = {q.get('label'): q for q in all_indices if q.get('label')}
extra = _fetch_extra_market_quotes() or [None] * 4
extra = (extra + [None] * 4)[:4]
ewy, sox, vix, tnx = extra
index_quotes = [
indices_by_label.get('KOSPI'),
indices_by_label.get('KOSDAQ'),
indices_by_label.get('S&P500'),
sox,
ewy,
vix,
tnx,
]
index_hint = '국내·미국 주요 지수'
index_descriptions = {
'KOSPI': (
'한국 유가증권시장에 상장된 보통주 전체로 산출하는 시가총액 가중 종합지수.\n\n'
'• 1980년 1월 4일 = 100 기준\n'
'• 약 800개 종목, 시총 약 2,500조원 규모\n'
'• 한국 경제·대형주 sentiment 대표 지표\n'
'• 외국인·기관 자금 흐름이 큰 영향\n\n'
'거래시간 (KST)\n'
'• 정규장: 09:00 ~ 15:30\n'
'• NXT 야간: 08:00~09:00 + 15:30~20:00'
),
'KOSDAQ': (
'한국 코스닥시장 종합지수 — 중소·벤처·기술주 중심.\n\n'
'• 1996년 7월 1일 = 1000 기준\n'
' (2004년 100 → 1000 재조정)\n'
'• 약 1,700개 종목\n'
'• 바이오·IT·2차전지 비중이 높음\n'
'• 글로벌 기술주(나스닥·SOX) sentiment에 민감\n'
'• 코스피보다 변동성·개인 비중이 큼'
),
'S&P500': (
'Standard & Poor\'s가 1957년 도입한 미국 대형주 500개 시가총액 가중 지수.\n\n'
'• 미국 상장 기업 시총의 약 80% 커버\n'
'• 기관 투자자 표준 벤치마크\n'
'• SPY ETF 시총 약 5,000억 달러\n\n'
'거래시간\n'
'• 정규장 ET 09:30 ~ 16:00\n'
'• KST 22:30 ~ 05:00 (DST)\n\n'
'한국 시장 다음날 개장 sentiment에 가장 큰 영향.'
),
'필라델피아 반도체': (
'PHLX Semiconductor Sector Index (^SOX) — 1993년 도입.\n\n'
'• 미국 상장 반도체 30개 종목 시총 가중\n'
'• Nvidia · TSMC ADR · Intel · AMD\n'
' ASML · Broadcom · Qualcomm 등 포함\n\n'
'한국 삼성전자·SK하이닉스와 0.7~0.8 상관관계.\n'
'글로벌 반도체 사이클 선행 지표로\n'
'한국 IT 비중이 큰 코스피에 직접 영향.'
),
'MSCI Korea (EWY)': (
'iShares MSCI South Korea ETF (NYSE 상장).\n\n'
'• MSCI Korea Index 추종\n'
'• 한국 시총 상위 large/mid cap 약 90종목 보유\n'
'• 삼성전자·SK하이닉스·LG에너지솔루션\n'
' 현대차 등 대표 종목 포함\n\n'
'미국 거래소 상장이라\n'
'미국 정규장 시간(KST 22:30~05:00 DST)에 라이브 거래.\n'
'ADR·대표주의 미국 야간 등락이 EWY에 반영되어\n'
'한국 시장 야간 sentiment 추정에 활용.'
),
'VIX (공포지수)': (
'CBOE Volatility Index — 1993년 도입.\n'
'S&P 500 옵션의 30일 implied volatility를 연율화한 변동성 지수.\n'
'"공포지수(Fear Gauge)" 별칭.\n\n'
'구간 해석\n'
'• 12 이하: 안일 (complacency)\n'
'• 12 ~ 20: 안정\n'
'• 20 ~ 30: 주의\n'
'• 30 ~ 40: 공포 (시장 패닉)\n'
'• 40 이상: 극단 위기 (2008·2020 수준)\n\n'
'VIX 상승 = 위험자산 회피\n'
'→ 한국 외국인 매도 압력 증가.'
),
'미국채 10년 (%)': (
'미국 10년 만기 국채(Treasury Note)의 연 수익률.\n\n'
'연준 통화정책 · 인플레이션 기대 · 재정 적자가 반영되는\n'
'글로벌 무위험 금리 기준.\n\n'
'한국 시장 영향\n'
'• 상승 → 자금이 채권으로 이동\n'
' → 한국 외국인 매도 압력 (위험자산 회피)\n'
'• 하락 → 신흥국·한국 자금 유입 가능성 ↑\n\n'
'구간 해석\n'
'• 통상 3.5 ~ 5%대가 정상\n'
'• 6% 이상은 시장 부담\n'
'• 단기 금리와의 역전(yield curve inversion)은\n'
' 경기 침체 신호로 해석'
),
}
index_rows: list[str] = []
for q in index_quotes:
if not q:
continue
direction = q.get('direction') or 'flat'
sign = '+' if direction == 'up' else ('−' if direction == 'down' else '')
price = q.get('price', '—')
change = q.get('change', '0')
pct = q.get('pct', '0.00')
label = q.get('label', '')
desc = index_descriptions.get(label)
# 색상 — 양봉=빨강(.up), 음봉=파랑(.down), 보합=회색(.flat). market-table CSS 그대로 사용.
# 설명 — ⓘ 버튼 클릭 시 idx-info-modal 팝업으로 표시. 가격/등락도 attr로 전달해 팝업 안에서 함께 표시.
# 차트는 data-idx-symbol 가 있으면 모달에서 lazy fetch (/api/idx-chart?symbol=...) 후 SVG 렌더.
symbol_attr = _IDX_CHART_SYMBOL.get(label, '')
info_btn = (
f''
) if desc else ''
index_rows.append(
f'
'
)
deal_rows: list[str] = []
for r in rows:
p_str, p_cls = _format_signed_billion(r.get('personal'))
f_str, f_cls = _format_signed_billion(r.get('foreign'))
i_str, i_cls = _format_signed_billion(r.get('institutional'))
deal_rows.append(
f'
'
f'
{html.escape(r["label"])}
'
f'
{p_str}
'
f'
{f_str}
'
f'
{i_str}
'
f'
'
)
date_suffix = f' {date_label}' if date_label else ''
refresh_btn = (
''
)
return (
'
'
f'
오늘의 시장{date_suffix}{refresh_btn}
'
+ index_section +
'
'
'
투자자별 매매순매수 금액 · 단위 억원
'
'
'
'
개인
외국인
기관
'
f'{"".join(deal_rows)}'
'
'
'
'
'
'
)
def _load_market_history(days: int = ADR_TREND_DAYS) -> dict[str, list[dict]]:
"""state/market_indicators_history.jsonl에서 시장별 최근 days 행 로드.
days=-1 → 전체. 반환: {'KOSPI': [{...오래된 순...}], 'KOSDAQ': [...]}. 파일 없거나 깨지면 빈 dict."""
if not MARKET_HISTORY_FILE.exists():
return {}
try:
rows: list[dict] = []
for ln in MARKET_HISTORY_FILE.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
rows.append(json.loads(ln))
except json.JSONDecodeError:
continue
except Exception as e:
sys.stderr.write(f'market history load failed: {e}\n')
return {}
grouped: dict[str, list[dict]] = {}
for r in rows:
m = r.get('market')
if not m:
continue
grouped.setdefault(m, []).append(r)
for m in grouped:
grouped[m].sort(key=lambda r: r.get('date', ''))
if days >= 0 and len(grouped[m]) > days:
grouped[m] = grouped[m][-days:]
return grouped
def _render_adr_trend_controls(selected_days: int) -> str:
"""ADR 추세 카드 상단 기간 버튼 그룹. localStorage(behive.adrTrendDays) 키로 보존.
클릭 시 클라이언트 JS의 document-level handler가 받아서 localStorage 저장 + load(fresh) 트리거."""
btns = ''.join(
f''
for lbl, d in ADR_TREND_PRESETS
)
return f'
{btns}
'
_ADR_MARKET_COLORS = {'KOSPI': '#fbbf24', 'KOSDAQ': '#34d399'}
_ADR_MARKET_LABELS = {'KOSPI': '코스피', 'KOSDAQ': '코스닥'}
def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
"""시장정보 sub-tab의 ADR 추세 카드. KOSPI/KOSDAQ 두 20일 이평선을 한 SVG 안에 합쳐 그린다.
저장 raw daily ADR은 stock.trade-journal launchd가 평일 21:00 누적, 오늘치 라이브는 _fetch_market_indicators(5분 SWR)에서.
차트는 각 시점의 trailing 20일 평균 — 누적 < 20일인 앞 구간은 평균 산출 불가라 점이 안 그려진다.
가이드선·날짜축은 양쪽 시장이 공유. 호버 시 한 세로선 + 시장당 점, 툴팁에 두 값 동시 표시."""
# 이평 산출엔 days 윈도우 밖의 과거 19일도 필요 → 전체 로드 후 trim.
history_all = _load_market_history(-1)
live_rows = _fetch_market_indicators() or []
live_by_market: dict[str, dict] = {}
for r in live_rows:
sym = r.get('symbol')
adr = r.get('adr')
bd = r.get('bizdate')
if not sym or adr is None:
continue
iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None
if iso:
# rise/fall 도 실어야 오늘 포함 윈도우가 정통 합계식으로 계산됨(없으면 그 구간만 비율평균 폴백).
live_by_market[sym] = {'adr': adr, 'date': iso, 'rise': r.get('rise'), 'fall': r.get('fall')}
# 시장별 trailing 20일 이평 시리즈 — (date, ma, is_live).
# raw 시리즈(history+오늘 라이브)에서 시점별 trailing 평균 산출 후, days 파라미터로 마지막 N개 trim.
market_pts: dict[str, list[tuple[str, float, bool]]] = {}
raw_count_max = 0
for sym in ('KOSPI', 'KOSDAQ'):
raw_rows = history_all.get(sym, [])
live_pt = live_by_market.get(sym)
base = _build_adr_series_with_live(raw_rows, live_pt)
raw_count_max = max(raw_count_max, len(base))
ma_series = _compute_adr_ma_series(base)
if days >= 0 and len(ma_series) > days:
ma_series = ma_series[-days:]
market_pts[sym] = ma_series
# 20일 ADR·오늘 ADR·종목수를 표(그래프 밖 상시)로 표시.
adr_table = _render_adr_info_table(live_rows)
# 그래프 펼침 토글 버튼 — 클릭 시 .adr-graph-wrap hidden 토글(순수 클라이언트, localStorage 보존).
graph_toggle = (
''
)
# 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내.
all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d})
if not all_dates:
hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — '
f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시')
return (
'
'
'
ADR 추세
'
+ adr_table
+ graph_toggle
+ '
'
+ f'
{html.escape(hint)}
'
+ _render_adr_trend_controls(days)
+ '
'
+ '
'
)
date_to_i = {d: i for i, d in enumerate(all_dates)}
n_dates = len(all_dates)
# y축 스케일 — 0~200 고정이면 데이터(통상 40~90)가 아래에 눌려 밋밋함. 데이터 범위에 맞추되
# 기준선 80/100/120 이 항상 보이도록 그 밴드를 범위에 포함시킨다.
_all_vals = [v for sym in ('KOSPI', 'KOSDAQ') for _, v, _ in market_pts[sym]]
if _all_vals:
_dmin, _dmax = min(_all_vals), max(_all_vals)
else:
_dmin, _dmax = 80.0, 120.0
_lo, _hi = min(_dmin, 80.0), max(_dmax, 120.0)
_pad = max(6.0, (_hi - _lo) * 0.1)
Y_MIN, Y_MAX = max(0.0, _lo - _pad), _hi + _pad
# 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. height는 sparkline 단일 70 대비 95 로 확장.
W, H = 360.0, 95.0
PAD_L, PAD_R, PAD_T, PAD_B = 4.0, 38.0, 10.0, 14.0
PLOT_W = W - PAD_L - PAD_R
PLOT_H = H - PAD_T - PAD_B
def x_for(i: int) -> float:
if n_dates == 1:
return PAD_L + PLOT_W / 2
return PAD_L + (PLOT_W * i / (n_dates - 1))
def y_for(v: float) -> float:
v = max(Y_MIN, min(Y_MAX, v))
return PAD_T + PLOT_H * (1 - (v - Y_MIN) / (Y_MAX - Y_MIN))
# 가이드선 — 기준 80/100/120. y축이 이 밴드를 포함하므로 셋 다 표시.
_guide_specs = [
(100, '#8b8f9a', '2 4', 0.25, 0.4),
(80, '#4a8cf0', '3 3', 0.25, 0.45),
(120, '#ff4d5e', '3 3', 0.25, 0.45),
]
guides = ''
for gv, gcolor, gdash, gline_op, gtext_op in _guide_specs:
if not (Y_MIN <= gv <= Y_MAX):
continue
gy = y_for(gv)
guides += (
f''
f'{gv}'
)
# 시장별 이평선 path + 마지막 마커. 이평은 매끄러운 단일 시리즈라 saved/live 분리 점선 불필요.
# 마지막 점이 오늘 라이브 raw를 포함한 평균이면 (is_live) open circle + 별표 마커.
line_parts: list[str] = []
marker_parts: list[str] = []
for sym in ('KOSPI', 'KOSDAQ'):
pts = market_pts[sym]
if not pts:
continue
color = _ADR_MARKET_COLORS[sym]
if len(pts) >= 2:
d_attr = ' L '.join(f'{x_for(date_to_i[d]):.1f} {y_for(v):.1f}' for d, v, _ in pts)
line_parts.append(f'')
last_d, last_v, last_is_live = pts[-1]
lx, ly = x_for(date_to_i[last_d]), y_for(last_v)
if last_is_live:
marker_parts.append(
f''
f'{last_v:.1f}*'
)
else:
marker_parts.append(
f''
f'{last_v:.1f}'
)
# 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리).
hover_g = (
f''
f''
f''
f''
f''
)
# 포인트 메타 — 날짜축 i 기준. 각 날짜에 두 시장 값(있는 만큼) 포함.
# JS는 가장 가까운 x 찾고 양쪽 시장 점에 dot 배치 + 툴팁 두 줄 표시.
pts_meta: list[dict] = []
for i, d in enumerate(all_dates):
entry: dict = {'d': d, 'x': round(x_for(i), 2)}
for sym in ('KOSPI', 'KOSDAQ'):
found = next(((pv, plive) for pd, pv, plive in market_pts[sym] if pd == d), None)
if found is not None:
pv, plive = found
key = 'kospi' if sym == 'KOSPI' else 'kosdaq'
entry[key] = {'v': round(pv, 2), 'y': round(y_for(pv), 2), 'live': bool(plive)}
pts_meta.append(entry)
pts_json = html.escape(json.dumps(pts_meta, separators=(',', ':')), quote=True)
# 선택 지점 정보 — 차트 아래 정적 정보줄(구 플로팅 툴팁 대체). 기본은 최신 지점, 차트를 짚으면 갱신.
# 시장 값 비어있으면 JS가 해당 줄 hide.
info_html = (
'
'
'—'
'코스피—'
'코스닥—'
'
'
)
# 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합).
legend_html = (
'
'
f'코스피'
f'코스닥'
'
'
)
# 차트 x축 하단 기간 라벨 — 시작/끝 날짜만 양 끝에 작게.
first_dt, last_dt = all_dates[0], all_dates[-1]
x_axis_y = H - 3
if first_dt == last_dt:
x_axis_label = (
f'{html.escape(first_dt[5:])}'
)
else:
x_axis_label = (
f'{html.escape(first_dt[5:])}'
f'{html.escape(last_dt[5:])}'
)
chart_row = (
f'
'
f''
f'{info_html}'
f'
'
)
# 누적 < window 면 아직 정식 20일 이평이 아니라 '가용한 만큼'의 평균 → 제목에 명시.
accum_note = f' · 누적 {raw_count_max}/{ADR_MA_WINDOW}일' if raw_count_max < ADR_MA_WINDOW else ''
return (
'
'
)
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str:
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
- 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트)
추가 키움 호출 없음 (_fetch_all_data가 모은 owner_data 재사용). 시장 카드는 네이버 m.stock 별도 fetch (5분 SWR)."""
if not ordered_owners:
return '
계좌 데이터가 없습니다.
'
balances = balances or {}
journal_by_label = journal_by_label or {}
# 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터.
period = (chart_ym or '').strip()
eff_unit = 'day' if period else chart_unit # 지정기간(연도별·월별)은 단위 일별 고정.
# 절대 선택이 과거 기간(이번달·올해가 아님)이면 오늘 라이브 점·라이브 갱신을 끈다 —
# 과거 차트(예: 4월)에 today 순자산이 마지막 점·델타·하단값으로 섞이는 것 방지. 프리셋은 항상 today 포함.
_now_dt = datetime.now(KST)
period_is_current = True
if period:
period_is_current = (period == _now_dt.strftime('%Y')) if len(period) == 4 else (period == _now_dt.strftime('%Y-%m'))
consolidated_parts: list[str] = []
by_account_parts: list[str] = []
chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())]
for owner in ordered_owners:
d = owner_data.get(owner)
if not d:
continue
full_title = _owner_full_title(owner)
consolidated_parts.append(_render_owner_kpi(owner, d, full_title, compact=True))
# 각계좌별도: owner의 labels 순서로 단일 계좌 카드 누적. prev_net 유무로 day_pl 근사 토글.
has_prev_snap = d.get('prev_net') is not None
owner_short = full_title.replace('님 자산', '님').replace(' 자산', '')
owner_prefix = f'{owner}_'
for lb in d.get('labels', []):
label_disp = lb[len(owner_prefix):] if lb.startswith(owner_prefix) else lb
by_account_parts.append(_render_account_kpi(
owner, lb, d, balances, journal_by_label,
owner_label_text=owner_short,
has_prev_snap=has_prev_snap,
label_disp=label_disp,
))
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=eff_unit, with_prev_baseline=True, period=period)
if chart_series:
# 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
daily_cf = _load_cash_flow_by_date(owner)
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], eff_unit) if daily_cf else {}
# 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
today_cf = None
if d.get('cash_in', 0) or d.get('cash_out', 0):
today_cf = {'cash_in': d.get('cash_in', 0), 'cash_out': d.get('cash_out', 0)}
if chart_mode in ('pnl', 'period'):
# pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다 (period는 _render에서 기간별 증분으로 그림).
# series[0] 직전 영업일 net_worth 를 baseline 으로 → 첫날 손익도 표시. fallback baseline 케이스(이번주 휴장, 1월 1일 등)는 prev_baseline=None.
prev_baseline_nw = prev_baseline[1] if prev_baseline else None
pnl_series, baseline_nw, cum_cf = _to_pnl_series(chart_series, cf_by_date, prev_baseline_nw=prev_baseline_nw)
# 차트 시각화에 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline ↔ first 구간은 점선.
prev_baseline_point = prev_baseline if prev_baseline else None
# live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf).
# series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관.
current_net = d.get('total_net') if period_is_current else None
if current_net is None:
current_pnl = None
else:
today_net_cf = 0
if today_cf:
today_net_cf = int(today_cf.get('cash_in', 0) or 0) - int(today_cf.get('cash_out', 0) or 0)
current_pnl = int(current_net) - baseline_nw - cum_cf - today_net_cf
chart_parts.append(_render_net_worth_chart(
pnl_series,
current_net=current_pnl,
selected_days=chart_days,
selected_unit=eff_unit,
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
today_cash_flow=None,
mode=chart_mode,
prev_baseline_point=prev_baseline,
rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
))
else:
# net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
chart_parts.append(_render_net_worth_chart(
chart_series,
current_net=(d.get('total_net') if period_is_current else None),
selected_days=chart_days,
selected_unit=eff_unit,
cash_flow_by_date=cf_by_date,
today_cash_flow=today_cf,
mode='net',
prev_baseline_point=prev_baseline,
rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
))
market_card = _render_market_indicators_card()
adr_trend_card = _render_adr_trend_card(days=adr_days)
# 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일.
assets_inner = (
'
'
+ '\n'.join(consolidated_parts)
+ '
'
'
'
+ '\n'.join(by_account_parts)
+ '
'
)
return (
'
'
+ assets_inner
+ '
'
'
'
+ '\n'.join(chart_parts)
+ '
'
'
'
+ market_card
+ adr_trend_card
+ '
'
'
'
''
''
''
'
'
)
def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]:
if not cards:
return '
감시종목이 비어있습니다.
', 0
market_active = _market_active_now()
show_day_change = _show_day_change_now()
alerts_map = _load_alerts_map()
for c in cards:
c['_show_day_change'] = show_day_change
c['_alerted'] = alerts_map.get(c.get('stock') or '', [])
active = [c for c in cards if c.get('status') != 'pending_delete']
trash = [c for c in cards if c.get('status') == 'pending_delete']
held = sorted([c for c in active if c.get('mode') == 'held'],
key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
watching = sorted([c for c in active if c.get('mode') != 'held'],
key=lambda c: c.get('saved_at', ''), reverse=True)
trash.sort(key=lambda c: c.get('pending_delete_at', ''), reverse=True)
sections: list[str] = []
if held:
sections.append(f'
보유중{len(held)}
')
sections.append('\n'.join(_render_row(c) for c in held))
if watching:
sections.append(f'
감시{len(watching)}
')
sections.append('\n'.join(_render_row(c) for c in watching))
if trash:
sections.append(f'
삭제예정{len(trash)}
')
sections.append('\n'.join(_render_row(c) for c in trash))
return '\n'.join(sections), len(cards)
def _interest_menu_html(reg: dict) -> str:
"""풀다운 오른쪽 '+' 버튼 + 드롭다운 메뉴(종목 추가 / 그룹 관리). 관심종목 패널 상단 컨트롤 행에 둔다.
그룹 관리 항목에 현재 그룹명·종목수를 실어보내 모달을 서버 왕복 없이 즉시 채운다."""
all_groups = reg.get('groups', [])
counts: dict = {}
for names in reg.get('members', {}).values():
for n in names:
counts[n] = counts.get(n, 0) + 1
all_attr = html.escape(','.join(all_groups), quote=True)
counts_attr = html.escape(json.dumps(counts, ensure_ascii=True), quote=True)
return (
'
'
''
'
'
''
f''
'
'
'
'
)
def _render_interests_edit_modal() -> str:
"""등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠.
삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고
HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다."""
return (
'
'
''
'
'
'
'
''
'
관심종목 수정 —
'
''
'
'
''
''
'
'
''
''
'
'
'
'
'
'
)
def _render_tag_modal() -> str:
"""종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다.
대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장."""
return (
'
'
''
'
'
'
'
'
종목 태그 —
'
''
'
'
''
'
'
''
''
''
'
'
'
'
'
'
)
def _render_note_modal() -> str:
"""종목별 자유 메모 모달. 어느 탭의 '메모' 버튼이든 동일하게 연다.
저장은 텍스트 그대로, 삭제는 빈 텍스트 submit 으로 항목 제거."""
return (
'
'
)
def _render_avg_calc_modal() -> str:
"""평단 계산기(물타기) 모달. ☰ 옵션 팝업의 '평단 계산기' 항목이 연다.
보유수량·현재평단가·추가매수 주수·추가매수가 → 새 평단·총수량·총매입금 즉시 계산(클라이언트).
'내 계좌 종목'을 고르면 보유수량·현재평단가 자동 입력(/api/holdings, code별 전 계좌 가중평균)."""
return (
'
'
''
'
'
'
'
'
평단 계산기
'
''
'
'
'
'
''
''
'
'
''
''
''
''
''
'
'
'
'
'
실 매수금액 —
'
'
새 평단가—
'
'
총 수량—
'
'
총 매입금—
'
'
보유수량·현재평단가·추가매수 주수(또는 금액)·추가매수가를 입력하세요.
'
'
'
'
'
'
'
''
'
'
'
'
'
'
)
def _render_holdeval_modal() -> str:
"""보유 평가 계산기 모달. ☰ 옵션 팝업의 '보유 평가 계산기' 항목이 연다.
저장된 목록을 먼저 보여주고, 항목을 누르면 현재가를 조회해 등락률·평가손익·매입/평가금액을 표시.
하단 '추가' 버튼이 입력 팝업(holdeval-add-modal)을 연다. /api/holdeval/* + /api/quote_book 재사용."""
return (
'
'
''
'
'
'
'
'
보유 평가 계산기
'
''
'
'
'
'
'
저장된 목록
'
'
불러오는 중…
'
''
'
'
'
목록에서 항목을 누르면 현재 상태가 표시됩니다.'
'
'
'
등락률—
'
'
평가손익 —
'
''
'
종목—
'
'
현재가—
'
'
평단가—
'
'
주수—
'
'
매입금액—
'
'
평가금액—
'
'
'
'
'
'
'
''
'
'
'
'
'
'
)
def _render_holdeval_add_modal() -> str:
"""보유 평가 계산기 '추가' 팝업(중첩 modal-top). 이름·종목검색·평단가·주수 입력 후 저장하면
holdeval-modal 목록에 추가된다. 현재가는 저장하지 않고 조회 시점에 라이브로 가져오므로 여기선 안 받는다."""
return (
'
'
''
'
'
'
'
'
항목 추가
'
''
'
'
'
'
''
''
''
''
'
'
''
''
'
'
'
'
'
추가 매수 합산 (물타기·불타기 — 입력 시 위 평단가·주수 자동 갱신)
'
'
'
''
''
'
'
''
'
'
''
'
'
'
'
''
'
'
'
'
'
'
)
def _render_group_assign_modal() -> str:
"""관심종목의 그룹 소속 편집 모달. 어느 관심종목 행이든 '그룹' 버튼이 동일하게 연다.
체크박스는 JS가 data-group-all 로 렌더·data-group-current 로 체크. 새 그룹 인라인 추가 가능."""
return (
'
'
''
'
'
'
'
'
그룹 배정 —
'
''
'
'
''
'
'
''
''
'
'
'
'
'
'
)
def _render_group_manage_modal() -> str:
"""관심종목 그룹 레지스트리 관리 모달. 생성/이름변경/삭제/순서변경.
각 op는 keepOpen fetch로 /interests/groups 호출 후 응답 groups 로 리스트 재렌더."""
return (
'
'
''
'
'
'
'
'
그룹 관리
'
''
'
'
''
'
'
''
''
'
'
''
'
'
''
'
'
'
'
'
'
)
def _render_candidate_modal() -> str:
"""다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
return (
'
'
''
'
'
'
'
'
종목 선택
'
''
'
'
''
''
'
'
''
'
'
'
'
'
'
)
def _render_stock_name_modal() -> str:
"""거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip.
셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide."""
return (
''
)
def _render_trade_modal() -> str:
"""종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더."""
return (
'
'
)
def _render_info_desc_modal() -> str:
"""지수 설명 팝업. info-modal 위에 겹쳐 뜨는 modal-top. ? 버튼 클릭 시 용어·설명 표시."""
return (
'
'
''
'
'
'
'
'
지수 설명
'
''
'
'
''
'
'
''
'
'
'
'
'
'
)
def _render_advice_modal() -> str:
"""기술적 권장가 팝업. order-modal 위에 겹쳐 뜨는 modal-top. [💡 권장가 보기] 클릭 시 표시.
가격 행 탭 → 단가 자동입력(호가단위 스냅) 후 팝업 닫힘. ▸ 근거로 계산식 설명 토글."""
return (
'
'
''
'
'
'
'
'
💡 권장가
'
''
'
'
'
불러오는 중…
'
'
'
''
'
'
'
'
'
'
)
def _render_order_modal() -> str:
"""주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.
1단계: 종목·계좌·수량·단가 입력 → /api/order/propose (PIN 텔레그램 발송)
2단계: PIN 입력 → /api/order/verify (실주문 실행)
3단계: 결과 표시
"""
return (
'
'
''
'
'
'
'
'
'
''
'
'
'
'
'
'
'—'
'—'
'—'
'
'
''
''
# 1단계: 입력
'
'
'
'
''
''
''
'
'
'
'
'
'
'
호가 로딩중…
'
'
'
'
'
''
''
''
# 트레일링: 폭(%)만 입력받고 조건단가·지정가는 서버가 현재가 기준으로 계산.
# 미리보기는 클라이언트에서 같은 식으로 계산해 보여주되 실제 값은 서버 계산분이 승자.
# 고점 기준 select 제거(2026-07-30) — 과거 고점(52주·매수후) 기준은 손절선이 현재가
# 위로 올라가 등록 즉시 발동하는 구조라 쓸 수 없었다. 초기 고점은 등록 시점 현재가 고정.
''
''
''
''
'
—
'
'
'
''
''
'
'
'
'
'
'
'
'
'
'
'
'
)
def _render_open_orders_modal() -> str:
"""매매등록된 미체결 주문 목록 팝업. [📋 진행중] 탭이 표시 — 4계좌 통합 + 행별 취소.
각 행: 종목·계좌·방향·수량·단가·상태 + [취소] 버튼 → POST /api/orders/cancel.
"""
return (
'
'
''
'
'
'
'
'
📋 진행중 매매
'
'
'
'
'
'
불러오는 중…
'
'
'
'
'
''
''
'
'
'
'
'
'
)
def _render_pin_modal() -> str:
"""매매 카드 PIN 입력 팝업. order-modal의 propose 성공 직후 띄움.
카드 요약 + PIN 입력 칸 + 만료 카운트다운 + [카드 취소][주문 실행] 액션.
verify 결과는 같은 모달 안 결과 영역으로 swap.
"""
return (
'
'
''
'
'
'
'
'
주문 확인 — —
'
'
'
'
—
'
# PIN 입력
'
'
'
'
''
''
'
'
'
—
'
'
'
''
''
'
'
'
'
# 결과
'
'
'
—
'
'
'
''
'
'
'
'
'
'
'
'
)
def _render_sell_choice_modal() -> str:
"""매도 시 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 중일 때 띄우는 선택 팝업.
[두 계좌 모두 매도] / [선택 계좌만 매도] / [취소] 3버튼. order-modal 위에 겹침(modal-top).
"""
return (
'
'
''
'
'
'
'
'
📌 두 계좌 모두 보유 중
'
'
'
'
—
'
'
'
''
''
''
'
'
'
'
'
'
)
def _render_order_confirm_modal() -> str:
"""매수/매도 버튼 → propose 직전 방향 확인 팝업. 매수/매도 오타 방지용.
매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 찾아 누르게 함(위치 랜덤).
주문 방향과 일치하면 진행, 다르면 팝업 닫고 취소. order-modal 위에 겹침(modal-top).
"""
return (
'
'
''
'
'
'
'
'
방향 확인 — 맞는 것을 누르세요
'
'
'
'
—
'
'
'
''
''
'
'
'
'
''
'
'
'
'
'
'
)
def _render_interests_modal() -> str:
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
자동 갱신 중에도 DOM·입력값이 보존된다."""
return (
'
'
''
'
'
'
'
'
관심종목 추가
'
''
'
'
''
'
'
'
'
)
def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
sections: list[str] = []
reg = _load_interest_groups()
menu_html = _interest_menu_html(reg) # 풀다운 오른쪽 '+' 메뉴 (종목 추가 / 그룹 관리)
if not cards:
sections.append(
f'
관심0{menu_html}
'
)
sections.append('
관심종목이 비어있습니다.
')
return '\n'.join(sections), 0
show_day_change = _show_day_change_now()
for c in cards:
c['_show_day_change'] = show_day_change
held = sorted([c for c in cards if c.get('mode') == 'held'],
key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
watching = sorted([c for c in cards if c.get('mode') != 'held'],
key=lambda c: c.get('saved_at', ''), reverse=True)
ordered = held + watching # 전체·그룹 공용 정렬 (보유중 먼저, 관심은 저장순)
# 그룹 풀다운 — 레지스트리 순서대로 멤버 있는 그룹만 옵션으로. 다중 소속은 각 패널에 중복 노출.
# 보유중은 mode=='held' 로 자동 채워지는 옵션(레지스트리 미등록). 선택 상태는 .sub-tab(자산탭)과
# 충돌 없게 전용 클래스 .igrp-select / .igrp-panel 사용.
members = reg.get('members', {})
group_order = reg.get('groups', [])
def _card_groups(c):
return [g for g in (members.get(c.get('stock') or '') or []) if g in group_order]
active_groups = [g for g in group_order if any(g in _card_groups(c) for c in ordered)]
ungrouped = [c for c in watching if not _card_groups(c)] # 미분류 = 미보유 & 그룹 미배정
if not held and not active_groups:
# 보유·그룹 없음 — 단일 목록 (풀다운 없이 라벨 + '+' 메뉴만).
sections.append(
f'
관심{len(watching)}{menu_html}
'
)
sections.append('\n'.join(_render_row(c, source='interests') for c in watching))
else:
tabs = [('__all__', '전체', ordered)]
if held:
tabs.append(('__held__', '보유중', held))
for g in active_groups:
tabs.append((g, g, [c for c in ordered if g in _card_groups(c)]))
if ungrouped:
tabs.append(('__ungrouped__', '미분류', ungrouped))
opts = ''.join(
f''
for i, (key, label, bucket) in enumerate(tabs)
)
sections.append(
f'
{menu_html}
'
)
panels = ''.join(
f'
'
+ '\n'.join(_render_row(c, source='interests') for c in bucket)
+ '
'
for i, (key, label, bucket) in enumerate(tabs)
)
sections.append(panels)
return '\n'.join(sections), len(cards)
# /api/panels?owner= 쿼리 값 → 내부 owner 키. None이면 전체.
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'}
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> dict:
"""fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
owner 지정 시: 해당 owner 계좌만 호출하고 응답 tabs는 owner 탭 하나만 (summary·wl 제외).
클라이언트가 partial swap으로 사용 — 보지 않는 owner 호출 폭주 방지.
부수효과: fetched owner_data를 `_owner_data_cache`에 owner 단위로 적재해
이후 partial fetch가 'all' 캐시의 summary 슬라이스를 in-place 갱신할 때 재사용한다."""
wl_entries = _load_watchlist()
in_entries = _load_interests()
combined_entries = wl_entries + in_entries
data = _fetch_all_data(combined_entries, only_owner=owner)
all_cards = data['cards']
wl_cards = all_cards[:len(wl_entries)]
in_cards = all_cards[len(wl_entries):]
owner_data = data['owner_data']
ordered_owners = data['ordered_owners']
balances = data['balances']
journal_by_label = data.get('journal_by_label', {}) or {}
_save_owner_slices(owner_data, balances, journal_by_label)
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
fetched_hms = now # 셸 초기 렌더의 "갱신 YYYY-MM-DD HH:MM:SS" 포맷과 통일.
market_active = _market_active_now()
show_day_change = _show_day_change_now()
tabs: list[dict] = []
if owner is None:
# 전체 — 모든 탭 채움. 모두 동일 fetched_at.
wl_body, wl_count = _render_watchlist_panel(wl_cards)
in_body, in_count = _render_interests_panel(in_cards)
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
for o in ordered_owners:
d = owner_data[o]
tab_id = _owner_tab_id(o)
full_title = _owner_full_title(o)
title_short = full_title.replace('님 자산', '').replace(' 자산', '')
tab_label = title_short
panel = _render_owner_panel(o, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms})
tabs.append({'id': 'tab-interests', 'label': f'관심종목 {in_count}', 'html': in_body, 'fetched_at': fetched_hms})
tabs.append({'id': 'tab-wl', 'label': f'감시종목 {wl_count}', 'html': wl_body, 'fetched_at': fetched_hms})
else:
# owner 단일 — 그 owner 탭만 응답. 클라이언트 apply가 해당 탭만 swap.
# 다른 탭들의 fetched_at은 클라이언트 측 tabFetched 누적 dict가 유지.
d = owner_data.get(owner)
if d:
tab_id = _owner_tab_id(owner)
full_title = _owner_full_title(owner)
title_short = full_title.replace('님 자산', '').replace(' 자산', '')
tab_label = title_short
panel = _render_owner_panel(owner, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms})
indices = _fetch_indices()
return {
'now': now,
'build': BUILD,
'market_active': market_active,
'first_tab_id': 'tab-summary',
'owner': owner,
'tabs': tabs,
'indices': indices,
'ticker_items': indices,
}
def _shell_tab_descriptors() -> list[tuple[str, str]]:
"""데이터 fetch 없이 (tab_id, placeholder_label) 순서. 셸 응답이 사용 — owner 라벨은 금액 없이 short form."""
descs: list[tuple[str, str]] = [('tab-summary', '자산정보')]
for owner, tid in OWNER_TAB_IDS.items():
full = _owner_full_title(owner)
short = full.replace('님 자산', '').replace(' 자산', '')
descs.append((f'tab-{tid}', short))
descs.append(('tab-interests', '관심종목'))
descs.append(('tab-wl', '감시종목'))
return descs
def render_html() -> str:
"""첫 진입용 셸 HTML — 데이터 fetch 없음, 즉시 응답.
클라이언트 JS가 /api/panels를 fetch해 본문/라벨을 DOM swap. 자동 갱신·PTR·새로고침 버튼도 같은 fetch 경로로 통일 (location.reload 없음)."""
descs = _shell_tab_descriptors()
valid_tids = [tid for tid, _ in descs]
first_tid = descs[0][0]
market_active = _market_active_now()
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
# 계단 프리셋은 셸에 실어 보낸다 — 거래 모달을 열 때마다 fetch 하면 느리고,
# 고정 4종이라 셸에 넣어도 부담이 없다.
trail_presets_json = json.dumps(TRAIL_PRESETS, ensure_ascii=False)
nav_html = '\n'.join(
f'{html.escape(label)}'
for tid, label in descs
)
spinner_placeholder = (
'
'
'불러오는 중…
'
)
content_html = '\n'.join(
f'{spinner_placeholder}'
for tid in valid_tids
)
show_rules = ',\n'.join(
f'html[data-tab="{tid}"] section[data-tab="{tid}"]' for tid in valid_tids
)
active_rules = ',\n'.join(
f'html[data-tab="{tid}"] nav.tabs a[href="#{tid}"]' for tid in valid_tids
)
dynamic_css = (
f'{show_rules},\n'
f'html:not([data-tab]) section[data-tab="{first_tid}"] {{ display: block; }}\n'
f'{active_rules},\n'
f'html:not([data-tab]) nav.tabs a[href="#{first_tid}"] '
f'{{ color: #f0f0f0; border-bottom-color: #ff4d5e; }}\n'
)
valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
# 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
# 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
head_script = (
''
)
# 패널 fetch + DOM swap.
# load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입)
# load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신)
# 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠.
# inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록.
panels_script = (
''
)
# 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익).
# data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신.
# MutationObserver로 swap 후 새 .net-chart 자동 재바인딩.
netchart_script = (
''
)
# ADR 통합 차트 인터랙션 — 터치/호버 시 세로선 + 시장당 점 + 듀얼 툴팁(날짜·코스피·코스닥).
# data-adr-pts JSON 각 entry: {d, x, kospi?:{v,y,live}, kosdaq?:{v,y,live}}.
# 영속화: hide 타이머 없음 + module 스코프 selectedDate 에 마지막 선택 날짜 저장 →
# 자동 갱신(panels innerHTML swap) 후 init 시 동일 날짜로 indicator 복원.
adr_hover_script = (
''
)
# 보유 카드 SVG 봉차트 — 카드 펼침 시 /api/chart_svg fetch.
# dailyCache: code -> 1Y/6M/1M HTML fragment.
# minuteCache: code:unit -> {svg, ts} (TTL 30s — 오늘 마지막 봉이 분 단위로 흐름).
# activeRange: code -> 마지막 활성 range. panels swap 후 1Y 로 reset 되는 거 방지.
# panels-loaded 이벤트마다 열린 카드 daily 복원 + activeRange 적용. 활성이 분봉이면 자동 재fetch.
chart_svg_script = (
''
)
# pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함.
# iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛.
ptr_script = (
''
)
# 시장 상태 배지 + 개장/마감 전환 감지 (2026-07-22 이벤트 기반 전환).
# ⚠️ 3초 주기 전면 패널 swap 폐지 — 매 3초 innerHTML 통째 교체가 스크롤·열린 UI를
# 파괴해 기능마다 "3초에도 유지" 처리를 반복해야 했음. 갱신은 이제 이벤트가 담당:
# · 시세·손익·KPI → realtime_script 1초 틱(WS)
# · 체결·주문(접수/취소) → state/fill_signal bump → 1초 틱이 감지해 필요할 때만 1회 갱신
# 여기 스크립트는 상단 상태 점(🟢장중/⚪장마감)만 유지 + 개장/마감 전환 시 1회 재렌더
# (day-change 게이트·시간대 표시 반영). 정상 상태에선 주기적 full swap 없음.
market_active_js = 'true' if market_active else 'false'
auto_reload_script = (
''
)
# 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백.
# 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제.
# 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음.
# 예외: 하단 지수 바(#market-ticker)는 탭할 때마다 묶음을 넘기는 UI라 빠른 연타가 정상 사용이다.
# 여기서 걸러내면 두 번째 탭부터 click이 안 와서 한 칸씩 건너뛴다. 그 바만 억제 대상에서 뺀다
# (더블탭 zoom은 html,body의 touch-action: manipulation이 1차로 막고, 이 바는 36px 최하단 띠라 위험이 작다).
nodbltap_script = (
''
)
# 등락률 계산기 모달 로직 — 기준가·현재가 입력 즉시 % / 차이 금액 계산 (순수 클라이언트).
calc_script = (
''
)
# 평단 계산기(물타기) 로직 — 보유수량·현재평단가·추가매수 주수·추가매수가 즉시 계산 (순수 클라이언트).
avg_calc_script = (
''
)
# 보유 평가 계산기 로직 — 임의 종목 검색(현재가 자동) + 평단가·주수 → 등락률·평가손익·매입/평가금액 (순수 클라이언트).
holdeval_script = (
''
)
idx_info_modal_html = (
'
'
''
'
'
'
'
'
지수 설명
'
''
'
'
'
'
'
'
'—'
'—'
'
'
''
'
—
'
'
'
'
'
''
'
'
'
'
'
'
)
interest_modal_html = _render_interests_modal()
candidate_modal_html = _render_candidate_modal()
interest_edit_modal_html = _render_interests_edit_modal()
tag_modal_html = _render_tag_modal()
note_modal_html = _render_note_modal()
calc_modal_html = _render_calc_modal()
avg_calc_modal_html = _render_avg_calc_modal()
holdeval_modal_html = _render_holdeval_modal()
holdeval_add_modal_html = _render_holdeval_add_modal()
group_assign_modal_html = _render_group_assign_modal()
group_manage_modal_html = _render_group_manage_modal()
trade_modal_html = _render_trade_modal()
kpi_modal_html = _render_kpi_modal()
info_modal_html = _render_info_modal()
info_desc_modal_html = _render_info_desc_modal()
advice_modal_html = _render_advice_modal()
order_modal_html = _render_order_modal()
pin_modal_html = _render_pin_modal()
sell_choice_modal_html = _render_sell_choice_modal()
order_confirm_modal_html = _render_order_confirm_modal()
open_orders_modal_html = _render_open_orders_modal()
stock_name_modal_html = _render_stock_name_modal()
# 모달 열기/닫기 + 폼 fetch — 트리거 버튼은 panels swap으로 다시 그려지므로 이벤트 위임.
# 모달 DOM 자체는 shell HTML 직속이라 swap 영향 받지 않음 → 입력값 보존.
# /interests/add 응답은 Accept: application/json 시 JSON. 다중 매칭이면 후보 버튼 표시.
modal_script = (
''
)
# 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide.
# 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback.
stock_name_tip_script = (
''
)
# details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음.
# capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open')
# 호출이 다시 trigger되어도 한 개만 남는다.
# ⚠️ 관심종목 그룹 탭은 같은 종목(=같은 data-row-key)이 전체·보유중·그룹 패널에 중복 존재한다.
# 같은 키끼리는 닫지 않는다(탭 간 동기화, 새로고침 시 보이는 탭 행이 닫히던 버그). 다른 종목만 닫음.
details_mutex_script = (
''
)
# 주문 모달 + PIN 모달 JS — 호가 1초 폴링, 매수/매도, 호가 클릭 → 단가, 계좌 select → /api/order/check.
# propose 성공 시 별도 PIN 모달(pin-modal)로 swap. verify는 PIN 모달 안에서 처리.
# window.openOrderModal({code, name, side?, account?, accounts?, price?}) — trigger 진입점.
order_modal_script = r''''''
# 실시간 시세 갱신 — /api/realtime/quotes(허브 메모리) 1초 폴링으로 행 가격·VI 배지를 라이브 갱신.
# 허브 콜드/끊김이면 응답이 비어 아무것도 안 함(REST로 그려진 값 유지). visibility 가드.
realtime_script = r'''
'''
# 수급 블록 — 팝업 .flow-box 를 열 때 lazy fetch(/api/investor_flow). 5일 표 + 더보기(20일 오버레이).
flow_script = r''''''
return f'''
자산현황
{head_script}
↓
자산현황
갱신 {now}
{content_html}
지수 불러오는 중…
{idx_info_modal_html}
{interest_modal_html}
{interest_edit_modal_html}
{tag_modal_html}
{note_modal_html}
{calc_modal_html}
{avg_calc_modal_html}
{holdeval_modal_html}
{holdeval_add_modal_html}
{group_assign_modal_html}
{group_manage_modal_html}
{candidate_modal_html}
{trade_modal_html}
{kpi_modal_html}
{info_modal_html}
{info_desc_modal_html}
{advice_modal_html}
{order_modal_html}
{pin_modal_html}
{sell_choice_modal_html}
{order_confirm_modal_html}
{open_orders_modal_html}
{stock_name_modal_html}
{ptr_script}
{panels_script}
{netchart_script}
{adr_hover_script}
{chart_svg_script}
{auto_reload_script}
{nodbltap_script}
{calc_script}
{avg_calc_script}
{holdeval_script}
{details_mutex_script}
{modal_script}
{order_modal_script}
{stock_name_tip_script}
{flow_script}
{realtime_script}
'''
# ── 패널 페이로드 인메모리 캐시 ──
# /api/panels GET마다 키움 호출 발생 → owner별 캐시로 사용자가 보는 탭만 갱신.
# key=None('all'): 전체 — kt00018·kt00001·ka10170 × 4계좌 + ka10095 = ~13콜
# key='본인': 본인 2계좌 — kt00018·kt00001·ka10170 × 2 = ~6콜 (워치리스트 quotes skip)
# key='가희': 가희 2계좌 — 동일 ~6콜
# 자동 갱신(10s) 주기에 맞춰 TTL=10s — 락이 동시 요청을 한 fetch로 모아 모바일 Safari timeout 흡수.
# 셸 HTML은 데이터 fetch 없이 매번 새로 만들어도 무료라 캐시 안 함. 워치리스트 변경(POST) 시 모든 owner 캐시 즉시 invalidate.
PANELS_CACHE_TTL = 10.0
_panels_cache: dict[str, dict] = {} # cache_key('all'|owner) → {'json': bytes, 'expires_at': float}
_panels_locks: dict[str, threading.Lock] = {}
_panels_locks_master = threading.Lock()
# 외부 매매(키움 앱 등) 감지 — fill_watcher는 웹/orders 경유 주문만 fill_signal을 bump하므로,
# 앱에서 직접 매매해 바뀐 보유수량은 이벤트 신호가 없다. 전 계좌 보유 시그니처를 브라우저가 짧게
# 폴링(20s)해 값이 바뀌면 패널을 1회 갱신. 서버는 TTL 캐시로 kt00018 콜을 20s당 1회로 제한
# (다중 탭·기기 공유). 폐지된 3초 전면 swap 대비 훨씬 가볍고, 브라우저는 장중에만 폴링.
_HOLDINGS_SEQ_TTL = 20.0
_holdings_seq_cache: dict = {'seq': None, 'expires_at': 0.0}
_holdings_seq_lock = threading.Lock()
# 종목별 외국인·기관·개인 수급 (ka10059) — 팝업 lazy fetch용. code별 TTL 캐시(재열람 시 재조회 방지).
_FLOW_TTL = 120.0
_flow_cache: dict = {} # code -> (rows, expires_at)
_flow_lock = threading.Lock()
def _holdings_seq() -> str:
"""전 계좌 보유 (code:qty:당일매수:당일매도) 해시. 외부 매매·체결로 수량이 변하면 값이 바뀐다.
당일매수/매도 수량까지 넣어 net 무변동 round-trip도 포착. TTL 캐시로 kt00018 폭주 방지.
실패 시 마지막 값(또는 '') 반환 — 폴링이 조용히 재시도."""
now = time.time()
with _holdings_seq_lock:
if _holdings_seq_cache['seq'] is not None and now < _holdings_seq_cache['expires_at']:
return _holdings_seq_cache['seq']
try:
import kiwoom_client as kc
import hashlib
pos = kc.get_positions_all() # {label: [positions]}
parts: list[str] = []
for label in sorted(pos):
for p in sorted(pos[label], key=lambda x: x.get('code') or ''):
parts.append(f"{label}|{p.get('code')}:{p.get('qty')}:{p.get('tdy_buyq')}:{p.get('tdy_sellq')}")
seq = hashlib.md5('\n'.join(parts).encode('utf-8')).hexdigest()
except Exception:
traceback.print_exc()
with _holdings_seq_lock:
return _holdings_seq_cache['seq'] or ''
with _holdings_seq_lock:
_holdings_seq_cache['seq'] = seq
_holdings_seq_cache['expires_at'] = time.time() + _HOLDINGS_SEQ_TTL
return seq
# 데이터 레이어 캐시 — owner별 raw owner_data + balances 보존.
# 자산정보 패널은 owner별 KPI 카드 스택이라 owner_data만으로 재렌더 가능. partial fetch가
# 자기 owner 슬라이스를 새로 채울 때 'all' 캐시의 summary 슬라이스와 해당 owner 탭만 in-place로
# 갈아끼워 자산정보-관리자 탭 간 KPI 불일치를 제거한다. TTL 별도 없음 — 'all'이 만료되면
# 어차피 full fetch로 동기화돼 누적 stale 위험 제한적.
_owner_data_cache: dict[str, dict] = {} # owner → {'owner_data': dict, 'balances': dict, 'fetched_at': float}
def _panels_lock_for(key: str) -> threading.Lock:
with _panels_locks_master:
lk = _panels_locks.get(key)
if lk is None:
lk = threading.Lock()
_panels_locks[key] = lk
return lk
def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict | None = None) -> None:
"""fetched owner_data를 owner 단위로 분리해 _owner_data_cache에 적재.
balances는 owner의 labels로 필터링 — 다른 owner 계좌 데이터가 섞이지 않도록.
journal_by_label도 동일하게 labels로 필터링해 보관 (자산보기 각계좌별도 토글 KPI용)."""
now_ts = time.time()
journal_by_label = journal_by_label or {}
for owner, d in owner_data.items():
owner_labels = d.get('labels') or []
owner_balances = {lb: balances[lb] for lb in owner_labels if lb in balances}
owner_journal = {lb: journal_by_label[lb] for lb in owner_labels if lb in journal_by_label}
_owner_data_cache[owner] = {
'owner_data': d,
'balances': owner_balances,
'journal_by_label': owner_journal,
'fetched_at': now_ts,
}
def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> None:
"""partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
다른 owner와 watchlist/interests 슬라이스는 cached 'all'에 있던 그대로 — TTL 만료 시 full fetch로 동기화.
핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록."""
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
all_lock = _panels_lock_for(all_key)
with all_lock:
slot = _panels_cache.get(all_key)
if not slot or slot['expires_at'] <= time.time():
return # 캐시 없거나 만료 → 다음 자산정보 진입에서 어차피 full fetch
try:
payload = json.loads(slot['json'].decode('utf-8'))
except Exception:
traceback.print_exc()
return
merged = {o: e['owner_data'] for o, e in _owner_data_cache.items()}
if not merged:
return
ordered = sorted(merged.keys(), key=lambda o: (o != '본인', o))
# 각계좌별도 토글 KPI에 필요 — slice된 balances·journal_by_label을 owner 단위로 다시 union.
merged_balances: dict = {}
merged_journal: dict = {}
for e in _owner_data_cache.values():
merged_balances.update(e.get('balances') or {})
merged_journal.update(e.get('journal_by_label') or {})
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
entry = _owner_data_cache.get(owner)
if not entry:
return
show_day_change = _show_day_change_now()
full_title = _owner_full_title(owner)
new_owner_panel = _render_owner_panel(owner, entry['owner_data'], entry['balances'], full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
owner_tab_id = f'tab-{_owner_tab_id(owner)}'
fetched_hms = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
for tab in payload.get('tabs', []):
tid = tab.get('id')
if tid == 'tab-summary':
tab['html'] = new_summary
tab['fetched_at'] = fetched_hms
elif tid == owner_tab_id:
tab['html'] = new_owner_panel
tab['fetched_at'] = fetched_hms
payload['now'] = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
payload['market_active'] = _market_active_now()
new_body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
# TTL 유지 — patch는 fetch가 아니라 일부 슬라이스만 갱신했으니 원래 만료 시각 그대로.
_panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']}
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> bytes:
"""fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출.
응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 그 후 자동 갱신은 새 캐시를 본다.
chart_days × chart_unit × chart_mode × adr_days 별로 캐시 키 분리 — 옵션마다 다른 series라 같은 owner라도 별도 슬롯.
owner partial fetch 직후엔 'all' 캐시의 summary 슬라이스와 해당 owner 탭을 in-place로
갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
있으면 재렌더 가능 — 다시 fetch할 필요 없음. 패치 실패 시 안전망으로 'all' 캐시 비워
다음 자산정보 진입에서 full fetch."""
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
if not fresh:
slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time():
return slot['json']
lock = _panels_lock_for(key)
with lock:
if not fresh:
slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time():
return slot['json']
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
_panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
if owner is not None:
try:
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
except Exception:
traceback.print_exc()
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', None)
return body
def _invalidate_panels_cache() -> None:
with _panels_locks_master:
_panels_cache.clear()
GZIP_MIN_BYTES = 1024 # 1KB 미만은 압축 오버헤드가 절감보다 큼 — 303 redirect·작은 ok 응답 등 skip
def _maybe_gzip(body: bytes, accept_encoding: str | None) -> tuple[bytes, str | None]:
"""클라이언트가 gzip 수용하면 압축본 반환. compresslevel=6은 디폴트(속도/크기 균형)."""
if not body or len(body) < GZIP_MIN_BYTES:
return body, None
ae = (accept_encoding or '').lower()
if 'gzip' not in ae:
return body, None
try:
return gzip.compress(body, compresslevel=6, mtime=0), 'gzip'
except Exception:
return body, None
class Handler(BaseHTTPRequestHandler):
server_version = 'BehiveWeb/1.0'
def log_message(self, fmt, *args): # type: ignore[override]
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} {fmt % args}\n')
sys.stdout.flush()
def _send_json(self, code: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self): # noqa: N802
from urllib.parse import urlparse, parse_qs
parsed = urlparse(self.path)
if parsed.path == '/api/realtime/quotes':
# 실시간 허브 메모리의 최신 현재가/VI (키움 호출 0). 허브 콜드/끊김이면 빈 응답 → JS가 무시.
qs = parse_qs(parsed.query)
raw = (qs.get('codes') or [''])[0]
codes = [c for c in raw.split(',') if c.strip()]
quotes, vi, sub_count, connected = {}, {}, 0, False
hub = _RT_HUB
if hub is not None:
import kiwoom_client as kc
connected = hub.is_connected()
sub_count = len(hub._subs)
if not codes:
codes = sorted(hub._subs)
quotes = hub.get_quotes(codes)
for c in codes:
v = hub.get_vi(c)
if v:
vi[kc._clean_code(c)] = v
# 체결 신호 — fill_watcher가 체결 시 갱신. 브라우저가 값 변하면 패널 자동 새로고침.
fill_seq = ''
try:
_fp = WORKSPACE / 'state' / 'fill_signal'
if _fp.exists():
fill_seq = _fp.read_text().strip()
except Exception:
pass
self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq})
return
# KPI 라벨 [!] 팝업 본문 — 서버가 HTML 조립, 클라이언트는 innerHTML만.
if parsed.path == '/api/surge':
# 급등락 알림 팝업 본문 (구간 + 오늘 소진 + 토글). 캐시 읽기뿐이라 키움 콜 0.
qs = parse_qs(parsed.query)
code_q = (qs.get('code') or [''])[0].strip()
name_q = (qs.get('name') or [''])[0].strip()
if not code_q:
self._send_json(400, {'error': 'code required'})
return
try:
self._send_json(200, {'html': _render_surge_body(code_q, name_q)})
except Exception as e:
sys.stderr.write(f'/api/surge failed: {e}\n')
self._send_json(500, {'error': str(e)})
return
if parsed.path in ('/api/principal', '/api/cashflow', '/api/deposit'):
qs = parse_qs(parsed.query)
owner_q = (qs.get('owner') or [''])[0].strip()
if not owner_q:
self._send_json(400, {'error': 'owner required'})
return
render = {'/api/principal': _render_principal_body,
'/api/cashflow': _render_cashflow_body,
'/api/deposit': _render_deposit_body}[parsed.path]
try:
self._send_json(200, {'html': render(owner_q)})
except Exception as e:
sys.stderr.write(f'{parsed.path} failed: {e}\n')
self._send_json(500, {'error': str(e)})
return
if parsed.path == '/api/trades':
qs = parse_qs(parsed.query)
code = (qs.get('code') or [''])[0].strip()
account = (qs.get('account') or [''])[0].strip()
owner_q = (qs.get('owner') or [''])[0].strip()
if not code and not owner_q:
self._send_json(400, {'error': 'code or owner required'})
return
try:
import trade_journal as tj
from stock_portfolio_report import OWNER_LABELS as _OWNER_LABELS
def _row_owner(r: dict) -> str:
return r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인')
all_rows = tj._load_all()
if code:
rows = [r for r in all_rows if r.get('code') == code]
else:
rows = [r for r in all_rows if _row_owner(r) == owner_q]
if account:
rows = [r for r in rows if r.get('account') == account]
# 매도 실현손익을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
# rows는 해당 (account,code)의 전체 이력을 포함하므로 재생이 정확하다.
rows = tj.rebase_pl(rows)
rows.sort(key=lambda r: (r.get('date', ''), r.get('account', '')), reverse=True)
name = rows[0]['name'] if rows and code else ''
mode = 'owner' if (not code and owner_q) else 'code'
total_buy = sum(r.get('buy_amt', 0) for r in rows)
total_sell = sum(r.get('sell_amt', 0) for r in rows)
total_pl = sum(r.get('pl_amt', 0) for r in rows)
total_fee = sum(r.get('cmsn_tax', 0) for r in rows)
def _owner_short(o: str) -> str:
full = _OWNER_LABELS.get(o, f'{o} 자산')
return full.replace('님 자산', '').replace(' 자산', '')
groups_map: dict[str, dict] = {}
for r in rows:
o = r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인')
g = groups_map.setdefault(o, {
'owner': o,
'label': _owner_short(o),
'rows': [],
'total_buy_amt': 0,
'total_sell_amt': 0,
'total_pl_amt': 0,
'total_cmsn_tax': 0,
})
g['rows'].append(r)
g['total_buy_amt'] += r.get('buy_amt', 0)
g['total_sell_amt'] += r.get('sell_amt', 0)
g['total_pl_amt'] += r.get('pl_amt', 0)
g['total_cmsn_tax'] += r.get('cmsn_tax', 0)
ordered_groups = sorted(groups_map.values(), key=lambda g: (g['owner'] != '본인', g['owner']))
except Exception:
traceback.print_exc()
self._send_json(500, {'error': 'trades load failed'})
return
self._send_json(200, {
'code': code,
'name': name,
'owner': owner_q,
'mode': mode,
'account': account,
'rows': rows,
'groups': ordered_groups,
'total_buy_amt': total_buy,
'total_sell_amt': total_sell,
'total_pl_amt': total_pl,
'total_cmsn_tax': total_fee,
})
return
if parsed.path == '/api/symbols/all':
# 거래 모달 상단 종목 변경 드롭다운용 — 보유+관심+감시 통합 (dedup by code)
try:
import kiwoom_client as kc
# split_owner=1: 보유를 소유자(본인/가희)별로 분리(같은 종목도 소유자마다 1건). 팝업 종목변경 드롭다운용.
# 기본(0): code 전역 dedup — 계산기·주문모달 등 기존 소비자 호환.
_sq = parse_qs(parsed.query)
split_owner = (_sq.get('split_owner') or ['0'])[0] == '1'
seen: set = set() # 관심/감시 dedup (보유 코드 포함)
held_owner_seen: set = set() # split_owner 시 (owner, code)
items: list = []
# 보유 (4계좌)
try:
pos_by_acc = kc.get_positions_all()
for label, positions in (pos_by_acc or {}).items():
owner = '가희' if label.startswith('가희_') else '본인'
for p in positions:
code = p.get('code')
if not code:
continue
if split_owner:
if (owner, code) in held_owner_seen:
continue
held_owner_seen.add((owner, code))
elif code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': p.get('name', ''), 'source': '보유', 'owner': owner})
except Exception:
traceback.print_exc()
# 관심종목
try:
for c in _load_interests():
code = c.get('code')
if not code or code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': c.get('stock', ''), 'source': '관심', 'owner': ''})
except Exception:
traceback.print_exc()
# 감시종목
try:
for c in _load_watchlist():
code = c.get('code')
if not code or code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': c.get('stock', ''), 'source': '감시', 'owner': ''})
except Exception:
traceback.print_exc()
self._send_json(200, {'items': items, 'count': len(items)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'symbols failed: {e}'})
return
if parsed.path == '/api/holdings':
# 평단 계산기용 — 소유자(본인/가희)별로 보유종목 합산(일반+ISA 합침, 가중평균 평단).
# 같은 종목을 본인·가희 둘 다 보유하면 각각 별도 항목.
try:
import kiwoom_client as kc
agg: dict = {}
pos_by_acc = kc.get_positions_all()
for label, positions in (pos_by_acc or {}).items():
owner = '가희' if label.startswith('가희_') else '본인'
for p in positions:
code = p.get('code')
qty = int(p.get('qty') or 0)
avg = float(p.get('avg_price') or 0)
if not code or qty <= 0:
continue
a = agg.setdefault((owner, code), {'code': code, 'name': p.get('name', ''),
'owner': owner, 'qty': 0, 'cost': 0.0})
a['qty'] += qty
a['cost'] += qty * avg
items = []
for a in agg.values():
items.append({'code': a['code'], 'name': a['name'], 'owner': a['owner'],
'qty': a['qty'], 'avg': int(round(a['cost'] / a['qty'])) if a['qty'] else 0})
items.sort(key=lambda x: x['qty'] * x['avg'], reverse=True)
self._send_json(200, {'items': items, 'count': len(items)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'holdings failed: {e}'})
return
if parsed.path == '/api/orders/open':
# 4계좌 통합 미체결 주문 — open-orders-modal이 표시
try:
import kiwoom_client as kc
rows = kc.get_open_orders_all()
# 트레일링 예약이면 그 상태를 실어 보낸다 (예약 파일 읽기, 키움 콜 0).
for _r in rows:
_r['trailing'] = _trailing_info_for(_r.get('ord_no', ''))
self._send_json(200, {'rows': rows, 'count': len(rows)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'open orders failed: {e}'})
return
if parsed.path == '/api/order/active':
# 현재 활성 카드 (PinStore.peek) + 미체결 주문 정보 — 거래 모달 [📋 진행중] 탭이 표시
try:
import time as _time
from orders.pin import PinStore
pending = PinStore().peek()
result: dict = {'active': False}
if pending is not None and not pending.is_expired() and not pending.consumed:
payload = pending.payload or {}
elapsed = _time.time() - pending.issued_at
remaining = max(0, int(pending.expiry_seconds - elapsed))
side_label = '매수' if payload.get('side') == 'BUY' else '매도'
price_val = payload.get('price')
price_str = f"{price_val:,}원" if price_val else '시장가'
card_message = (
f"💳 활성 카드 [{pending.card_id}]\n"
f"종목: {payload.get('symbol_name','')} ({payload.get('symbol','')})\n"
f"{side_label} {payload.get('qty',0):,}주 @ {price_str}\n"
f"계좌: {payload.get('account','')}"
)
result = {
'active': True,
'card_id': pending.card_id,
'symbol_name': payload.get('symbol_name', ''),
'symbol': payload.get('symbol', ''),
'card_message': card_message,
'expiry_seconds': remaining,
}
# 4계좌 미체결 주문 ka10075 통합
open_orders_count = 0
try:
import kiwoom_client as kc
rows = kc.get_open_orders_all()
open_orders_count = len(rows)
except Exception:
traceback.print_exc()
result['open_orders_count'] = open_orders_count
# 진행중 아이템 수 = 미체결 주문 건수(팝업 행과 동일) + 활성 PIN 카드(있으면 1).
# 종목코드 중복제거 X — 같은 종목 2계좌 매도도 2건으로 표시(팝업과 일치).
result['unique_count'] = open_orders_count + (1 if result.get('active') else 0)
self._send_json(200, result)
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'active card lookup failed: {e}'})
return
if parsed.path == '/api/market_state':
try:
self._send_json(200, _market_phase_state())
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'market_state failed: {e}'})
return
if parsed.path == '/api/holdings_seq':
# 외부 매매 감지용 보유 시그니처 (TTL 캐시). 브라우저가 장중 20s 폴링 → 변화 시 패널 1회 갱신.
try:
self._send_json(200, {'seq': _holdings_seq()})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'holdings_seq failed: {e}'})
return
if parsed.path == '/api/investor_flow':
# 종목별 외국인·기관·개인 일별 순매수 (ka10059, 최신순 20일). 팝업 수급 블록 lazy fetch.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
now = time.time()
with _flow_lock:
hit = _flow_cache.get(code)
if hit and now < hit[1]:
self._send_json(200, {'rows': hit[0]})
return
try:
import kiwoom_client as kc
raw = kc.get_investor_flow(code, days=20) or []
# get_investor_flow 는 천주 단위(unit_tp=1000) → 주 단위로 환산해 반환.
rows = [{'date': r.get('date'),
'foreign': (r.get('foreign') or 0) * 1000,
'institution': (r.get('institution') or 0) * 1000,
'individual': (r.get('individual') or 0) * 1000} for r in raw]
with _flow_lock:
_flow_cache[code] = (rows, time.time() + _FLOW_TTL)
self._send_json(200, {'rows': rows})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'investor_flow failed: {e}'})
return
if parsed.path == '/api/quote_book':
# 거래 모달 호가창용. ka10004(호가 10단계+잔량) + ka10001(현재가·등락) 묶음.
# 1초 polling 가정 — visibility 가드는 클라이언트 측 책임.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
try:
import kiwoom_client as kc
# NXT 거래 가능 여부 먼저 판정 (거래소 선택에 사용). 캐시 없거나 모르면 보수적 True.
try:
meta = kc.lookup_stock_meta(code)
nxt_enable = True if (not meta) else bool(meta.get('nxt_enable', True))
except Exception:
nxt_enable = True
# NXT 시간대엔 KRX 호가창이 15:30 종가로 고정 → 현재가(NXT)와 갭 발생.
# 그 시간대엔 NXT 호가창·NXT 현재가로 정합시킨다. 단 NXT 미상장 종목은 NXT 호가가
# 없으므로 그 시간대에도 KRX 기준으로 표시.
_ph = _market_phase_state().get('phase')
use_nxt = (_ph == 'nxt') and nxt_enable
if use_nxt:
book = kc.get_quote_book(code + '_NX')
quote = kc.get_stock_quote(code, exchange='NX')
else:
book = kc.get_quote_book(code)
quote = kc.get_stock_quote(code, exchange='KRX')
book['price'] = quote.get('price', 0)
book['change'] = quote.get('change', 0)
book['change_pct'] = quote.get('change_pct', 0.0)
book['name'] = quote.get('name', '')
# 상한가·하한가 (ka10001 upl_pric/lst_pric) — 사다리 호가 생성 범위
qraw = quote.get('raw') if isinstance(quote.get('raw'), dict) else {}
try:
book['upper_limit'] = abs(int(str(qraw.get('upl_pric') or '0').strip() or '0'))
book['lower_limit'] = abs(int(str(qraw.get('lst_pric') or '0').strip() or '0'))
except (ValueError, TypeError):
book['upper_limit'] = 0
book['lower_limit'] = 0
# ⚠️ 52주 최고가(250hgst)는 여기 싣지 않는다 — 트레일링 '전고점 기준'
# 미리보기용으로 추가했다가 옵션 제거와 함께 제거(2026-07-30).
# 기업정보 모달의 '52주 최고'는 /api/stock_info(get_stock_basic) 경로다.
book['nxt_enable'] = nxt_enable # 위에서 판정한 값 재사용
# 종목이 속한 시장(KOSPI/KOSDAQ)의 20일 ADR 과매수/과매도 — 극단일 때만 실림.
_mkt = (meta or {}).get('market') or ''
book['market'] = _mkt
_adr_sig = _market_adr_signal(_mkt)
if _adr_sig:
book['adr'] = _adr_sig
book.pop('raw', None)
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'quote_book failed: {e}'})
return
self._send_json(200, book)
return
if parsed.path == '/api/stock_info':
# 기업정보 모달용. ka10001 기반 시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
try:
import kiwoom_client as kc
info = kc.get_stock_basic(code)
info.pop('raw', None)
# 분기 영업이익(네이버) — 확정 분기 중 최신. 실패해도 키움 데이터는 유지.
q = _fetch_quarter_op_profit(code)
info['op_profit_q'] = q['value'] if q else None
info['op_profit_q_period'] = q['period'] if q else None
# FnGuide 성장성·컨센서스 보강 (조회 실패·ETF면 None).
try:
import fnguide_client as fg
fund = fg.get_fundamentals(code)
info['fundamentals'] = {
'growth': fund.get('growth'),
'latest_period': fund.get('latest_period'),
'consensus': fund.get('consensus'),
} if fund else None
except Exception:
info['fundamentals'] = None
# WISEreport 컨센서스(컴팩트) — 리비전 방향·서프라이즈·올해 추정.
try:
import wisereport_client as wr
cons = wr.get_consensus(code)
if cons:
rev = cons.get('revision') or {}
sup = (cons.get('surprise') or {}).get('items') or {}
est_row = next((a for a in (cons.get('annual') or [])
if a.get('is_estimate')), None)
info['consensus'] = {
'target_change_pct': rev.get('target_change_pct'),
'est_change_pct': rev.get('est_change_pct'),
'rev_item': rev.get('item_name'),
'rev_weeks': rev.get('n_points'),
'this_year': ({
'period': est_row.get('period'),
'sales': est_row.get('sales'),
'op': est_row.get('op'),
'eps': est_row.get('eps'),
'roe': est_row.get('roe'),
} if est_row else None),
'surprise_sales': (sup.get('매출액') or {}).get('fy0_surprise_pct'),
'surprise_op': (sup.get('영업이익') or {}).get('fy0_surprise_pct'),
'surprise_year': (sup.get('매출액') or {}).get('fy0_year'),
}
else:
info['consensus'] = None
except Exception:
info['consensus'] = None
# 최근 증권사 분석리포트 (메타+요약, PDF 제외). 없으면 None.
try:
import wisereport_client as wr2
info['reports'] = wr2.get_reports(code, limit=6)
except Exception:
info['reports'] = None
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'stock_info failed: {e}'})
return
self._send_json(200, info)
return
if parsed.path == '/api/stock_search':
# 기업비교용 종목 검색. 단일 매칭이면 1건, 다중이면 후보 리스트 반환.
qs = parse_qs(parsed.query)
q = (qs.get('q') or [''])[0].strip()
if not q:
self._send_json(400, {'error': 'q required'})
return
try:
hit = _resolve_stock_fuzzy(q)
self._send_json(200, {'results': [{'code': hit['code'], 'name': hit['name']}]})
except MultipleMatchError as e:
self._send_json(200, {'results': [{'code': c['code'], 'name': c['name']} for c in e.candidates[:20]]})
except Exception as e:
self._send_json(200, {'results': [], 'error': str(e)})
return
if parsed.path == '/api/holdeval/list':
# 보유 평가 계산기 저장 목록 (라이브 시세 없이 저장 필드만; 현재가는 선택 시 quote_book로 조회).
try:
self._send_json(200, {'items': _load_holdeval_saved()})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'holdeval list failed: {e}'})
return
if parsed.path == '/api/order/advice':
# 거래 모달용: 종목·side 기술적 권장 매수가/매도가 + 근거(이평·ATR). 매도는 avg_price(평단) 선택.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
side = (qs.get('side') or [''])[0].strip().upper()
try:
avg_price = int((qs.get('avg_price') or ['0'])[0].strip() or '0') or None
except ValueError:
avg_price = None
if not code or side not in ('BUY', 'SELL'):
self._send_json(400, {'error': 'code required, side=BUY|SELL'})
return
try:
advice = _price_advice(code, side, avg_price)
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'order/advice failed: {e}'})
return
self._send_json(200, {'code': code, 'side': side, 'advice': advice})
return
if parsed.path == '/api/order/check':
# 거래 모달용: 계좌·종목·side(BUY/SELL)·order_type·단가(선택) 받아서
# 최대 거래 가능 주수, 잔액·보유주수·평단가, 매도 시 손익 미리보기 반환.
# MARKET BUY 는 키움 증거금 기준(상한가)로 max_qty 계산해 사전 차단.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
account = (qs.get('account') or [''])[0].strip()
side = (qs.get('side') or [''])[0].strip().upper()
order_type = (qs.get('order_type') or ['LIMIT'])[0].strip().upper() or 'LIMIT'
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
order_type = 'LIMIT'
try:
price = int((qs.get('price') or ['0'])[0].strip() or '0')
except ValueError:
price = 0
if not code or not account or side not in ('BUY', 'SELL'):
self._send_json(400, {'error': 'code/account required, side=BUY|SELL'})
return
try:
import kiwoom_client as kc
result = {'code': code, 'account': account, 'side': side,
'order_type': order_type, 'price': price}
if side == 'BUY':
bal = kc.get_balance(account)
# 매수가능 기준은 d2_entra(D+2 정산예수금) — propose 가드(validate_balance_for_buy)와
# 동일. kt00001 ord_alow_amt는 오늘 즉시현금(entr)만 잡혀 매도결제분(D+2)을 빠뜨려
# max_qty가 과소 계산되고 "예수금 부족" 오탐을 냈다. ord_alow_amt는 참고 표시용만 유지.
d2 = int(bal.get('d2_entra') or 0)
# d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감.
locked = _pending_buy_lock(account)
avail = max(0, d2 - locked)
result['ord_alow_amt'] = int(bal.get('ord_alow_amt') or 0)
result['d2_entra'] = d2
result['pending_buy_locked'] = locked
result['avail_cash'] = avail
if order_type == 'MARKET':
# 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시.
try:
q = kc.get_stock_quote(code, account_label=account, exchange='AL')
raw = q.get('raw') if isinstance(q, dict) and isinstance(q.get('raw'), dict) else (q or {})
upper = abs(int(raw.get('upl_pric') or 0))
except Exception:
upper = 0
result['upper_limit'] = upper
result['max_qty'] = (avail // upper) if upper > 0 else 0
else:
result['max_qty'] = (avail // price) if price > 0 else 0
else: # SELL
positions = kc.get_positions(account)
pos = next((p for p in positions if p['code'] == code), None)
if pos:
# trde_able_qty가 미체결 매도를 이미 제외하는지 불확실 → min으로 이중차감 방지.
# (제외됨: trde_able ≤ 보유−미체결, min=trde_able 유지 / 미제외: min=보유−미체결)
pend_sell = _pending_sell_qty(account, code)
sellable = max(0, min(pos['trde_able_qty'], pos['qty'] - pend_sell))
result['hold_qty'] = pos['qty']
result['trde_able_qty'] = sellable
result['pending_sell_qty'] = pend_sell
result['avg_price'] = pos['avg_price']
result['cur_price'] = pos['cur_price']
result['max_qty'] = sellable
# 선택 단가 기준 손익 미리보기 — 단가 0이면 현재가 fallback
ref_price = price or pos['cur_price']
result['pl_preview'] = (ref_price - pos['avg_price']) * result['max_qty']
else:
result.update({'hold_qty': 0, 'trde_able_qty': 0, 'pending_sell_qty': 0,
'avg_price': 0, 'cur_price': 0, 'max_qty': 0, 'pl_preview': 0})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'order/check failed: {e}'})
return
self._send_json(200, result)
return
if parsed.path == '/api/order/account_status':
# 계좌 select 색/보유주수 표시용 — 한 종목에 대해 4계좌 d2_entra·보유주수 일괄 반환.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
try:
import kiwoom_client as kc
labels = [a['label'] for a in kc.list_accounts()]
with ThreadPoolExecutor(max_workers=len(labels) + 2) as ex:
pos_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels)
bal_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels}
pos_by = pos_f.result()
bals = {lb: bal_fs[lb].result() for lb in labels}
cur_price = 0
accounts = []
for lb in labels:
p = next((x for x in (pos_by.get(lb, []) or []) if x['code'] == code), None)
if p and p.get('cur_price') and not cur_price:
cur_price = p['cur_price']
d2 = int(bals[lb].get('d2_entra') or 0)
locked = _pending_buy_lock(lb) # 미체결 매수 묶임 차감
hold = p['qty'] if p else 0
# 매도가능 = min(trde_able, 보유−미체결매도) — check 핸들러와 동일 로직
pend_sell = _pending_sell_qty(lb, code) if p else 0
sellable = max(0, min(p['trde_able_qty'], hold - pend_sell)) if p else 0
accounts.append({
'label': lb,
'd2_entra': d2,
'avail_cash': max(0, d2 - locked),
'pending_buy_locked': locked,
'hold_qty': hold,
'trde_able_qty': sellable,
})
self._send_json(200, {'code': code, 'cur_price': cur_price, 'accounts': accounts})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'account_status failed: {e}'})
return
if parsed.path == '/api/chart_svg':
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
unit = (qs.get('unit') or [''])[0].strip()
if not code:
self._send_json(400, {'error': 'code required'})
return
# 분봉 분기 — 캐시 미사용 (장중 매 호출마다 ka10080 한 콜). 응답은 SVG 단일.
if unit in ('1m', '5m', '15m'):
tic_scope = {'1m': 1, '5m': 5, '15m': 15}[unit]
# range 파라미터 — 30min/1h/3h/5h/1d. 분 단위로 변환. 1d=None(오늘 전체).
range_str = (qs.get('range') or ['1d'])[0].strip()
range_minutes_map = {'30min': 30, '1h': 60, '3h': 180, '5h': 300, '1d': None}
if range_str not in range_minutes_map:
range_str = '1d'
range_minutes = range_minutes_map[range_str]
try:
import stock_analysis as sa
import kiwoom_client as kc
# _AL(통합) — 정규장(KRX)→NXT(15:30~20:00)가 한 차트에 연속.
mins = kc.get_minute_candles(code, tic_scope=tic_scope, today_only=True, exchange='AL')
if not mins:
self.send_error(404, 'no minute candles')
return
# range 슬라이스 — 최근 N 봉만.
if range_minutes is not None:
num_bars = max(1, range_minutes // tic_scope)
mins = mins[-num_bars:]
# render_svg_chart 호환 — date 필드에 HH:MM 박아 X축 라벨로 사용.
formatted = []
for m in mins:
t = m.get('time', '')
hhmm = f'{t[:2]}:{t[2:4]}' if len(t) >= 4 else t
formatted.append({**m, 'date': hhmm, 'value': 0, 'turnover_rate': None})
closes = [c['close'] for c in formatted]
snap = {
'candles_asc': formatted,
'sma5': sa.sma(closes, 5),
'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60),
}
# 전일종가 — 일봉 캐시에서 오늘 이전 마지막 종가 → 기준선+전일대비 등락률 표시
prev_close = None
try:
import daily_candles_cache as dcc
_today = datetime.now(KST).strftime('%Y%m%d')
for b in dcc._select_latest(code, 3):
if b.get('date') and str(b['date']) < _today and b.get('close'):
prev_close = b['close']
break
except Exception:
prev_close = None
if not prev_close:
# 일봉 캐시 미스 → ka10095(현재가−전일대비)로 전일종가 보강. 모든 종목에 전일대비 % 표시.
try:
_q = kc.get_watchlist_quotes([code]).get(code) or {}
_pc = (_q.get('price') or 0) - (_q.get('change') or 0)
if _pc > 0:
prev_close = _pc
except Exception:
pass
svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close,
intraday=True, hlines=_get_chart_hlines(code))
body = svg.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'image/svg+xml; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
traceback.print_exc()
self.send_error(500, 'minute chart render failed')
return
try:
import daily_candles_cache as dcc
import stock_analysis as sa
import kiwoom_client as kc
candles_asc = dcc.get_candles(code, count=250)
today = datetime.now(KST).strftime('%Y%m%d')
try:
qmap = kc.get_watchlist_quotes([code], exchange='AL')
tq = qmap.get(code) if isinstance(qmap, dict) else None
except Exception:
tq = None
# 오늘 봉 거래량은 KRX 기준(일봉 히스토리와 동일 기준 → 막대·RVOL·회전율 일관).
# 가격 OHLC는 통합(AL) 유지, 거래량만 KRX.
krx_vol = 0
try:
_qk = kc.get_watchlist_quotes([code], exchange='KRX').get(code) or {}
krx_vol = _qk.get('volume', 0) or 0
except Exception:
krx_vol = 0
live_today_ok = bool(
tq and tq.get('price') and tq.get('open')
and tq.get('high') and tq.get('low')
)
if (live_today_ok
and (not candles_asc or candles_asc[-1]['date'] != today)):
candles_asc = candles_asc + [{
'date': today,
'open': tq['open'],
'high': tq['high'],
'low': tq['low'],
'close': tq['price'],
'volume': krx_vol or tq.get('volume', 0),
'value': 0,
'turnover_rate': None,
}]
if not candles_asc:
self.send_error(404, 'no candles')
return
closes = [c['close'] for c in candles_asc]
snap = {
'candles_asc': candles_asc,
'sma5': sa.sma(closes, 5),
'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60),
'sma120': sa.sma(closes, 120),
}
# 마지막 봉이 오늘·정규장 진행형이면 세션경과 비율로 오늘 거래량을 하루치 환산(회전율 '예상')
vfrac = None
if candles_asc[-1]['date'] == today and _market_phase_state().get('phase') == 'regular':
now = datetime.now(KST)
hm = now.hour * 60 + now.minute
vfrac = min(1.0, max(0.15, (hm - 9 * 60) / 390.0)) # 09:00~15:30 = 390분
# 오늘 회전율·RVOL 값 — 차트 아래 표시 (텍스트 라벨은 차트에 안 그림)
_turn, _tscaled = sa.turnover_today(candles_asc, vfrac)
_rvol, _rscaled = sa.rvol_today(candles_asc, vfrac)
_cap_bits = []
if _rvol is not None:
_cap_bits.append(f'RVOL x{_rvol:.1f}')
if _turn is not None:
_cap_bits.append(f'회전율 {_turn:.2f}%')
_turn_cap = (
f'
{" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}
'
if _cap_bits else ''
)
# 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
hlines = _get_chart_hlines(code)
svg_1y = sa.render_svg_chart(snap, range_key='1Y', hlines=hlines)
svg_6m = sa.render_svg_chart(snap, range_key='6M', hlines=hlines)
svg_3m = sa.render_svg_chart(snap, range_key='3M', hlines=hlines)
svg_1m = sa.render_svg_chart(snap, range_key='1M', hlines=hlines)
# 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
# subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
def _minute_panel(unit_id: str) -> str:
return (
f'
'
'
'
''
''
''
''
''
'
'
''
'
'
)
html_block = (
'
'
''
''
''
''
''
''
'
'
+ _minute_panel('1m')
+ _minute_panel('5m')
+ f'
{svg_1m}
'
f'
{svg_3m}
'
f'
{svg_6m}
'
f'
{svg_1y}
'
+ _turn_cap
+ _chart_hlines_ui(code, hlines)
)
body = html_block.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
traceback.print_exc()
self.send_error(500, 'chart render failed')
return
if parsed.path == '/api/idx-chart':
qs = parse_qs(parsed.query)
sym = (qs.get('symbol') or [''])[0]
if sym not in _IDX_CHART_SYMBOL.values():
self.send_error(400, 'bad symbol')
return
series = _fetch_idx_chart_series(sym)
if not series:
self.send_error(502, 'fetch failed')
return
body = json.dumps({'symbol': sym, 'series': series}, ensure_ascii=False).encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'max-age=60')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
if parsed.path == '/api/panels':
qs = parse_qs(parsed.query)
tab_key = (qs.get('owner') or [None])[0]
fresh = (qs.get('fresh') or ['0'])[0] == '1'
# 명시적 새로고침(fresh=1)일 땐 시장 지수 캐시도 무효화 — 시장 카드 새로고침 버튼이 fresh=1 사용.
if fresh:
with _indices_lock:
_indices_cache['expires_at'] = 0
with _market_indicators_lock:
_market_indicators_cache['expires_at'] = 0
with _extra_market_quotes_lock:
_extra_market_quotes_cache['expires_at'] = 0
owner = TAB_KEY_TO_OWNER.get(tab_key) if tab_key else None
try:
chart_days = int((qs.get('chart_days') or [str(NET_WORTH_CHART_DAYS)])[0])
except (TypeError, ValueError):
chart_days = NET_WORTH_CHART_DAYS
chart_unit = (qs.get('chart_unit') or [NET_WORTH_CHART_UNIT])[0]
if chart_unit not in {u for _, u in NET_WORTH_UNIT_PRESETS}:
chart_unit = NET_WORTH_CHART_UNIT
# 기간×단위 비허용 조합은 첫 허용 단위로 강제 (stale localStorage 방어)
_allowed = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(chart_days)
if _allowed and chart_unit not in _allowed:
chart_unit = _allowed[0]
chart_mode = (qs.get('chart_mode') or [NET_WORTH_CHART_MODE])[0]
if chart_mode not in {m for _, m in NET_WORTH_MODE_PRESETS}:
chart_mode = NET_WORTH_CHART_MODE
try:
adr_days = int((qs.get('adr_days') or [str(ADR_TREND_DAYS)])[0])
except (TypeError, ValueError):
adr_days = ADR_TREND_DAYS
if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
adr_days = ADR_TREND_DAYS
# chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋).
chart_ym = (qs.get('chart_ym') or [''])[0]
_ymp = (chart_ym or '').split('-')
_ym_ok = (
(len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit())
or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit())
)
if not _ym_ok:
chart_ym = ''
try:
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
except Exception:
traceback.print_exc()
self.send_error(500, 'panels failed')
return
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
# /stock/ — 종목 분석 상세 페이지
if parsed.path.startswith('/stock/'):
tail = parsed.path[len('/stock/'):].strip('/')
if not tail:
self.send_error(404)
return
code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum())
if not code:
self.send_error(400, 'bad stock code')
return
qs = parse_qs(parsed.query)
selected = (qs.get('r') or [None])[0]
err_msg = (qs.get('err') or [None])[0]
try:
import stock_analysis as sa
page_html = sa.render_stock_page(code, selected_filename=selected, error_msg=err_msg)
except Exception:
traceback.print_exc()
self.send_error(500, 'analysis page failed')
return
body = page_html.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
if parsed.path not in ('/', '/index.html'):
self.send_error(404)
return
try:
# 셸 HTML은 데이터 fetch 없이 즉시 응답 — 키움 호출은 /api/panels에서.
html_body = render_html()
except Exception:
traceback.print_exc()
self.send_error(500, 'render failed')
return
body = html_body.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_HEAD(self): # noqa: N802
# PWA navigation preload·link preview·NAS reverse proxy health check 등이 HEAD를 보낸다.
# 키움 호출 트리거 없이 200만 돌려준다.
if self.path not in ('/', '/index.html'):
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
def do_POST(self): # noqa: N802
wl_actions = {'/delete': 'delete', '/restore': 'restore', '/purge': 'purge'}
in_actions = {'/interests/add': 'add', '/interests/update': 'update', '/interests/delete': 'delete'}
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length).decode('utf-8') if length else ''
from urllib.parse import parse_qs
# keep_blank_values=True: edit modal의 "비우면 제거" UX가 동작하려면 빈 값도 payload에 살아있어야 한다.
params = parse_qs(body, keep_blank_values=True)
stock = (params.get('stock') or [''])[0].strip()
wants_json = 'application/json' in (self.headers.get('Accept') or '')
if self.path == '/api/order/propose':
# 거래 모달 1단계: 주문 정보 → handler.propose_and_send → 카드 발급, PIN은 텔레그램.
account = (params.get('account') or [''])[0].strip()
side = (params.get('side') or [''])[0].strip().upper()
symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
symbol_name = (params.get('symbol_name') or [''])[0].strip()
order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
try:
qty = int((params.get('qty') or ['0'])[0].strip() or '0')
except ValueError:
qty = 0
price_raw = (params.get('price') or [''])[0].strip()
try:
price = int(price_raw) if price_raw else None
except ValueError:
price = None
stop_raw = (params.get('stop_price') or [''])[0].strip()
try:
stop_price = int(stop_raw) if stop_raw else None
except ValueError:
stop_price = None
steps_raw = (params.get('trail_steps') or [''])[0].strip()
try:
trail_steps = json.loads(steps_raw) if steps_raw else None
except ValueError:
trail_steps = None
min_sell_raw = (params.get('min_sell_price') or [''])[0].strip()
try:
min_sell_price = int(min_sell_raw) if min_sell_raw else None
except ValueError:
min_sell_price = None
if not account or side not in ('BUY', 'SELL') or not symbol or qty <= 0:
self._send_json(400, {'ok': False, 'error': 'account/side/symbol/qty required'})
return
if order_type not in ('LIMIT', 'MARKET', 'STOP_LIMIT', 'TRAILING_STOP'):
self._send_json(400, {'ok': False,
'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT|TRAILING_STOP'})
return
if order_type == 'LIMIT' and (price is None or price <= 0):
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
return
if order_type == 'STOP_LIMIT':
if side != 'SELL':
self._send_json(400, {'ok': False, 'error': '스톱지정가는 매도만 지원'})
return
if price is None or price <= 0 or stop_price is None or stop_price <= 0:
self._send_json(400, {'ok': False, 'error': '스톱지정가는 단가와 조건단가가 모두 필요'})
return
if order_type == 'TRAILING_STOP':
# 계단별 조건단가·지정가·주수는 handler 가 현재가·보유수량 기준으로 계산 —
# 여기선 계단 정의(하락률·누적 비중)만 받는다. 형식 검증은 handler 가
# orders/trailing.normalize_steps 로 하므로 여기선 최소한만 본다.
if side != 'SELL':
self._send_json(400, {'ok': False, 'error': '트레일링 스톱은 매도만 지원'})
return
if not isinstance(trail_steps, list) or not trail_steps:
self._send_json(400, {'ok': False, 'error': '매도 계단을 입력하세요'})
return
if min_sell_price is not None and min_sell_price <= 0:
self._send_json(400, {'ok': False, 'error': '최저 매도가는 0보다 커야 합니다'})
return
try:
if not symbol_name:
import kiwoom_client as kc
try:
meta = kc.resolve_stock_code(symbol)
symbol_name = meta.get('name') or symbol
except Exception:
symbol_name = symbol
from orders import handler
# propose_trade 직접 호출 — 거부 메시지는 web 토스트로만 (텔레그램 중복 발송 X).
# 성공 시에만 카드+PIN을 텔레그램으로 발송 (사용자가 PIN 받아야 함).
res = handler.propose_trade(
account=account, side=side, symbol=symbol, symbol_name=symbol_name,
qty=qty, order_type=order_type,
price=(price if order_type in ('LIMIT', 'STOP_LIMIT') else None),
stop_price=(stop_price if order_type == 'STOP_LIMIT' else None),
trail_steps=(trail_steps if order_type == 'TRAILING_STOP' else None),
min_sell_price=(min_sell_price if order_type == 'TRAILING_STOP' else None),
)
if res.get('ok'):
# 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.
# 텔레그램은 매매등록(submit_with_pin)·매매체결(fill_watcher) 시점만 발송.
# PIN은 iMessage로 iOS 자동입력 지원.
try:
from orders.pin import PinStore as _PinStore
_pending = _PinStore().peek()
if _pending and not _pending.consumed:
side_label = '매수' if side == 'BUY' else '매도'
handler.send_imessage_pin(
pin=_pending.pin,
card_id=_pending.card_id,
side_label=side_label,
symbol_name=symbol_name,
)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'propose failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # PIN 대기 배지(매수예정/매도예정) 즉시 반영
status = 200 if res.get('ok') else 400
self._send_json(status, res)
return
if self.path == '/api/order/propose_multi':
# 거래 모달 매도 — 같은 소유자 그룹 2계좌 동시 매도. 카드 1장 + PIN 1개가 두 레그 승인.
# accounts/qtys 는 콤마 구분 동수 리스트 (예: accounts=일반,ISA & qtys=5,12).
side = (params.get('side') or ['SELL'])[0].strip().upper()
symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
symbol_name = (params.get('symbol_name') or [''])[0].strip()
order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
accounts = [a.strip() for a in (params.get('accounts') or [''])[0].split(',') if a.strip()]
try:
qtys = [int(q.strip()) for q in (params.get('qtys') or [''])[0].split(',') if q.strip()]
except ValueError:
qtys = []
price_raw = (params.get('price') or [''])[0].strip()
try:
price = int(price_raw) if price_raw else None
except ValueError:
price = None
if side != 'SELL' or not symbol or len(accounts) < 2 \
or len(accounts) != len(qtys) or any(q <= 0 for q in qtys):
self._send_json(400, {'ok': False, 'error': 'side=SELL + accounts/qtys(2개 이상, 동수) 필요'})
return
if order_type not in ('LIMIT', 'MARKET'):
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'})
return
if order_type == 'LIMIT' and (price is None or price <= 0):
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
return
try:
if not symbol_name:
import kiwoom_client as kc
try:
meta = kc.resolve_stock_code(symbol)
symbol_name = meta.get('name') or symbol
except Exception:
symbol_name = symbol
from orders import handler
# 단일 propose 와 동일 정책 — 거부는 web 토스트만, PIN 은 iMessage.
res = handler.propose_trade_multi(
legs=[{'account': a, 'qty': q} for a, q in zip(accounts, qtys)],
side='SELL', symbol=symbol, symbol_name=symbol_name,
order_type=order_type,
price=(price if order_type == 'LIMIT' else None),
)
if res.get('ok'):
try:
from orders.pin import PinStore as _PinStore
_pending = _PinStore().peek()
if _pending and not _pending.consumed:
handler.send_imessage_pin(
pin=_pending.pin,
card_id=_pending.card_id,
side_label='매도(2계좌)',
symbol_name=symbol_name,
)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'propose_multi failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # PIN 대기 배지 즉시 반영
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/order/verify':
# 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False).
pin = (params.get('pin') or [''])[0].strip()
if not pin:
self._send_json(400, {'ok': False, 'error': 'pin required'})
return
try:
from orders import handler
# submit_with_pin 직접 호출 — 거부 시 텔레그램 중복 발송 X.
# 성공·체결 시에만 텔레그램 결과 발송.
res = handler.submit_with_pin(pin, dry_run=False)
if res.get('ok') and res.get('message'):
try:
handler.send_telegram(res['message'], parse_mode='Markdown')
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'verify failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # 주문 접수 → 미체결 배지(매수대기/매도대기) 반영 (체결은 fill_watcher가 별도 bump)
status = 200 if res.get('ok') else 400
self._send_json(status, res)
return
if self.path == '/api/orders/cancel':
# 미체결 주문 취소 (kt10003). ord_no + account 필수.
ord_no = (params.get('ord_no') or [''])[0].strip()
account = (params.get('account') or [''])[0].strip()
if not ord_no or not account:
self._send_json(400, {'ok': False, 'error': 'ord_no/account required'})
return
try:
from orders import handler
res = handler.cancel_open_order(ord_no=ord_no, account=account)
# 매매체결(취소) 알림은 텔레그램으로 (handler.cancel_open_order는 send 안 함)
if res.get('ok') and res.get('message'):
try:
handler.send_telegram(res['message'], parse_mode=None)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'open order cancel failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # 미체결 취소 → 배지 제거 반영
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/order/cancel':
# 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님.
try:
from orders import handler
# cancel_active_card 반환은 dict {'ok','message'} — 활성 카드 없어도 베스트에포트라 200.
res = handler.cancel_active_card()
if res.get('ok'):
_bump_panel_signal() # 실제 활성 카드 취소 시만 → PIN 대기 배지 제거 (카드 없이 닫으면 reload 안 함)
self._send_json(200, {'ok': True, 'message': res.get('message') or '카드 취소됨'})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'})
return
return
if self.path in wl_actions:
action = wl_actions[self.path]
if not stock:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
try:
_apply_watchlist_action(action, stock)
_invalidate_panels_cache()
except KeyError:
self.send_error(404, 'Not Found', explain=f'감시종목에 없음: {stock}')
return
except Exception as e:
traceback.print_exc()
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} stock={stock} → ok\n')
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', '/#tab-wl')
self.end_headers()
return
if self.path == '/alerts/reset':
if not stock:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
try:
_apply_alerts_reset(stock)
except Exception as e:
traceback.print_exc()
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} stock={stock} → ok\n')
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', '/#tab-wl')
self.end_headers()
return
if self.path == '/interests/groups/set':
# 관심종목 그룹 멤버십 편집 — stock(name) 필수. groups 는 체크박스 다중값(반복 필드).
# new_group 있으면 먼저 생성 후 포함.
if not stock:
if wants_json:
self._send_json(400, {'ok': False, 'error': '종목명 누락'})
else:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
group_names = [g.strip() for g in (params.get('groups') or []) if g.strip()]
new_group = (params.get('new_group') or [''])[0].strip()
try:
if new_group:
_create_interest_group(new_group)
group_names.append(new_group)
_set_interest_groups(stock, group_names)
_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} stock={stock} groups={group_names} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True})
else:
self.send_response(303)
self.send_header('Location', '/#tab-interests')
self.end_headers()
return
if self.path == '/interests/groups':
# 그룹 레지스트리 관리 — op ∈ {create, rename, delete, reorder}.
# 관리 모달은 shell 직속이라 panels 리로드로 자동 갱신 안 됨 → 응답에 갱신 목록 동봉.
op = (params.get('op') or [''])[0].strip()
name = (params.get('name') or [''])[0].strip()
new_name = (params.get('new_name') or [''])[0].strip()
order_raw = (params.get('order') or [''])[0]
try:
if op == 'create':
_create_interest_group(name)
elif op == 'rename':
_rename_interest_group(name, new_name)
elif op == 'delete':
_delete_interest_group(name)
elif op == 'reorder':
order = [x.strip() for x in order_raw.split(',') if x.strip()]
_reorder_interest_groups(order)
elif op == 'list':
pass # 순수 조회 — 관리 모달 초기 로드용
else:
raise ValueError(f'unknown op: {op}')
if op != 'list':
_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
reg = _load_interest_groups()
counts: dict = {}
for names in reg.get('members', {}).values():
for n in names:
counts[n] = counts.get(n, 0) + 1
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} op={op} name={name!r} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True, 'groups': reg.get('groups', []), 'counts': counts})
else:
self.send_response(303)
self.send_header('Location', '/#tab-interests')
self.end_headers()
return
if self.path in in_actions:
action = in_actions[self.path]
# add는 stock 또는 code 둘 중 하나면 OK — _apply_interests_action 안에서 검증.
if not stock and action != 'add':
if wants_json:
self._send_json(400, {'ok': False, 'error': '종목명 누락'})
else:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
payload = {k: (v[0] if v else '') for k, v in params.items() if k != 'stock'}
try:
_apply_interests_action(action, stock, payload)
_invalidate_panels_cache()
except MultipleMatchError as e:
cands = [{'name': c['name'], 'code': c['code']} for c in e.candidates]
if wants_json:
self._send_json(200, {'ok': False, 'error': 'multiple', 'query': e.query, 'candidates': cands})
else:
names = ', '.join(c['name'] for c in e.candidates[:5])
self.send_error(409, 'Conflict', explain=f'"{e.query}"와 일치하는 종목이 여러 개입니다. 후보: {names}')
return
except KeyError:
msg = f'관심종목에 없음: {stock}'
if wants_json:
self._send_json(404, {'ok': False, 'error': msg})
else:
self.send_error(404, 'Not Found', explain=msg)
return
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} stock={stock} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True})
else:
self.send_response(303)
self.send_header('Location', '/#tab-interests')
self.end_headers()
return
if self.path == '/tags/set':
# 종목 공용 태그 — stock(name) 또는 code 둘 중 하나는 필수.
# tag(text)+leader 모두 비어/false면 항목 제거.
code = (params.get('code') or [''])[0].strip()
tag_raw = (params.get('tag') or [''])[0]
leader_raw = (params.get('leader') or ['0'])[0].strip().lower()
leader = leader_raw in ('1', 'true', 'on', 'yes')
if not stock and not code:
if wants_json:
self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'})
else:
self.send_error(400, 'Bad Request', explain='종목 식별자 없음')
return
try:
_set_stock_tag(code or None, stock or None, tag_raw, leader)
_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} stock={stock} code={code} tag={tag_raw!r} leader={leader} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True})
else:
self.send_response(303)
self.send_header('Location', '/')
self.end_headers()
return
if self.path in ('/chart/hlines/add', '/chart/hlines/remove'):
# 종목차트 사용자 수평선 추가/제거 — code+price 필수. 응답은 JSON(갱신된 리스트 포함).
code = ''.join(ch for ch in (params.get('code') or [''])[0].strip() if ch.isalnum())
price_raw = (params.get('price') or [''])[0].strip()
if not code or not price_raw:
self._send_json(400, {'ok': False, 'error': 'code/price 필요'})
return
try:
if self.path.endswith('/add'):
_add_chart_hline(code, price_raw)
else:
_remove_chart_hline(code, price_raw)
except ValueError as e:
self._send_json(400, {'ok': False, 'error': str(e)})
return
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} price={price_raw} → ok\n')
sys.stdout.flush()
self._send_json(200, {'ok': True, 'lines': _get_chart_hlines(code)})
return
if self.path == '/holdeval/save':
# 보유 평가 계산기 저장 — owner/code/name/avg/qty. code·avg·qty 필수.
owner = (params.get('owner') or [''])[0].strip()
code = (params.get('code') or [''])[0].strip()
name = (params.get('name') or [''])[0].strip()
try:
avg = float((params.get('avg') or ['0'])[0].strip() or '0')
qty = float((params.get('qty') or ['0'])[0].strip() or '0')
except ValueError:
avg = qty = 0
try:
items = _add_holdeval_saved(owner, code, name, avg, qty)
self._send_json(200, {'ok': True, 'items': items})
except ValueError as e:
self._send_json(400, {'ok': False, 'error': str(e)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
if self.path == '/holdeval/update':
# 저장 항목 수정 — id 필수, owner/avg/qty 갱신. code/name은 있으면 교체.
entry_id = (params.get('id') or [''])[0].strip()
owner = (params.get('owner') or [''])[0].strip()
code = (params.get('code') or [''])[0].strip()
name = (params.get('name') or [''])[0].strip()
try:
avg = float((params.get('avg') or ['0'])[0].strip() or '0')
qty = float((params.get('qty') or ['0'])[0].strip() or '0')
except ValueError:
avg = qty = 0
if not entry_id:
self._send_json(400, {'ok': False, 'error': 'id required'})
return
try:
items = _update_holdeval_saved(entry_id, owner, avg, qty, code, name)
self._send_json(200, {'ok': True, 'items': items})
except ValueError as e:
self._send_json(400, {'ok': False, 'error': str(e)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
if self.path == '/holdeval/delete':
entry_id = (params.get('id') or [''])[0].strip()
if not entry_id:
self._send_json(400, {'ok': False, 'error': 'id required'})
return
try:
items = _delete_holdeval_saved(entry_id)
self._send_json(200, {'ok': True, 'items': items})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
if self.path == '/notes/set':
# 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수.
# note 가 빈 텍스트면 항목 제거.
code = (params.get('code') or [''])[0].strip()
note_raw = (params.get('note') or [''])[0]
if not stock and not code:
if wants_json:
self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'})
else:
self.send_error(400, 'Bad Request', explain='종목 식별자 없음')
return
try:
_set_stock_note(code or None, stock or None, note_raw)
_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} stock={stock} code={code} note_len={len(note_raw.strip())} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True})
else:
self.send_response(303)
self.send_header('Location', '/')
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//generate — 새 보고서 큐 등록
if self.path.startswith('/stock/') and self.path.endswith('/generate'):
tail = self.path[len('/stock/'):-len('/generate')]
code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum())
if not code:
self.send_error(400, 'bad stock code')
return
try:
import stock_analysis as sa
result = sa.enqueue(code)
except Exception as e:
traceback.print_exc()
self.send_error(500, f'enqueue failed: {e}')
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} → {result["status"]}\n')
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', f'/stock/{code}')
self.end_headers()
return
# /stock//peers/add | /peers/delete
if self.path.startswith('/stock/') and ('/peers/' in self.path):
tail = self.path[len('/stock/'):]
parts = tail.split('/')
code = ''.join(ch for ch in parts[0] if ch.isalnum())
action = parts[2] if len(parts) >= 3 else ''
if not code or action not in ('add', 'delete'):
self.send_error(400, 'bad peers route')
return
import stock_analysis as sa
if action == 'add':
query = (params.get('stock') or [''])[0].strip()
if not query:
self.send_error(400, 'stock empty')
return
from urllib.parse import quote as _urlquote
err_redirect: str | None = None
try:
p = sa.add_peer(code, query)
sys.stdout.write(f'[{self.log_date_time_string()}] POST peers/add code={code} → {p["code"]} {p["name"]}\n')
except sa.MultipleMatchError as e:
names = ', '.join(c['name'] for c in e.candidates[:5])
err_redirect = f'"{e.query}"와 일치하는 종목이 여러 개예요. 더 정확히 입력해주세요. 후보: {names}'
except ValueError as e:
err_redirect = str(e)
except Exception as e:
traceback.print_exc()
err_redirect = f'추가 실패: {e}'
if err_redirect:
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', f'/stock/{code}?err={_urlquote(err_redirect)}')
self.end_headers()
return
else: # delete
peer_code = (params.get('peer_code') or [''])[0].strip()
if not peer_code:
self.send_error(400, 'peer_code empty')
return
try:
sa.remove_peer(code, peer_code)
sys.stdout.write(f'[{self.log_date_time_string()}] POST peers/delete code={code} → {peer_code}\n')
except Exception as e:
traceback.print_exc()
self.send_error(500, f'peers delete failed: {e}')
return
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', f'/stock/{code}')
self.end_headers()
return
self.send_error(404)
# ---------------- 실시간 시세 허브 (realtime_hub 연동) ----------------
# 키움 WebSocket 실시간 현재가(0B)·VI(1h)를 메모리에 들고 화면에 라이브로 표시.
# kill-switch: 환경변수 BEHIVE_REALTIME=0 이면 비활성(REST 폴백만). 기본 활성.
# hub 시작·연결 실패가 페이지 서빙을 막지 않도록 전부 fail-safe.
_RT_HUB = None
_RT_MAX_CODES = 200 # 폭주 방지 상한. 키움은 59종목(=118등록) 수락 확인. 초과분은 우선순위 컷+로깅.
def _rt_enabled() -> bool:
import os
return os.environ.get('BEHIVE_REALTIME', '1') not in ('0', 'false', 'no')
def _rt_gather_codes() -> list:
"""구독 대상: 보유(4계좌) > 관심 > 감시 우선순위. 상한 초과분은 잘라내고 로깅."""
import kiwoom_client as kc
seen: set = set()
codes: list = []
def _add(code):
if not code:
return
cc = kc._clean_code(code)
if cc and cc not in seen:
seen.add(cc)
codes.append(cc)
try:
for _label, positions in (kc.get_positions_all() or {}).items():
for p in positions:
_add(p.get('code'))
except Exception:
traceback.print_exc()
try:
for c in _load_interests():
_add(c.get('code'))
except Exception:
pass
try:
for c in _load_watchlist():
_add(c.get('code'))
except Exception:
pass
if len(codes) > _RT_MAX_CODES:
dropped = codes[_RT_MAX_CODES:]
sys.stderr.write(f'[realtime] 구독 상한 {_RT_MAX_CODES} 초과 → {len(dropped)}종목 제외: {dropped}\n')
codes = codes[:_RT_MAX_CODES]
return codes
def _rt_resubscribe_loop():
while True:
try:
if _RT_HUB is not None:
codes = _rt_gather_codes()
if codes:
_RT_HUB.set_subscriptions(codes)
except Exception:
traceback.print_exc()
time.sleep(60)
def _rt_start():
"""실시간 허브 기동 (fail-safe). 실패해도 서버는 정상 서빙(REST 폴백)."""
global _RT_HUB
if not _rt_enabled():
print('[realtime] BEHIVE_REALTIME=0 → 실시간 허브 비활성 (REST 폴백)', flush=True)
return
try:
import realtime_hub as rh
_RT_HUB = rh.RealtimeHub()
codes = _rt_gather_codes()
_RT_HUB.set_subscriptions(codes)
_RT_HUB.start()
threading.Thread(target=_rt_resubscribe_loop, name='realtime-resub', daemon=True).start()
print(f'[realtime] 허브 기동 — {len(codes)}종목 구독', flush=True)
except Exception:
traceback.print_exc()
_RT_HUB = None
print('[realtime] 허브 기동 실패 → REST 폴백으로 계속', flush=True)
def cmd_serve() -> int:
_rt_start()
server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)
print(f'serving on http://{BIND_HOST}:{BIND_PORT}', flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
def cmd_render(out: str | None) -> int:
body = render_html()
if out:
Path(out).write_text(body)
print(f'wrote {out} ({len(body)} bytes)')
else:
sys.stdout.write(body)
return 0
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog='behive_web')
sub = p.add_subparsers(dest='cmd', required=True)
sub.add_parser('serve')
pr = sub.add_parser('render')
pr.add_argument('--out')
args = p.parse_args(argv)
if args.cmd == 'serve':
return cmd_serve()
if args.cmd == 'render':
return cmd_render(args.out)
return 2
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))