fed3526b20
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10125 lines
521 KiB
Python
10125 lines
521 KiB
Python
#!/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
|
||
WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
|
||
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
|
||
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.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'))
|
||
|
||
|
||
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
|
||
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
|
||
_quote_cache: dict[str, tuple[dict, float]] = {}
|
||
_quote_cache_lock = threading.Lock()
|
||
|
||
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())
|
||
|
||
|
||
_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 _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['_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
|
||
|
||
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])
|
||
return cards
|
||
|
||
# NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리).
|
||
nxt_batch: dict[str, dict] = {}
|
||
try:
|
||
nxt_batch = kc.get_watchlist_quotes(miss_codes, exchange='NXT')
|
||
except Exception as ex:
|
||
sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n')
|
||
|
||
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
|
||
else:
|
||
out['quote_error'] = '가격 0'
|
||
# 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
|
||
nxt_q = nxt_batch.get(code) if nxt_batch else None
|
||
nxt_price = (nxt_q or {}).get('price', 0) if nxt_q else 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'],
|
||
'_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')
|
||
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')
|
||
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'],
|
||
}
|
||
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 결제 전까지 변하지 않아
|
||
# 신규 매수 시 손익이 매수원가만큼 부풀려지는 버그가 있다.
|
||
deposit = sum(balances.get(lb, {}).get('d2_entra', 0) for lb in lbls)
|
||
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
|
||
# 휴장일/주말은 시장 변동이 없어야 자연. 단 NXT 가격 보정으로 어제 스냅샷과 차이가 생겨
|
||
# day_pl_total이 0이 아닌 값을 갖게 됨 → 사용자 혼란 차단 위해 None으로 강제(미표시).
|
||
if _market_phase_state().get('phase') in ('holiday', 'weekend'):
|
||
day_pl_total = None
|
||
|
||
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,
|
||
'total_net': total_net,
|
||
'traded_today': traded_today,
|
||
'prev_net': prev_net,
|
||
'day_pl_total': day_pl_total,
|
||
'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,
|
||
}
|
||
|
||
# 당일정산(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] = {}
|
||
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')
|
||
_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')
|
||
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 = {}
|
||
|
||
# 미체결 → 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', ''),
|
||
'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 단일 캔들은 phase 우선.
|
||
if ohlc_map_krx or ohlc_map_nxt:
|
||
_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),
|
||
}
|
||
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),
|
||
}
|
||
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
|
||
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')
|
||
if _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)
|
||
|
||
|
||
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'<span class="{cls}">{chip_text}</span>'
|
||
|
||
|
||
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'<button type="button" class="btn-add-tag" data-tag-edit="1"'
|
||
f' data-tag-stock="{name_attr}" data-tag-code="{code_attr}"'
|
||
f' data-tag-text="{html.escape(text, quote=True)}"'
|
||
f' data-tag-leader="{"1" if leader else "0"}"'
|
||
f' title="태그 설정">+ 태그</button>'
|
||
)
|
||
|
||
|
||
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'<button type="button" class="btn-info" data-info-code="{html.escape(code_s, quote=True)}"'
|
||
f' data-info-stock="{html.escape((name or "").strip(), quote=True)}"'
|
||
f' title="기업정보 (시총·PER·PBR 등)">📈 상세</button>'
|
||
)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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'<div class="buy-raw">{html.escape(raw)}</div>')
|
||
for lv in levels:
|
||
mark = ' <span class="alert-mark">알림됨</span>' if f'buy@{lv}' in alerted else ''
|
||
parts.append(f'<div class="buy-level">{lv:,}원{mark}</div>')
|
||
return ''.join(parts)
|
||
|
||
|
||
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'<span class="dot dot-{tier}" title="매수 가능권 {tier}단계"></span>' if tier else ''
|
||
|
||
if price is None:
|
||
price_cell = '<span class="muted">-</span>'
|
||
diff_cell = f'<span class="muted">{html.escape(err) if err else "조회 불가"}</span>'
|
||
else:
|
||
# 가격 출처 마크 — _krx_price/_nxt_price 비교. NXT 가격이 살아있고 KRX와 다르면 NXT.
|
||
krx_p = c.get('_krx_price', 0) or 0
|
||
nxt_p = c.get('_nxt_price', 0) or 0
|
||
if nxt_p > 0 and (krx_p == 0 or krx_p != nxt_p):
|
||
mark = 'NXT'
|
||
else:
|
||
mark = 'KRX'
|
||
mark_html = f'<span class="px-mark px-{mark.lower()}">{mark}</span>'
|
||
# 미리보기 등락은 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:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
|
||
else:
|
||
price_cell = f'{mark_html}{price:,}'
|
||
if mode == 'watching' and isinstance(ref_price, (int, float)) and ref_price > 0:
|
||
diff_cell = f'<span class="muted small">매수가</span> {ref_price:,.0f}'
|
||
else:
|
||
diff_cell = ''
|
||
|
||
if mode == 'held':
|
||
qty = c.get('held_qty', 0)
|
||
status_cell = f'<span class="badge held">보유 {qty:,}주</span>'
|
||
accounts = c.get('held_accounts') or []
|
||
avg = c.get('held_avg_price', 0)
|
||
held_row = (
|
||
f'<span class="lbl">매입평단</span>'
|
||
f'<span class="val num">{avg:,}원<span class="muted small"> · {", ".join(accounts)}</span></span>'
|
||
)
|
||
else:
|
||
status_cell = '<span class="badge watching">매수전</span>'
|
||
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'<span class="badge alerted">알림 {"·".join(_alert_kinds)}</span>'
|
||
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 += ' <span class="alert-mark">알림됨</span>'
|
||
|
||
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 += ' <span class="alert-mark">알림됨</span>'
|
||
|
||
chart_block = ''
|
||
if code:
|
||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||
|
||
summary_lines = c.get('summary') or []
|
||
summary_block = ''
|
||
if summary_lines:
|
||
items = ''.join(f'<li>{html.escape(s)}</li>' for s in summary_lines)
|
||
summary_block = f'<div class="block"><h3>주요내용</h3><ul>{items}</ul></div>'
|
||
|
||
notes_lines = c.get('notes') or []
|
||
notes_block = ''
|
||
if notes_lines:
|
||
items = ''.join(f'<li>{html.escape(n)}</li>' for n in notes_lines)
|
||
notes_block = f'<div class="block"><h3>기타</h3><ul>{items}</ul></div>'
|
||
|
||
video = c.get('video') or {}
|
||
video_block = ''
|
||
if video.get('url'):
|
||
title = html.escape(video.get('title', '영상'))
|
||
video_block = (
|
||
f'<div class="block"><h3>출처</h3>'
|
||
f'<a class="video-link" href="{html.escape(video["url"])}" target="_blank" rel="noopener">{title} ↗</a>'
|
||
f'</div>'
|
||
)
|
||
|
||
saved = html.escape((c.get('saved_at') or '')[:10])
|
||
is_pending = c.get('status') == 'pending_delete'
|
||
pending_at = html.escape((c.get('pending_delete_at') or '')[:16].replace('T', ' '))
|
||
stock_attr = html.escape(c.get('stock', ''), quote=True)
|
||
|
||
raw_code = c.get('code') or ''
|
||
has_trades = bool(raw_code) and raw_code in _load_traded_codes()
|
||
trade_btn = (
|
||
f'<button type="button" class="btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}">거래내역</button>'
|
||
) if has_trades else ''
|
||
if source == 'watchlist' and raw_code:
|
||
analyze_btn = (
|
||
f'<button type="button" class="btn-add-from-wl" data-modal-open="interest-modal"'
|
||
f' data-prefill-stock="{stock_attr}" data-prefill-code="{html.escape(raw_code, quote=True)}"'
|
||
f' title="관심종목 추가 모달 열기 (종목명·코드 자동 입력)">+ 관심추가</button>'
|
||
)
|
||
else:
|
||
# 📊 분석은 상세(기업정보) 팝업 안으로 이동 — 카드 actions에선 미노출.
|
||
analyze_btn = ''
|
||
trade_mark = (
|
||
f'<button type="button" class="badge badge-trade btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}" title="거래내역 보기">거래내역</button>'
|
||
) if has_trades and source == 'interests' else ''
|
||
# 매매 진입 버튼 — 보유 모드면 매도 default, 그 외는 매수 default. 모달에서 토글 가능.
|
||
order_side = 'SELL' if c.get('mode') == 'held' else 'BUY'
|
||
order_btn = (
|
||
f'<button type="button" class="btn-order" data-order-code="{html.escape(raw_code, quote=True)}" data-order-stock="{stock_attr}" data-order-side="{order_side}" title="매수/매도">💰 거래</button>'
|
||
) if raw_code 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)}"'
|
||
)
|
||
tag_add_btn = _tag_add_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 = (
|
||
'<div class="actions">'
|
||
f'{tag_add_btn}'
|
||
f'{analyze_btn}'
|
||
f'{info_btn}'
|
||
f'{trade_btn}'
|
||
f'{order_btn}'
|
||
f'<button type="button" class="btn-edit"{edit_attrs}>수정</button>'
|
||
'</div>'
|
||
)
|
||
elif is_pending:
|
||
actions_html = (
|
||
'<div class="actions">'
|
||
f'<span class="muted small">삭제예정 {pending_at}</span>'
|
||
f'<form method="post" action="/restore"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-restore">복원</button></form>'
|
||
f'<form method="post" action="/purge" onsubmit="return confirm(\'완전 삭제하시겠습니까? 되돌릴 수 없습니다.\')"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-purge">완전 삭제</button></form>'
|
||
'</div>'
|
||
)
|
||
cls = f'{cls} trash'
|
||
else:
|
||
wl_tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||
wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
|
||
actions_html = (
|
||
'<div class="actions">'
|
||
f'{wl_tag_add_btn}'
|
||
f'{analyze_btn}'
|
||
f'{wl_info_btn}'
|
||
f'{trade_btn}'
|
||
f'{order_btn}'
|
||
f'<form method="post" action="/alerts/reset"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-alerts-reset">알림 리셋</button></form>'
|
||
f'<form method="post" action="/delete"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-delete">감시 삭제</button></form>'
|
||
'</div>'
|
||
)
|
||
|
||
tag_chip = _tag_chip_html(c.get('code') or '', c.get('stock') or '', interactive=True)
|
||
|
||
row_key = code or stock
|
||
return f'''<details class="row {cls} mode-{mode}" data-row-key="{row_key}">
|
||
<summary>
|
||
<div class="left">
|
||
<div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}</div>
|
||
</div>
|
||
<div class="right">
|
||
<div class="price">{price_cell}<span class="caret" aria-hidden="true">▾</span></div>
|
||
{f'<div class="diff">{diff_cell}</div>' if diff_cell else ''}
|
||
</div>
|
||
</summary>
|
||
<div class="detail">
|
||
{'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
|
||
<div class="grid2">
|
||
{held_row}
|
||
<span class="lbl">매수가</span><span class="val">{buy_raw}</span>
|
||
<span class="lbl">목표가</span><span class="val">{target_raw}</span>
|
||
<span class="lbl">손절가</span><span class="val">{stop_raw}</span>
|
||
<span class="lbl">저장일</span><span class="val muted">{saved}</span>
|
||
</div>
|
||
{ohlc_mini_html}
|
||
{_pending_detail_html(c)}
|
||
{summary_block}
|
||
{notes_block}
|
||
{video_block}
|
||
{actions_html}
|
||
{chart_block}
|
||
</div>
|
||
</details>'''
|
||
|
||
|
||
def _profit_class(value: int) -> str:
|
||
"""한국 시세 관례: 양수=빨강(up), 음수=파랑(down)."""
|
||
if value is None:
|
||
return 'neutral'
|
||
if value > 0:
|
||
return 'up'
|
||
if value < 0:
|
||
return 'down'
|
||
return 'neutral'
|
||
|
||
|
||
def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool = False) -> str:
|
||
"""owner 블록 상단 KPI 카드 (총 평가/손익/예수금/순자산 + 계좌별 예수금).
|
||
|
||
compact=True: 자산정보 탭용 — 당일 매매·당일 실현손익 행 생략.
|
||
"""
|
||
profit = d['total_profit']
|
||
profit_rate = d['total_profit_rate']
|
||
pcls = _profit_class(profit)
|
||
held_count = len([r for r in d['consolidated'] if not r.get('phantom')])
|
||
sign = '+' if profit >= 0 else ''
|
||
labels_disp = ' + '.join(d['labels'])
|
||
|
||
day_pl_total = d.get('day_pl_total')
|
||
if day_pl_total is None:
|
||
# 휴장일/주말은 시장 변동이 없는 정상 상태 — "전날 스냅샷 없음"과 구분된 문구.
|
||
if _market_phase_state().get('phase') in ('holiday', 'weekend'):
|
||
day_pl_html = '<span class="muted small">휴장 — 변동 없음</span>'
|
||
else:
|
||
day_pl_html = '<span class="muted small">전날 스냅샷 없음</span>'
|
||
else:
|
||
dcls = _profit_class(day_pl_total)
|
||
dsign = '+' if day_pl_total >= 0 else ''
|
||
prev_net = d.get('prev_net') or 0
|
||
pct_html = ''
|
||
if prev_net:
|
||
pct = (day_pl_total / prev_net) * 100
|
||
pct_html = f' ({dsign}{pct:.2f}%)'
|
||
day_pl_html = f'<span class="{dcls}">{dsign}{day_pl_total:,}원{pct_html}</span>'
|
||
|
||
deposit_pct_html = ''
|
||
if d['total_value']:
|
||
dep_pct = (d['deposit'] / d['total_value']) * 100
|
||
deposit_pct_html = f'<span class="muted small">({dep_pct:.1f}%)</span> '
|
||
locked = d.get('pending_buy_locked', 0) or 0
|
||
deposit_locked_html = (
|
||
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
|
||
)
|
||
kpis = [
|
||
('총 평가금액', f'{d["total_value"]:,}원'),
|
||
('총 매입금액', f'{d["total_cost"]:,}원'),
|
||
('총 평가손익', f'<span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span>'),
|
||
('당일 평가손익', day_pl_html),
|
||
('예수금', f'{deposit_pct_html}{d["deposit"]:,}원{deposit_locked_html}'),
|
||
('순자산', f'<span class="net">{d["total_net"]:,}원</span>'),
|
||
]
|
||
cash_in = d.get('cash_in', 0)
|
||
cash_out = d.get('cash_out', 0)
|
||
if cash_in or cash_out:
|
||
net = cash_in - cash_out
|
||
ncls = _profit_class(net)
|
||
nsign = '+' if net >= 0 else ''
|
||
cf_detail = []
|
||
if cash_in:
|
||
cf_detail.append(f'입금 {cash_in:,}원')
|
||
if cash_out:
|
||
cf_detail.append(f'출금 {cash_out:,}원')
|
||
cf_detail_html = f'<span class="muted small"> · {" · ".join(cf_detail)}</span>'
|
||
kpis.append(('당일 입출금', f'<span class="{ncls}">{nsign}{net:,}원</span>{cf_detail_html}'))
|
||
if compact:
|
||
kpis.append(('보유 종목', f'{held_count}개'))
|
||
else:
|
||
traded_count = len(d["traded_today"])
|
||
kpis.append(('보유종목/당일매매', f'{held_count}개 / {traded_count}개'))
|
||
realized_pl = d.get('realized_pl_total', 0)
|
||
realized_fees = d.get('realized_fees_total', 0)
|
||
if d.get('traded_today') and (realized_pl or realized_fees):
|
||
rcls = _profit_class(realized_pl)
|
||
rsign = '+' if realized_pl >= 0 else ''
|
||
fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
|
||
realized_html = f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'
|
||
kpis.append(('당일 실현손익', realized_html))
|
||
rows = ''.join(
|
||
f'<tr><th>{html.escape(k)}</th><td>{v}</td></tr>' for k, v in kpis
|
||
)
|
||
trade_btn_html = ''
|
||
if not compact:
|
||
owner_attr = html.escape(owner, quote=True)
|
||
label_attr = html.escape(owner_label_text, quote=True)
|
||
trade_btn_html = (
|
||
f'<button type="button" class="btn-trades btn-trades-all" '
|
||
f'data-trade-owner="{owner_attr}" data-trade-stock="{label_attr}">'
|
||
f'📋 전체 거래내역</button>'
|
||
)
|
||
return f'''<section class="owner-card">
|
||
<div class="owner-title-row">
|
||
<div class="owner-title">{html.escape(owner_label_text)}</div>
|
||
{trade_btn_html}
|
||
</div>
|
||
<div class="owner-sub">{html.escape(labels_disp)}</div>
|
||
<table class="kpi">{rows}</table>
|
||
</section>'''
|
||
|
||
|
||
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 = '<span class="muted small">전날 스냅샷 없음</span>'
|
||
else:
|
||
dcls = _profit_class(approx_day_pl)
|
||
dsign = '+' if approx_day_pl >= 0 else ''
|
||
day_pl_html = f'<span class="{dcls}">{dsign}{approx_day_pl:,}원</span><span class="muted small"> · 근사</span>'
|
||
|
||
deposit_pct_html = ''
|
||
if total_value:
|
||
dep_pct = (deposit / total_value) * 100
|
||
deposit_pct_html = f'<span class="muted small">({dep_pct:.1f}%)</span> '
|
||
deposit_locked_html = (
|
||
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
|
||
)
|
||
|
||
kpis = [
|
||
('총 평가금액', f'{total_value:,}원'),
|
||
('총 매입금액', f'{total_cost:,}원'),
|
||
('총 평가손익', f'<span class="{pcls}">{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)</span>'),
|
||
('당일 평가손익', day_pl_html),
|
||
('예수금', f'{deposit_pct_html}{deposit:,}원{deposit_locked_html}'),
|
||
('순자산', f'<span class="net">{total_net:,}원</span>'),
|
||
('보유 종목', f'{held_count}개'),
|
||
]
|
||
if traded_today_codes and (realized_pl or realized_fees):
|
||
rcls = _profit_class(realized_pl)
|
||
rsign = '+' if realized_pl >= 0 else ''
|
||
fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
|
||
kpis.append(('당일 실현손익', f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'))
|
||
|
||
rows_html = ''.join(
|
||
f'<tr><th>{html.escape(k)}</th><td>{v}</td></tr>' for k, v in kpis
|
||
)
|
||
sub_text = label_disp if label_disp is not None else label
|
||
return f'''<section class="owner-card">
|
||
<div class="owner-title-row">
|
||
<div class="owner-title">{html.escape(owner_label_text)}</div>
|
||
</div>
|
||
<div class="owner-sub">{html.escape(sub_text)}</div>
|
||
<table class="kpi">{rows_html}</table>
|
||
</section>'''
|
||
|
||
|
||
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none') -> 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) -> str:
|
||
dim_cls = ' ohlc-dim' if dim else ''
|
||
if not d:
|
||
return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
|
||
if key == 'price':
|
||
price = d.get('price', 0)
|
||
pct = 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'<span class="ohlc-cell-empty{dim_cls}">-</span>'
|
||
return f'<span class="ohlc-cell{dim_cls}"><span class="ohlc-price">{price:,}원</span></span>'
|
||
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'<span class="ohlc-cell-empty{dim_cls}">-</span>'
|
||
cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral')
|
||
sgn = '+' if pct >= 0 else ''
|
||
return (
|
||
f'<span class="ohlc-cell{dim_cls}"><span class="ohlc-price">{price:,}원</span>'
|
||
f'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span></span>'
|
||
)
|
||
|
||
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'),
|
||
]
|
||
krx_th_dim = ' ohlc-dim' if krx_dim else ''
|
||
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
|
||
parts = [
|
||
'<div class="ohlc-mini ohlc-dual">',
|
||
'<span class="ohlc-th"></span>',
|
||
f'<span class="ohlc-th{krx_th_dim}">KRX</span>',
|
||
f'<span class="ohlc-th{nxt_th_dim}">NXT</span>',
|
||
]
|
||
for lbl, key in rows:
|
||
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
|
||
parts.append(_cell(krx if has_krx else None, key, krx_dim))
|
||
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim))
|
||
parts.append('</div>')
|
||
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 '<span class="ohlc-pct neutral">-</span>'
|
||
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'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span>'
|
||
|
||
rows = [
|
||
('현재가', current),
|
||
('시가', o),
|
||
('고가', h),
|
||
('저가', l),
|
||
]
|
||
parts = [
|
||
'<div class="ohlc-mini">',
|
||
'<span class="ohlc-th"></span>',
|
||
'<span class="ohlc-th"></span>',
|
||
'<span class="ohlc-th">KRX</span>',
|
||
'<span class="ohlc-th">NXT</span>',
|
||
]
|
||
for lbl, p in rows:
|
||
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
|
||
parts.append(f'<span class="ohlc-price">{p:,}원</span>')
|
||
parts.append(_pct_cell(p, pred_krx))
|
||
parts.append(_pct_cell(p, pred_nxt))
|
||
parts.append('</div>')
|
||
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'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" class="candle-svg" aria-hidden="true">'
|
||
f'<line x1="{cx:.1f}" y1="{wick_top:.1f}" x2="{cx:.1f}" y2="{wick_bot:.1f}" stroke="{color}" stroke-width="1.5"/>'
|
||
f'<rect x="{body_x:.1f}" y="{body_top:.1f}" width="{body_w:.1f}" height="{body_h:.1f}" fill="{color}"/>'
|
||
f'</svg>'
|
||
)
|
||
|
||
|
||
_EMPTY_KV_PAIR = '<dt class="empty"></dt><dd class="empty"></dd>'
|
||
|
||
|
||
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'<span class="badge pending-buy" title="키움 미체결 매수 {buy:,}주 — 자세히보기에서 확인">💰</span>'
|
||
)
|
||
if sell:
|
||
parts.append(
|
||
f'<span class="badge pending-sell" title="키움 미체결 매도 {sell:,}주 — 자세히보기에서 확인">💰</span>'
|
||
)
|
||
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
|
||
|
||
|
||
def _pending_detail_html(r: dict) -> str:
|
||
"""자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시.
|
||
`r['pending_orders']` 가 ord별 dict 리스트.
|
||
레이아웃: dt(라벨, 컬러) / dd 2줄(상단=수량@가격, 하단=계좌·시각·주문번호 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']
|
||
|
||
def _row(o: dict, kind: str) -> str:
|
||
qty = o.get('qty', 0)
|
||
price = o.get('price', 0)
|
||
otype = o.get('order_type') or ''
|
||
price_str = f'{price:,}원 지정가' if price else (otype or '시장가')
|
||
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 '미체결 매도'
|
||
return (
|
||
f'<dt class="pending-{kind}-label">{label}</dt>'
|
||
f'<dd>'
|
||
f'<div class="num pending-line-main">{qty:,}주 @ {price_str}</div>'
|
||
f'<div class="muted small pending-line-meta">{" · ".join(meta_parts)}</div>'
|
||
f'</dd>'
|
||
)
|
||
|
||
pairs: list[str] = []
|
||
for o in buys:
|
||
pairs.append(_row(o, 'buy'))
|
||
for o in sells:
|
||
pairs.append(_row(o, 'sell'))
|
||
return f'<dl class="pending-detail">{"".join(pairs)}</dl>'
|
||
|
||
|
||
def _buy_rank_badge(buy_amount: int) -> str:
|
||
"""매입 총액 기반 등급 뱃지. 100/200/500/1000만원 경계로 1~5등급. 막대 stack(군대 계급 모티프)."""
|
||
if not buy_amount or buy_amount <= 0:
|
||
return ''
|
||
M = 10_000
|
||
if buy_amount <= 100 * M:
|
||
tier = 1
|
||
elif buy_amount <= 200 * M:
|
||
tier = 2
|
||
elif buy_amount <= 500 * M:
|
||
tier = 3
|
||
elif buy_amount <= 1000 * M:
|
||
tier = 4
|
||
else:
|
||
tier = 5
|
||
stripes = ''.join('<span class="stripe"></span>' for _ in range(tier))
|
||
title = f'매입 {buy_amount:,}원 · 등급 {tier}'
|
||
return f'<span class="rank-badge rank-{tier}" title="{title}" aria-label="등급 {tier}">{stripes}</span>'
|
||
|
||
|
||
def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False, key_suffix: str = '') -> str:
|
||
stock = html.escape(r['stock'])
|
||
code = html.escape(r.get('code', '') or '')
|
||
accounts = html.escape('+'.join(r.get('accounts', [])))
|
||
qty = r.get('qty', 0)
|
||
avg = r.get('avg', 0)
|
||
price = r.get('price', 0)
|
||
profit = r.get('profit', 0)
|
||
profit_rate = r.get('profit_rate', 0.0)
|
||
eval_value = r.get('eval_value', 0)
|
||
buy_amount = r.get('buy_amount', 0)
|
||
weight = (eval_value / total_value * 100) if total_value else 0.0
|
||
pcls = _profit_class(profit)
|
||
sign = '+' if profit >= 0 else ''
|
||
# 오늘 매매 별표 — 매수/매도 구분. 둘 다 발생 시 두 개 표시.
|
||
star_parts: list[str] = []
|
||
if r.get('tdy_buyq'):
|
||
star_parts.append(f'<span class="star buy" title="오늘 매수 {r.get("tdy_buyq",0):,}주">★</span>')
|
||
if r.get('tdy_sellq'):
|
||
star_parts.append(f'<span class="star sell" title="오늘 매도 {r.get("tdy_sellq",0):,}주">★</span>')
|
||
star = ''.join(star_parts)
|
||
day_change = r.get('day_change', 0)
|
||
day_change_pct = r.get('day_change_pct', 0.0)
|
||
|
||
candle_summary = ''
|
||
ohlc = r.get('ohlc') or {}
|
||
if ohlc:
|
||
mini = _render_single_candle(ohlc.get('o', 0), ohlc.get('h', 0), ohlc.get('l', 0), ohlc.get('c', 0), width=24, height=35)
|
||
if mini:
|
||
candle_summary = f'<div class="candle-mini">{mini}</div>'
|
||
|
||
j = r.get('journal') or {}
|
||
journal_lines: list[str] = []
|
||
summary_journal_html = ''
|
||
if j.get('buy_qty'):
|
||
journal_lines.append(
|
||
f'<dt>매수</dt><dd class="num">{j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</dd>'
|
||
)
|
||
if j.get('sell_qty'):
|
||
sell_pl = j.get('pl_amt', 0)
|
||
scls = _profit_class(sell_pl)
|
||
ssign = '+' if sell_pl >= 0 else ''
|
||
journal_lines.append(
|
||
f'<dt>매도</dt><dd class="num">{j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</dd>'
|
||
)
|
||
journal_lines.append(
|
||
f'<dt>실현손익</dt><dd class="num"><span class="{scls}">{ssign}{sell_pl:,}원 '
|
||
f'({ssign}{j["prft_rt"]:.2f}%)</span><span class="muted small"> · 수수료·세금 {j["cmsn_tax"]:,}원</span></dd>'
|
||
)
|
||
# 부분매도 케이스 — 기본 카드 뷰에서도 실현손익이 보이도록 별도 줄로 표시.
|
||
# 금액이 크면 비중과 같은 줄에서 폭이 터지므로 line2 를 한 줄 더 분리.
|
||
summary_journal_html = (
|
||
f'<div class="line2"><span class="muted small">매도 {j["sell_qty"]:,}주 실현 '
|
||
f'<span class="{scls}">{ssign}{sell_pl:,}원</span></span></div>'
|
||
)
|
||
|
||
pl_pairs: list[str] = [
|
||
f'<dt>평가손익</dt><dd class="num"><span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span></dd>',
|
||
]
|
||
if day_change:
|
||
dcls = _profit_class(day_change)
|
||
dsign = '+' if day_change >= 0 else ''
|
||
day_value = day_change * qty
|
||
dvsign = '+' if day_value >= 0 else ''
|
||
pl_pairs.append(
|
||
f'<dt>평가금액 변동</dt><dd class="num"><span class="{dcls}">{dvsign}{day_value:,}원</span></dd>'
|
||
)
|
||
pl_pairs.append(
|
||
f'<dt>당일 등락</dt><dd class="num"><span class="{dcls}">{dsign}{day_change:,}원 '
|
||
f'({dsign}{day_change_pct:.2f}%)</span></dd>'
|
||
)
|
||
# 가격 출처 마크 — cur_price 가 어느 시장 응답 가격과 일치하는지로 판정.
|
||
# NXT 미거래 종목(ohlc_nxt 빈 dict 또는 NXT price==KRX price)은 KRX 마크.
|
||
nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0)
|
||
krx_p = (r.get('ohlc_krx') or {}).get('price', 0)
|
||
if nxt_p and nxt_p == price and nxt_p != krx_p:
|
||
mark = 'NXT'
|
||
else:
|
||
mark = 'KRX'
|
||
mark_html = f'<span class="px-mark px-{mark.lower()}">{mark}</span>'
|
||
|
||
# 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')
|
||
_pred_krx = r.get('pred_close_krx', 0) or 0
|
||
if ohlc_krx_for_render and _pred_krx > 0:
|
||
ohlc_krx_for_render = dict(ohlc_krx_for_render)
|
||
ohlc_krx_for_render['prev_close'] = _pred_krx
|
||
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
|
||
ohlc_mini_html = _render_ohlc_mini_dual(
|
||
ohlc_krx_for_render,
|
||
ohlc_nxt_for_render,
|
||
active_market=mark.lower(),
|
||
)
|
||
|
||
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
|
||
# raw pred_close_krx 는 kt00018 결함으로 정규장 시작 전·마감 후 cur_price 와 같은 값이 들어와
|
||
# 등락률이 항상 0% 으로 깔리는 문제가 있어 사용 X.
|
||
d_pct_val = r.get('day_change_pct')
|
||
if show_day_change and isinstance(d_pct_val, (int, float)) and d_pct_val != 0:
|
||
d_sign = '+' if d_pct_val >= 0 else ''
|
||
d_cls = 'day-pct up' if d_pct_val > 0 else 'day-pct down'
|
||
price_html = f'{mark_html}{price:,}<span class="{d_cls}">{d_sign}{d_pct_val:.2f}%</span>'
|
||
else:
|
||
price_html = f'{mark_html}{price:,}'
|
||
|
||
chart_block = ''
|
||
if code:
|
||
# 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움.
|
||
# data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로.
|
||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||
|
||
left_pairs = [
|
||
f'<dt>종목코드</dt><dd class="muted">{code}</dd>',
|
||
f'<dt>현재가</dt><dd class="num">{price:,}원</dd>',
|
||
f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>',
|
||
]
|
||
right_pairs = [
|
||
f'<dt>보유수량</dt><dd class="num">{qty:,}주</dd>',
|
||
f'<dt>매입금액</dt><dd class="num">{buy_amount:,}원</dd>',
|
||
f'<dt>평가금액</dt><dd class="num">{eval_value:,}원</dd>',
|
||
]
|
||
dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
|
||
pl_pairs.extend(journal_lines)
|
||
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 '')
|
||
trade_btn = (
|
||
f'<div class="actions">'
|
||
f'{tag_add_btn}'
|
||
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
|
||
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
|
||
f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="SELL" title="매수/매도">💰 거래</button>'
|
||
f'</div>'
|
||
) if code else ''
|
||
tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
|
||
return f'''<details class="row {pcls} mode-held" data-row-key="{row_key}">
|
||
<summary>
|
||
<div class="left">
|
||
<div class="line1">{_buy_rank_badge(buy_amount)}<span class="stock">{stock}</span>{tag_chip}{star}<span class="code">{accounts}</span></div>
|
||
<div class="line2"><span class="badge held">보유 {qty:,}주</span>{_pending_badges_html(r)}<span class="muted small">비중 {weight:.2f}%</span></div>
|
||
{summary_journal_html}
|
||
</div>
|
||
<div class="right">
|
||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||
<div class="ref">평단 {avg:,}</div>
|
||
<div class="diff">{sign}{profit:,}<span class="pct">{sign}{profit_rate:.2f}%</span></div>
|
||
</div>
|
||
{candle_summary}
|
||
</summary>
|
||
<div class="detail">
|
||
<dl>{dl_inner}</dl>
|
||
<dl class="pl-summary">{pl_dl_inner}</dl>
|
||
{ohlc_mini_html}
|
||
{_pending_detail_html(r)}
|
||
{trade_btn}
|
||
{chart_block}
|
||
</div>
|
||
</details>'''
|
||
|
||
|
||
def _render_phantom_row(r: dict) -> str:
|
||
"""잔고에 없는 round-trip / 풀매도 거래. 실현손익만 표시."""
|
||
stock = html.escape(r['stock'])
|
||
code = html.escape(r.get('code', '') or '')
|
||
accounts = html.escape('+'.join(r.get('accounts', [])))
|
||
j = r.get('journal') or {}
|
||
pl = j.get('pl_amt', 0)
|
||
pcls = _profit_class(pl)
|
||
sign = '+' if pl >= 0 else ''
|
||
rate = j.get('prft_rt', 0.0)
|
||
pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
|
||
live_price = r.get('live_price') or 0
|
||
if live_price:
|
||
lpct = r.get('live_change_pct', 0.0)
|
||
lsign = '+' if lpct >= 0 else ''
|
||
lcls = _profit_class(lpct)
|
||
pairs.append(
|
||
f'<dt>현재가</dt><dd class="num">{live_price:,}원 '
|
||
f'<span class="{lcls} small">({lsign}{lpct:.2f}%)</span></dd>'
|
||
)
|
||
if j.get('sell_qty'):
|
||
pairs.append(
|
||
f'<dt>매도</dt><dd class="num">{j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</dd>'
|
||
)
|
||
pairs.append(
|
||
f'<dt>실현손익</dt><dd class="num"><span class="{pcls}">{sign}{pl:,}원 '
|
||
f'({sign}{rate:.2f}%)</span><span class="muted small"> · 수수료·세금 {j["cmsn_tax"]:,}원</span></dd>'
|
||
)
|
||
# 어제 보유 → 오늘 풀매도 케이스: 어제 스냅샷의 평단을 '매수 평단'으로 노출
|
||
prev_avg = j.get('prev_avg', 0)
|
||
if prev_avg > 0 and not j.get('buy_qty'):
|
||
pairs.append(
|
||
f'<dt>매수 평단</dt><dd class="num">{prev_avg:,}원<span class="muted small"> · 어제 잔고 기준</span></dd>'
|
||
)
|
||
if j.get('buy_qty'):
|
||
pairs.append(
|
||
f'<dt>매수</dt><dd class="num">{j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</dd>'
|
||
)
|
||
chart_block = ''
|
||
if code:
|
||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||
|
||
row_key = code or stock
|
||
kind_label = '단타' if j.get('buy_qty') else '전량매도'
|
||
return f'''<details class="row {pcls} mode-phantom" data-row-key="{row_key}">
|
||
<summary>
|
||
<div class="left">
|
||
<div class="line1"><span class="stock">{stock}</span><span class="badge phantom">당일정산</span>{_pending_badges_html(r)}<span class="code">{accounts}</span></div>
|
||
<div class="line2"><span class="muted small">{kind_label} · 매도 {j.get("sell_avg", 0):,}원 × {j.get("sell_qty", 0):,}주</span></div>
|
||
{f'<div class="line2"><span class="muted small">현재 {live_price:,}원</span></div>' if live_price else ''}
|
||
</div>
|
||
<div class="right">
|
||
<div class="price">실현 {sign}{pl:,}원<span class="caret" aria-hidden="true">▾</span></div>
|
||
<div class="diff"><span class="{pcls}">{sign}{rate:.2f}%</span></div>
|
||
</div>
|
||
</summary>
|
||
<div class="detail">
|
||
<dl>{''.join(pairs)}</dl>
|
||
{_pending_detail_html(r)}
|
||
{chart_block}
|
||
</div>
|
||
</details>'''
|
||
|
||
|
||
def _render_pending_buy_held_row(r: dict) -> str:
|
||
"""보유 종목 중 매수 미체결 걸린 행. 매수등록 섹션 (보유분) 카드.
|
||
summary는 매수 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력."""
|
||
stock = html.escape(r['stock'])
|
||
code = html.escape(r.get('code', '') or '')
|
||
accounts = html.escape('+'.join(r.get('accounts', [])))
|
||
qty = r.get('qty', 0)
|
||
avg = r.get('avg', 0)
|
||
price = r.get('price', 0)
|
||
buy_qty = r.get('pending_buy_qty', 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 = sum(o['qty'] for o in priced)
|
||
avg_buy = sum(o['qty'] * o['price'] for o in priced) // total if total else 0
|
||
else:
|
||
avg_buy = 0
|
||
|
||
# 매수 주문 평균가 vs 현재가 → 추가매수 거리
|
||
if avg_buy and price:
|
||
gap = price - avg_buy
|
||
gap_pct = (gap / avg_buy * 100) if avg_buy else 0.0
|
||
gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral')
|
||
gsign = '+' if gap >= 0 else ''
|
||
diff_html = f'<span class="{gcls}">{gsign}{gap:,.0f}<span class="pct">{gsign}{gap_pct:.2f}%</span></span>'
|
||
else:
|
||
diff_html = ''
|
||
|
||
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
|
||
|
||
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}주'
|
||
if avg_buy:
|
||
summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
|
||
if qty and avg:
|
||
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
|
||
summary_line2 += '</span>'
|
||
|
||
chart_block = ''
|
||
if code:
|
||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||
|
||
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
|
||
if price:
|
||
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
|
||
if avg:
|
||
detail_pairs.append(f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>')
|
||
if avg_buy:
|
||
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_buy:,}원</dd>')
|
||
if qty:
|
||
detail_pairs.append(f'<dt>보유 수량</dt><dd class="num">{qty:,}주</dd>')
|
||
detail_pairs.append(f'<dt>매수 대기</dt><dd class="num">{buy_qty:,}주</dd>')
|
||
|
||
row_key = (code or stock) + ':pending-buy'
|
||
return f'''<details class="row neutral mode-pending-buy" data-row-key="{row_key}">
|
||
<summary>
|
||
<div class="left">
|
||
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-buy" title="키움 미체결 매수">매수등록</span><span class="code">{accounts}</span></div>
|
||
<div class="line2">{summary_line2}</div>
|
||
</div>
|
||
<div class="right">
|
||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
|
||
</div>
|
||
</summary>
|
||
<div class="detail">
|
||
<dl>{''.join(detail_pairs)}</dl>
|
||
{_pending_detail_html(r)}
|
||
{chart_block}
|
||
</div>
|
||
</details>'''
|
||
|
||
|
||
def _render_pending_sell_held_row(r: dict) -> str:
|
||
"""보유 종목 중 매도 미체결 걸린 행. 매도등록 섹션 전용 카드.
|
||
summary는 매도 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력."""
|
||
stock = html.escape(r['stock'])
|
||
code = html.escape(r.get('code', '') or '')
|
||
accounts = html.escape('+'.join(r.get('accounts', [])))
|
||
qty = r.get('qty', 0)
|
||
avg = r.get('avg', 0)
|
||
price = r.get('price', 0)
|
||
sell_qty = r.get('pending_sell_qty', 0)
|
||
|
||
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'SELL']
|
||
priced = [o for o in orders if o.get('price', 0) > 0]
|
||
if priced:
|
||
total = sum(o['qty'] for o in priced)
|
||
avg_sell = sum(o['qty'] * o['price'] for o in priced) // total if total else 0
|
||
else:
|
||
avg_sell = 0
|
||
|
||
if avg_sell and avg:
|
||
gap = avg_sell - avg
|
||
gap_pct = (gap / avg * 100) if avg else 0.0
|
||
gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral')
|
||
gsign = '+' if gap >= 0 else ''
|
||
diff_html = f'<span class="{gcls}">{gsign}{gap:,.0f}<span class="pct">{gsign}{gap_pct:.2f}%</span></span>'
|
||
else:
|
||
diff_html = ''
|
||
|
||
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
|
||
|
||
summary_line2 = f'<span class="muted small">매도 대기 {sell_qty:,}주'
|
||
if avg_sell:
|
||
summary_line2 += f' · 평균 주문가 {avg_sell:,}원'
|
||
if qty and avg:
|
||
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
|
||
summary_line2 += '</span>'
|
||
|
||
chart_block = ''
|
||
if code:
|
||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||
|
||
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
|
||
if price:
|
||
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
|
||
if avg:
|
||
detail_pairs.append(f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>')
|
||
if avg_sell:
|
||
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_sell:,}원</dd>')
|
||
if qty:
|
||
detail_pairs.append(f'<dt>보유 수량</dt><dd class="num">{qty:,}주</dd>')
|
||
detail_pairs.append(f'<dt>매도 대기</dt><dd class="num">{sell_qty:,}주</dd>')
|
||
|
||
row_key = (code or stock) + ':pending-sell'
|
||
return f'''<details class="row neutral mode-pending-sell" data-row-key="{row_key}">
|
||
<summary>
|
||
<div class="left">
|
||
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-sell" title="키움 미체결 매도">매도등록</span><span class="code">{accounts}</span></div>
|
||
<div class="line2">{summary_line2}</div>
|
||
</div>
|
||
<div class="right">
|
||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
|
||
</div>
|
||
</summary>
|
||
<div class="detail">
|
||
<dl>{''.join(detail_pairs)}</dl>
|
||
{_pending_detail_html(r)}
|
||
{chart_block}
|
||
</div>
|
||
</details>'''
|
||
|
||
|
||
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:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
|
||
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'<span class="{dcls}">{dsign}{diff:,.0f}<span class="pct">{dsign}{dpct:.2f}%</span></span>'
|
||
else:
|
||
diff_html = ''
|
||
else:
|
||
price_html = '<span class="muted">-</span>'
|
||
diff_html = ''
|
||
|
||
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}주'
|
||
if avg_buy:
|
||
summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
|
||
summary_line2 += '</span>'
|
||
|
||
chart_block = ''
|
||
if code:
|
||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||
|
||
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
|
||
if isinstance(price, (int, float)) and price:
|
||
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
|
||
if avg_buy:
|
||
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_buy:,}원</dd>')
|
||
detail_pairs.append(f'<dt>매수 대기</dt><dd class="num">{buy_qty:,}주</dd>')
|
||
|
||
row_key = code or stock
|
||
return f'''<details class="row neutral mode-pending-unheld" data-row-key="{row_key}">
|
||
<summary>
|
||
<div class="left">
|
||
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-buy" title="키움 미체결 매수">매수등록</span><span class="code">{acc_text}</span></div>
|
||
<div class="line2">{summary_line2}</div>
|
||
</div>
|
||
<div class="right">
|
||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
|
||
</div>
|
||
</summary>
|
||
<div class="detail">
|
||
<dl>{''.join(detail_pairs)}</dl>
|
||
{_pending_detail_html(r)}
|
||
{chart_block}
|
||
</div>
|
||
</details>'''
|
||
|
||
|
||
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 _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False):
|
||
"""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 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개 들고 와야 caller 의 라이브 current_net 과 합쳐 2점 차트가 그려진다.
|
||
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) -> 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'<div class="net-chart" data-chart-days="{selected_days}" data-chart-unit="{html.escape(selected_unit)}" data-chart-mode="{html.escape(mode)}">'
|
||
f'<div class="chart-empty">{msg}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
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: 좌52 우16 상14 하34 (날짜라벨 + 상단 cf 라벨 영역).
|
||
W, H = 800, 360
|
||
L, R, T, B = 52, 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)]
|
||
grid_lines = []
|
||
for gv in grid_vals:
|
||
gy = y_at(int(gv))
|
||
grid_lines.append(f'<line class="grid" x1="{L}" x2="{L + iw}" y1="{gy:.2f}" y2="{gy:.2f}" />')
|
||
label = f'{int(gv) // 1_000_000:,}M' if abs(gv) >= 1_000_000 else f'{int(gv) // 1000:,}K'
|
||
grid_lines.append(f'<text class="axis" x="{L - 6}" y="{gy + 3.5:.2f}" text-anchor="end">{html.escape(label)}</text>')
|
||
|
||
# 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시.
|
||
# 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡).
|
||
zero_line = ''
|
||
if y_lo <= 0 <= y_hi:
|
||
zy = y_at(0)
|
||
zero_line = (
|
||
f'<line class="zero-line" x1="{L}" x2="{L + iw}" y1="{zy:.2f}" y2="{zy:.2f}" />'
|
||
)
|
||
|
||
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'<text class="axis" x="{x_at(i):.2f}" y="{H - 8:.2f}" text-anchor="middle">{html.escape(date_str)}</text>'
|
||
)
|
||
|
||
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'<line class="cf-line" x1="{cf_x:.2f}" x2="{cf_x:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" '
|
||
f'stroke="{cf_color}" stroke-width="1" stroke-dasharray="3 3" opacity="0.55"/>'
|
||
)
|
||
cf_markers.append(
|
||
f'<text class="cf-label" x="{cf_x:.2f}" y="{T - 2:.2f}" text-anchor="middle" '
|
||
f'fill="{cf_color}" font-size="14" font-weight="600">{html.escape(cf_label)}</text>'
|
||
)
|
||
|
||
# per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게.
|
||
# (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만)
|
||
period_seps = []
|
||
point_dots = []
|
||
for _i, (px, py) in enumerate(pts):
|
||
if per_period:
|
||
# 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색).
|
||
period_seps.append(
|
||
f'<line class="period-sep" x1="{px:.2f}" x2="{px:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" />'
|
||
)
|
||
_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'<circle class="pt-dot" cx="{px:.2f}" cy="{py:.2f}" r="2.6" fill="{_dot_color}"/>')
|
||
|
||
svg_parts = [
|
||
f'<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" class="net-chart-svg" role="img" '
|
||
f'aria-label="순자산 추이 {html.escape(range_label)}">',
|
||
'<defs>'
|
||
f'<linearGradient id="{fill_id}" x1="0" x2="0" y1="0" y2="1">'
|
||
f'<stop offset="0%" stop-color="{line_color}" stop-opacity="0.32"/>'
|
||
f'<stop offset="100%" stop-color="{line_color}" stop-opacity="0"/>'
|
||
'</linearGradient>'
|
||
'</defs>',
|
||
''.join(grid_lines),
|
||
zero_line,
|
||
''.join(period_seps),
|
||
]
|
||
if area_d:
|
||
svg_parts.append(f'<path class="area" d="{area_d}" fill="url(#{fill_id})"/>')
|
||
# 입출금 수직선은 line/area 위, dot 아래 — 추세선 위에 살짝 비치고 사용자 데이터 점은 가리지 않음.
|
||
if cf_markers:
|
||
svg_parts.append(''.join(cf_markers))
|
||
if baseline_dashed_d:
|
||
svg_parts.append(f'<path class="line dashed" d="{baseline_dashed_d}" stroke="{line_color}" />')
|
||
if solid_d:
|
||
svg_parts.append(f'<path class="line" d="{solid_d}" stroke="{line_color}" />')
|
||
if dashed_d:
|
||
svg_parts.append(f'<path class="line dashed" d="{dashed_d}" stroke="{line_color}" />')
|
||
if point_dots:
|
||
svg_parts.append(''.join(point_dots))
|
||
if has_live:
|
||
svg_parts.append(
|
||
f'<circle class="live-halo" cx="{last_x:.2f}" cy="{last_y:.2f}" r="6" fill="{line_color}" fill-opacity="0.18"/>'
|
||
)
|
||
svg_parts.append(
|
||
f'<circle class="{last_dot_class}" cx="{last_x:.2f}" cy="{last_y:.2f}" r="3.6" fill="{line_color}"/>'
|
||
)
|
||
# 전고점 dot — 그려진 라인(plot_values) 의 max 시점에 노란 마커. 마지막 포인트와 같으면 last_dot 으로 충분해 생략.
|
||
peak_i = max(range(len(plot_values)), key=lambda i: plot_values[i])
|
||
if peak_i != n - 1:
|
||
peak_x, peak_y = pts[peak_i]
|
||
svg_parts.append(
|
||
f'<circle class="peak-dot" cx="{peak_x:.2f}" cy="{peak_y:.2f}" r="3.6" fill="#fbbf24"/>'
|
||
)
|
||
# 신고가 마커 — 오늘(마지막) 그려진 값이 이전 모든 포인트를 strict 하게 초과하면 last dot 위에 금색 ring + 라벨.
|
||
# per_period면 '역대 최고 기간 손익', 아니면 누적/순자산 신고가.
|
||
if n >= 2 and plot_values[-1] > max(plot_values[:-1]):
|
||
svg_parts.append(
|
||
f'<circle class="ath-ring" cx="{last_x:.2f}" cy="{last_y:.2f}" r="7" fill="none" '
|
||
f'stroke="#fbbf24" stroke-width="1.6"/>'
|
||
)
|
||
_ath_label_y = max(last_y - 14, T + 12)
|
||
svg_parts.append(
|
||
f'<text class="ath-label" x="{last_x:.2f}" y="{_ath_label_y:.2f}" text-anchor="middle" '
|
||
f'fill="#fbbf24" font-size="14" font-weight="700">신고가</text>'
|
||
)
|
||
svg_parts.append(''.join(x_labels))
|
||
svg_parts.append(
|
||
'<g class="nw-hover" opacity="0" pointer-events="none">'
|
||
f'<line class="nw-hover-line" x1="0" x2="0" y1="{T}" y2="{T + ih}" />'
|
||
'<circle class="nw-hover-dot" cx="0" cy="0" r="3.6" />'
|
||
'</g>'
|
||
)
|
||
svg_parts.append('</svg>')
|
||
|
||
_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 = (
|
||
'<div class="net-chart-tip" aria-hidden="true">'
|
||
'<button type="button" class="tip-close" data-nw-step="clear" aria-label="닫기">✕</button>'
|
||
'<div class="tip-date"></div>'
|
||
f'<div class="tip-row"><span class="muted">{html.escape(_day_label)}</span><b class="tip-day">—</b></div>'
|
||
'<div class="tip-row"><span class="muted">누적 손익</span><b class="tip-total">—</b></div>'
|
||
f'<div class="tip-row tip-net-row"{_net_row_hidden}><span class="muted">순자산</span><b class="tip-net">—</b></div>'
|
||
# 입출금 행 — 클라이언트 JS가 cf_net 있을 때만 display 토글 (pnl 모드에선 영구 hidden).
|
||
f'<div class="tip-row tip-cf-row"{_cf_row_hidden}><span class="muted">입출금</span><b class="tip-cf">—</b></div>'
|
||
'</div>'
|
||
)
|
||
|
||
head_html = (
|
||
'<div class="net-chart-head">'
|
||
f'<span class="net-chart-title">{html.escape(title)}</span>'
|
||
f'<span class="net-chart-delta {delta_class}">{html.escape(delta_label)}</span>'
|
||
'</div>'
|
||
)
|
||
|
||
foot_html = (
|
||
'<div class="net-chart-foot">'
|
||
f'<span class="muted">{html.escape(range_label)}</span>'
|
||
'<span class="net-chart-foot-r">'
|
||
'<button type="button" class="nw-nav" data-nw-step="prev" aria-label="이전 날짜">◀</button>'
|
||
'<button type="button" class="nw-nav" data-nw-step="next" aria-label="다음 날짜">▶</button>'
|
||
f'<span class="net-chart-last">{html.escape(last_label_v)}</span>'
|
||
'</span>'
|
||
'</div>'
|
||
)
|
||
return (
|
||
f'<div class="net-chart" data-pts="{pts_attr}" data-chart-days="{selected_days}" data-chart-unit="{html.escape(selected_unit)}" data-chart-mode="{html.escape(mode)}">'
|
||
+ head_html
|
||
+ ''.join(svg_parts)
|
||
+ tip_html
|
||
+ foot_html
|
||
+ '</div>'
|
||
)
|
||
|
||
|
||
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 인자는 시그니처 호환 위해 보존 (호출처 영향 최소).
|
||
parts = [_render_owner_kpi(owner, d, owner_label_text)]
|
||
|
||
cash_lines: list[str] = []
|
||
for lb in d['labels']:
|
||
b = balances.get(lb)
|
||
if not b:
|
||
continue
|
||
lk = b.get('pending_buy_locked', 0) or 0
|
||
lock_html = f'<span class="muted small"> · 매수 묶임 {lk:,}원</span>' if lk > 0 else ''
|
||
cash_lines.append(
|
||
f'<li><span class="muted">[{html.escape(lb)}]</span> 예수금 {b["d2_entra"]:,}원{lock_html}</li>'
|
||
)
|
||
if cash_lines:
|
||
parts.append(
|
||
'<div class="section-label">계좌별 예수금</div>'
|
||
f'<ul class="cash-list">{"".join(cash_lines)}</ul>'
|
||
)
|
||
|
||
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'<div class="section-label">당일 매매<span class="count">{len(traded_held) + len(phantoms)}</span></div>'
|
||
)
|
||
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'<div class="section-label">매수등록<span class="count">{buy_total}</span></div>'
|
||
)
|
||
for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)):
|
||
parts.append(_render_pending_buy_held_row(r))
|
||
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'<div class="section-label">매도등록<span class="count">{len(pending_sell_held)}</span></div>'
|
||
)
|
||
for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)):
|
||
parts.append(_render_pending_sell_held_row(r))
|
||
|
||
# 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함.
|
||
# 같은 종목이 양쪽에 들어가니 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(
|
||
'<div class="holdings-toggle-wrap">'
|
||
'<div class="account-view-pane" data-account-view-pane="consolidated">'
|
||
f'<div class="section-label">보유 종목<span class="count">{held_count}</span></div>'
|
||
+ ('\n'.join(consolidated_rows_html) if consolidated_rows_html else '<div class="empty">보유 종목 데이터가 없습니다.</div>')
|
||
+ '</div>'
|
||
'<div class="account-view-pane" data-account-view-pane="by-account" hidden>'
|
||
f'<div class="section-label">보유 종목<span class="count">{acc_count}</span></div>'
|
||
+ ('\n'.join(by_account_rows_html) if by_account_rows_html else '<div class="empty">보유 종목 데이터가 없습니다.</div>')
|
||
+ '</div>'
|
||
'</div>'
|
||
)
|
||
|
||
if not held and not phantoms:
|
||
parts.append('<div class="empty">보유 종목 데이터가 없습니다.</div>')
|
||
|
||
return '\n'.join(parts)
|
||
|
||
|
||
_CSS = '''
|
||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||
html, body { margin: 0; padding: 0; overscroll-behavior-y: none; touch-action: manipulation; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 28px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||
|
||
/* 하단 고정 시장 지수 — 코스피/코스닥. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */
|
||
/* 증권사 스타일 무한 가로 스크롤. 같은 항목 세트를 2번 렌더 후 -50% 이동 → seamless loop. */
|
||
.market-ticker {
|
||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||
background: rgba(11,13,18,0.96);
|
||
backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px);
|
||
border-top: 1px solid #1f2330;
|
||
padding: 5px 0;
|
||
padding-bottom: calc(5px + env(safe-area-inset-bottom));
|
||
font-size: 10.5px;
|
||
line-height: 1.2;
|
||
font-variant-numeric: tabular-nums;
|
||
color: #e6e6e6;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
.ticker-track {
|
||
display: inline-flex;
|
||
width: max-content;
|
||
/* intro(5s linear): viewport 오른쪽 끝(100vw)에서 자연 위치(0)까지 일정 속도로 슬라이드 인 — 첫 로딩 시 KOSPI 잘림 방지.
|
||
ease-out은 시작 순간이 peak 속도라 "엄청 빠름" 인상을 줘서 linear로 고정.
|
||
scroll(20s linear infinite, 5s 지연): intro 끝난 직후 무한 가로 스크롤로 인계. translateX(0)에서 매끄럽게 연결. */
|
||
animation: ticker-intro 5s linear, ticker-scroll 20s linear 5s infinite;
|
||
will-change: transform;
|
||
}
|
||
.ticker-set { display: inline-flex; align-items: center; gap: 20px; flex-shrink: 0; }
|
||
.ticker-sep { display: inline-flex; align-items: center; padding: 0 22px; color: #4a5060; font-size: 14px; font-weight: 700; user-select: none; }
|
||
@keyframes ticker-intro {
|
||
from { transform: translateX(100vw); }
|
||
to { transform: translateX(0); }
|
||
}
|
||
@keyframes ticker-scroll {
|
||
from { transform: translateX(0); }
|
||
to { transform: translateX(-50%); }
|
||
}
|
||
/* PC 마우스 호버 시만 일시정지. 모바일 :active는 페이지 터치 후 잔류해서 ticker가 멈춰버리는 이슈로 제외. */
|
||
@media (hover: hover) {
|
||
.market-ticker:hover .ticker-track { animation-play-state: paused; }
|
||
}
|
||
.market-ticker .idx { display: inline-flex; align-items: baseline; gap: 4px; font-size: 10.5px; }
|
||
.market-ticker .idx-label { color: #8b8f9a; font-weight: 500; font-size: 10.5px; }
|
||
.market-ticker .idx-price { color: #f0f0f0; font-weight: 500; font-size: 10.5px; }
|
||
.market-ticker .idx-delta { font-weight: 400; font-size: 10.5px; }
|
||
.market-ticker .idx-delta.up { color: #ff4d5e; }
|
||
.market-ticker .idx-delta.down { color: #4d9bff; }
|
||
.market-ticker .idx-delta.flat { color: #8b8f9a; }
|
||
.market-ticker .idx-empty { color: #5a5f6c; font-size: 10px; padding: 0 14px; }
|
||
|
||
/* ── pull-to-refresh ── */
|
||
/* .page가 손가락 따라 translateY로 내려감 → topbar+컨텐츠 다 함께 내려가고 그 위로 빈공간 노출.
|
||
.page.dragging 일 때만 topbar sticky를 일시 해제(position:static)해서 wrapper 변환에 묶이게 함.
|
||
평상시(non-dragging)엔 topbar 그대로 sticky → 정상 스크롤 시 헤더 박혀있음.
|
||
.ptr은 .page 내부 absolute top:-50 → 평소엔 viewport 위에 숨어있다가 .page 내려가면 갭에 emerge. */
|
||
.page {
|
||
position: relative;
|
||
background: #0b0d12;
|
||
transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
}
|
||
/* will-change: transform이 항상 켜져있으면 .page가 containing block이 되어
|
||
자식 .topbar의 position:sticky가 viewport가 아닌 .page 좌표계에 갇혀 스크롤을 따라 올라간다.
|
||
드래그 시점에만 promote — GPU 가속이 필요한 순간에만 적용. */
|
||
.page.dragging { transition: none; will-change: transform; }
|
||
.page.dragging .topbar { position: static; }
|
||
.ptr {
|
||
position: absolute;
|
||
/* 노치에선 env(safe-area-inset-top)만큼 더 아래로 내려서 시계·카메라 영역 회피 */
|
||
top: calc(-52px + env(safe-area-inset-top, 0px));
|
||
left: 50%;
|
||
width: 40px; height: 40px; margin-left: -20px;
|
||
background: rgba(28, 34, 48, 0.95);
|
||
border: 1px solid #2a3142;
|
||
border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: #8b8f9a;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
z-index: 25; /* topbar(20)보다 위 — ptr이 topbar 영역 위로 떠올라 가려지지 않음 */
|
||
font-variant-numeric: tabular-nums;
|
||
transition: color 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.2s;
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||
}
|
||
.ptr .arrow { font-size: 17px; font-weight: 700; line-height: 1; }
|
||
.ptr .spinner {
|
||
display: none;
|
||
width: 18px; height: 18px;
|
||
border: 2px solid currentColor;
|
||
border-top-color: transparent;
|
||
border-radius: 50%;
|
||
}
|
||
.ptr.committed {
|
||
color: #ff4d5e;
|
||
border-color: #ff4d5e;
|
||
background: rgba(255, 77, 94, 0.20);
|
||
box-shadow: 0 4px 16px rgba(255, 77, 94, 0.35);
|
||
}
|
||
.ptr.spinning .arrow { display: none; }
|
||
.ptr.spinning .spinner { display: block; animation: ptr-spin 0.7s linear infinite; }
|
||
@keyframes ptr-spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.topbar {
|
||
position: sticky; top: 0; z-index: 20;
|
||
background: rgba(11,13,18,0.94); backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px);
|
||
border-bottom: 1px solid #1f2330;
|
||
}
|
||
header.top {
|
||
padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
|
||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||
}
|
||
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
|
||
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
|
||
/* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */
|
||
@keyframes refresh-flash {
|
||
0%, 20% { color: #ff4d5e; }
|
||
100% { color: #f0f0f0; }
|
||
}
|
||
@keyframes refresh-flash-meta {
|
||
0%, 20% { color: #ff4d5e; }
|
||
100% { color: #8b8f9a; }
|
||
}
|
||
.refresh {
|
||
flex: none; display: inline-flex; align-items: center; justify-content: center;
|
||
width: 36px; height: 36px; border-radius: 8px;
|
||
background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
|
||
font-size: 17px; text-decoration: none; user-select: none;
|
||
transition: background 0.15s, transform 0.1s;
|
||
}
|
||
.refresh:hover { background: #232a3b; }
|
||
.refresh:active { transform: scale(0.95); background: #2a3245; }
|
||
.refresh.spinning { background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e; }
|
||
|
||
.top-actions { display: flex; align-items: center; gap: 8px; flex: none; }
|
||
.auto-toggle {
|
||
flex: none; display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 0 10px; height: 36px; border-radius: 8px;
|
||
background: #1c2230; border: 1px solid #2a3142; color: #8b8f9a;
|
||
font: 600 12px/1 inherit;
|
||
font-variant-numeric: tabular-nums;
|
||
cursor: pointer; user-select: none;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||
}
|
||
.auto-toggle:hover { background: #232a3b; }
|
||
.auto-toggle:active { transform: scale(0.95); }
|
||
.auto-toggle .auto-dot {
|
||
width: 7px; height: 7px; border-radius: 50%;
|
||
background: #5a5f6c;
|
||
transition: background 0.15s, box-shadow 0.15s;
|
||
}
|
||
.auto-toggle[aria-pressed="true"] {
|
||
background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e;
|
||
}
|
||
.auto-toggle[aria-pressed="true"] .auto-dot {
|
||
background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.7);
|
||
animation: auto-pulse 1.5s ease-in-out infinite;
|
||
}
|
||
.auto-toggle.closed,
|
||
.auto-toggle.closed:hover {
|
||
background: #15171f; border-color: #1f2330; color: #5a5f6c;
|
||
cursor: not-allowed;
|
||
}
|
||
.auto-toggle.closed:active { transform: none; }
|
||
.auto-toggle.closed .auto-dot {
|
||
background: #3a3f4a; box-shadow: none; animation: none;
|
||
}
|
||
@keyframes auto-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.45; }
|
||
}
|
||
|
||
.section-label {
|
||
padding: 22px 18px 8px; margin-top: 10px;
|
||
font-size: 12px; font-weight: 600; letter-spacing: 0.05em; color: #8b8f9a;
|
||
border-top: 1px solid #2a3142;
|
||
}
|
||
/* row 바로 다음에 오는 section-label은 분리감 더 강조 — '당일 매매' 끝 → '보유 종목' 시작 경계. */
|
||
details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-color: #3a4154; }
|
||
.section-label .count { color: #5a5f6c; font-weight: 400; margin-left: 4px; }
|
||
|
||
.row { border-bottom: 1px solid #14171f; }
|
||
.row > summary {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 4px 16px;
|
||
padding: 8px 18px;
|
||
cursor: pointer;
|
||
list-style: none;
|
||
font-variant-numeric: tabular-nums;
|
||
user-select: none;
|
||
transition: background 0.1s;
|
||
}
|
||
/* candle-mini는 grid flow에서 빠지고 카드 row 우측 끝에 absolute 고정 — grid 충돌·implicit row 차단. */
|
||
.row > summary:has(.candle-mini) { position: relative; padding-right: 56px; }
|
||
.candle-mini { position: absolute; top: 50%; right: 14px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||
.row > summary::-webkit-details-marker { display: none; }
|
||
.row > summary::marker { display: none; }
|
||
.row:hover > summary { background: #11141c; }
|
||
.row[open] > summary { background: #14171f; }
|
||
|
||
.left { min-width: 0; display: flex; flex-direction: column; gap: 6px; justify-content: center; }
|
||
.right { text-align: right; display: flex; flex-direction: column; gap: 2px; align-items: flex-end; justify-content: center; }
|
||
|
||
.line1 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 4px; }
|
||
.stock { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.code { font-size: 11px; color: #5a5f6c; font-weight: 400; flex: none; }
|
||
|
||
.line2 { display: flex; align-items: center; gap: 8px; font-size: 12px; min-width: 0; }
|
||
|
||
.price { font-size: 18px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; display: flex; align-items: center; gap: 6px; }
|
||
.right .ref { font-size: 12px; color: #8b8f9a; font-weight: 400; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||
.diff { font-size: 13px; font-weight: 500; color: #8b8f9a; }
|
||
.diff .pct { font-size: 13px; margin-left: 4px; opacity: 0.95; }
|
||
|
||
.row.up .price, .row.up .diff { color: #ff4d5e; }
|
||
.row.down .price, .row.down .diff { color: #4a8cf0; }
|
||
|
||
.day-pct { font-size: 13px; font-weight: 500; }
|
||
.day-pct.up { color: #ff4d5e; }
|
||
.day-pct.down { color: #4a8cf0; }
|
||
.day-pct.neutral { color: #8b8f9a; }
|
||
.open-pct { display: inline-block; margin-left: 4px; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||
.open-pct.up { color: #ff4d5e; }
|
||
.open-pct.down { color: #4a8cf0; }
|
||
.open-pct.neutral { color: #8b8f9a; }
|
||
/* OHLC mini-table — 라벨/가격/시가대비%/전일대비% 4-col grid.
|
||
첫 줄에 시가/전일 컬럼 헤더가 한 번만 표시되고, 4행(현재가/시가/고가/저가)이 컬럼 단위로 정렬된다.
|
||
`시가` 행의 시가 컬럼은 자기 자신이라 `-` placeholder. */
|
||
.ohlc-mini { display: grid; grid-template-columns: max-content max-content 1fr 1fr; column-gap: 14px; row-gap: 4px; margin: 8px 0 4px; width: 100%; font-variant-numeric: tabular-nums; align-items: baseline; }
|
||
.ohlc-mini .ohlc-th { font-size: 10px; color: #8b8f9a; font-weight: 500; text-align: right; padding-bottom: 2px; border-bottom: 1px solid #1f2330; }
|
||
.ohlc-mini .ohlc-th:first-child, .ohlc-mini .ohlc-th:nth-child(2) { text-align: left; border-bottom: none; }
|
||
.ohlc-mini .ohlc-rl { color: #8b8f9a; font-size: 12px; }
|
||
.ohlc-mini .ohlc-price { color: #d6d6d6; font-size: 13px; font-weight: 500; text-align: left; }
|
||
.ohlc-mini .ohlc-pct { font-size: 12px; font-weight: 500; text-align: right; min-width: 56px; }
|
||
.ohlc-mini .ohlc-pct.up { color: #ff4d5e; }
|
||
.ohlc-mini .ohlc-pct.down { color: #4a8cf0; }
|
||
.ohlc-mini .ohlc-pct.neutral { color: #8b8f9a; }
|
||
/* 보유종목 자세히 보기 — KRX/NXT 두 거래소 분리 표시. 3-col grid (라벨/KRX셀/NXT셀). */
|
||
.ohlc-mini.ohlc-dual { grid-template-columns: max-content 1fr 1fr; column-gap: 16px; }
|
||
.ohlc-mini.ohlc-dual .ohlc-th { text-align: center; border-bottom: 1px solid #1f2330; }
|
||
.ohlc-mini.ohlc-dual .ohlc-th:first-child { text-align: left; border-bottom: none; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 8px; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell-empty { color: #5a5e69; text-align: right; font-size: 12px; }
|
||
/* 비활성 시장 컬럼 회색 처리 — 헤더·셀·가격·등락률 모두 흐리게. */
|
||
.ohlc-mini.ohlc-dual .ohlc-th.ohlc-dim { color: #4a4e58; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-price { color: #5a5e69; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-pct { color: #5a5e69 !important; }
|
||
.ohlc-mini.ohlc-dual .ohlc-cell-empty.ohlc-dim { color: #3a3e48; }
|
||
/* 미리보기 현재가 왼쪽 출처 마크 — NXT(파랑톤) / KRX(회색톤). 어두운 채도로 보조 정보 강조 X. */
|
||
.px-mark { font-size: 8px; font-weight: 600; margin-right: 4px; padding: 1px 3px; border-radius: 2px; background: #14171f; vertical-align: 2px; letter-spacing: 0.3px; }
|
||
.px-mark.px-nxt { color: #2d5a8a; }
|
||
.px-mark.px-krx { color: #4a4e58; }
|
||
|
||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
|
||
.dot-1 { background: #f5d423; color: rgba(245,212,35,0.5); }
|
||
.dot-2 { background: #f59023; color: rgba(245,144,35,0.55); }
|
||
.dot-3 { background: #ff4d5e; color: rgba(255,77,94,0.6); }
|
||
|
||
/* 매입 총액 등급 뱃지 — 군대 계급 모티프(가로 막대 1~5개). 노랑→진한빨강 그라데이션. */
|
||
.rank-badge { display: inline-flex; flex-direction: column; justify-content: center; gap: 1.5px; flex: none; padding: 0 1px; }
|
||
.rank-badge .stripe { width: 9px; height: 1.5px; border-radius: 1px; }
|
||
.rank-1 .stripe { background: #ffe082; box-shadow: 0 0 4px rgba(255,224,130,0.45); }
|
||
.rank-2 .stripe { background: #ffc107; box-shadow: 0 0 4px rgba(255,193,7,0.5); }
|
||
.rank-3 .stripe { background: #ff9800; box-shadow: 0 0 5px rgba(255,152,0,0.55); }
|
||
.rank-4 .stripe { background: #f57c00; box-shadow: 0 0 5px rgba(245,124,0,0.6); }
|
||
.rank-5 .stripe { background: #d32f2f; box-shadow: 0 0 6px rgba(211,47,47,0.65); }
|
||
|
||
.muted { color: #5a5f6c; font-weight: 400; }
|
||
|
||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 500; line-height: 1.6; font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; letter-spacing: 0.02em; }
|
||
.tag-chip { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; }
|
||
.tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; }
|
||
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
|
||
.btn-add-tag:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); }
|
||
.tag-quick-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||
.btn-tag-quick { padding: 4px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; color: #b8bcc6; background: #1a1e2a; border: 1px solid #2a2f3c; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; }
|
||
.btn-tag-quick:hover { background: #20242f; color: #f0f0f0; }
|
||
.btn-tag-quick.active { color: #ff6b78; background: rgba(255,77,94,0.14); border-color: rgba(255,77,94,0.50); }
|
||
.btn-tag-quick.active:hover { background: rgba(255,77,94,0.22); }
|
||
.badge.watching { background: #1a1e2a; color: #7a8090; }
|
||
.badge.held { background: #2a1820; color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); }
|
||
.badge.pending-buy { background: #2a1822; color: #ff9fb3; border: 1px solid rgba(255,140,170,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
|
||
.badge.pending-sell { background: #182030; color: #7fb8ff; border: 1px solid rgba(122,180,255,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
|
||
dl.pending-detail { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.08); grid-template-columns: 92px minmax(0, 1fr); }
|
||
.row.mode-held .detail dl.pending-detail { grid-template-columns: 92px minmax(0, 1fr); }
|
||
dl.pending-detail dt { font-weight: 600; letter-spacing: 0; white-space: nowrap; }
|
||
dl.pending-detail dt.pending-buy-label { color: #ff9fb3; }
|
||
dl.pending-detail dt.pending-sell-label { color: #7fb8ff; }
|
||
dl.pending-detail dd { line-height: 1.45; }
|
||
dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; }
|
||
dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||
|
||
.caret { color: #4a4f5a; font-size: 10px; transition: transform 0.2s; }
|
||
.row[open] .caret { transform: rotate(180deg); color: #8b8f9a; }
|
||
|
||
.detail {
|
||
padding: 6px 16px 16px;
|
||
background: #0d1018;
|
||
border-top: 1px solid #14171f;
|
||
font-size: 13px;
|
||
}
|
||
.detail dl { display: grid; grid-template-columns: 80px 1fr; gap: 6px 14px; margin: 6px 0 14px; }
|
||
.row.mode-held .detail dl {
|
||
grid-template-columns: 80px minmax(0, 1fr) 80px minmax(0, 1fr);
|
||
column-gap: 14px;
|
||
row-gap: 6px;
|
||
}
|
||
.row.mode-held .detail dt.empty,
|
||
.row.mode-held .detail dd.empty { visibility: hidden; }
|
||
.row.mode-held .detail dl.pl-summary {
|
||
grid-template-columns: 92px minmax(0, 1fr);
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid #1f2330;
|
||
}
|
||
.row.mode-held .detail dl.pl-summary dt { white-space: nowrap; }
|
||
.row.mode-held .detail dl.pl-summary .pl-divider { grid-column: 1 / -1; border-top: 1px solid #1f2330; margin: 4px 0; height: 0; }
|
||
@media (max-width: 640px) {
|
||
.row.mode-held .detail dl.pl-summary { grid-template-columns: 92px minmax(0, 1fr); }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.row.mode-held .detail dl {
|
||
grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
|
||
column-gap: 8px;
|
||
}
|
||
}
|
||
.detail dt { color: #8b8f9a; font-size: 12px; }
|
||
.detail dd { margin: 0; color: #d6d6d6; }
|
||
/* 워치리스트·관심종목 카드 본문 — 라벨/값 2-col grid (매수가/목표가/손절가/저장일·매입평단).
|
||
OHLC는 별도 `.ohlc-mini` 블록으로 분리. */
|
||
.detail .grid2 { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 12px; row-gap: 6px; margin: 6px 0 14px; }
|
||
.detail .grid2 > .lbl { color: #8b8f9a; font-size: 12px; white-space: nowrap; align-self: center; }
|
||
.detail .grid2 > .val { color: #d6d6d6; font-size: 13px; word-break: break-all; min-width: 0; align-self: center; }
|
||
.detail .grid2 > .val.num { font-variant-numeric: tabular-nums; }
|
||
.detail .grid2 > .val.muted { color: #8b8f9a; }
|
||
@media (max-width: 480px) {
|
||
.detail .grid2 { column-gap: 8px; row-gap: 5px; }
|
||
.detail .grid2 > .lbl { font-size: 11px; }
|
||
.detail .grid2 > .val { font-size: 12px; }
|
||
}
|
||
.detail .block { margin: 12px 0 0; }
|
||
.detail h3 { margin: 0 0 6px; font-size: 11px; color: #8b8f9a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||
.detail ul { margin: 0; padding-left: 18px; line-height: 1.6; color: #c9ccd3; }
|
||
.detail li { margin-bottom: 3px; }
|
||
.detail .video-link { color: #7aa2ff; text-decoration: none; }
|
||
.detail .video-link:hover { text-decoration: underline; }
|
||
.detail .block.chart { margin: 8px 0 14px; }
|
||
.detail .block.chart img { display: block; width: 100%; max-width: 700px; height: auto; border-radius: 6px; background: #fff; }
|
||
.detail .block.chart-svg { margin: 8px 0 14px; min-height: 200px; border-radius: 6px; overflow: hidden; }
|
||
.detail .block.chart-svg svg { width: 100%; height: auto; display: block; }
|
||
.detail .block.chart-svg .chart-loading { padding: 60px 0; text-align: center; color: #7a8493; font-size: 12px; }
|
||
.detail .block.chart-svg .chart-error { padding: 30px 12px; text-align: center; color: #ef4444; font-size: 12px; }
|
||
.detail .block.chart-svg .chart-tabs { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||
.detail .block.chart-svg .chart-tabs button { padding: 5px 12px; background: #1f2937; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; font-family: inherit; }
|
||
.detail .block.chart-svg .chart-tabs button:hover { color: #cfd5df; border-color: #3f4654; }
|
||
.detail .block.chart-svg .chart-tabs button.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: #fbbf24; }
|
||
.detail .block.chart-svg .chart-panel { display: none; }
|
||
.detail .block.chart-svg .chart-panel.active { display: block; }
|
||
.detail .block.chart-svg .chart-subtabs { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
|
||
.detail .block.chart-svg .chart-subtabs button { padding: 3px 9px; background: transparent; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 5px; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; }
|
||
.detail .block.chart-svg .chart-subtabs button:hover { color: #cfd5df; border-color: #3f4654; }
|
||
.detail .block.chart-svg .chart-subtabs button.active { background: rgba(52,211,153,0.10); color: #34d399; border-color: rgba(52,211,153,0.45); }
|
||
.detail .block.chart-svg .chart-subpanel { min-height: 200px; }
|
||
|
||
.actions {
|
||
margin-top: 14px; padding-top: 12px;
|
||
border-top: 1px solid #1a1e29;
|
||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||
}
|
||
.actions form { display: inline; margin: 0; }
|
||
.actions button {
|
||
font: inherit; cursor: pointer;
|
||
padding: 6px 14px; border-radius: 6px;
|
||
border: 1px solid #2a3142; background: transparent;
|
||
color: #b0b4be; font-size: 12px; font-weight: 500;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
}
|
||
.actions button:hover { background: #1c2030; }
|
||
.actions button:active { transform: scale(0.97); }
|
||
.actions .btn-delete { color: #ff8a96; border-color: rgba(255,77,94,0.32); }
|
||
.actions .btn-delete:hover { background: rgba(255,77,94,0.08); }
|
||
.actions .btn-alerts-reset { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
|
||
.actions .btn-alerts-reset:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
|
||
.alert-mark { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); vertical-align: middle; }
|
||
.badge.alerted { color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.alert-notice { margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; font-size: 12px; color: #ffd66e; background: rgba(255,214,110,0.08); border-left: 3px solid #ffd66e; }
|
||
.buy-raw { color: #c9ccd3; margin-bottom: 4px; }
|
||
.buy-level { color: #e6e6e6; line-height: 1.5; }
|
||
.buy-level + .buy-level { margin-top: 2px; }
|
||
.actions .btn-restore { color: #5cd99a; border-color: rgba(46,194,126,0.32); }
|
||
.actions .btn-restore:hover { background: rgba(46,194,126,0.08); }
|
||
.actions .btn-purge { color: #fff; background: #c43c3c; border-color: #c43c3c; }
|
||
.actions .btn-purge:hover { background: #d34a4a; border-color: #d34a4a; }
|
||
.actions .btn-edit { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
|
||
.actions .btn-edit:hover { background: rgba(201,204,211,0.06); border-color: #ff4d5e; color: #ff4d5e; }
|
||
.actions .small { font-size: 11px; margin-right: auto; }
|
||
|
||
.section-label.trash-label { color: #6c7180; }
|
||
.row.trash > summary { opacity: 0.5; }
|
||
.row.trash[open] > summary { opacity: 0.85; }
|
||
|
||
.empty { padding: 60px 18px; text-align: center; color: #6c7180; }
|
||
|
||
.add-trigger-wrap { padding: 20px 14px 28px; display: flex; justify-content: center; }
|
||
.btn-add-trigger { background: #14171f; color: #e6e6e6; border: 1px dashed #2f3543; border-radius: 10px; padding: 12px 22px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; }
|
||
.btn-add-trigger:hover { border-color: #ff4d5e; color: #ff4d5e; }
|
||
.btn-add-trigger:active { transform: translateY(1px); }
|
||
|
||
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: max(60px, calc(env(safe-area-inset-top) + 68px)) 20px 20px; }
|
||
.modal.modal-top { z-index: 1010; }
|
||
.modal.hidden { display: none; }
|
||
.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); }
|
||
.modal-box { position: relative; background: #0d1018; border: 1px solid #1f2330; border-radius: 12px; padding: 18px 18px 16px; width: 100%; max-width: 480px; max-height: calc(100dvh - env(safe-area-inset-top) - 96px); overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.45); }
|
||
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||
.modal-title { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: 0.02em; }
|
||
.modal-close { background: transparent; border: 0; color: #8b8f9a; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px; font-family: inherit; }
|
||
.modal-close:hover { color: #f0f0f0; }
|
||
.form-feedback { margin-top: 12px; }
|
||
.feedback-error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; border-radius: 8px; padding: 10px 12px; font-size: 13px; line-height: 1.5; }
|
||
.feedback-title { color: #c9ccd3; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
|
||
.candidate-list { display: flex; flex-direction: column; gap: 6px; }
|
||
.candidate-btn { display: flex; align-items: center; justify-content: space-between; gap: 10px; background: #14171f; border: 1px solid #1f2330; color: #e6e6e6; border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; text-align: left; }
|
||
.candidate-btn:hover { border-color: #ff4d5e; }
|
||
.candidate-btn:active { transform: translateY(1px); }
|
||
.candidate-btn .cand-code { color: #6c7180; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||
.modal-actions { display: flex; gap: 8px; margin-top: 14px; }
|
||
.modal-actions .info-analyze-link { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px; background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; text-decoration: none; font-family: inherit; }
|
||
.modal-actions .info-analyze-link:hover { background: rgba(251,191,36,0.16); border-color: #fbbf24; }
|
||
.modal-actions .info-analyze-link:active { transform: translateY(1px); }
|
||
.modal-actions .btn-cancel { flex: 1; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||
.modal-actions .btn-add { flex: 2; background: #ff4d5e; color: #fff; border: 0; border-radius: 8px; padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||
.modal-actions .btn-add:active,
|
||
.modal-actions .btn-cancel:active { transform: translateY(1px); }
|
||
.modal-head .btn-delete { background: transparent; color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); border-radius: 6px; padding: 4px 10px; font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit; }
|
||
.modal-head .btn-delete:hover { background: rgba(255,77,94,0.08); }
|
||
.modal-head .btn-delete:active { transform: translateY(1px); }
|
||
|
||
.add-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
|
||
.add-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
|
||
.add-form-grid input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
|
||
.add-form-grid input:focus { outline: none; border-color: #ff4d5e; }
|
||
@media (max-width: 480px) {
|
||
.add-form-grid { grid-template-columns: 1fr; }
|
||
.modal { padding: max(56px, calc(env(safe-area-inset-top) + 60px)) 12px 12px; }
|
||
.modal-box { padding: 16px 14px 14px; }
|
||
}
|
||
|
||
.loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 18px; color: #8b8f9a; font-size: 13px; }
|
||
.loading-spin { width: 16px; height: 16px; border: 2px solid #2a2e3a; border-top-color: #ff4d5e; border-radius: 50%; animation: behive-spin 0.8s linear infinite; }
|
||
@keyframes behive-spin { to { transform: rotate(360deg); } }
|
||
|
||
.actions .btn-trades { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
|
||
.actions .btn-trades:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
|
||
.actions .btn-order { color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); background: rgba(255,138,149,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||
.actions .btn-order:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
|
||
.actions .btn-order:active { transform: translateY(1px); }
|
||
.actions .btn-analyze { color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); background: rgba(251,191,36,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 3px; }
|
||
.actions .btn-analyze:hover { background: rgba(251,191,36,0.12); border-color: #fbbf24; }
|
||
.actions .btn-info { color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); background: rgba(125,211,168,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||
.actions .btn-info:hover { background: rgba(125,211,168,0.14); border-color: #7dd3a8; }
|
||
.actions .btn-info:active { transform: translateY(1px); }
|
||
.info-body { display: flex; flex-direction: column; gap: 4px; }
|
||
.info-grid { display: grid; grid-template-columns: max-content 1fr; gap: 7px 16px; align-items: baseline; }
|
||
.info-grid dt { color: #8b8f9a; font-size: 12px; }
|
||
.info-grid dd { margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; color: #e8eaed; }
|
||
.info-grid dd .up { color: #ff4d5e; } .info-grid dd .down { color: #6aa9ff; }
|
||
.info-sec-title { color: #6c7180; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; margin: 12px 0 4px; }
|
||
.info-sec-title:first-child { margin-top: 0; }
|
||
/* 기업정보 모달 탭 (요약/기업정보·가치/성장성·컨센서스/투자리포트) — 자산탭 sub-tab과 분리된 전용 클래스 */
|
||
.info-tabs { display: flex; gap: 2px; margin: 0 0 12px; border-bottom: 1px solid #1f2330; flex-wrap: wrap; }
|
||
.info-tabbtn { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 11px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; white-space: nowrap; }
|
||
.info-tabbtn:hover { color: #d6d8dd; }
|
||
.info-tabbtn.active { color: #f0f0f0; border-bottom-color: #ff4d5e; }
|
||
.info-tabpanel[hidden] { display: none; }
|
||
.info-q { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; margin-left: 4px; border-radius: 50%; border: 1px solid rgba(125,211,168,0.4); background: rgba(125,211,168,0.08); color: #7dd3a8; font-size: 10px; font-weight: 700; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; }
|
||
.info-q:hover { background: rgba(125,211,168,0.2); border-color: #7dd3a8; }
|
||
.info-q:active { transform: translateY(1px); }
|
||
.info-desc-box { max-width: 420px; }
|
||
.info-desc-body { font-size: 13px; line-height: 1.7; color: #d4d7dd; padding: 4px 2px 8px; }
|
||
/* 기업비교 바 */
|
||
.info-compare-bar { display: flex; flex-direction: column; gap: 6px; margin: 2px 0 10px; }
|
||
.info-search-row { display: flex; gap: 6px; }
|
||
.info-search-input { flex: 1; min-width: 0; background: #14171f; color: #e8eaed; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; }
|
||
.info-search-input:focus { outline: none; border-color: #7dd3a8; }
|
||
.info-search-btn { flex: 0 0 auto; background: rgba(125,211,168,0.1); color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||
.info-search-btn:hover { background: rgba(125,211,168,0.18); }
|
||
.info-symbols-select { width: 100%; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; color-scheme: dark; }
|
||
.info-search-results { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 10px; }
|
||
.info-search-results.hidden { display: none; }
|
||
.info-search-hit { background: #14171f; color: #e8eaed; border: 1px solid #2a2e3a; border-radius: 8px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; }
|
||
.info-search-hit:hover { border-color: #7dd3a8; color: #7dd3a8; }
|
||
/* 비교표 */
|
||
.info-cmp-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||
.info-cmp-table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
|
||
.info-cmp-table th, .info-cmp-table td { padding: 8px 10px; text-align: right; font-size: 13px; white-space: nowrap; border-bottom: 1px solid #1c2030; }
|
||
.info-cmp-table thead th { color: #e8eaed; font-weight: 700; border-bottom: 1px solid #2a2e3a; position: relative; }
|
||
.info-cmp-table .info-cmp-label { text-align: left; color: #8b8f9a; font-weight: 500; position: sticky; left: 0; background: #0d1018; z-index: 1; }
|
||
.info-cmp-table thead .info-cmp-label { background: #0d1018; }
|
||
.info-cmp-name { color: #e8eaed; }
|
||
.info-cmp-remove { margin-left: 6px; width: 16px; height: 16px; padding: 0; border-radius: 50%; border: 1px solid rgba(255,138,149,0.4); background: rgba(255,138,149,0.08); color: #ff8a95; font-size: 11px; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; }
|
||
.info-cmp-remove:hover { background: rgba(255,138,149,0.2); }
|
||
.badge.badge-trade { color: #6c7180; background: transparent; border: 1px solid rgba(120,124,135,0.20); cursor: pointer; font-family: inherit; }
|
||
.badge.badge-trade:hover { color: #c9ccd3; border-color: rgba(201,204,211,0.38); background: rgba(201,204,211,0.04); }
|
||
.modal-box-wide { max-width: 720px; }
|
||
.trade-summary { margin: -6px 0 10px; line-height: 1.5; font-size: 11px; }
|
||
.trade-body { overflow-x: auto; }
|
||
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||
.trade-table th, .trade-table td { padding: 7px 8px; border-bottom: 1px solid #1f2330; text-align: right; white-space: nowrap; }
|
||
.trade-table th { color: #8b8f9a; font-weight: 600; background: #0d1018; position: sticky; top: 0; }
|
||
.trade-table td.l, .trade-table th.l { text-align: left; }
|
||
.trade-table tr.seed td { background: rgba(255,255,255,0.025); color: #8b8f9a; }
|
||
.trade-table .pl-pos { color: #ff4d5e; }
|
||
.trade-table .pl-neg { color: #2c7be5; }
|
||
.trade-table .muted { color: #6c7180; }
|
||
.trade-table .side-buy { color: #ff4d5e; font-weight: 600; }
|
||
.trade-table .side-sell { color: #2c7be5; font-weight: 600; }
|
||
.trade-table .trade-stock-col { max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||
.trade-table td.l, .trade-table th.l { font-size: 10.5px; }
|
||
.trade-pager-wrap { display: block; }
|
||
.trade-pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 12px; user-select: none; }
|
||
.trade-pager .pg-btn { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 6px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; }
|
||
.trade-pager .pg-btn:hover:not(:disabled) { border-color: #6aa9ff; color: #6aa9ff; }
|
||
.trade-pager .pg-btn:active:not(:disabled) { transform: translateY(1px); }
|
||
.trade-pager .pg-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
.trade-pager .pg-indicator { color: #c9ccd3; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 56px; text-align: center; }
|
||
.trade-stock-col { cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||
.trade-list { list-style: none; padding: 0; margin: 0; }
|
||
.trade-item { padding: 8px 10px; border-bottom: 1px solid #1f2330; display: flex; flex-direction: column; gap: 3px; font-variant-numeric: tabular-nums; }
|
||
.trade-item.seed { background: rgba(255,255,255,0.025); color: #8b8f9a; }
|
||
.trade-item.placeholder { visibility: hidden; }
|
||
body.modal-open { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; }
|
||
.ti-row1 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; color: #8b8f9a; }
|
||
.ti-meta { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
|
||
.ti-row2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 13px; color: #e6e6e6; }
|
||
.ti-data { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
|
||
.ti-side { font-weight: 600; }
|
||
.ti-side.side-buy { color: #ff4d5e; }
|
||
.ti-side.side-sell { color: #2c7be5; }
|
||
.ti-pl { font-weight: 600; font-size: 12px; flex: 0 0 auto; white-space: nowrap; }
|
||
.ti-pl.pl-pos { color: #ff4d5e; }
|
||
.ti-pl.pl-neg { color: #2c7be5; }
|
||
.ti-pl.muted { color: #6c7180; }
|
||
.ti-stock { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ti-row1 .ti-stock { color: #c9ccd3; font-weight: 500; }
|
||
.ti-sep { color: #4a4f5a; }
|
||
.ti-label { color: #6c7180; font-size: 11px; font-weight: 500; margin-right: 2px; }
|
||
.ti-stock-line { color: #c9ccd3; font-weight: 600; font-size: 12.5px; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 2px; }
|
||
.stock-name-tip {
|
||
position: fixed; z-index: 2000; display: none;
|
||
max-width: 260px; padding: 7px 11px;
|
||
background: #1c2230; color: #f0f0f0;
|
||
font-size: 13px; font-weight: 600; line-height: 1.35;
|
||
border: 1px solid #2d3340; border-radius: 8px;
|
||
box-shadow: 0 6px 20px rgba(0,0,0,0.55);
|
||
pointer-events: none; word-break: keep-all; white-space: normal;
|
||
}
|
||
.stock-name-tip.show { display: block; }
|
||
.stock-name-tip::after {
|
||
content: ''; position: absolute; left: 50%; bottom: -6px;
|
||
width: 0; height: 0; transform: translateX(-50%);
|
||
border-left: 6px solid transparent; border-right: 6px solid transparent;
|
||
border-top: 6px solid #1c2230;
|
||
}
|
||
.stock-name-tip.below::after { top: -6px; bottom: auto; border-top: 0; border-bottom: 6px solid #1c2230; }
|
||
.owner-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 2px; }
|
||
.btn-trades-all { background: #14171f; color: #c9ccd3; border: 1px solid rgba(201,204,211,0.28); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; flex: 0 0 auto; }
|
||
.btn-trades-all:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
|
||
.btn-trades-all:active { transform: translateY(1px); }
|
||
.trade-tabs { display: flex; gap: 2px; margin: 0 0 10px; border-bottom: 1px solid #1f2330; }
|
||
.trade-tab { background: transparent; border: 0; padding: 8px 14px; color: #8b8f9a; font-size: 12px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||
.trade-tab:hover { color: #c9ccd3; }
|
||
.trade-tab[aria-selected="true"] { color: #c9ccd3; border-bottom-color: #ff4d5e; }
|
||
.trade-tab-panel[hidden] { display: none; }
|
||
.trade-group-sub { font-variant-numeric: tabular-nums; margin: 0 0 8px; font-size: 11px; }
|
||
|
||
@media (max-width: 600px) {
|
||
.row > summary { padding: 8px 14px; }
|
||
.stock { font-size: 13px; }
|
||
.code { font-size: 10px; }
|
||
.price { font-size: 17px; }
|
||
.diff { font-size: 12px; }
|
||
.diff .pct { font-size: 12px; }
|
||
.line2 { gap: 6px; font-size: 11px; }
|
||
.badge { font-size: 10px; padding: 2px 7px; }
|
||
.detail { padding: 6px 14px 14px; }
|
||
.detail dl { grid-template-columns: 72px 1fr; }
|
||
.section-label { padding: 18px 14px 6px; }
|
||
details.row + .section-label { padding-top: 20px; }
|
||
}
|
||
|
||
/* ── 탭 (URL fragment + html[data-tab] attribute, FOUC 방지) ── */
|
||
/* head의 inline script가 body 렌더 전에 location.hash → documentElement.dataset.tab 동기화.
|
||
첫 paint 시점에 이미 올바른 탭이 표시되어 새로고침 깜빡임 없음.
|
||
섹션은 data-tab 속성으로 매칭 (id 충돌 회피). 탭 anchor 타겟은 없으므로 클릭해도 스크롤 안 일어남. */
|
||
nav.tabs {
|
||
display: flex; gap: 0;
|
||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
nav.tabs::-webkit-scrollbar { display: none; }
|
||
nav.tabs a.tab-label {
|
||
flex: none;
|
||
padding: 12px 16px;
|
||
cursor: pointer; user-select: none;
|
||
font-size: 13px; font-weight: 500; color: #8b8f9a;
|
||
border-bottom: 2px solid transparent;
|
||
white-space: nowrap;
|
||
transition: color 0.12s, border-color 0.12s;
|
||
font-variant-numeric: tabular-nums;
|
||
text-decoration: none;
|
||
}
|
||
nav.tabs a.tab-label:hover { color: #c9ccd3; }
|
||
.tab-content { display: none; }
|
||
/* 짧은 탭(자산정보 등)도 viewport를 채워 모바일 Safari 주소창 토글이 일관되게 동작하도록.
|
||
ticker(fixed bottom) 위치가 다른 탭과 시각적으로 동일해진다. */
|
||
section.tab-content { min-height: calc(100dvh - 110px); }
|
||
@supports not (height: 100dvh) {
|
||
section.tab-content { min-height: calc(100vh - 110px); }
|
||
}
|
||
|
||
/* ── 계좌현황 패널 ── */
|
||
.owner-card {
|
||
margin: 14px 14px 8px;
|
||
padding: 14px 16px;
|
||
background: #11141c;
|
||
border: 1px solid #1f2330;
|
||
border-radius: 10px;
|
||
}
|
||
.owner-title { font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; }
|
||
.owner-sub { font-size: 11px; color: #5a5f6c; margin-top: 2px; margin-bottom: 10px; }
|
||
.owner-card table.kpi { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||
.owner-card table.kpi th {
|
||
text-align: left; font-weight: 400; color: #8b8f9a;
|
||
padding: 4px 8px 4px 0; font-size: 12px; width: 110px; vertical-align: top;
|
||
white-space: nowrap;
|
||
}
|
||
.owner-card table.kpi td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; }
|
||
.owner-card table.kpi td .net { color: #f0f0f0; font-weight: 600; }
|
||
.owner-card table.kpi td .up { color: #ff4d5e; font-weight: 600; }
|
||
.owner-card table.kpi td .down { color: #4a8cf0; font-weight: 600; }
|
||
.owner-card table.kpi td .neutral { color: #c9ccd3; }
|
||
|
||
.cash-list { list-style: none; margin: 4px 14px 12px; padding: 8px 12px; background: #0d1018; border: 1px solid #14171f; border-radius: 8px; font-size: 12px; color: #c9ccd3; font-variant-numeric: tabular-nums; }
|
||
.cash-list li { padding: 2px 0; }
|
||
|
||
.detail dl .num { font-variant-numeric: tabular-nums; }
|
||
.detail .up { color: #ff4d5e; font-weight: 600; }
|
||
.detail .down { color: #4a8cf0; font-weight: 600; }
|
||
.detail .neutral { color: #c9ccd3; }
|
||
|
||
.star { color: #d97706; font-size: 12px; flex: none; }
|
||
.star.buy { color: #ff4d5e; }
|
||
.star.sell { color: #4a8cf0; }
|
||
.star.buy + .star.sell { margin-left: 2px; }
|
||
.badge.phantom { background: #2a2618; color: #f5b441; border: 1px solid rgba(245,180,65,0.3); }
|
||
|
||
.row.mode-held .right .ref { color: #8b8f9a; }
|
||
.row.mode-phantom .price { font-size: 15px; }
|
||
|
||
.net-chart {
|
||
margin: 12px 14px 0;
|
||
padding: 10px 12px 8px;
|
||
background: #11141c;
|
||
border: 1px solid #1f2330;
|
||
border-radius: 10px;
|
||
position: relative;
|
||
}
|
||
.net-chart-head {
|
||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.net-chart-title { font-size: 12px; color: #8b8f9a; letter-spacing: -0.01em; }
|
||
.net-chart-delta { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.net-chart-delta.up { color: #ff4d5e; }
|
||
.net-chart-delta.down { color: #4a8cf0; }
|
||
.net-chart-options {
|
||
display: flex; flex-wrap: nowrap; gap: 6px;
|
||
margin-top: 8px;
|
||
overflow-x: auto; overflow-y: hidden;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: thin; scrollbar-color: #2a2e36 transparent;
|
||
padding-bottom: 2px;
|
||
}
|
||
.net-chart-options::-webkit-scrollbar { height: 4px; }
|
||
.net-chart-options::-webkit-scrollbar-track { background: transparent; }
|
||
.net-chart-options::-webkit-scrollbar-thumb { background: #2a2e36; border-radius: 2px; }
|
||
/* 단위 토글(일/주/월/연) — 기간 토글 바로 아래에 분리 표시. */
|
||
.net-chart-options.nw-units { margin-top: 4px; }
|
||
.nw-chip {
|
||
appearance: none; background: transparent; color: #c9ccd3;
|
||
border: 1px solid #2a2e36; border-radius: 999px;
|
||
padding: 5px 12px; font-size: 11px; cursor: pointer;
|
||
font-variant-numeric: tabular-nums; line-height: 1;
|
||
flex: none; white-space: nowrap;
|
||
}
|
||
.nw-chip:hover, .nw-chip:active { border-color: #3a3f4a; color: #f0f0f0; }
|
||
.nw-chip.active { background: #2a2e36; color: #f0f0f0; border-color: #4a4f5a; font-weight: 600; }
|
||
.net-chart-foot-r { display: inline-flex; align-items: center; gap: 6px; }
|
||
.nw-nav {
|
||
appearance: none; background: transparent; color: #c9ccd3;
|
||
border: 1px solid #2a2e36; border-radius: 4px;
|
||
width: 22px; height: 22px; padding: 0;
|
||
font-size: 9px; line-height: 1; cursor: pointer;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
}
|
||
.nw-nav:hover, .nw-nav:active { color: #f0f0f0; border-color: #3a3f4a; background: #1a1d24; }
|
||
.net-chart-svg {
|
||
display: block; width: 100%; height: auto;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||
touch-action: pan-y;
|
||
cursor: crosshair;
|
||
}
|
||
.net-chart-svg .grid { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; }
|
||
.net-chart-svg .period-sep { stroke: #4a5266; stroke-width: 1; stroke-dasharray: 2 4; opacity: 0.85; }
|
||
.net-chart-svg .zero-line { stroke: #8b93a7; stroke-width: 1.2; stroke-dasharray: 5 3; opacity: 0.9; }
|
||
.net-chart-svg .pt-dot { stroke: #0b0d12; stroke-width: 1.2; }
|
||
.net-chart-svg .axis { fill: #6b7280; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||
.net-chart-svg .area { stroke: none; }
|
||
.net-chart-svg .line { fill: none; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; }
|
||
.net-chart-svg .line.dashed { stroke-dasharray: 5 4; stroke-opacity: 0.85; }
|
||
.net-chart-svg .last-dot { stroke: #0b0d12; stroke-width: 1.5; }
|
||
.net-chart-svg .peak-dot { stroke: #0b0d12; stroke-width: 1.5; }
|
||
.net-chart-svg .last-dot.live { stroke: #0b0d12; stroke-width: 2; animation: live-pulse 1.6s ease-in-out infinite; }
|
||
.net-chart-svg .live-halo { animation: live-halo 1.6s ease-in-out infinite; }
|
||
.net-chart-svg .ath-ring { animation: ath-pulse 1.8s ease-in-out infinite; }
|
||
.net-chart-svg .ath-label { paint-order: stroke; stroke: #0b0d12; stroke-width: 2.5; stroke-linejoin: round; }
|
||
@keyframes live-pulse {
|
||
0%, 100% { r: 3.6; }
|
||
50% { r: 4.4; }
|
||
}
|
||
@keyframes live-halo {
|
||
0%, 100% { fill-opacity: 0.18; r: 6; }
|
||
50% { fill-opacity: 0.32; r: 8; }
|
||
}
|
||
@keyframes ath-pulse {
|
||
0%, 100% { r: 7; stroke-opacity: 0.95; }
|
||
50% { r: 8.5; stroke-opacity: 0.55; }
|
||
}
|
||
.net-chart-svg .nw-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
|
||
.net-chart-svg .nw-hover-dot { fill: #f0f0f0; stroke: #0b0d12; stroke-width: 1.5; }
|
||
.net-chart-tip {
|
||
position: absolute;
|
||
top: 30px;
|
||
pointer-events: none;
|
||
background: rgba(20, 22, 28, 0.94);
|
||
border: 1px solid #2a2e36;
|
||
border-radius: 6px;
|
||
padding: 6px 24px 6px 8px;
|
||
font-size: 11px;
|
||
color: #d6d8dd;
|
||
opacity: 0;
|
||
transition: opacity 0.12s ease;
|
||
white-space: nowrap;
|
||
z-index: 5;
|
||
min-width: 140px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||
}
|
||
.tip-close {
|
||
position: absolute; top: 2px; right: 2px;
|
||
appearance: none; background: transparent; border: none;
|
||
color: #6b7280; font-size: 11px; line-height: 1;
|
||
width: 18px; height: 18px; padding: 0; cursor: pointer;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
border-radius: 3px;
|
||
pointer-events: auto;
|
||
}
|
||
.tip-close:hover, .tip-close:active { color: #f0f0f0; background: rgba(255,255,255,0.08); }
|
||
.net-chart-tip .tip-date { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 4px; font-size: 11px; }
|
||
.net-chart-tip .tip-row { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; }
|
||
.net-chart-tip .tip-row[hidden] { display: none; }
|
||
.net-chart-tip .tip-row + .tip-row { margin-top: 2px; }
|
||
.net-chart-tip .tip-row .muted { color: #6b7280; font-weight: 400; }
|
||
.net-chart-tip .tip-row b { font-weight: 600; }
|
||
.net-chart-tip .up { color: #ff4d5e; }
|
||
.net-chart-tip .down { color: #4a8cf0; }
|
||
.net-chart-tip .neutral { color: #c9ccd3; }
|
||
.net-chart-foot {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
margin-top: 2px; font-size: 11px;
|
||
}
|
||
.net-chart-foot .muted { color: #6b7280; }
|
||
.net-chart-foot .net-chart-last { color: #f0f0f0; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.chart-empty {
|
||
margin: 12px 14px 0;
|
||
padding: 12px;
|
||
background: #11141c;
|
||
border: 1px solid #1f2330;
|
||
border-radius: 10px;
|
||
font-size: 12px; color: #8b8f9a; text-align: center;
|
||
}
|
||
/* 시장 상황 카드 — 자산정보 탭의 시장정보 sub-tab 내부 (ADR + 투자자별 매매). */
|
||
.market-card {
|
||
margin: 14px 14px 8px;
|
||
padding: 14px 16px;
|
||
background: #11141c;
|
||
border: 1px solid #1f2330;
|
||
border-radius: 10px;
|
||
}
|
||
.market-card-error {
|
||
font-size: 12px; color: #8b8f9a; text-align: center; padding: 12px;
|
||
}
|
||
.market-card-title {
|
||
font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em;
|
||
margin-bottom: 10px;
|
||
display: flex; align-items: baseline; gap: 8px;
|
||
}
|
||
.market-card .market-date {
|
||
font-size: 11px; color: #5a5f6c; font-weight: 400; font-variant-numeric: tabular-nums;
|
||
}
|
||
.market-refresh-btn {
|
||
margin-left: auto; padding: 2px 9px; background: #1a1d24; color: #c9ccd3; border: 1px solid #2a2f3a;
|
||
border-radius: 999px; cursor: pointer; font-family: inherit; font-size: 13px; line-height: 1;
|
||
font-weight: 600; transition: background 0.15s, color 0.15s;
|
||
}
|
||
.market-refresh-btn:hover, .market-refresh-btn:active { background: #2a2f3a; color: #f0f0f0; }
|
||
.market-refresh-btn.spinning { animation: market-spin 0.6s linear infinite; }
|
||
@keyframes market-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||
.market-idx-info {
|
||
margin-left: 6px; padding: 0 5px; background: transparent; color: #6b7280; border: 1px solid #2a2f3a;
|
||
border-radius: 50%; font-family: inherit; font-size: 10px; line-height: 16px; cursor: pointer; vertical-align: middle;
|
||
font-weight: 600; transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||
}
|
||
.market-idx-info:hover { color: #ff8a95; border-color: #ff8a95; background: rgba(255,138,149,0.08); }
|
||
.idx-info-body {
|
||
padding: 14px 16px; font-size: 14px; color: #c9ccd3; line-height: 1.6; letter-spacing: -0.01em;
|
||
}
|
||
.idx-info-stats {
|
||
display: flex; align-items: baseline; gap: 12px; padding-bottom: 10px;
|
||
margin-bottom: 10px; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums;
|
||
}
|
||
.idx-info-price { font-size: 22px; font-weight: 700; color: #f0f0f0; }
|
||
.idx-info-change { font-size: 14px; font-weight: 600; }
|
||
.idx-info-change.up { color: #ff4d5e; }
|
||
.idx-info-change.down { color: #4a8cf0; }
|
||
.idx-info-change.flat { color: #8b8f9a; }
|
||
.idx-info-desc {
|
||
font-size: 13px; color: #c9ccd3; white-space: pre-line; line-height: 1.7;
|
||
}
|
||
.idx-info-chart {
|
||
margin: 0 0 12px; padding: 8px 10px; background: #0e1117; border: 1px solid #1f2330; border-radius: 8px;
|
||
position: relative;
|
||
}
|
||
.idx-chart-svg { display: block; width: 100%; height: auto; cursor: crosshair; }
|
||
.idx-chart-svg .idx-axis { fill: #6b7280; font-size: 9px; font-variant-numeric: tabular-nums; }
|
||
.idx-chart-svg .idx-grid { stroke: #1f2330; stroke-width: 1; stroke-dasharray: 2 3; }
|
||
.idx-chart-svg .idx-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.75; }
|
||
.idx-chart-svg .idx-hover-dot { stroke: #0b0d12; stroke-width: 1.5; }
|
||
.idx-chart-tip {
|
||
position: absolute; top: 6px; right: 14px; background: #14171f; border: 1px solid #2a2f3a;
|
||
border-radius: 6px; padding: 4px 8px; font-size: 11px; color: #f0f0f0;
|
||
display: flex; gap: 8px; align-items: center; font-variant-numeric: tabular-nums;
|
||
}
|
||
.idx-chart-tip[hidden] { display: none; }
|
||
.idx-chart-tip .tip-d { color: #8b8f9a; pointer-events: none; }
|
||
.idx-chart-tip .tip-v { font-weight: 600; pointer-events: none; }
|
||
.idx-chart-tip .tip-close {
|
||
position: static; width: auto; height: auto;
|
||
background: transparent; color: #8b8f9a; border: 0; padding: 0 4px; cursor: pointer;
|
||
font-family: inherit; font-size: 14px; line-height: 1; margin-left: 4px;
|
||
display: inline-flex; align-items: center;
|
||
}
|
||
.idx-chart-tip .tip-close:hover { color: #ff8a95; background: transparent; }
|
||
.idx-chart-loading, .idx-chart-err {
|
||
padding: 24px 0; text-align: center; font-size: 12px; color: #8b8f9a;
|
||
}
|
||
.idx-chart-err { color: #ff8a95; }
|
||
.idx-chart-foot {
|
||
display: flex; justify-content: space-between; margin-top: 6px;
|
||
font-size: 11px; color: #5a5f6c; font-variant-numeric: tabular-nums;
|
||
}
|
||
.market-section { margin-top: 10px; }
|
||
.market-section:first-of-type { margin-top: 0; }
|
||
.market-section-head {
|
||
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
|
||
margin-bottom: 4px;
|
||
}
|
||
.market-section-label { font-size: 12px; color: #d6d8dd; font-weight: 600; }
|
||
.market-section-hint { font-size: 10px; color: #5a5f6c; }
|
||
table.market-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||
table.market-table th {
|
||
text-align: left; font-weight: 400; color: #8b8f9a;
|
||
padding: 4px 8px 4px 0; font-size: 11px; vertical-align: middle;
|
||
}
|
||
table.market-table td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; }
|
||
table.market-table thead th { text-align: right; font-size: 10px; color: #6b7280; padding-bottom: 2px; }
|
||
table.market-table thead th:first-child { text-align: left; }
|
||
table.market-table .up { color: #ff4d5e; font-weight: 600; }
|
||
table.market-table .down { color: #4a8cf0; font-weight: 600; }
|
||
table.market-table .flat { color: #c9ccd3; }
|
||
table.market-table .neutral { color: #8b8f9a; }
|
||
table.market-adr th { width: 64px; }
|
||
table.market-adr td.adr-val { width: 88px; }
|
||
table.market-adr td.adr-val b { font-size: 14px; }
|
||
table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
|
||
.adr-tag {
|
||
display: inline-block; padding: 1px 6px; border-radius: 999px;
|
||
font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
|
||
margin-left: 4px; vertical-align: middle;
|
||
}
|
||
.adr-tag.up { background: rgba(255,77,94,0.16); color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); }
|
||
.adr-tag.down { background: rgba(74,140,240,0.16); color: #8db4f4; border: 1px solid rgba(74,140,240,0.3); }
|
||
.adr-tag.flat { background: #1a1d24; color: #8b8f9a; border: 1px solid #2a2e36; }
|
||
.adr-sub { margin-top: 2px; font-size: 10px; line-height: 1.1; color: #8b8f9a; }
|
||
@media (max-width: 600px) {
|
||
.market-card { margin: 10px 10px 6px; padding: 12px 14px; }
|
||
table.market-table td { font-size: 12px; }
|
||
table.market-adr th { width: 56px; font-size: 11px; }
|
||
table.market-adr td.adr-breakdown { font-size: 10px; }
|
||
}
|
||
/* ADR 추세 카드 — 코스피·코스닥 한 SVG 안에 통합. 시장정보 sub-tab 내부. */
|
||
.adr-trend-card .market-card-sub {
|
||
font-size: 11px; color: #5a5f6c; font-weight: 400; margin-left: 4px;
|
||
}
|
||
.adr-trend-card .market-section-hint { margin-bottom: 8px; }
|
||
.adr-combo-meta {
|
||
display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
|
||
margin: 8px 0 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.adr-legend {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
font-size: 11px; color: #d6d8dd; font-weight: 500;
|
||
}
|
||
.adr-legend-swatch {
|
||
display: inline-block; width: 10px; height: 10px; border-radius: 2px;
|
||
}
|
||
.adr-combo-range { margin-left: auto; }
|
||
.adr-trend-row {
|
||
position: relative;
|
||
margin-top: 6px;
|
||
touch-action: pan-y;
|
||
}
|
||
.adr-trend-svg {
|
||
width: 100%; height: 100px; display: block;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
/* 호버 인디케이터 — 세로선 1 + 시장당 점. net-chart nw-hover 패턴 확장. */
|
||
.adr-trend-svg .adr-hover { transition: opacity 0.1s; }
|
||
.adr-trend-svg .adr-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
|
||
.adr-trend-svg .adr-hover-dot { stroke-width: 1.2; }
|
||
.adr-trend-tip {
|
||
position: absolute;
|
||
background: #11141c;
|
||
border: 1px solid #2a2e36;
|
||
border-radius: 6px;
|
||
padding: 5px 8px;
|
||
font-size: 11px;
|
||
color: #d6d8dd;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.1s;
|
||
z-index: 5;
|
||
white-space: nowrap;
|
||
min-width: 110px;
|
||
}
|
||
.adr-trend-tip .tip-d { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 2px; }
|
||
.adr-trend-tip .tip-line {
|
||
display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.adr-trend-tip .tip-line[hidden] { display: none; }
|
||
.adr-trend-tip .tip-mk { font-size: 10px; color: #8b8f9a; }
|
||
.adr-trend-tip .tip-kospi .tip-mk { color: #fbbf24; }
|
||
.adr-trend-tip .tip-kosdaq .tip-mk { color: #34d399; }
|
||
.adr-trend-tip .tip-v { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||
.adr-trend-tip .tip-v.up { color: #ff8a96; }
|
||
.adr-trend-tip .tip-v.down { color: #8db4f4; }
|
||
.adr-trend-tip .tip-v.flat { color: #d6d8dd; }
|
||
.adr-trend-tip .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; }
|
||
.adr-trend-empty {
|
||
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
|
||
padding: 8px 0;
|
||
}
|
||
.adr-trend-controls {
|
||
display: flex; gap: 6px; margin: 8px 0 4px; flex-wrap: wrap;
|
||
}
|
||
.adr-range-btn {
|
||
appearance: none; -webkit-appearance: none;
|
||
background: #11141c; color: #c9ccd3;
|
||
border: 1px solid #2a2e36; border-radius: 8px;
|
||
padding: 5px 11px; font-size: 11px; font-weight: 500; font-family: inherit;
|
||
cursor: pointer;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.adr-range-btn:hover, .adr-range-btn:active { background: #1a1d26; color: #f0f0f0; }
|
||
.adr-range-btn.active {
|
||
background: rgba(251,191,36,0.12);
|
||
color: #fbbf24;
|
||
border-color: rgba(251,191,36,0.4);
|
||
}
|
||
.adr-range-btn:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; }
|
||
.adr-live-badge {
|
||
display: inline-block; margin-left: 6px;
|
||
font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
|
||
color: #fbbf24;
|
||
}
|
||
/* 자산정보 탭 내부 sub-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */
|
||
.sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; }
|
||
.sub-tab { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 500; }
|
||
.sub-tab:hover, .sub-tab:active { color: #d6d8dd; }
|
||
.sub-tab.active { color: #f0f0f0; border-bottom-color: #ff4d5e; }
|
||
.sub-tab-panel[hidden] { display: none; }
|
||
/* 하단 배치 — 탭 강조선 제거하고 둥근 버튼 형태. */
|
||
.sub-tabs.sub-tabs-bottom { margin: 14px 14px; border: 0; padding: 0; gap: 8px; justify-content: center; }
|
||
.sub-tabs.sub-tabs-bottom .sub-tab {
|
||
border: 1px solid #2a2e36;
|
||
border-radius: 8px;
|
||
background: #11141c;
|
||
color: #c9ccd3;
|
||
padding: 8px 18px;
|
||
}
|
||
.sub-tabs.sub-tabs-bottom .sub-tab:hover, .sub-tabs.sub-tabs-bottom .sub-tab:active { background: #1a1d26; color: #f0f0f0; }
|
||
.sub-tabs.sub-tabs-bottom .sub-tab.active {
|
||
background: #2a1820;
|
||
color: #ff8a96;
|
||
border-color: rgba(255,77,94,0.32);
|
||
}
|
||
|
||
/* 합산 / 각계좌별도 단일 토글 버튼 — 상단 자동토글 옆 1곳에만 배치. 클릭마다 두 상태 swap.
|
||
data-account-view-state 속성으로 현재 상태 표시. 기본(합산)은 중성색, 각계좌별도일 때 강조. */
|
||
.account-view-toggle-btn {
|
||
flex: none; display: inline-flex; align-items: center; justify-content: center;
|
||
min-width: 36px; height: 36px; padding: 0 10px;
|
||
background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
|
||
border-radius: 8px; font-size: 17px; line-height: 1; cursor: pointer;
|
||
font-family: inherit; user-select: none;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
|
||
}
|
||
.account-view-toggle-btn:hover { background: #232a3b; }
|
||
.account-view-toggle-btn:active { transform: scale(0.95); }
|
||
.account-view-toggle-btn[data-account-view-state="by-account"] {
|
||
background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e;
|
||
}
|
||
.account-view-pane[hidden] { display: none; }
|
||
.holdings-toggle-wrap { margin-top: 4px; }
|
||
|
||
/* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */
|
||
.chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; }
|
||
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
|
||
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
|
||
.chart-controls .nw-select {
|
||
appearance: none; -webkit-appearance: none;
|
||
background: #11141c url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='%238b8f9a' d='M2 4l4 4 4-4'/></svg>") no-repeat right 8px center;
|
||
background-size: 10px;
|
||
color: #d6d8dd; border: 1px solid #2a2e36; border-radius: 8px;
|
||
padding: 6px 26px 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit;
|
||
min-width: 96px;
|
||
}
|
||
.chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; }
|
||
|
||
@media (max-width: 600px) {
|
||
nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; }
|
||
.owner-card { margin: 10px 10px 6px; padding: 12px 14px; }
|
||
.owner-card table.kpi th { width: auto; font-size: 11px; }
|
||
.owner-card table.kpi td { font-size: 12px; }
|
||
.cash-list { margin: 4px 10px 10px; font-size: 11px; }
|
||
.net-chart { margin: 10px 10px 0; padding: 8px 10px 6px; }
|
||
/* 모바일은 viewBox(800) > viewport 라 scale 0.5 정도 — viewBox unit 폰트를 크게 잡아 화면에서 적당히 보이게. */
|
||
.net-chart-svg .axis { font-size: 22px; }
|
||
}
|
||
|
||
/* 주문 모달 (order-modal) — 거래내역 보기용 trade-modal과 별개. 모달 박스 자체 스크롤 X (호가창만 스크롤) */
|
||
.order-box { max-width: 560px; overflow-y: hidden; display: flex; flex-direction: column; }
|
||
.order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||
.order-symbol-select { background: #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; }
|
||
.order-symbol-select:focus { outline: none; border-color: #ff4d5e; }
|
||
.order-symbol-select option { background: #14171f; color: #f0f0f0; }
|
||
/* native select 화살표(chevron) — 다크 테마로 밝은 색 강제 */
|
||
.order-inputs select, .open-orders-box select { color-scheme: dark; }
|
||
.order-meta { display: flex; gap: 8px; align-items: baseline; margin-bottom: 12px; flex-wrap: nowrap; }
|
||
.order-meta .cur-price { font-size: clamp(14px, 4.5vw, 22px); font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||
.order-meta .change { font-size: clamp(10px, 2.8vw, 13px); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.order-meta .change.up { color: #ff5b69; }
|
||
.order-meta .change.down { color: #5b9bff; }
|
||
.order-market-phase { font-size: 10px; padding: 2px 7px; border-radius: 4px; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||
.order-market-phase.regular { background: rgba(31,90,40,0.35); color: #8aff95; border: 1px solid rgba(31,90,40,0.6); }
|
||
.order-market-phase.nxt { background: rgba(86,42,138,0.35); color: #d3a7ff; border: 1px solid rgba(86,42,138,0.6); }
|
||
.order-market-phase.closed, .order-market-phase.holiday, .order-market-phase.weekend { background: rgba(90,31,40,0.35); color: #ff8a95; border: 1px solid rgba(90,31,40,0.6); }
|
||
.order-side-toggle { display: flex; gap: 0; margin-bottom: 12px; border-radius: 8px; overflow: hidden; border: 1px solid #1f2330; }
|
||
.order-side-toggle button { flex: 1; padding: 10px 12px; background: #14171f; color: #c9ccd3; border: 0; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border-right: 1px solid #1f2330; }
|
||
.order-side-toggle button:last-child { border-right: 0; }
|
||
.order-side-toggle button.active[data-side="BUY"] { background: #c9303e; color: #fff; }
|
||
.order-side-toggle button.active[data-side="SELL"] { background: #2a5fb3; color: #fff; }
|
||
.order-side-toggle button.info-tab { color: #8db4ff; max-width: 100px; }
|
||
.order-side-toggle button.info-tab:hover { background: #1f2840; color: #c9e0ff; }
|
||
/* 좌우 2-컬럼 강제 (호가창 좁게, 인풋 영역 넓게). flex로 grid 안 남은 공간 채워 stretch */
|
||
.order-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
|
||
/* 호가창만 폰트 ~70% 압축, 별도 스크롤. height 100%로 인풋 영역과 동일 stretch */
|
||
.orderbook { display: flex; flex-direction: column; justify-content: center; border: 1px solid #1f2330; border-radius: 6px; overflow-y: auto; overflow-x: hidden; height: 100%; min-height: 0; scrollbar-width: thin; }
|
||
.orderbook::-webkit-scrollbar { width: 4px; }
|
||
.orderbook::-webkit-scrollbar-thumb { background: #2a2e3a; border-radius: 2px; }
|
||
.orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; }
|
||
.orderbook .ob-row:last-of-type { border-bottom: 0; }
|
||
.orderbook .ob-row.ask { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; }
|
||
.orderbook .ob-row.bid { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; }
|
||
.orderbook .ob-row:hover { background: #1c1f29; }
|
||
.orderbook .ob-row.selected { box-shadow: inset 0 0 0 1.5px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; }
|
||
.orderbook .ob-divider { padding: 2px 5px; font-size: 8.5px; font-weight: 700; color: #f0f0f0; background: #161922; text-align: center; border-top: 1px solid #1f2330; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums; }
|
||
.orderbook .ob-row .price { text-align: left; }
|
||
.orderbook .ob-row .qty { text-align: right; opacity: 0.8; font-size: 7px; }
|
||
.price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 4px; }
|
||
.price-tick-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 4px; font-size: 11px; cursor: pointer; font-family: inherit; }
|
||
.price-tick-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
|
||
.price-tick-buttons button:active { transform: translateY(1px); }
|
||
.qty-step-buttons { display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; margin-top: 4px; }
|
||
.qty-step-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 2px; font-size: 10px; cursor: pointer; font-family: inherit; }
|
||
.qty-step-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
|
||
.qty-step-buttons button:active { transform: translateY(1px); }
|
||
.qty-step-buttons button[data-qty-step="100%"],
|
||
.qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; }
|
||
.qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; }
|
||
.order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||
.order-inputs .order-actions { margin-top: auto; }
|
||
.order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
|
||
.order-inputs label.inline-row { flex-direction: row; align-items: center; gap: 8px; }
|
||
.order-inputs label.inline-row > select { flex: 1; min-width: 0; }
|
||
.order-inputs input, .order-inputs select { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
|
||
.order-inputs input:focus, .order-inputs select:focus { outline: none; border-color: #ff4d5e; }
|
||
.order-inputs .max-qty { font-size: 10.5px; color: #8b8f9a; font-weight: 400; margin-left: 4px; }
|
||
.order-info { font-size: clamp(9.5px, 2.6vw, 12px); color: #c9ccd3; padding: 8px 10px; background: #11141b; border: 1px solid #1f2330; border-radius: 6px; line-height: 1.55; word-break: keep-all; }
|
||
.order-info b { white-space: nowrap; }
|
||
.order-info .pl-up { color: #ff8a95; white-space: nowrap; }
|
||
.order-info .pl-down { color: #8db4ff; white-space: nowrap; }
|
||
.order-info b.qty-over { color: #ff8a95; }
|
||
input[data-order-qty].qty-over { color: #ff8a95; }
|
||
.order-modal-msg { padding: 12px; margin-bottom: 12px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; font-variant-numeric: tabular-nums; }
|
||
.order-modal-msg.error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; }
|
||
.order-modal-msg.success { background: #142a18; border: 1px solid #1f5a28; color: #8aff95; }
|
||
.order-modal-msg.info { background: #14171f; border: 1px solid #1f2330; color: #c9ccd3; }
|
||
.order-modal-msg.hidden { display: none; }
|
||
.order-pin-row { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
|
||
.order-pin-row label { font-size: 11px; color: #8b8f9a; }
|
||
.order-pin-row input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 12px 14px; color: #e6e6e6; font-size: 22px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.2em; text-align: center; }
|
||
.order-pin-row input:focus { outline: none; border-color: #ff4d5e; }
|
||
.order-countdown { font-size: 13px; color: #8b8f9a; margin-top: 6px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; transition: color 0.15s; }
|
||
.order-countdown.warn { color: #ffcc77; }
|
||
.order-countdown.danger { color: #ff5b69; }
|
||
.order-actions { display: flex; gap: 8px; margin-top: 14px; }
|
||
.order-actions button { flex: 1; padding: 11px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: 0; }
|
||
.order-actions .btn-cancel { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; }
|
||
.order-actions .btn-confirm { background: #c9303e; color: #fff; }
|
||
.order-actions .btn-confirm[data-side="SELL"] { background: #2a5fb3; }
|
||
.order-actions button:active { transform: translateY(1px); }
|
||
.order-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.sub-tab-order-btn { margin-left: auto; background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45); border-radius: 6px; padding: 6px 14px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }
|
||
.sub-tab-order-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; }
|
||
.sub-tab-order-btn:active { transform: translateY(1px); }
|
||
.pin-box { max-width: 400px; }
|
||
.pin-box > .hidden { display: none; }
|
||
.open-orders-box { max-width: 560px; }
|
||
.open-orders-list { display: flex; flex-direction: column; gap: 8px; max-height: 50dvh; overflow-y: auto; margin-bottom: 12px; }
|
||
.open-orders-list .empty { padding: 32px 12px; text-align: center; color: #8b8f9a; font-size: 12px; }
|
||
.open-order-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; align-items: center; }
|
||
.open-order-row.active-card { border-color: rgba(255,204,51,0.45); background: rgba(255,204,51,0.06); }
|
||
.open-order-row .row-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||
.open-order-row .row-line1 { display: flex; gap: 6px; align-items: baseline; font-size: 13px; font-weight: 600; color: #f0f0f0; }
|
||
.open-order-row .row-line1 .badge-side { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 700; }
|
||
.open-order-row .row-line1 .badge-side.buy { background: #c9303e; color: #fff; }
|
||
.open-order-row .row-line1 .badge-side.sell { background: #2a5fb3; color: #fff; }
|
||
.open-order-row .row-line1 .badge-side.pin { background: #ffcc33; color: #14171f; }
|
||
.open-order-row .row-line2 { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; }
|
||
.open-order-row .row-action button { background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||
.open-order-row .row-action button:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
|
||
.open-order-row .row-action button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.open-order-row.active-card .row-action button { background: rgba(255,204,51,0.12); color: #ffcc77; border-color: rgba(255,204,51,0.45); }
|
||
.open-order-row.active-card .row-action button:hover { background: rgba(255,204,51,0.25); }
|
||
/* 매매 거부·검증 에러 토스트 — 화면 하단, 자동 사라짐 */
|
||
.order-toast { position: fixed; left: 50%; bottom: 80px; transform: translateX(-50%); z-index: 2000; background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; padding: 12px 18px; border-radius: 8px; font-size: 13px; max-width: 90vw; box-shadow: 0 6px 20px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: pre-wrap; line-height: 1.55; font-variant-numeric: tabular-nums; }
|
||
.order-toast.show { opacity: 1; }
|
||
.order-toast.success { background: #142a18; border-color: #1f5a28; color: #8aff95; }
|
||
.order-toast.info { background: #14171f; border-color: #1f2330; color: #c9ccd3; }
|
||
'''
|
||
|
||
|
||
OWNER_TAB_IDS = {'본인': 'self', '가희': 'gahee'}
|
||
|
||
|
||
def _owner_tab_id(owner: str) -> str:
|
||
return OWNER_TAB_IDS.get(owner, 'owner-' + owner.encode('ascii', 'replace').decode().replace('?', 'x'))
|
||
|
||
|
||
def _owner_full_title(owner: str) -> str:
|
||
"""stock_portfolio_report.OWNER_LABELS를 단일 진실 출처로 사용 (drift 방지)."""
|
||
from stock_portfolio_report import OWNER_LABELS
|
||
return OWNER_LABELS.get(owner, f'{owner} 자산')
|
||
|
||
|
||
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE) -> str:
|
||
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select 3개.
|
||
자산정보 탭 상단에 1세트만 노출, 본인·가희 차트 둘 다 동시 컨트롤.
|
||
의미없는 단위(이번주+월별 등)는 disabled."""
|
||
allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
|
||
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
|
||
)
|
||
range_opts = ''.join(
|
||
f'<option value="{d}"{" selected" if d == selected_days else ""}>{html.escape(lbl)}</option>'
|
||
for lbl, d in NET_WORTH_CHART_PRESETS
|
||
)
|
||
unit_opts = ''.join(
|
||
f'<option value="{u}"'
|
||
f'{" selected" if u == selected_unit else ""}'
|
||
f'{" disabled" if u not in allowed_units else ""}>'
|
||
f'{html.escape(lbl)}</option>'
|
||
for lbl, u in NET_WORTH_UNIT_PRESETS
|
||
)
|
||
mode_opts = ''.join(
|
||
f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
|
||
for lbl, m in NET_WORTH_MODE_PRESETS
|
||
)
|
||
return (
|
||
'<div class="chart-controls">'
|
||
f'<label class="cc-field"><span class="cc-label">기간</span>'
|
||
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
|
||
# 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치.
|
||
f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
|
||
f'<select class="nw-select" data-nw-unit>{unit_opts}</select>'
|
||
f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
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 색상·해석 라벨. 통상 ≤75 침체(매수권), ≥125 과열(매도권).
|
||
20일 이동평균 기준 — 단일일 raw ratio는 변동성이 커서 임계치 해석이 무의미."""
|
||
if adr is None:
|
||
return 'flat', ''
|
||
if adr <= 75:
|
||
return 'down', '침체'
|
||
if adr >= 125:
|
||
return 'up', '과열'
|
||
return 'flat', ''
|
||
|
||
|
||
def _build_adr_series_with_live(history_rows: list[dict], live_pt: dict | None) -> list[dict]:
|
||
"""history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,is_live}, ...] 반환.
|
||
라이브는 history 마지막 date 와 다를 때만 append (21:00 누적 후엔 자동 중복)."""
|
||
base: list[dict] = [
|
||
{'date': r['date'], 'adr': float(r['adr']), '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']), 'is_live': True})
|
||
return base
|
||
|
||
|
||
def _compute_adr_ma_latest(symbol: str, today_raw_adr: float | None, today_date: str | None,
|
||
window: int = ADR_MA_WINDOW) -> tuple[float | None, int, bool]:
|
||
"""심볼별 최근 N일 이평. (avg or None, available_count, used_live).
|
||
available_count < window 이면 avg=None (= "누적 중 count/window")."""
|
||
history = _load_market_history(-1).get(symbol, [])
|
||
live_pt = ({'date': today_date, 'adr': today_raw_adr}
|
||
if today_raw_adr is not None and today_date else None)
|
||
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)
|
||
if len(used) < window:
|
||
return (None, len(used), used_live)
|
||
avg = sum(r['adr'] for r in used) / len(used)
|
||
return (avg, 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,is_live}) → 각 시점 trailing window 평균 시리즈.
|
||
[(date, ma, is_live), ...]. window 채워지지 않은 앞쪽은 skip.
|
||
is_live는 그 시점 윈도우에 라이브 점이 포함됐는지 (= 마지막 평균만 라이브일 가능성)."""
|
||
series: list[tuple[str, float, bool]] = []
|
||
for i in range(window - 1, len(rows)):
|
||
win = rows[i + 1 - window : i + 1]
|
||
avg = sum(r['adr'] for r in win) / window
|
||
is_live = any(r.get('is_live') for r in win)
|
||
series.append((rows[i]['date'], avg, is_live))
|
||
return series
|
||
|
||
|
||
def _render_adr_summary_block(rows: list[dict] | None) -> str:
|
||
"""ADR 20일 이평 표 + 임계치 hint. ADR 추세 카드 상단 mini section.
|
||
rows가 비면 안내 텍스트로 graceful degrade — 차트 카드 자체는 계속 그려진다."""
|
||
if not rows:
|
||
return (
|
||
'<div class="market-section">'
|
||
+ '<div class="market-section-hint">시장 지표 미수신</div>'
|
||
+ '</div>'
|
||
)
|
||
body_rows: list[str] = []
|
||
for r in rows:
|
||
raw_adr = r.get('adr')
|
||
bd = r.get('bizdate')
|
||
today_iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None
|
||
ma, count, _used_live = _compute_adr_ma_latest(r['symbol'], raw_adr, today_iso)
|
||
adr_cls, adr_tag = _adr_class(ma)
|
||
if ma is not None:
|
||
adr_str = f'{ma:.1f}'
|
||
sub_html = '<div class="adr-sub muted small">20일 이평</div>'
|
||
else:
|
||
adr_str = '—'
|
||
sub_html = f'<div class="adr-sub muted small">누적 중 {count}/{ADR_MA_WINDOW}</div>'
|
||
tag_html = f' <span class="adr-tag {adr_cls}">{adr_tag}</span>' if adr_tag else ''
|
||
body_rows.append(
|
||
f'<tr>'
|
||
f'<th>{html.escape(r["label"])}</th>'
|
||
f'<td class="adr-val"><b class="{adr_cls}">{adr_str}</b>{tag_html}{sub_html}</td>'
|
||
f'<td class="adr-breakdown"><span class="up">{r["rise"]:,}↑</span> / <span class="down">{r["fall"]:,}↓</span> / <span class="neutral">{r["steady"]:,}→</span></td>'
|
||
f'</tr>'
|
||
)
|
||
return (
|
||
'<div class="market-section">'
|
||
+ f'<table class="market-table market-adr"><tbody>{"".join(body_rows)}</tbody></table>'
|
||
+ '</div>'
|
||
)
|
||
|
||
|
||
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 '<div class="market-card market-card-error">시장 지표를 불러올 수 없습니다.</div>'
|
||
|
||
# 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'<button type="button" class="market-idx-info" data-idx-info '
|
||
f'data-idx-label="{html.escape(label, quote=True)}" '
|
||
f'data-idx-desc="{html.escape(desc, quote=True)}" '
|
||
f'data-idx-price="{html.escape(price, quote=True)}" '
|
||
f'data-idx-change="{html.escape(change, quote=True)}" '
|
||
f'data-idx-pct="{html.escape(pct, quote=True)}" '
|
||
f'data-idx-direction="{direction}" '
|
||
f'data-idx-symbol="{html.escape(symbol_attr, quote=True)}" '
|
||
f'aria-label="설명">ⓘ</button>'
|
||
) if desc else ''
|
||
index_rows.append(
|
||
f'<tr class="market-idx-row">'
|
||
f'<th>{html.escape(label)}{info_btn}</th>'
|
||
f'<td class="market-idx-price">{html.escape(price)}</td>'
|
||
f'<td><span class="{direction}">{sign}{html.escape(change)} ({sign}{html.escape(pct)}%)</span></td>'
|
||
f'</tr>'
|
||
)
|
||
index_section = ''
|
||
if index_rows:
|
||
index_section = (
|
||
'<div class="market-section">'
|
||
'<table class="market-table market-index">'
|
||
f'<tbody>{"".join(index_rows)}</tbody>'
|
||
'</table>'
|
||
'</div>'
|
||
)
|
||
|
||
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'<tr>'
|
||
f'<th>{html.escape(r["label"])}</th>'
|
||
f'<td><span class="{p_cls}">{p_str}</span></td>'
|
||
f'<td><span class="{f_cls}">{f_str}</span></td>'
|
||
f'<td><span class="{i_cls}">{i_str}</span></td>'
|
||
f'</tr>'
|
||
)
|
||
|
||
date_suffix = f' <span class="market-date">{date_label}</span>' if date_label else ''
|
||
refresh_btn = (
|
||
'<button type="button" class="market-refresh-btn" data-refresh-market '
|
||
'title="시장 지표 새로고침" aria-label="시장 지표 새로고침">↻</button>'
|
||
)
|
||
return (
|
||
'<div class="market-card">'
|
||
f'<div class="market-card-title">오늘의 시장{date_suffix}{refresh_btn}</div>'
|
||
+ index_section +
|
||
'<div class="market-section">'
|
||
'<div class="market-section-head"><span class="market-section-label">투자자별 매매</span><span class="market-section-hint">순매수 금액 · 단위 억원</span></div>'
|
||
'<table class="market-table market-deal">'
|
||
'<thead><tr><th></th><th>개인</th><th>외국인</th><th>기관</th></tr></thead>'
|
||
f'<tbody>{"".join(deal_rows)}</tbody>'
|
||
'</table>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
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'<button type="button" class="adr-range-btn{" active" if d == selected_days else ""}"'
|
||
f' data-adr-range="{d}" aria-pressed="{"true" if d == selected_days else "false"}">'
|
||
f'{html.escape(lbl)}</button>'
|
||
for lbl, d in ADR_TREND_PRESETS
|
||
)
|
||
return f'<div class="adr-trend-controls" role="group" aria-label="ADR 추세 기간">{btns}</div>'
|
||
|
||
|
||
_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:
|
||
live_by_market[sym] = {'adr': adr, 'date': iso}
|
||
|
||
# 시장별 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
|
||
|
||
summary_block = _render_adr_summary_block(live_rows)
|
||
|
||
# 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내.
|
||
all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d})
|
||
if not all_dates:
|
||
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
|
||
hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — '
|
||
f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시')
|
||
return (
|
||
'<div class="market-card adr-trend-card">'
|
||
f'<div class="market-card-title">ADR 추세 <span class="market-card-sub">{ADR_MA_WINDOW}일 이평 · '
|
||
+ html.escape(sel_label) + '</span></div>'
|
||
+ summary_block
|
||
+ f'<div class="market-section-hint">{html.escape(hint)}</div>'
|
||
+ _render_adr_trend_controls(days)
|
||
+ '</div>'
|
||
)
|
||
|
||
date_to_i = {d: i for i, d in enumerate(all_dates)}
|
||
n_dates = len(all_dates)
|
||
|
||
# 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. height는 sparkline 단일 70 대비 95 로 확장.
|
||
Y_MIN, Y_MAX = 0.0, 200.0
|
||
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))
|
||
|
||
# 가이드선 — 75/100/125. 두 라인 위에 깔리지만 흐려서 노이즈 적음.
|
||
y75, y100, y125 = y_for(75), y_for(100), y_for(125)
|
||
guides = (
|
||
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y100:.1f}" y2="{y100:.1f}" stroke="#8b8f9a" stroke-width="0.6" stroke-dasharray="2 4" opacity="0.2"/>'
|
||
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y75:.1f}" y2="{y75:.1f}" stroke="#4a8cf0" stroke-width="0.6" stroke-dasharray="3 3" opacity="0.22"/>'
|
||
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y125:.1f}" y2="{y125:.1f}" stroke="#ff4d5e" stroke-width="0.6" stroke-dasharray="3 3" opacity="0.22"/>'
|
||
f'<text x="{W-PAD_R+3:.1f}" y="{y100:.1f}" dominant-baseline="middle" fill="#8b8f9a" font-size="9" opacity="0.35">100</text>'
|
||
f'<text x="{W-PAD_R+3:.1f}" y="{y75:.1f}" dominant-baseline="middle" fill="#4a8cf0" font-size="9" opacity="0.4">75</text>'
|
||
f'<text x="{W-PAD_R+3:.1f}" y="{y125:.1f}" dominant-baseline="middle" fill="#ff4d5e" font-size="9" opacity="0.4">125</text>'
|
||
)
|
||
|
||
# 시장별 이평선 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'<path d="M {d_attr}" fill="none" stroke="{color}" stroke-width="1.4"/>')
|
||
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'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="3.0" fill="#11141c" stroke="{color}" stroke-width="1.6"/>'
|
||
f'<text x="{lx+6:.1f}" y="{ly:.1f}" dominant-baseline="middle" fill="{color}" font-size="10" font-weight="600">{last_v:.1f}*</text>'
|
||
)
|
||
else:
|
||
marker_parts.append(
|
||
f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="2.4" fill="{color}"/>'
|
||
f'<text x="{lx+5:.1f}" y="{ly:.1f}" dominant-baseline="middle" fill="{color}" font-size="10" font-weight="600">{last_v:.1f}</text>'
|
||
)
|
||
|
||
# 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리).
|
||
hover_g = (
|
||
f'<g class="adr-hover" opacity="0" pointer-events="none">'
|
||
f'<line class="adr-hover-line" x1="0" x2="0" y1="{PAD_T:.1f}" y2="{H-PAD_B:.1f}"/>'
|
||
f'<circle class="adr-hover-dot adr-hover-dot-kospi" cx="0" cy="0" r="3.2" fill="{_ADR_MARKET_COLORS["KOSPI"]}" stroke="#0b0d12" stroke-width="1.2"/>'
|
||
f'<circle class="adr-hover-dot adr-hover-dot-kosdaq" cx="0" cy="0" r="3.2" fill="{_ADR_MARKET_COLORS["KOSDAQ"]}" stroke="#0b0d12" stroke-width="1.2"/>'
|
||
f'</g>'
|
||
)
|
||
|
||
# 포인트 메타 — 날짜축 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.
|
||
tip_html = (
|
||
'<div class="adr-trend-tip" aria-hidden="true">'
|
||
'<div class="tip-d">—</div>'
|
||
'<div class="tip-line tip-kospi"><span class="tip-mk">코스피</span><span class="tip-v">—</span></div>'
|
||
'<div class="tip-line tip-kosdaq"><span class="tip-mk">코스닥</span><span class="tip-v">—</span></div>'
|
||
'</div>'
|
||
)
|
||
|
||
# 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합).
|
||
legend_html = (
|
||
'<div class="adr-combo-meta muted small">'
|
||
f'<span class="adr-legend"><span class="adr-legend-swatch" style="background:{_ADR_MARKET_COLORS["KOSPI"]}"></span>코스피</span>'
|
||
f'<span class="adr-legend"><span class="adr-legend-swatch" style="background:{_ADR_MARKET_COLORS["KOSDAQ"]}"></span>코스닥</span>'
|
||
'</div>'
|
||
)
|
||
|
||
# 차트 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'<text x="{PAD_L + PLOT_W/2:.1f}" y="{x_axis_y:.1f}" text-anchor="middle" '
|
||
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(first_dt[5:])}</text>'
|
||
)
|
||
else:
|
||
x_axis_label = (
|
||
f'<text x="{PAD_L:.1f}" y="{x_axis_y:.1f}" text-anchor="start" '
|
||
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(first_dt[5:])}</text>'
|
||
f'<text x="{W-PAD_R:.1f}" y="{x_axis_y:.1f}" text-anchor="end" '
|
||
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(last_dt[5:])}</text>'
|
||
)
|
||
|
||
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
|
||
chart_row = (
|
||
f'<div class="adr-trend-row adr-combo-row" data-adr-pts="{pts_json}" data-adr-vb="{W:.0f} {H:.0f}">'
|
||
f'<svg class="adr-trend-svg" viewBox="0 0 {W:.0f} {H:.0f}" preserveAspectRatio="none" role="img" aria-label="코스피·코스닥 ADR 추세">'
|
||
f'{guides}'
|
||
f'{"".join(line_parts)}'
|
||
f'{"".join(marker_parts)}'
|
||
f'{x_axis_label}'
|
||
f'{hover_g}'
|
||
f'</svg>'
|
||
f'{tip_html}'
|
||
f'</div>'
|
||
)
|
||
|
||
return (
|
||
'<div class="market-card adr-trend-card">'
|
||
f'<div class="market-card-title">ADR 추세 <span class="market-card-sub">{ADR_MA_WINDOW}일 이평 · '
|
||
+ html.escape(sel_label) + '</span></div>'
|
||
+ summary_block
|
||
+ f'<div class="market-section-hint">{ADR_MA_WINDOW}일 이동평균 ADR · 회색점선=100(균형) · 파란점선=75(침체) · 빨간점선=125(과열) · *=오늘(라이브 포함)</div>'
|
||
+ _render_adr_trend_controls(days)
|
||
+ legend_html
|
||
+ chart_row
|
||
+ '</div>'
|
||
)
|
||
|
||
|
||
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) -> 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 '<div class="empty">계좌 데이터가 없습니다.</div>'
|
||
|
||
balances = balances or {}
|
||
journal_by_label = journal_by_label or {}
|
||
|
||
consolidated_parts: list[str] = []
|
||
by_account_parts: list[str] = []
|
||
chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)]
|
||
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=chart_unit, with_prev_baseline=True)
|
||
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], chart_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 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=chart_unit,
|
||
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
|
||
today_cash_flow=None,
|
||
mode=chart_mode,
|
||
prev_baseline_point=prev_baseline,
|
||
))
|
||
else:
|
||
# net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
|
||
chart_parts.append(_render_net_worth_chart(
|
||
chart_series,
|
||
current_net=d.get('total_net'),
|
||
selected_days=chart_days,
|
||
selected_unit=chart_unit,
|
||
cash_flow_by_date=cf_by_date,
|
||
today_cash_flow=today_cf,
|
||
mode='net',
|
||
prev_baseline_point=prev_baseline,
|
||
))
|
||
|
||
market_card = _render_market_indicators_card()
|
||
adr_trend_card = _render_adr_trend_card(days=adr_days)
|
||
# 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일.
|
||
assets_inner = (
|
||
'<div class="account-view-pane" data-account-view-pane="consolidated">'
|
||
+ '\n'.join(consolidated_parts)
|
||
+ '</div>'
|
||
'<div class="account-view-pane" data-account-view-pane="by-account" hidden>'
|
||
+ '\n'.join(by_account_parts)
|
||
+ '</div>'
|
||
)
|
||
return (
|
||
'<div class="sub-tab-panel" data-sub-panel="assets" role="tabpanel">'
|
||
+ assets_inner
|
||
+ '</div>'
|
||
'<div class="sub-tab-panel" data-sub-panel="chart" role="tabpanel" hidden>'
|
||
+ '\n'.join(chart_parts)
|
||
+ '</div>'
|
||
'<div class="sub-tab-panel" data-sub-panel="market" role="tabpanel" hidden>'
|
||
+ market_card
|
||
+ adr_trend_card
|
||
+ '</div>'
|
||
'<div class="sub-tabs sub-tabs-bottom" role="tablist">'
|
||
'<button type="button" class="sub-tab active" data-sub-tab="assets" role="tab" aria-selected="true">자산보기</button>'
|
||
'<button type="button" class="sub-tab" data-sub-tab="chart" role="tab" aria-selected="false">차트보기</button>'
|
||
'<button type="button" class="sub-tab" data-sub-tab="market" role="tab" aria-selected="false">시장정보</button>'
|
||
'<button type="button" class="sub-tab-order-btn" data-open-order-modal title="거래 — 종목은 모달에서 선택">💰 거래</button>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]:
|
||
if not cards:
|
||
return '<div class="empty">감시종목이 비어있습니다.</div>', 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'<div class="section-label">보유중<span class="count">{len(held)}</span></div>')
|
||
sections.append('\n'.join(_render_row(c) for c in held))
|
||
if watching:
|
||
sections.append(f'<div class="section-label">매수전<span class="count">{len(watching)}</span></div>')
|
||
sections.append('\n'.join(_render_row(c) for c in watching))
|
||
if trash:
|
||
sections.append(f'<div class="section-label trash-label">삭제예정<span class="count">{len(trash)}</span></div>')
|
||
sections.append('\n'.join(_render_row(c) for c in trash))
|
||
return '\n'.join(sections), len(cards)
|
||
|
||
|
||
def _render_interests_add_trigger() -> str:
|
||
"""패널 본문 하단에 표시되는 모달 오픈 트리거. 폼 자체는 shell HTML의 모달에 고정."""
|
||
return (
|
||
'<div class="add-trigger-wrap">'
|
||
'<button type="button" class="btn-add-trigger" data-modal-open="interest-modal">+ 관심종목 추가</button>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_interests_edit_modal() -> str:
|
||
"""등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠.
|
||
삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고
|
||
HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다."""
|
||
return (
|
||
'<div id="interest-edit-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="interest-edit-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<button type="submit" form="interest-edit-delete-form" class="btn-delete">삭제</button>'
|
||
'<div class="modal-title" id="interest-edit-modal-title">관심종목 수정 — <span data-edit-title></span></div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<form class="add-form" id="interest-edit-form" method="post" action="/interests/update">'
|
||
'<input type="hidden" name="stock">'
|
||
'<div class="add-form-grid">'
|
||
'<label>매수가 <input name="buy_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
|
||
'<label>목표가 <input name="target_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
|
||
'<label>손절가 <input name="stop_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
|
||
'<label>메모 <input name="memo" maxlength="120" placeholder="비우면 제거" autocomplete="off"></label>'
|
||
'</div>'
|
||
'<div class="form-feedback" data-feedback hidden></div>'
|
||
'</form>'
|
||
'<form id="interest-edit-delete-form" class="interest-delete-form" method="post" action="/interests/delete">'
|
||
'<input type="hidden" name="stock">'
|
||
'</form>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||
'<button type="submit" form="interest-edit-form" class="btn-add">저장</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_tag_modal() -> str:
|
||
"""종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다.
|
||
대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장."""
|
||
return (
|
||
'<div id="tag-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="tag-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="tag-modal-title">종목 태그 — <span data-tag-title></span></div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<form class="add-form" id="tag-form" method="post" action="/tags/set">'
|
||
'<input type="hidden" name="stock">'
|
||
'<input type="hidden" name="code">'
|
||
'<input type="hidden" name="leader" value="0">'
|
||
'<div class="tag-quick-row">'
|
||
'<button type="button" class="btn-tag-quick" data-tag-toggle="leader">대장</button>'
|
||
'<span class="muted small">색깔 토글 — 텍스트는 그대로 두고 빨강만 on/off</span>'
|
||
'</div>'
|
||
'<div class="add-form-grid">'
|
||
'<label>태그 <input name="tag" maxlength="20" placeholder="자유 입력 (예: 반도체, # 생략)" autocomplete="off"></label>'
|
||
'</div>'
|
||
'<div class="form-feedback" data-feedback hidden></div>'
|
||
'</form>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||
'<button type="button" class="btn-delete" data-tag-delete>삭제</button>'
|
||
'<button type="submit" form="tag-form" class="btn-add">저장</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_candidate_modal() -> str:
|
||
"""다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
|
||
return (
|
||
'<div id="candidate-modal" class="modal modal-top hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="candidate-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="candidate-modal-title">종목 선택</div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<div class="feedback-title" data-cand-title></div>'
|
||
'<div class="candidate-list" data-cand-list></div>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_stock_name_modal() -> str:
|
||
"""거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip.
|
||
셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide."""
|
||
return (
|
||
'<div id="stock-name-tip" class="stock-name-tip" role="tooltip" aria-hidden="true"></div>'
|
||
)
|
||
|
||
|
||
def _render_trade_modal() -> str:
|
||
"""종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더."""
|
||
return (
|
||
'<div id="trade-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="trade-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box modal-box-wide" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="trade-modal-title"><span data-trade-title></span></div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<div class="trade-summary muted small" data-trade-summary></div>'
|
||
'<div class="trade-body" data-trade-body><div class="muted small">불러오는 중…</div></div>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_info_modal() -> str:
|
||
"""종목별 기업정보 팝업 (시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율 등).
|
||
shell HTML 직속이라 panels swap 영향 없음. '상세' 버튼 클릭 → /api/stock_info fetch → 렌더."""
|
||
return (
|
||
'<div id="info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="info-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="info-modal-title"><span data-info-title>기업정보</span></div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
# 기업비교 바 — 종목 검색 + 내 목록(보유·관심·감시) 드롭다운으로 비교 대상 추가.
|
||
'<div class="info-compare-bar">'
|
||
'<div class="info-search-row">'
|
||
'<input type="text" class="info-search-input" data-info-search placeholder="🔍 종목 검색 (이름·코드)" autocomplete="off">'
|
||
'<button type="button" class="info-search-btn" data-info-search-btn>검색</button>'
|
||
'</div>'
|
||
'<select class="info-symbols-select" data-info-symbols aria-label="내 목록에서 비교 추가">'
|
||
'<option value="">+ 내 목록에서 추가</option>'
|
||
'</select>'
|
||
'</div>'
|
||
'<div class="info-search-results hidden" data-info-search-results></div>'
|
||
'<div class="info-body" data-info-body><div class="muted small">불러오는 중…</div></div>'
|
||
'<div class="modal-actions">'
|
||
'<a class="btn-analyze info-analyze-link" data-info-analyze href="#" target="_blank" rel="noopener" title="분석 보고서 페이지 열기">📊 분석 보고서</a>'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_info_desc_modal() -> str:
|
||
"""지수 설명 팝업. info-modal 위에 겹쳐 뜨는 modal-top. ? 버튼 클릭 시 용어·설명 표시."""
|
||
return (
|
||
'<div id="info-desc-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="info-desc-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box info-desc-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="info-desc-modal-title"><span data-desc-term>지수 설명</span></div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<div class="info-desc-body" data-desc-body></div>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_order_modal() -> str:
|
||
"""주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.
|
||
|
||
1단계: 종목·계좌·수량·단가 입력 → /api/order/propose (PIN 텔레그램 발송)
|
||
2단계: PIN 입력 → /api/order/verify (실주문 실행)
|
||
3단계: 결과 표시
|
||
"""
|
||
return (
|
||
'<div id="order-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="order-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box order-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="order-modal-title" style="flex:1; min-width:0;">'
|
||
'<select class="order-symbol-select" data-order-symbol-select aria-label="종목 변경">'
|
||
'<option value="">종목 선택</option>'
|
||
'</select>'
|
||
'</div>'
|
||
'</div>'
|
||
'<div class="order-meta">'
|
||
'<span class="cur-price" data-order-cur-price>—</span>'
|
||
'<span class="change" data-order-change>—</span>'
|
||
'<span class="order-market-phase" data-market-phase>—</span>'
|
||
'</div>'
|
||
'<div class="order-modal-msg hidden" data-order-msg></div>'
|
||
# 1단계: 입력
|
||
'<div data-order-step="1">'
|
||
'<div class="order-side-toggle">'
|
||
'<button type="button" data-side="BUY" class="active">매수</button>'
|
||
'<button type="button" data-side="SELL">매도</button>'
|
||
'<button type="button" class="info-tab" data-show-active title="진행 중인 카드(PIN 발급된 거래) + 미체결 주문 종목 수">📋 진행중<span data-active-count></span></button>'
|
||
'</div>'
|
||
'<div class="order-body">'
|
||
'<div class="orderbook" data-orderbook>'
|
||
'<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>'
|
||
'</div>'
|
||
'<div class="order-inputs">'
|
||
'<label class="inline-row">계좌<select data-order-account></select></label>'
|
||
'<label class="inline-row">주문유형<select data-order-type>'
|
||
'<option value="LIMIT">지정가</option>'
|
||
'<option value="MARKET">시장가</option>'
|
||
'</select></label>'
|
||
'<label data-order-price-row>단가'
|
||
'<input type="number" inputmode="numeric" min="0" step="1" data-order-price-input>'
|
||
'<div class="price-tick-buttons">'
|
||
'<button type="button" data-tick-dir="-1">▼ 한 호가 아래</button>'
|
||
'<button type="button" data-tick-dir="1">▲ 한 호가 위</button>'
|
||
'</div>'
|
||
'</label>'
|
||
'<label data-order-budget-row>'
|
||
'<span>금액 <span class="muted small" style="font-weight:400;">(매수액 → 수량 자동)</span></span>'
|
||
'<input type="number" inputmode="numeric" min="0" step="1000" placeholder="예: 1000000" data-order-budget>'
|
||
'</label>'
|
||
'<label><span>수량 <span class="max-qty" data-order-max>(최대: —)</span></span>'
|
||
'<input type="number" inputmode="numeric" min="0" step="1" data-order-qty>'
|
||
'<div class="qty-step-buttons" data-qty-buttons></div>'
|
||
'</label>'
|
||
'<div class="order-info" data-order-info>—</div>'
|
||
'<div class="order-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||
'<button type="button" class="btn-confirm" data-order-submit data-side="BUY">매수</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_open_orders_modal() -> str:
|
||
"""매매등록된 미체결 주문 목록 팝업. [📋 진행중] 탭이 표시 — 4계좌 통합 + 행별 취소.
|
||
|
||
각 행: 종목·계좌·방향·수량·단가·상태 + [취소] 버튼 → POST /api/orders/cancel.
|
||
"""
|
||
return (
|
||
'<div id="open-orders-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="open-orders-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box open-orders-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="open-orders-modal-title">📋 진행중 매매</div>'
|
||
'</div>'
|
||
'<div class="open-orders-list" data-open-orders-list>'
|
||
'<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>'
|
||
'</div>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'<button type="button" class="btn-add" data-open-orders-refresh>새로고침</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_pin_modal() -> str:
|
||
"""매매 카드 PIN 입력 팝업. order-modal의 propose 성공 직후 띄움.
|
||
|
||
카드 요약 + PIN 입력 칸 + 만료 카운트다운 + [카드 취소][주문 실행] 액션.
|
||
verify 결과는 같은 모달 안 결과 영역으로 swap.
|
||
"""
|
||
return (
|
||
'<div id="pin-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="pin-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box pin-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="pin-modal-title">주문 확인 — <span data-pin-symbol>—</span></div>'
|
||
'</div>'
|
||
'<div class="order-modal-msg info" data-pin-card-summary>—</div>'
|
||
# PIN 입력
|
||
'<div data-pin-input-step>'
|
||
'<div class="order-pin-row">'
|
||
'<label for="pin-modal-input">PIN — 텔레그램으로 발송됨 (iOS 자동입력 지원)</label>'
|
||
'<input id="pin-modal-input" type="text" autocomplete="one-time-code" inputmode="numeric" maxlength="8" data-pin-input>'
|
||
'</div>'
|
||
'<div class="order-countdown" data-pin-countdown>—</div>'
|
||
'<div class="order-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'<button type="button" class="btn-confirm" data-pin-verify>주문 실행</button>'
|
||
'</div>'
|
||
'</div>'
|
||
# 결과
|
||
'<div class="hidden" data-pin-result-step>'
|
||
'<div class="order-modal-msg info" data-pin-result>—</div>'
|
||
'<div class="order-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_interests_modal() -> str:
|
||
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
|
||
자동 갱신 중에도 DOM·입력값이 보존된다."""
|
||
return (
|
||
'<div id="interest-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="interest-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="interest-modal-title">관심종목 추가</div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<form class="add-form" id="interest-add-form" method="post" action="/interests/add">'
|
||
'<div class="add-form-grid">'
|
||
'<label>종목명 <input name="stock" maxlength="40" placeholder="예: 카카오 (코드 알면 생략)" autocomplete="off"></label>'
|
||
'<label>종목코드 <input name="code" maxlength="20" placeholder="예: 005930" inputmode="numeric" autocomplete="off"></label>'
|
||
'</div>'
|
||
'<div class="form-feedback" data-feedback hidden></div>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||
'<button type="submit" class="btn-add">추가</button>'
|
||
'</div>'
|
||
'</form>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
|
||
sections: list[str] = []
|
||
if not cards:
|
||
sections.append('<div class="empty">관심종목이 비어있습니다.</div>')
|
||
else:
|
||
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)
|
||
if held:
|
||
sections.append(f'<div class="section-label">보유중<span class="count">{len(held)}</span></div>')
|
||
sections.append('\n'.join(_render_row(c, source='interests') for c in held))
|
||
if watching:
|
||
sections.append(f'<div class="section-label">관심<span class="count">{len(watching)}</span></div>')
|
||
sections.append('\n'.join(_render_row(c, source='interests') for c in watching))
|
||
sections.append(_render_interests_add_trigger())
|
||
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) -> 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)
|
||
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,
|
||
'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')
|
||
|
||
nav_html = '\n'.join(
|
||
f'<a class="tab-label" href="#{tid}">{html.escape(label)}</a>'
|
||
for tid, label in descs
|
||
)
|
||
spinner_placeholder = (
|
||
'<div class="loading"><span class="loading-spin"></span>'
|
||
'<span class="loading-text">불러오는 중…</span></div>'
|
||
)
|
||
content_html = '\n'.join(
|
||
f'<section class="tab-content" data-tab="{tid}">{spinner_placeholder}</section>'
|
||
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)
|
||
# 자동 갱신 동작 탭 — 자산정보·본인·가희(owner 부분 fetch)·관심종목(전체 fetch). 감시종목은 시세 변화 추적 대상 아니라 제외.
|
||
auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids if tid != 'tab-wl')
|
||
|
||
# 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
|
||
# 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
|
||
head_script = (
|
||
'<script>(function(){'
|
||
f'var V=[{valid_tids_js}],FIRST="{first_tid}";'
|
||
'function s(){var h=(location.hash||"").replace(/^#/,"");'
|
||
'document.documentElement.setAttribute("data-tab",V.indexOf(h)>=0?h:FIRST);}'
|
||
's();'
|
||
# 탭 이동마다 강제 새로고침(fresh) — owner 탭이면 그 owner partial, 그 외(자산정보·관심·감시)는 전체.
|
||
'addEventListener("hashchange",function(){'
|
||
's();'
|
||
'if(window.__behive_set_meta_active)window.__behive_set_meta_active();'
|
||
'if(window.__behive_load){'
|
||
'var a=document.documentElement.getAttribute("data-tab")||"";'
|
||
'var ow=(a==="tab-self"||a==="tab-gahee")?a.slice(4):null;'
|
||
'window.__behive_load(ow,true);'
|
||
'}'
|
||
'});'
|
||
'})();</script>'
|
||
)
|
||
|
||
# 패널 fetch + DOM swap.
|
||
# load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입)
|
||
# load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신)
|
||
# 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠.
|
||
# inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록.
|
||
panels_script = (
|
||
'<script>(function(){'
|
||
'function setMeta(s){var m=document.querySelector("header.top .meta");if(m)m.textContent=s?("갱신 "+s):"";}'
|
||
# 탭별 fetched_at 누적 dict — partial 응답은 자기 탭만 갱신하므로 다른 탭은 이전 값 유지.
|
||
'var tabFetched={};'
|
||
'function setMetaActive(){'
|
||
'var a=document.documentElement.getAttribute("data-tab")||"";'
|
||
'setMeta(tabFetched[a]||"");'
|
||
'}'
|
||
'window.__behive_set_meta_active=setMetaActive;'
|
||
'function setMarket(act){'
|
||
'var btn=document.getElementById("auto-toggle");if(!btn)return;'
|
||
'var prev=window.__behive_market_active;'
|
||
'window.__behive_market_active=!!act;'
|
||
'if(act){btn.classList.remove("closed");btn.removeAttribute("disabled");'
|
||
'btn.title="10초마다 자동 갱신 (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)";}'
|
||
'else{btn.classList.add("closed");btn.setAttribute("disabled","");'
|
||
'btn.title="휴장 또는 장 마감 (NXT 평일 08:00~20:00 운영) — 자동 갱신 비활성";}'
|
||
'if(prev!==!!act&&window.__behive_auto_arm)window.__behive_auto_arm();'
|
||
'}'
|
||
'window.__behive_set_market=setMarket;'
|
||
'var tickerItems=[];'
|
||
'var arrowMap={up:"▲",down:"▼",flat:"-"};'
|
||
'function tickerItemHtml(item){'
|
||
'var d=item.direction||"flat";'
|
||
# label은 data-key로 박아 in-place 업데이트 매칭 키로 사용.
|
||
'var key=String(item.label).replace(/"/g,""");'
|
||
'return \'<span class="idx \'+(item.kind||"")+\'" data-key="\'+key+\'">\'+'
|
||
'\'<span class="idx-label">\'+item.label+\'</span>\'+'
|
||
'\'<span class="idx-price">\'+item.price+\'</span>\'+'
|
||
'\'<span class="idx-delta \'+d+\'">\'+arrowMap[d]+\' \'+item.change+\' (\'+item.pct+\'%)</span></span>\';'
|
||
'}'
|
||
# 같은 세트를 2번 렌더 → CSS translateX(-50%)로 seamless 무한 스크롤.
|
||
'function renderTicker(){'
|
||
'var el=document.getElementById("market-ticker");if(!el)return;'
|
||
'if(!tickerItems.length){el.innerHTML="<span class=\\"idx-empty\\">데이터 없음</span>";return;}'
|
||
'var setHtml=tickerItems.map(tickerItemHtml).join("")+\'<span class="ticker-sep">·</span>\';'
|
||
'el.innerHTML=\'<div class="ticker-track">\'+'
|
||
'\'<div class="ticker-set">\'+setHtml+\'</div>\'+'
|
||
'\'<div class="ticker-set" aria-hidden="true">\'+setHtml+\'</div>\'+'
|
||
'\'</div>\';'
|
||
# intro 속도 = scroll 속도가 되도록 동적 계산.
|
||
# scroll은 20s에 트랙 절반(=한 세트 너비) 이동 → px/s = setW/20.
|
||
# intro 거리는 viewport 너비(100vw) → duration = viewport / (setW/20) = viewport*20/setW.
|
||
# 2~12s 캡: 너무 짧으면 pop-in, 너무 길면 사용자가 첫 등장 기다림.
|
||
'var trk=el.querySelector(".ticker-track");'
|
||
'if(trk){'
|
||
'var setW=trk.scrollWidth/2,vw=window.innerWidth||document.documentElement.clientWidth||0;'
|
||
'if(setW>0&&vw>0){'
|
||
'var d=vw*20/setW;'
|
||
'if(d<2)d=2;else if(d>12)d=12;'
|
||
'trk.style.animation="ticker-intro "+d+"s linear, ticker-scroll 20s linear "+d+"s infinite";'
|
||
'}'
|
||
'}'
|
||
'}'
|
||
# 항목 키 셋이 동일하면 가격·등락만 in-place 갱신 → CSS 애니메이션 진행 상태 보존.
|
||
# 자동 갱신 중에도 스크롤이 0초부터 리셋되지 않는다.
|
||
'function updateTickerInPlace(arr){'
|
||
'var el=document.getElementById("market-ticker");if(!el)return false;'
|
||
'var nodes=el.querySelectorAll(".idx[data-key]");if(!nodes.length)return false;'
|
||
'var byKey={};'
|
||
'for(var i=0;i<nodes.length;i++){'
|
||
'var k=nodes[i].getAttribute("data-key");'
|
||
'(byKey[k]=byKey[k]||[]).push(nodes[i]);'
|
||
'}'
|
||
'if(Object.keys(byKey).length!==arr.length)return false;'
|
||
'for(var j=0;j<arr.length;j++){if(!byKey[arr[j].label])return false;}'
|
||
'arr.forEach(function(item){'
|
||
'var d=item.direction||"flat";'
|
||
'var deltaText=arrowMap[d]+" "+item.change+" ("+item.pct+"%)";'
|
||
'byKey[item.label].forEach(function(n){'
|
||
'var price=n.querySelector(".idx-price");'
|
||
'var delta=n.querySelector(".idx-delta");'
|
||
'if(price)price.textContent=item.price;'
|
||
'if(delta){delta.className="idx-delta "+d;delta.textContent=deltaText;}'
|
||
'});'
|
||
'});'
|
||
'return true;'
|
||
'}'
|
||
# 빈 배열이면 기존 표시 유지 (owner-only 응답에서 ticker_items 비어도 stale 보존).
|
||
'function setTicker(arr){'
|
||
'if(!arr||!arr.length){if(!tickerItems.length)renderTicker();return;}'
|
||
'if(updateTickerInPlace(arr)){tickerItems=arr;return;}'
|
||
'tickerItems=arr;'
|
||
'renderTicker();'
|
||
'}'
|
||
'function apply(p){'
|
||
'setMarket(!!p.market_active);'
|
||
'setTicker(p.ticker_items||p.indices||[]);'
|
||
'var byId={};(p.tabs||[]).forEach(function(t){'
|
||
'byId[t.id]=t;'
|
||
# partial 응답이라도 자기 탭만 fetched_at 갱신 — 다른 탭 값은 그대로.
|
||
'if(t.fetched_at)tabFetched[t.id]=t.fetched_at;'
|
||
'});'
|
||
'setMetaActive();'
|
||
'document.querySelectorAll("nav.tabs a.tab-label").forEach(function(a){'
|
||
'var id=(a.getAttribute("href")||"").replace(/^#/,"");'
|
||
'var t=byId[id];if(t)a.textContent=t.label;'
|
||
'});'
|
||
'document.querySelectorAll("section.tab-content").forEach(function(sec){'
|
||
'var id=sec.getAttribute("data-tab");var t=byId[id];'
|
||
'if(!t)return;'
|
||
'var openKeys={};'
|
||
'sec.querySelectorAll("details.row[open][data-row-key]").forEach(function(d){'
|
||
'openKeys[d.getAttribute("data-row-key")]=1;'
|
||
'});'
|
||
'sec.innerHTML=t.html;'
|
||
'Object.keys(openKeys).forEach(function(k){'
|
||
'var d=sec.querySelector(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\');'
|
||
'if(d)d.setAttribute("open","");'
|
||
'});'
|
||
'});'
|
||
'document.dispatchEvent(new CustomEvent("behive:panels-loaded",{detail:p}));'
|
||
'}'
|
||
'function failVisible(msg){'
|
||
'document.querySelectorAll("section.tab-content").forEach(function(sec){'
|
||
'if(sec.querySelector(".loading"))sec.innerHTML="<div class=\\"empty\\">"+msg+"</div>";'
|
||
'});'
|
||
'}'
|
||
'var inFlight={};'
|
||
# 마지막 성공 갱신 시각 — 5분 초과 시 다음 load는 첫 진입처럼 spinner 리셋 + 전체 fetch로 승격.
|
||
'var lastLoadAt=0,STALE_MS=5*60*1000;'
|
||
'var SPINNER=\'<div class="loading"><span class="loading-spin"></span><span class="loading-text">불러오는 중…</span></div>\';'
|
||
'function resetShellLook(){'
|
||
'document.querySelectorAll("section.tab-content").forEach(function(sec){sec.innerHTML=SPINNER;});'
|
||
'var tk=document.getElementById("market-ticker");'
|
||
'if(tk){tk.innerHTML=\'<span class="idx-empty">지수 불러오는 중…</span>\';tickerItems=[];}'
|
||
'}'
|
||
'function chartDays(){'
|
||
'var v=parseInt(localStorage.getItem("behive.chartDaysV2"),10);'
|
||
'if(isNaN(v))return null;'
|
||
'return v;'
|
||
'}'
|
||
'function chartUnit(){'
|
||
'var v=localStorage.getItem("behive.chartUnit");'
|
||
'if(!v)return null;'
|
||
'if(v!=="day"&&v!=="week"&&v!=="month"&&v!=="year")return null;'
|
||
'return v;'
|
||
'}'
|
||
'function chartMode(){'
|
||
'var v=localStorage.getItem("behive.chartMode");'
|
||
'if(!v)return null;'
|
||
'if(v!=="net"&&v!=="pnl"&&v!=="period")return null;'
|
||
'return v;'
|
||
'}'
|
||
'function adrDays(){'
|
||
'var v=parseInt(localStorage.getItem("behive.adrTrendDays"),10);'
|
||
'if(isNaN(v))return null;'
|
||
# 허용된 preset 값만 통과 (서버 sanity와 일치). preset 변경 시 양쪽 같이 갱신.
|
||
'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
|
||
'return v;'
|
||
'}'
|
||
'window.__behive_chart_days=chartDays;'
|
||
'window.__behive_chart_unit=chartUnit;'
|
||
'window.__behive_chart_mode=chartMode;'
|
||
'window.__behive_adr_days=adrDays;'
|
||
'function load(owner,fresh){'
|
||
# 5분 이상 stale 상태에서 들어오면 모든 탭을 새로 채워야 하니 owner 부분 fetch도 전체로 승격.
|
||
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){resetShellLook();owner=null;}'
|
||
'var key=owner||"all";'
|
||
'if(!fresh&&inFlight[key])return inFlight[key];'
|
||
'var qs=[];if(owner)qs.push("owner="+encodeURIComponent(owner));if(fresh)qs.push("fresh=1");'
|
||
'var cd=chartDays();if(cd!==null)qs.push("chart_days="+cd);'
|
||
'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
|
||
'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
|
||
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
|
||
'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
|
||
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
|
||
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
|
||
'.then(function(j){apply(j);lastLoadAt=Date.now();})'
|
||
'.catch(function(){failVisible("갱신 실패 — 다시 시도해주세요");});'
|
||
'var done=function(){if(!fresh)delete inFlight[key];};'
|
||
'pr.finally?pr.finally(done):pr.then(done,done);'
|
||
'if(!fresh)inFlight[key]=pr;'
|
||
'return pr;'
|
||
'}'
|
||
'function activeOwner(){'
|
||
'var a=document.documentElement.getAttribute("data-tab")||"";'
|
||
'return (a==="tab-self"||a==="tab-gahee")?a.slice(4):null;'
|
||
'}'
|
||
'var spinCount=0;'
|
||
'function manualRefresh(){'
|
||
'var btn=document.querySelector("a.refresh");'
|
||
'spinCount++;'
|
||
'if(btn)btn.classList.add("spinning");'
|
||
'var p=load(activeOwner(),true);'
|
||
'var done=function(){'
|
||
'spinCount=Math.max(0,spinCount-1);'
|
||
'if(spinCount===0&&btn)btn.classList.remove("spinning");'
|
||
'if(window.__behive_auto_arm)window.__behive_auto_arm();'
|
||
'};'
|
||
'if(p&&p.finally)p.finally(done);'
|
||
'else if(p&&p.then)p.then(done,done);'
|
||
'else done();'
|
||
'}'
|
||
'window.__behive_load=load;'
|
||
'window.__behive_active_owner=activeOwner;'
|
||
'window.__behive_manual_refresh=manualRefresh;'
|
||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){load();});'
|
||
'else load();'
|
||
'})();</script>'
|
||
)
|
||
|
||
# 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익).
|
||
# data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신.
|
||
# MutationObserver로 swap 후 새 .net-chart 자동 재바인딩.
|
||
netchart_script = (
|
||
'<script>(function(){'
|
||
'var W=800;'
|
||
'function fmt(n){return (n<0?"-":"+")+Math.abs(n).toLocaleString()+"원";}'
|
||
'function cls(n){return n>0?"up":(n<0?"down":"neutral");}'
|
||
'function init(root){'
|
||
'if(root.__nw_bound)return;root.__nw_bound=true;'
|
||
'var svg=root.querySelector("svg.net-chart-svg");'
|
||
'var tip=root.querySelector(".net-chart-tip");'
|
||
'var hg=svg&&svg.querySelector("g.nw-hover");'
|
||
'if(!svg||!tip||!hg)return;'
|
||
'var line=hg.querySelector("line"),dot=hg.querySelector("circle");'
|
||
'var pts;try{pts=JSON.parse(root.getAttribute("data-pts")||"[]");}catch(e){pts=[];}'
|
||
'if(!pts.length)return;'
|
||
'var dEl=tip.querySelector(".tip-date");'
|
||
'var dayEl=tip.querySelector(".tip-day");'
|
||
'var totEl=tip.querySelector(".tip-total");'
|
||
'var netEl=tip.querySelector(".tip-net");'
|
||
'var cfRow=tip.querySelector(".tip-cf-row");'
|
||
'var cfEl=tip.querySelector(".tip-cf");'
|
||
'var hideTimer=null;'
|
||
'function scheduleHide(){if(hideTimer)clearTimeout(hideTimer);hideTimer=setTimeout(clear,15000);}'
|
||
'function applyPoint(p){'
|
||
'line.setAttribute("x1",p.x);line.setAttribute("x2",p.x);'
|
||
'dot.setAttribute("cx",p.x);dot.setAttribute("cy",p.y);'
|
||
'hg.setAttribute("opacity","1");'
|
||
# 날짜 라벨 — unit 단위에 맞춰: year=YYYY, month=YYYY-MM, week/day=YYYY-MM-DD (요일)
|
||
'var unit=root.getAttribute("data-chart-unit")||"day";'
|
||
'var dateLabel;'
|
||
'if(unit==="year"){dateLabel=p.d.substring(0,4);}'
|
||
'else if(unit==="month"){dateLabel=p.d.substring(0,7);}'
|
||
'else{var dt=new Date(p.d+"T00:00:00+09:00");'
|
||
'var dow=isNaN(dt.getTime())?"":["일","월","화","수","목","금","토"][dt.getDay()];'
|
||
'dateLabel=p.d+(dow?" ("+dow+")":"");}'
|
||
'dEl.textContent=dateLabel;'
|
||
'dayEl.textContent=fmt(p.day);dayEl.className="tip-day "+cls(p.day);'
|
||
'totEl.textContent=fmt(p.total);totEl.className="tip-total "+cls(p.total);'
|
||
'netEl.textContent=p.v.toLocaleString()+"원";'
|
||
# 입출금 행: cf_net 있으면 표시, 없으면 hide. 입금만/출금만/둘다 케이스 분기.
|
||
'if(cfRow&&cfEl){'
|
||
'if(typeof p.cf_net==="number"){'
|
||
'var ci=p.cf_in||0,co=p.cf_out||0,parts=[];'
|
||
'if(ci)parts.push("입금 "+ci.toLocaleString()+"원");'
|
||
'if(co)parts.push("출금 "+co.toLocaleString()+"원");'
|
||
'cfEl.textContent=parts.join(" · ")||"—";'
|
||
'cfEl.className="tip-cf "+cls(p.cf_net);'
|
||
'cfRow.hidden=false;'
|
||
'}else{cfRow.hidden=true;}'
|
||
'}'
|
||
'tip.style.opacity="1";'
|
||
'scheduleHide();'
|
||
'requestAnimationFrame(function(){'
|
||
'var r=svg.getBoundingClientRect();if(!r.width)return;'
|
||
'var rr=root.getBoundingClientRect();'
|
||
'var pxX=p.x/W*r.width+(r.left-rr.left);'
|
||
'var tw=tip.offsetWidth;'
|
||
'var lx=pxX-tw/2;'
|
||
'var maxL=rr.width-tw-4;'
|
||
'if(lx<4)lx=4;if(lx>maxL)lx=maxL;'
|
||
'tip.style.left=lx+"px";'
|
||
# top: indicator 점(=사용자 손가락 근처)이 차트 위쪽 절반이면 tooltip을 하단,
|
||
# 아래쪽 절반이면 상단에 배치 → 터치 지점·indicator 라인 항상 피함.
|
||
'var svgTop=r.top-rr.top;'
|
||
'var th=tip.offsetHeight;'
|
||
'var ty=(p.y<90)?(svgTop+r.height-th-4):(svgTop+4);'
|
||
'tip.style.top=ty+"px";'
|
||
'});'
|
||
'}'
|
||
'function moveByPx(cx){'
|
||
'var r=svg.getBoundingClientRect();if(!r.width)return;'
|
||
'var px=(cx-r.left)/r.width*W;'
|
||
'var bi=0,bd=Math.abs(pts[0].x-px);'
|
||
'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
|
||
'root.__nw_idx=bi;applyPoint(pts[bi]);'
|
||
'}'
|
||
# 인덱스 단위 좌/우 이동. 첫 클릭이면 prev는 마지막 포인트, next는 첫 포인트에서 시작.
|
||
'function step(dir){'
|
||
'var idx=(typeof root.__nw_idx==="number")?root.__nw_idx:(dir>0?-1:pts.length);'
|
||
'idx+=dir;'
|
||
'if(idx<0)idx=0;'
|
||
'if(idx>=pts.length)idx=pts.length-1;'
|
||
'root.__nw_idx=idx;applyPoint(pts[idx]);'
|
||
'}'
|
||
'function clear(){if(hideTimer){clearTimeout(hideTimer);hideTimer=null;}hg.setAttribute("opacity","0");tip.style.opacity="0";delete root.__nw_idx;}'
|
||
'root.__nw_step=step;root.__nw_clear=clear;'
|
||
# 터치/마우스 떠나도 indicator·툴팁 유지. 다음 차트 터치/호버로만 위치 변경.
|
||
'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
|
||
'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
|
||
'}'
|
||
# 기간 chip + 날짜 nav 버튼 — document 위임. swap/MutationObserver 타이밍 의존 없이 동작.
|
||
# modal_script와는 closest selector가 달라 충돌 없음.
|
||
# 지수 모달 안 sparkline 차트 — 축 라벨(Y min/max, X 시작/끝일) + 클릭 시 세로선 인디케이터 + 툴팁.
|
||
'window.__behive_render_idx_chart=function(host,series){'
|
||
'if(!series||series.length<2){host.innerHTML=\'<div class="idx-chart-err">차트 데이터 없음</div>\';return;}'
|
||
'var W=320,H=110,L=44,R=10,T=8,B=20;var iw=W-L-R,ih=H-T-B;var n=series.length;'
|
||
'var vs=series.map(function(p){return p.v;});'
|
||
'var mn=Math.min.apply(null,vs),mx=Math.max.apply(null,vs);'
|
||
'var sp=Math.max(mx-mn,1e-9);'
|
||
'function xAt(i){return L+(i/(n-1))*iw;}'
|
||
'function yAt(v){return T+(1-(v-mn)/sp)*ih;}'
|
||
'var pts=series.map(function(p,i){return{d:p.d,v:p.v,x:+xAt(i).toFixed(2),y:+yAt(p.v).toFixed(2)};});'
|
||
'var up=vs[vs.length-1]>=vs[0];var col=up?"#ff4d5e":"#4a8cf0";'
|
||
'var dPath="M "+pts.map(function(p){return p.x+","+p.y;}).join(" L ");'
|
||
# Y축 라벨 (max·mid·min) + X축 라벨 (시작·끝일 MM-DD)
|
||
'function fmtNum(v){return (v>=1000?v.toFixed(0):(v>=10?v.toFixed(2):v.toFixed(3)));}'
|
||
'var midV=(mn+mx)/2;'
|
||
'var yLabels=\'\';'
|
||
'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(T+3)+\'" text-anchor="end">\'+fmtNum(mx)+\'</text>\';'
|
||
'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(yAt(midV)+3)+\'" text-anchor="end">\'+fmtNum(midV)+\'</text>\';'
|
||
'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(T+ih+3)+\'" text-anchor="end">\'+fmtNum(mn)+\'</text>\';'
|
||
'var startD=series[0].d.slice(5),endD=series[n-1].d.slice(5);'
|
||
'var xLabels=\'\';'
|
||
'xLabels+=\'<text class="idx-axis" x="\'+L+\'" y="\'+(H-6)+\'" text-anchor="start">\'+startD+\'</text>\';'
|
||
'xLabels+=\'<text class="idx-axis" x="\'+(L+iw)+\'" y="\'+(H-6)+\'" text-anchor="end">\'+endD+\'</text>\';'
|
||
'var grid=\'\';'
|
||
'grid+=\'<line class="idx-grid" x1="\'+L+\'" x2="\'+(L+iw)+\'" y1="\'+(yAt(midV))+\'" y2="\'+(yAt(midV))+\'"/>\';'
|
||
'var lx=pts[n-1].x,ly=pts[n-1].y;'
|
||
'var svgHtml=\'<svg viewBox="0 0 \'+W+\' \'+H+\'" class="idx-chart-svg" preserveAspectRatio="xMidYMid meet">\'+'
|
||
'grid+yLabels+xLabels+'
|
||
'\'<path d="\'+dPath+\'" fill="none" stroke="\'+col+\'" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>\'+'
|
||
'\'<circle cx="\'+lx+\'" cy="\'+ly+\'" r="3" fill="\'+col+\'"/>\'+'
|
||
# hover/click 인디케이터 — 초기 숨김
|
||
'\'<g class="idx-hover" opacity="0" pointer-events="none">\'+'
|
||
'\'<line class="idx-hover-line" x1="0" x2="0" y1="\'+T+\'" y2="\'+(T+ih)+\'"/>\'+'
|
||
'\'<circle class="idx-hover-dot" cx="0" cy="0" r="3.4"/>\'+'
|
||
'\'</g>\'+'
|
||
'\'</svg>\';'
|
||
'host.innerHTML=svgHtml+\'<div class="idx-chart-tip" hidden><span class="tip-d"></span><span class="tip-v"></span><button type="button" class="tip-close" data-tip-close aria-label="닫기">×</button></div>\'+\'<div class="idx-chart-foot"><span>1개월 추이</span><span>\'+n+\'개 영업일</span></div>\';'
|
||
'var svg=host.querySelector("svg.idx-chart-svg");'
|
||
'var hg=svg.querySelector("g.idx-hover"),hl=hg.querySelector("line"),hd=hg.querySelector("circle");'
|
||
'var tip=host.querySelector(".idx-chart-tip");'
|
||
'var tipD=tip.querySelector(".tip-d"),tipV=tip.querySelector(".tip-v");'
|
||
'function apply(p){'
|
||
'hl.setAttribute("x1",p.x);hl.setAttribute("x2",p.x);'
|
||
'hd.setAttribute("cx",p.x);hd.setAttribute("cy",p.y);hd.setAttribute("fill",col);'
|
||
'hg.setAttribute("opacity","1");'
|
||
'tipD.textContent=p.d;tipV.textContent=fmtNum(p.v);tip.hidden=false;'
|
||
'}'
|
||
'function nearestByPx(cx){'
|
||
'var r=svg.getBoundingClientRect();if(!r.width)return null;'
|
||
'var px=(cx-r.left)/r.width*W;'
|
||
'var bi=0,bd=Math.abs(pts[0].x-px);'
|
||
'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
|
||
'return pts[bi];'
|
||
'}'
|
||
'function onPtr(e){var p=nearestByPx(e.clientX);if(p)apply(p);}'
|
||
'svg.addEventListener("click",onPtr);'
|
||
'svg.addEventListener("pointermove",function(e){if(e.pressure>0||e.buttons>0)onPtr(e);});'
|
||
# tip 닫기 버튼 — hover 인디케이터 + 툴팁 둘 다 숨김
|
||
'var closeBtn=tip.querySelector("[data-tip-close]");'
|
||
'if(closeBtn)closeBtn.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();tip.hidden=true;hg.setAttribute("opacity","0");});'
|
||
'};'
|
||
# 시장 카드 지수 설명 ⓘ 버튼 — 클릭 시 idx-info-modal 팝업 (제목/본문 동적 채움)
|
||
'document.addEventListener("click",function(e){'
|
||
'var ib=e.target.closest&&e.target.closest("[data-idx-info]");'
|
||
'if(!ib)return;'
|
||
'e.preventDefault();'
|
||
'var label=ib.getAttribute("data-idx-label")||"지수";'
|
||
'var desc=ib.getAttribute("data-idx-desc")||"";'
|
||
'var price=ib.getAttribute("data-idx-price")||"";'
|
||
'var change=ib.getAttribute("data-idx-change")||"";'
|
||
'var pct=ib.getAttribute("data-idx-pct")||"";'
|
||
'var dir=ib.getAttribute("data-idx-direction")||"flat";'
|
||
'var sign=dir==="up"?"+":(dir==="down"?"−":"");'
|
||
'var modal=document.getElementById("idx-info-modal");if(!modal)return;'
|
||
'var tEl=modal.querySelector("[data-idx-modal-title]");'
|
||
'var bEl=modal.querySelector("[data-idx-modal-body]");'
|
||
'var pEl=modal.querySelector("[data-idx-modal-price]");'
|
||
'var cEl=modal.querySelector("[data-idx-modal-change]");'
|
||
'if(tEl)tEl.textContent=label;'
|
||
'if(bEl)bEl.textContent=desc;'
|
||
'if(pEl)pEl.textContent=price||"—";'
|
||
'if(cEl){cEl.textContent=change?(sign+change+" ("+sign+pct+"%)"):"—";cEl.className="idx-info-change "+dir;}'
|
||
# 차트 lazy fetch — symbol 있으면 /api/idx-chart 호출 후 SVG 동적 렌더. window.__behive_render_idx_chart 호출.
|
||
'var symbol=ib.getAttribute("data-idx-symbol")||"";'
|
||
'var chEl=modal.querySelector("[data-idx-modal-chart]");'
|
||
'if(chEl){'
|
||
'if(!symbol){chEl.hidden=true;chEl.innerHTML="";}'
|
||
'else{'
|
||
'chEl.hidden=false;'
|
||
'chEl.innerHTML=\'<div class="idx-chart-loading">차트 불러오는 중…</div>\';'
|
||
'fetch("/api/idx-chart?symbol="+encodeURIComponent(symbol),{cache:"default"})'
|
||
'.then(function(r){return r.ok?r.json():Promise.reject();})'
|
||
'.then(function(j){if(window.__behive_render_idx_chart)window.__behive_render_idx_chart(chEl,(j&&j.series)||[]);})'
|
||
'.catch(function(){chEl.innerHTML=\'<div class="idx-chart-err">차트 불러오기 실패</div>\';});'
|
||
'}'
|
||
'}'
|
||
'modal.classList.remove("hidden");'
|
||
'modal.setAttribute("aria-hidden","false");'
|
||
'if(window.__behiveLockScroll)window.__behiveLockScroll();'
|
||
'});'
|
||
# 시장 카드 새로고침 버튼 — manual_refresh (fresh=1) 호출 → 서버 시장 캐시 무효화 + panels 다시 fetch
|
||
'document.addEventListener("click",function(e){'
|
||
'var rb=e.target.closest&&e.target.closest("[data-refresh-market]");'
|
||
'if(!rb)return;'
|
||
'e.preventDefault();'
|
||
'rb.classList.add("spinning");rb.disabled=true;'
|
||
'var done=function(){rb.classList.remove("spinning");rb.disabled=false;};'
|
||
'var p=window.__behive_manual_refresh&&window.__behive_manual_refresh();'
|
||
'if(p&&p.finally)p.finally(done);'
|
||
'else if(p&&p.then)p.then(done,done);'
|
||
'else setTimeout(done,1500);'
|
||
'});'
|
||
'document.addEventListener("click",function(e){'
|
||
'var chip=e.target.closest&&e.target.closest(".nw-chip");'
|
||
'if(chip){'
|
||
'e.preventDefault();'
|
||
'var rootC=chip.closest(".net-chart");if(!rootC)return;'
|
||
'var d=chip.getAttribute("data-chart-days");'
|
||
'var u=chip.getAttribute("data-chart-unit");'
|
||
'if(d!==null){try{localStorage.setItem("behive.chartDaysV2",d);}catch(_){}}'
|
||
'else if(u!==null){try{localStorage.setItem("behive.chartUnit",u);}catch(_){}}'
|
||
'else{return;}'
|
||
# 레거시 chip click 흐름은 유지(혹시 다른 곳 chip 남아있을 때 대비). 새 UI는 아래 select change로.
|
||
# active 토글은 같은 chip 그룹(parentNode = 한 .net-chart-options)으로 제한 — 다른 그룹 active 보존.
|
||
'var grp=chip.parentNode;'
|
||
'if(grp)grp.querySelectorAll(".nw-chip").forEach(function(x){x.classList.remove("active");});'
|
||
'chip.classList.add("active");'
|
||
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
|
||
'if(window.__behive_load)window.__behive_load(ow,true);'
|
||
'return;'
|
||
'}'
|
||
'var nav=e.target.closest&&e.target.closest("[data-nw-step]");'
|
||
'if(nav){'
|
||
'e.preventDefault();e.stopPropagation();'
|
||
'var rootN=nav.closest(".net-chart");if(!rootN)return;'
|
||
'var act=nav.getAttribute("data-nw-step");'
|
||
'if(act==="clear"){if(rootN.__nw_clear)rootN.__nw_clear();}'
|
||
'else if(rootN.__nw_step){rootN.__nw_step(act==="next"?1:-1);}'
|
||
'return;'
|
||
'}'
|
||
'});'
|
||
# 자산정보 탭의 sub-tab (자산보기/차트보기/시장정보) 클릭 핸들러 + swap 후 복원.
|
||
'function applySub(root,key){'
|
||
'if(!root)return;'
|
||
'root.querySelectorAll(".sub-tab").forEach(function(x){'
|
||
'var on=x.getAttribute("data-sub-tab")===key;'
|
||
'x.classList.toggle("active",on);'
|
||
'x.setAttribute("aria-selected",on?"true":"false");'
|
||
'});'
|
||
'root.querySelectorAll(".sub-tab-panel").forEach(function(p){'
|
||
'if(p.getAttribute("data-sub-panel")===key)p.removeAttribute("hidden");'
|
||
'else p.setAttribute("hidden","");'
|
||
'});'
|
||
'}'
|
||
# 첫 페이지 진입 = '자산보기' default. 사용자가 차트보기 누른 뒤엔 자동 갱신 후에도 유지.
|
||
# localStorage 대신 in-memory 변수 — 새로고침/재진입 시 자산보기로 초기화된다.
|
||
'var subState="assets";'
|
||
'function restoreSubs(){'
|
||
'if(subState==="assets")return;'
|
||
'document.querySelectorAll(".sub-tabs").forEach(function(t){applySub(t.parentNode||t,subState);});'
|
||
'}'
|
||
'document.addEventListener("click",function(e){'
|
||
'var st=e.target.closest&&e.target.closest(".sub-tab");'
|
||
'if(!st)return;'
|
||
'var key=st.getAttribute("data-sub-tab");if(!key)return;'
|
||
'e.preventDefault();'
|
||
'var root=st.parentNode&&st.parentNode.parentNode;'
|
||
'applySub(root,key);'
|
||
'subState=key;'
|
||
'});'
|
||
# 합산/각계좌별도 단일 토글 — 클릭마다 두 상태 swap. owner KPI 카드·자산정보 sub-panel 어디서나
|
||
# 동일 in-memory state 공유. 패널 swap 후 restoreAccountViews가 모든 토글·pane을 다시 동기화.
|
||
'function applyAccountView(key){'
|
||
'document.querySelectorAll(".account-view-toggle-btn").forEach(function(b){'
|
||
'b.setAttribute("data-account-view-state",key);'
|
||
'});'
|
||
'document.querySelectorAll(".account-view-pane").forEach(function(p){'
|
||
'if(p.getAttribute("data-account-view-pane")===key)p.removeAttribute("hidden");'
|
||
'else p.setAttribute("hidden","");'
|
||
'});'
|
||
'}'
|
||
'var accountViewState="consolidated";'
|
||
'function restoreAccountViews(){'
|
||
'if(accountViewState==="consolidated")return;'
|
||
'applyAccountView(accountViewState);'
|
||
'}'
|
||
'document.addEventListener("click",function(e){'
|
||
'var bt=e.target.closest&&e.target.closest(".account-view-toggle-btn");'
|
||
'if(!bt)return;'
|
||
'e.preventDefault();'
|
||
'var cur=bt.getAttribute("data-account-view-state")||"consolidated";'
|
||
'var next=cur==="consolidated"?"by-account":"consolidated";'
|
||
'accountViewState=next;'
|
||
'applyAccountView(next);'
|
||
'});'
|
||
# ADR 추세 카드의 기간 버튼 (1주/1달/3달/6달/1년/전체) — localStorage 저장 후 전체 재fetch.
|
||
'document.addEventListener("click",function(e){'
|
||
'var bt=e.target.closest&&e.target.closest(".adr-range-btn");'
|
||
'if(!bt)return;'
|
||
'var v=bt.getAttribute("data-adr-range");if(v===null)return;'
|
||
'e.preventDefault();'
|
||
'try{localStorage.setItem("behive.adrTrendDays",v);}catch(_){}'
|
||
# 같은 그룹의 다른 버튼 active 해제 — 서버 응답 오기 전 즉시 시각 피드백.
|
||
'var grp=bt.parentNode;'
|
||
'if(grp){grp.querySelectorAll(".adr-range-btn").forEach(function(b){'
|
||
'var on=b===bt;'
|
||
'b.classList.toggle("active",on);'
|
||
'b.setAttribute("aria-pressed",on?"true":"false");'
|
||
'});}'
|
||
# 시장정보 sub-tab은 자산정보 탭의 일부 → owner=null full fetch (fresh=true).
|
||
'if(window.__behive_load)window.__behive_load(null,true);'
|
||
'});'
|
||
# select 드롭다운(기간·단위) 변경 핸들러 — 자산정보 탭 상단의 공통 컨트롤.
|
||
# chip 흐름과 동일한 localStorage 키 사용 → 백엔드 변경 없음.
|
||
'document.addEventListener("change",function(e){'
|
||
'var sel=e.target;'
|
||
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
|
||
'if(sel.hasAttribute("data-nw-range")){'
|
||
'try{localStorage.setItem("behive.chartDaysV2",sel.value);}catch(_){}'
|
||
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
|
||
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
|
||
'var allowed=allowedMap[sel.value]||["day","week","month","year"];'
|
||
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
|
||
'var curU=unitSel?unitSel.value:"day";'
|
||
'if(allowed.indexOf(curU)<0){'
|
||
'try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}'
|
||
'}'
|
||
'}else if(sel.hasAttribute("data-nw-unit")){'
|
||
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
|
||
'}else if(sel.hasAttribute("data-nw-mode")){'
|
||
'try{localStorage.setItem("behive.chartMode",sel.value);}catch(_){}'
|
||
'}else{return;}'
|
||
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
|
||
'if(window.__behive_load)window.__behive_load(ow,true);'
|
||
'});'
|
||
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();}'
|
||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
|
||
'else scan();'
|
||
# swap 후 새 차트가 들어오면 재바인딩 + sub-tab 복원
|
||
'new MutationObserver(scan).observe(document.body,{childList:true,subtree:true});'
|
||
'})();</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 = (
|
||
'<script>(function(){'
|
||
'function cls(v){return v>=125?"up":(v<=75?"down":"flat");}'
|
||
# selectedDate — 마지막 선택 날짜 ISO('YYYY-MM-DD'). 단일 차트라 시장 키 불필요.
|
||
'var selectedDate=null;'
|
||
'function init(row){'
|
||
'if(row.__adr_bound)return;row.__adr_bound=true;'
|
||
'var svg=row.querySelector("svg.adr-trend-svg");'
|
||
'var tip=row.querySelector(".adr-trend-tip");'
|
||
'var hg=svg&&svg.querySelector("g.adr-hover");'
|
||
'if(!svg||!tip||!hg)return;'
|
||
'var line=hg.querySelector("line");'
|
||
'var dotK=hg.querySelector(".adr-hover-dot-kospi");'
|
||
'var dotD=hg.querySelector(".adr-hover-dot-kosdaq");'
|
||
'var pts;try{pts=JSON.parse(row.getAttribute("data-adr-pts")||"[]");}catch(e){pts=[];}'
|
||
'if(!pts.length)return;'
|
||
'var vb=(row.getAttribute("data-adr-vb")||"360 95").split(/\\s+/);'
|
||
'var VW=parseFloat(vb[0])||360,VH=parseFloat(vb[1])||95;'
|
||
'var dEl=tip.querySelector(".tip-d");'
|
||
'var lineK=tip.querySelector(".tip-kospi");'
|
||
'var lineD=tip.querySelector(".tip-kosdaq");'
|
||
'var vK=lineK?lineK.querySelector(".tip-v"):null;'
|
||
'var vD=lineD?lineD.querySelector(".tip-v"):null;'
|
||
'function setDot(dot,pt){'
|
||
'if(!dot)return;'
|
||
'if(pt){dot.setAttribute("cx",pt.y!=null?pt.x:0);dot.setAttribute("cy",pt.y);dot.setAttribute("opacity","1");}'
|
||
'else{dot.setAttribute("opacity","0");}'
|
||
'}'
|
||
'function setTipLine(lineEl,vEl,pt){'
|
||
'if(!lineEl||!vEl)return;'
|
||
'if(pt){'
|
||
'vEl.textContent=pt.v.toFixed(1);'
|
||
'vEl.className="tip-v "+cls(pt.v)+(pt.live?" live":"");'
|
||
'lineEl.hidden=false;'
|
||
'}else{lineEl.hidden=true;}'
|
||
'}'
|
||
'function applyPoint(p,save){'
|
||
'line.setAttribute("x1",p.x);line.setAttribute("x2",p.x);'
|
||
'hg.setAttribute("opacity","1");'
|
||
# 점 — 시장별 데이터 있는 쪽만 표시
|
||
'var pk=p.kospi||null,pd=p.kosdaq||null;'
|
||
'if(pk){dotK.setAttribute("cx",p.x);dotK.setAttribute("cy",pk.y);dotK.setAttribute("opacity","1");}'
|
||
'else dotK.setAttribute("opacity","0");'
|
||
'if(pd){dotD.setAttribute("cx",p.x);dotD.setAttribute("cy",pd.y);dotD.setAttribute("opacity","1");}'
|
||
'else dotD.setAttribute("opacity","0");'
|
||
# 날짜 — MM-DD (요일)
|
||
'var dt=new Date((p.d||"")+"T00:00:00+09:00");'
|
||
'var dow=isNaN(dt.getTime())?"":["일","월","화","수","목","금","토"][dt.getDay()];'
|
||
'var dlbl=(p.d||"").length>=10?p.d.slice(5):(p.d||"");'
|
||
'dEl.textContent=dlbl+(dow?" ("+dow+")":"");'
|
||
'setTipLine(lineK,vK,pk);'
|
||
'setTipLine(lineD,vD,pd);'
|
||
'tip.style.opacity="1";'
|
||
'if(save!==false&&p.d)selectedDate=p.d;'
|
||
# 위치 — svg 내 x 비율로 변환 후 row(absolute parent) 기준 left 보정.
|
||
'requestAnimationFrame(function(){'
|
||
'var r=svg.getBoundingClientRect();if(!r.width)return;'
|
||
'var rr=row.getBoundingClientRect();'
|
||
'var pxX=p.x/VW*r.width+(r.left-rr.left);'
|
||
'var tw=tip.offsetWidth;'
|
||
'var lx=pxX-tw/2;'
|
||
'var maxL=rr.width-tw-4;'
|
||
'if(lx<4)lx=4;if(lx>maxL)lx=maxL;'
|
||
'tip.style.left=lx+"px";'
|
||
# top: 두 점 평균 y 가 차트 상단이면 svg 아래, 하단이면 svg 위.
|
||
'var svgTop=r.top-rr.top;'
|
||
'var th=tip.offsetHeight;'
|
||
'var ys=[];if(pk)ys.push(pk.y);if(pd)ys.push(pd.y);'
|
||
'var avgY=ys.length?(ys.reduce(function(a,b){return a+b;},0)/ys.length):VH/2;'
|
||
'var pyRatio=avgY/VH;'
|
||
'var ty=(pyRatio<0.5)?(svgTop+r.height-th-2):(svgTop-th-2);'
|
||
'if(ty<-2)ty=svgTop+r.height-th-2;'
|
||
'tip.style.top=ty+"px";'
|
||
'});'
|
||
'}'
|
||
'function moveByPx(cx){'
|
||
'var r=svg.getBoundingClientRect();if(!r.width)return;'
|
||
'var px=(cx-r.left)/r.width*VW;'
|
||
'var bi=0,bd=Math.abs(pts[0].x-px);'
|
||
'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
|
||
'applyPoint(pts[bi],true);'
|
||
'}'
|
||
'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
|
||
'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
|
||
# 영속 복원 — 마지막 선택 날짜가 있고 새 데이터에도 있으면 indicator 재배치.
|
||
'if(selectedDate){'
|
||
'for(var k=0;k<pts.length;k++){'
|
||
'if(pts[k].d===selectedDate){applyPoint(pts[k],false);break;}'
|
||
'}'
|
||
'}'
|
||
'}'
|
||
'function scan(){document.querySelectorAll(".adr-trend-row[data-adr-pts]").forEach(init);}'
|
||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
|
||
'else scan();'
|
||
# swap(자동 갱신) 후 새 DOM 들어오면 재바인딩 + 선택 복원.
|
||
'new MutationObserver(scan).observe(document.body,{childList:true,subtree:true});'
|
||
'})();</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 = (
|
||
'<script>(function(){'
|
||
'var dailyCache=new Map();'
|
||
# minuteCache key = "code:unit:subrange" (예: "005930:5m:1h"). value = {svg, ts}.
|
||
'var minuteCache=new Map();'
|
||
'var activeRange=new Map();'
|
||
# activeSubRange key = "code:unit". value = "30min"|"1h"|"3h"|"5h"|"1d". 기본 "1h".
|
||
'var activeSubRange=new Map();'
|
||
'var inFlight=new Map();'
|
||
'var MINUTE_TTL_MS=30000;'
|
||
'var DEFAULT_SUBRANGE="1h";'
|
||
'var Q=function(s){return s.replace(/"/g,\'\\\\"\');};'
|
||
'function applyActive(scope,range){'
|
||
'if(!scope||!range)return;'
|
||
'scope.querySelectorAll(".chart-tabs button").forEach(function(b){'
|
||
'b.classList.toggle("active",b.getAttribute("data-range")===range);'
|
||
'});'
|
||
'scope.querySelectorAll(".chart-panel").forEach(function(p){'
|
||
'p.classList.toggle("active",p.getAttribute("data-range")===range);'
|
||
'});'
|
||
'}'
|
||
'function applySubActive(panel,subrange){'
|
||
'if(!panel||!subrange)return;'
|
||
'panel.querySelectorAll(".chart-subtabs button").forEach(function(b){'
|
||
'b.classList.toggle("active",b.getAttribute("data-subrange")===subrange);'
|
||
'});'
|
||
'}'
|
||
# 일봉 → daily 복원 직후 activeRange 적용. 활성이 분봉이면 fetchMinute 자동.
|
||
'function applyAndMaybeMinute(scope,code){'
|
||
'var ar=activeRange.get(code);'
|
||
'if(!ar)return;'
|
||
'applyActive(scope,ar);'
|
||
'if(ar==="5m"||ar==="1m"||ar==="15m"){'
|
||
'var panel=scope.querySelector(\'.chart-panel[data-range="\'+Q(ar)+\'"]\');'
|
||
'if(panel){'
|
||
'var sr=activeSubRange.get(code+":"+ar)||DEFAULT_SUBRANGE;'
|
||
'applySubActive(panel,sr);'
|
||
'var subpanel=panel.querySelector(".chart-subpanel");'
|
||
'if(subpanel)fetchMinute(code,ar,sr,subpanel);'
|
||
'}'
|
||
'}'
|
||
'}'
|
||
'function fetchDaily(code,container){'
|
||
'if(!code||!container)return null;'
|
||
'if(dailyCache.has(code)){'
|
||
'container.innerHTML=dailyCache.get(code);'
|
||
'applyAndMaybeMinute(container,code);'
|
||
'}else if(!container.firstElementChild){'
|
||
'container.innerHTML=\'<div class="chart-loading">차트 불러오는 중…</div>\';'
|
||
'}'
|
||
'var key="d:"+code;'
|
||
'if(inFlight.has(key))return inFlight.get(key);'
|
||
'var p=fetch("/api/chart_svg?code="+encodeURIComponent(code),{credentials:"same-origin",cache:"no-store"})'
|
||
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text();})'
|
||
'.then(function(html){'
|
||
'dailyCache.set(code,html);'
|
||
'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"]\').forEach(function(el){'
|
||
'if(el.closest("details[open]")){'
|
||
'el.innerHTML=html;'
|
||
'applyAndMaybeMinute(el,code);'
|
||
'}'
|
||
'});'
|
||
'})'
|
||
'.catch(function(){'
|
||
'if(!dailyCache.has(code))container.innerHTML=\'<div class="chart-error">차트 불러오기 실패</div>\';'
|
||
'})'
|
||
'.finally(function(){inFlight.delete(key);});'
|
||
'inFlight.set(key,p);'
|
||
'return p;'
|
||
'}'
|
||
'function fetchMinute(code,unit,subrange,subpanel){'
|
||
'if(!code||!unit||!subpanel)return null;'
|
||
'var sr=subrange||DEFAULT_SUBRANGE;'
|
||
'var key=code+":"+unit+":"+sr;'
|
||
'var cached=minuteCache.get(key);'
|
||
'var now=Date.now();'
|
||
'if(cached){'
|
||
'subpanel.innerHTML=cached.svg;'
|
||
'if(now-cached.ts<MINUTE_TTL_MS)return null;'
|
||
'}else if(!subpanel.firstElementChild){'
|
||
'subpanel.innerHTML=\'<div class="chart-loading">분봉 불러오는 중…</div>\';'
|
||
'}'
|
||
'var inKey="m:"+key;'
|
||
'if(inFlight.has(inKey))return inFlight.get(inKey);'
|
||
'var url="/api/chart_svg?code="+encodeURIComponent(code)+"&unit="+encodeURIComponent(unit)+"&range="+encodeURIComponent(sr);'
|
||
'var p=fetch(url,{credentials:"same-origin",cache:"no-store"})'
|
||
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text();})'
|
||
'.then(function(svg){'
|
||
'minuteCache.set(key,{svg:svg,ts:Date.now()});'
|
||
# 같은 종목/유닛 모든 subpanel 갱신 — 단 사용자가 그 subrange 를 현재 보고 있을 때만.
|
||
'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"] .chart-panel[data-range="\'+Q(unit)+\'"] .chart-subpanel\').forEach(function(p){'
|
||
'var curSr=activeSubRange.get(code+":"+unit)||DEFAULT_SUBRANGE;'
|
||
'if(curSr===sr&&p.closest("details[open]"))p.innerHTML=svg;'
|
||
'});'
|
||
'})'
|
||
'.catch(function(){'
|
||
'if(!cached)subpanel.innerHTML=\'<div class="chart-error">분봉 불러오기 실패</div>\';'
|
||
'})'
|
||
'.finally(function(){inFlight.delete(inKey);});'
|
||
'inFlight.set(inKey,p);'
|
||
'return p;'
|
||
'}'
|
||
'function loadChartsInOpen(root){'
|
||
'(root||document).querySelectorAll("details[open]").forEach(function(d){'
|
||
'var c=d.querySelector(".chart-svg[data-chart-code]");'
|
||
'if(c)fetchDaily(c.getAttribute("data-chart-code"),c);'
|
||
'});'
|
||
'}'
|
||
# <details> toggle bubble 안 함 → capture=true.
|
||
'document.addEventListener("toggle",function(ev){'
|
||
'var d=ev.target;'
|
||
'if(!d||d.tagName!=="DETAILS"||!d.open)return;'
|
||
'var c=d.querySelector(".chart-svg[data-chart-code]");'
|
||
'if(c)fetchDaily(c.getAttribute("data-chart-code"),c);'
|
||
'},true);'
|
||
# crosshair: SVG 안에 overlay group 추가. press-and-hold 시작, 이동 따라다님, 떼면 svg.__drag만 해제(표시 유지).
|
||
# panels swap 으로 SVG 가 새 DOM 으로 갈아끼워져도 crosshairState 의 좌표·모드를 새 SVG 에 복원.
|
||
'var SVG_NS="http://www.w3.org/2000/svg";'
|
||
'var crosshairState=new Map();'
|
||
'function _mk(t,a){var e=document.createElementNS(SVG_NS,t);for(var k in a)e.setAttribute(k,a[k]);return e;}'
|
||
'function _stateKey(svg){'
|
||
'var ch=svg.closest&&svg.closest(".chart-svg");if(!ch)return null;'
|
||
'var code=ch.getAttribute("data-chart-code");if(!code)return null;'
|
||
'var panel=svg.closest(".chart-panel");if(!panel)return code;'
|
||
'var range=panel.getAttribute("data-range")||"";'
|
||
'var subpanel=svg.closest(".chart-subpanel");'
|
||
'if(subpanel){'
|
||
'var sr=(activeSubRange.get(code+":"+range)||DEFAULT_SUBRANGE);'
|
||
'return code+":"+range+":"+sr;'
|
||
'}'
|
||
'return code+":"+range;'
|
||
'}'
|
||
'function bindCrosshair(svg){'
|
||
'if(svg.__xh)return;svg.__xh=true;'
|
||
'var meta;try{meta=JSON.parse(svg.getAttribute("data-meta")||"null");}catch(e){return;}'
|
||
'if(!meta||!meta.candles||!meta.candles.length)return;'
|
||
'var ov=_mk("g",{"class":"crosshair-overlay"});'
|
||
'ov.style.display="none";ov.style.pointerEvents="none";'
|
||
'var vline=_mk("line",{stroke:"#cfd5df","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.65"});'
|
||
'var hline=_mk("line",{stroke:"#cfd5df","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.65"});'
|
||
# X 모드: 차트 내부 floating OHLC 팝업 (xTipG). Y 모드: hline 왼쪽 끝 가격 라벨 (yTipG).
|
||
'var xTipG=_mk("g",{"class":"x-tip"});'
|
||
'var xTipBg=_mk("rect",{x:"0",y:"0",width:"320",height:"176",fill:"rgba(15,20,25,0.95)",stroke:"#2a2f3a","stroke-width":"1",rx:"8"});'
|
||
'var xTipTxt=_mk("text",{fill:"#cfd5df"});'
|
||
'xTipG.appendChild(xTipBg);xTipG.appendChild(xTipTxt);'
|
||
'xTipG.style.display="none";'
|
||
'var yTipG=_mk("g",{"class":"y-tip"});'
|
||
'var yTipBg=_mk("rect",{x:"0",y:"0",width:"152",height:"40",fill:"rgba(15,20,25,0.96)",stroke:"#cfd5df","stroke-width":"1",rx:"6"});'
|
||
'var yTipTxt=_mk("text",{fill:"#cfd5df","text-anchor":"middle","dominant-baseline":"central","font-size":"26","font-weight":"700",x:"76",y:"20"});'
|
||
'yTipG.appendChild(yTipBg);yTipG.appendChild(yTipTxt);'
|
||
'yTipG.style.display="none";'
|
||
# 닫기 버튼 — OHLC 팝업(xTipG) 우상단 안쪽에 부착. xTipG transform 따라 함께 이동.
|
||
# net-chart-tip 의 .tip-close 와 동일 패턴(팝업 자체 우상단).
|
||
# ov 는 pointerEvents:none 이라 closeG 만 auto 로 override 해 클릭 통과.
|
||
'var closeG=_mk("g",{"class":"crosshair-close"});'
|
||
'var closeBg=_mk("circle",{cx:"0",cy:"0",r:"14",fill:"rgba(15,20,25,0.95)",stroke:"#cfd5df","stroke-width":"1.2"});'
|
||
'var closeTxt=_mk("text",{x:"0",y:"1","text-anchor":"middle","dominant-baseline":"central","font-size":"22","font-weight":"700",fill:"#cfd5df"});'
|
||
'closeTxt.textContent="×";'
|
||
'closeG.appendChild(closeBg);closeG.appendChild(closeTxt);'
|
||
'closeG.style.cursor="pointer";'
|
||
'closeG.style.pointerEvents="auto";'
|
||
# xTipBg 320×176 안의 우상단 모서리. r=14 라 여백 18 두고 중심 (300,20).
|
||
'closeG.setAttribute("transform","translate(300,20)");'
|
||
'function closeOverlay(ev){'
|
||
'if(ev){ev.stopPropagation();if(ev.cancelable)ev.preventDefault();}'
|
||
'ov.style.display="none";'
|
||
'svg.__drag=false;svg.__mode=null;'
|
||
'var k=_stateKey(svg);'
|
||
'if(k)crosshairState.delete(k);'
|
||
'}'
|
||
'closeG.addEventListener("click",closeOverlay);'
|
||
'closeG.addEventListener("touchstart",closeOverlay,{passive:false});'
|
||
# svg 의 mousedown=start 가 bubble 로 잡지 않도록 closeG 의 mousedown 도 차단.
|
||
'closeG.addEventListener("mousedown",function(ev){ev.stopPropagation();});'
|
||
'xTipG.appendChild(closeG);'
|
||
'ov.appendChild(vline);ov.appendChild(hline);ov.appendChild(xTipG);ov.appendChild(yTipG);'
|
||
'svg.appendChild(ov);'
|
||
# mode: "x" = 세로선+OHLC, "y" = 가로선+가격. 시작 위치로 결정 후 드래그 중 유지.
|
||
'svg.__mode=null;'
|
||
'function pt(ev){'
|
||
'var r=svg.getBoundingClientRect();'
|
||
'var cx=ev.touches?ev.touches[0].clientX:ev.clientX;'
|
||
'var cy=ev.touches?ev.touches[0].clientY:ev.clientY;'
|
||
'return [(cx-r.left)/r.width*meta.chartW,(cy-r.top)/r.height*meta.chartH];'
|
||
'}'
|
||
# 시가 대비 색상 — up 빨강, down 파랑 (한국 관습).
|
||
'function _col(v,base){return v>base?"#ef4444":(v<base?"#3b82f6":"#cfd5df");}'
|
||
'function _mkTspan(attrs,txt,fill){'
|
||
'var s=document.createElementNS(SVG_NS,"tspan");'
|
||
'for(var k in attrs)s.setAttribute(k,attrs[k]);'
|
||
'if(fill)s.setAttribute("fill",fill);'
|
||
's.textContent=txt;'
|
||
'return s;'
|
||
'}'
|
||
'function _pctStr(v,base){'
|
||
'if(!base)return "";'
|
||
'var p=(v-base)/base*100;'
|
||
'return (p>=0?"+":"")+p.toFixed(2)+"%";'
|
||
'}'
|
||
'function _setOhlcText(d,c,xPos){'
|
||
'while(xTipTxt.firstChild)xTipTxt.removeChild(xTipTxt.firstChild);'
|
||
'var ccol=_col(c.c,c.o),hcol=_col(c.h,c.o),lcol=_col(c.l,c.o);'
|
||
# 5행 팝업: 날짜 → 시 → 고 → 저 → 종(강조)
|
||
'xTipTxt.appendChild(_mkTspan({x:16,y:28,"font-size":22,"font-weight":500},d,"#8a8f9a"));'
|
||
'xTipTxt.appendChild(_mkTspan({x:16,dy:32,"font-size":24,"font-weight":500},"시 "+c.o.toLocaleString()));'
|
||
'xTipTxt.appendChild(_mkTspan({x:16,dy:30,"font-size":24,"font-weight":500},"고 "+c.h.toLocaleString()+" ",hcol));'
|
||
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.h,c.o),hcol));'
|
||
'xTipTxt.appendChild(_mkTspan({x:16,dy:30,"font-size":24,"font-weight":500},"저 "+c.l.toLocaleString()+" ",lcol));'
|
||
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.l,c.o),lcol));'
|
||
'xTipTxt.appendChild(_mkTspan({x:16,dy:34,"font-size":28,"font-weight":700},"종 "+c.c.toLocaleString()+" ",ccol));'
|
||
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":700},_pctStr(c.c,c.o),ccol));'
|
||
# 팝업 위치 — vline 의 반대 쪽으로 자동 배치 (좌/우). 화면 벗어남 방지 clamp.
|
||
'var tipW=320,tipH=176,gap=10;'
|
||
'var tipX=xPos+gap;'
|
||
'if(tipX+tipW>meta.chartW-meta.padR)tipX=xPos-gap-tipW;'
|
||
'if(tipX<meta.padL)tipX=meta.padL+2;'
|
||
'var tipY=meta.priceTop+8;'
|
||
'xTipG.setAttribute("transform","translate("+tipX+","+tipY+")");'
|
||
'}'
|
||
'function updateAt(vx,vy,mode){'
|
||
'svg.__mode=mode;'
|
||
'if(mode==="y"){'
|
||
# 가격(가로선) 모드 — 가로선 + 왼쪽 끝 가격 라벨.
|
||
'vline.style.display="none";'
|
||
'hline.style.display="";'
|
||
'xTipG.style.display="none";'
|
||
'yTipG.style.display="";'
|
||
'var clampY=Math.max(meta.priceTop,Math.min(meta.priceBot,vy));'
|
||
'hline.setAttribute("x1",meta.padL);hline.setAttribute("x2",meta.chartW-meta.padR);'
|
||
'hline.setAttribute("y1",clampY);hline.setAttribute("y2",clampY);'
|
||
'var frac=(meta.priceBot-clampY)/(meta.priceBot-meta.priceTop);'
|
||
'var price=meta.ymin+frac*(meta.ymax-meta.ymin);'
|
||
'yTipTxt.textContent=Math.round(price).toLocaleString();'
|
||
# 가로선 왼쪽 끝 위치 — y는 가로선 중심에 맞춰 ±lblH/2. 차트 안쪽 priceTop~priceBot clamp.
|
||
'var lblW=152,lblH=40;'
|
||
'var lblX=meta.padL+2;'
|
||
'var lblY=clampY-lblH/2;'
|
||
'if(lblY<meta.priceTop)lblY=meta.priceTop;'
|
||
'if(lblY+lblH>meta.priceBot)lblY=meta.priceBot-lblH;'
|
||
'yTipG.setAttribute("transform","translate("+lblX+","+lblY+")");'
|
||
'}else{'
|
||
# X(봉) 모드 — 세로선 + 차트내 OHLC 팝업.
|
||
'vline.style.display="";'
|
||
'hline.style.display="none";'
|
||
'yTipG.style.display="none";'
|
||
'xTipG.style.display="";'
|
||
'var n=meta.candles.length;'
|
||
'var plotW=meta.chartW-meta.padL-meta.padR;'
|
||
'var slot=plotW/n;'
|
||
'var i=Math.floor((vx-meta.padL)/slot);'
|
||
'if(i<0)i=0;if(i>=n)i=n-1;'
|
||
'var c=meta.candles[i];'
|
||
'var xPos=meta.padL+slot*(i+0.5);'
|
||
'vline.setAttribute("x1",xPos);vline.setAttribute("x2",xPos);'
|
||
'vline.setAttribute("y1",meta.priceTop);vline.setAttribute("y2",meta.volBot);'
|
||
'var d=c.d;'
|
||
'if(d&&d.length===8)d=d.slice(0,4)+"-"+d.slice(4,6)+"-"+d.slice(6,8);'
|
||
'_setOhlcText(d,c,xPos);'
|
||
'}'
|
||
'ov.style.display="";'
|
||
# panels swap 후 새 SVG 에 복원할 좌표·모드 저장.
|
||
'var key=_stateKey(svg);'
|
||
'if(key)crosshairState.set(key,{mode:mode,vx:vx,vy:vy});'
|
||
'}'
|
||
'function update(ev){'
|
||
'var p=pt(ev);'
|
||
'updateAt(p[0],p[1],svg.__mode);'
|
||
'}'
|
||
'function start(ev){'
|
||
'var p=pt(ev);'
|
||
# 우측 Y축 라벨 영역(가격 라벨 자리) 누르면 Y(가격) 모드, 그 외는 X(봉) 모드.
|
||
'svg.__mode=(p[0]>meta.chartW-meta.padR)?"y":"x";'
|
||
'svg.__drag=true;'
|
||
'updateAt(p[0],p[1],svg.__mode);'
|
||
'ev.preventDefault();'
|
||
'}'
|
||
'function moveIfDrag(ev){if(svg.__drag)update(ev);}'
|
||
# 손가락 떼도 overlay 유지 — drag flag 만 해제, 표시는 다음 누름 시 갱신.
|
||
'function endFn(){svg.__drag=false;}'
|
||
# panels swap 직후 새 SVG 에 이전 좌표·모드 복원.
|
||
'var _sk=_stateKey(svg);'
|
||
'if(_sk){'
|
||
'var _st=crosshairState.get(_sk);'
|
||
'if(_st)updateAt(_st.vx,_st.vy,_st.mode);'
|
||
'}'
|
||
'svg.addEventListener("mousedown",start);'
|
||
'svg.addEventListener("touchstart",start,{passive:false});'
|
||
# window 레벨 listen — 드래그가 SVG 영역 밖으로 벗어나도 떼지 않으면 유지.
|
||
'window.addEventListener("mousemove",moveIfDrag);'
|
||
'window.addEventListener("touchmove",function(ev){if(svg.__drag){update(ev);ev.preventDefault();}},{passive:false});'
|
||
'window.addEventListener("mouseup",endFn);'
|
||
'window.addEventListener("touchend",endFn);'
|
||
'window.addEventListener("touchcancel",endFn);'
|
||
'}'
|
||
'function bindAllCharts(root){'
|
||
'(root||document).querySelectorAll(".chart-svg svg[data-meta]").forEach(bindCrosshair);'
|
||
'}'
|
||
# MutationObserver — innerHTML 으로 들어오는 SVG 자동 바인딩.
|
||
'new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1)bindAllCharts(n);});});}).observe(document.body,{childList:true,subtree:true});'
|
||
# apply() swap 완료 이벤트 — 열린 카드 daily 복원 + activeRange 분봉 자동 갱신.
|
||
'document.addEventListener("behive:panels-loaded",function(){loadChartsInOpen();});'
|
||
# chart-tabs 클릭 — activeRange 기록 + 패널 토글 + 분봉이면 lazy fetch.
|
||
'document.addEventListener("click",function(ev){'
|
||
'var btn=ev.target.closest(".chart-svg .chart-tabs button[data-range]");'
|
||
'if(!btn)return;'
|
||
'var scope=btn.closest(".chart-svg");'
|
||
'if(!scope)return;'
|
||
'var code=scope.getAttribute("data-chart-code");'
|
||
'var range=btn.getAttribute("data-range");'
|
||
'var unit=btn.getAttribute("data-unit");'
|
||
'if(code)activeRange.set(code,range);'
|
||
'applyActive(scope,range);'
|
||
'if(unit&&code){'
|
||
'var panel=scope.querySelector(\'.chart-panel[data-range="\'+Q(range)+\'"]\');'
|
||
'if(panel){'
|
||
'var sr=activeSubRange.get(code+":"+unit)||DEFAULT_SUBRANGE;'
|
||
'applySubActive(panel,sr);'
|
||
'var subpanel=panel.querySelector(".chart-subpanel");'
|
||
'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
|
||
'}'
|
||
'}'
|
||
'},false);'
|
||
# chart-subtabs 클릭 — subrange 기록 + fetch.
|
||
'document.addEventListener("click",function(ev){'
|
||
'var btn=ev.target.closest(".chart-svg .chart-subtabs button[data-subrange]");'
|
||
'if(!btn)return;'
|
||
'var panel=btn.closest(".chart-panel[data-unit]");'
|
||
'var scope=btn.closest(".chart-svg");'
|
||
'if(!panel||!scope)return;'
|
||
'var code=scope.getAttribute("data-chart-code");'
|
||
'var unit=panel.getAttribute("data-unit");'
|
||
'var sr=btn.getAttribute("data-subrange");'
|
||
'if(!code||!unit||!sr)return;'
|
||
'activeSubRange.set(code+":"+unit,sr);'
|
||
'applySubActive(panel,sr);'
|
||
'var subpanel=panel.querySelector(".chart-subpanel");'
|
||
'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
|
||
'},false);'
|
||
# 첫 페이지 로드 시 이미 열려있는 카드 (드물지만 안전).
|
||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){loadChartsInOpen();});'
|
||
'else loadChartsInOpen();'
|
||
'})();</script>'
|
||
)
|
||
|
||
# pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함.
|
||
# iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛.
|
||
ptr_script = (
|
||
'<script>(function(){'
|
||
'var c=document.getElementById("page"),p=document.getElementById("ptr");if(!c||!p)return;'
|
||
'var sy=0,dy=0,pull=false,ok=false,T=70,MAX=110;'
|
||
# dragging 클래스 해제는 반드시 transform 클리어와 동시에. 분리되면 sticky topbar가 .page transform 좌표계에 갇혀 스크롤 따라옴.
|
||
'function reset(){c.classList.remove("dragging");c.style.transform="";p.style.transform="";p.style.opacity="";p.classList.remove("committed","spinning");}'
|
||
'document.addEventListener("touchstart",function(e){'
|
||
'if(window.scrollY<=0&&e.touches.length===1){'
|
||
'sy=e.touches[0].clientY;dy=0;pull=true;ok=false;'
|
||
'c.classList.add("dragging");'
|
||
'}},{passive:true});'
|
||
'document.addEventListener("touchmove",function(e){'
|
||
'if(!pull)return;dy=e.touches[0].clientY-sy;'
|
||
'if(dy<=0){reset();return;}'
|
||
'var d=dy*0.55;if(d>MAX)d=MAX;'
|
||
'var pct=Math.min(d/T,1),r=pct*180;'
|
||
'c.style.transform="translateY("+d+"px)";'
|
||
'p.style.transform="rotate("+r+"deg)";'
|
||
'p.style.opacity=Math.min(d/25,1);'
|
||
'if(d>T){if(!ok){ok=true;p.classList.add("committed");}}'
|
||
'else if(ok){ok=false;p.classList.remove("committed");}'
|
||
'},{passive:true});'
|
||
'document.addEventListener("touchend",function(){'
|
||
'if(!pull)return;'
|
||
'var d=dy*0.55;if(d>MAX)d=MAX;'
|
||
'if(d>T){'
|
||
'c.style.transform="translateY("+(T+15)+"px)";'
|
||
'p.style.transform="";'
|
||
'p.style.opacity="1";'
|
||
'p.classList.add("spinning");'
|
||
'var done=function(){'
|
||
'reset();'
|
||
'if(window.__behive_auto_arm)window.__behive_auto_arm();'
|
||
'};'
|
||
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
|
||
'var f=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
|
||
'f.then(done,done);'
|
||
'}else{reset();}'
|
||
'pull=false;dy=0;'
|
||
'});'
|
||
'})();</script>'
|
||
)
|
||
|
||
# 자동 갱신 토글 — localStorage 영속, 계좌 탭 보일 때만 __behive_load 호출.
|
||
# 사이클: off(0) → 10s(1) → 3s(2) → off. 기존 "1" 값은 10s 모드와 호환.
|
||
# location.reload() 제거 → 깜빡임·탭 클릭 묻힘 없음. fetch 완료 후에 재무장(arm)해 호출 중첩 방지.
|
||
market_active_js = 'true' if market_active else 'false'
|
||
auto_reload_script = (
|
||
'<script>(function(){'
|
||
f'var O=[{auto_refresh_tids_js}];'
|
||
f'var MARKET={market_active_js};'
|
||
'var INT=[0,10,3],TICK=1000,KEY="behive.autoreload",t=null,remain=0;'
|
||
# 장 닫힘 동안 시장 열림 감지용 저속 폴링. visible 일 때만 동작.
|
||
'var SLOW_MS=60000,slow=null;'
|
||
'var btn=document.getElementById("auto-toggle");if(!btn)return;'
|
||
'var lbl=btn.querySelector("span:last-child");'
|
||
'function mode(){var v=parseInt(localStorage.getItem(KEY)||"0",10);return (v>=0&&v<INT.length)?v:0;}'
|
||
'function secs(){return INT[mode()];}'
|
||
'function idleLbl(){var s=secs();return s>0?("자동 "+s+"s"):"자동";}'
|
||
'function active(){return document.documentElement.getAttribute("data-tab")||"";}'
|
||
'function marketNow(){return (typeof window.__behive_market_active!=="undefined")?window.__behive_market_active:MARKET;}'
|
||
'function ok(){return marketNow()&&mode()>0&&document.visibilityState==="visible"&&O.indexOf(active())>=0;}'
|
||
'function setLbl(s){if(lbl)lbl.textContent=s;}'
|
||
'function idleClosedLbl(){return marketNow()?idleLbl():"장마감";}'
|
||
'function stop(){if(t){clearInterval(t);t=null;}}'
|
||
'function stopSlow(){if(slow){clearInterval(slow);slow=null;}}'
|
||
'function startSlow(){'
|
||
'stopSlow();'
|
||
'slow=setInterval(function(){'
|
||
'if(marketNow()){stopSlow();return;}'
|
||
'if(document.visibilityState!=="visible")return;'
|
||
'if(window.__behive_load)window.__behive_load();'
|
||
'},SLOW_MS);'
|
||
'}'
|
||
'function arm(){'
|
||
'stop();'
|
||
'if(marketNow())stopSlow();else startSlow();'
|
||
'if(!ok()){setLbl(idleClosedLbl());return;}'
|
||
'remain=secs();setLbl("자동 "+remain+"s");'
|
||
't=setInterval(function(){'
|
||
'if(!ok()){stop();setLbl(idleClosedLbl());return;}'
|
||
# 차트 툴팁 떠 있는 동안 자동 갱신 미룸 — swap으로 사라지는 거 방지.
|
||
'var tt=document.querySelectorAll(".net-chart-tip");'
|
||
'for(var ti=0;ti<tt.length;ti++){if(tt[ti].style.opacity==="1")return;}'
|
||
# select 드롭다운 열림 상태(=focus)에서도 갱신 미룸 — 사용자가 옵션 고르는 중.
|
||
'var ae=document.activeElement;'
|
||
'if(ae&&ae.tagName==="SELECT")return;'
|
||
'remain-=1;'
|
||
'if(remain<=0){'
|
||
'stop();'
|
||
'var a=active();'
|
||
'var ow=(a&&a.indexOf("tab-")===0)?a.slice(4):null;'
|
||
'var p=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
|
||
'p.then(arm,arm);'
|
||
'return;'
|
||
'}'
|
||
'setLbl("자동 "+remain+"s");'
|
||
'},TICK);'
|
||
'}'
|
||
'function setUI(m){btn.setAttribute("aria-pressed",m>0?"true":"false");}'
|
||
'function set(m){localStorage.setItem(KEY,String(m));setUI(m);arm();}'
|
||
'btn.addEventListener("click",function(){'
|
||
'if(!marketNow())return;' # 장 닫힘 중엔 토글 무시 (button disabled 안전망)
|
||
'set((mode()+1)%INT.length);'
|
||
'});'
|
||
'window.__behive_auto_arm=arm;'
|
||
'document.addEventListener("visibilitychange",arm);'
|
||
'addEventListener("hashchange",arm);'
|
||
# 첫 진입 시 휴장·주말·장외이면 button에 closed/disabled 즉시 적용 — panels fetch 전에도 시각 일관.
|
||
'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
|
||
'setUI(mode());arm();'
|
||
'})();</script>'
|
||
)
|
||
|
||
# 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백.
|
||
# 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제.
|
||
# 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음.
|
||
nodbltap_script = (
|
||
'<script>(function(){'
|
||
'var lastT=0;'
|
||
'document.addEventListener("touchend",function(e){'
|
||
'var now=Date.now();'
|
||
'if(now-lastT<=300)e.preventDefault();'
|
||
'lastT=now;'
|
||
'},{passive:false});'
|
||
'})();</script>'
|
||
)
|
||
|
||
idx_info_modal_html = (
|
||
'<div id="idx-info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="idx-info-modal-title">'
|
||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||
'<div class="modal-box" role="document">'
|
||
'<div class="modal-head">'
|
||
'<div class="modal-title" id="idx-info-modal-title" data-idx-modal-title>지수 설명</div>'
|
||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||
'</div>'
|
||
'<div class="modal-body idx-info-body">'
|
||
'<div class="idx-info-stats">'
|
||
'<span class="idx-info-price" data-idx-modal-price>—</span>'
|
||
'<span class="idx-info-change" data-idx-modal-change>—</span>'
|
||
'</div>'
|
||
'<div class="idx-info-chart" data-idx-modal-chart hidden></div>'
|
||
'<div class="idx-info-desc" data-idx-modal-body>—</div>'
|
||
'</div>'
|
||
'<div class="modal-actions">'
|
||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||
'</div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
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()
|
||
trade_modal_html = _render_trade_modal()
|
||
info_modal_html = _render_info_modal()
|
||
info_desc_modal_html = _render_info_desc_modal()
|
||
order_modal_html = _render_order_modal()
|
||
pin_modal_html = _render_pin_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 = (
|
||
'<script>(function(){'
|
||
'var __savedScrollY=0;'
|
||
'function lockScroll(){'
|
||
'if(document.body.classList.contains("modal-open"))return;'
|
||
'__savedScrollY=window.scrollY||window.pageYOffset||0;'
|
||
'document.body.style.top=(-__savedScrollY)+"px";'
|
||
'document.body.classList.add("modal-open");'
|
||
'}'
|
||
'function unlockScroll(){'
|
||
'if(document.querySelector(".modal:not(.hidden)"))return;'
|
||
'document.body.classList.remove("modal-open");'
|
||
'document.body.style.top="";'
|
||
'window.scrollTo(0,__savedScrollY);'
|
||
'}'
|
||
'window.__behiveLockScroll=lockScroll;window.__behiveUnlockScroll=unlockScroll;'
|
||
'function openModal(id){var m=document.getElementById(id);if(!m)return;'
|
||
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
|
||
'lockScroll();'
|
||
'var f=m.querySelector("input[name=stock]");if(f)setTimeout(function(){f.focus();},30);'
|
||
'}'
|
||
'function closeModal(m){if(!m)return;'
|
||
'm.classList.add("hidden");m.setAttribute("aria-hidden","true");'
|
||
'clearFeedback(m);'
|
||
'unlockScroll();'
|
||
'}'
|
||
'function clearFeedback(m){var fb=m&&m.querySelector("[data-feedback]");if(!fb)return;'
|
||
'fb.hidden=true;fb.innerHTML="";'
|
||
'}'
|
||
'function showError(m,msg){var fb=m&&m.querySelector("[data-feedback]");'
|
||
'if(!fb){alert(msg);return;}' # delete form은 modal 밖이라 fb 없음 — alert로 fallback
|
||
'fb.hidden=false;fb.innerHTML=\'<div class="feedback-error"></div>\';'
|
||
'fb.firstChild.textContent=msg;'
|
||
'}'
|
||
'function openCandidates(query,cands){'
|
||
'var cm=document.getElementById("candidate-modal");if(!cm)return;'
|
||
'var title=cm.querySelector("[data-cand-title]");'
|
||
'if(title)title.textContent=\'"\'+query+\'"와 일치하는 종목이 여러 개입니다. 선택해주세요.\';'
|
||
'var list=cm.querySelector("[data-cand-list]");'
|
||
'if(list){'
|
||
'list.innerHTML="";'
|
||
'cands.forEach(function(c){'
|
||
'var btn=document.createElement("button");'
|
||
'btn.type="button";'
|
||
'btn.className="candidate-btn";'
|
||
'btn.setAttribute("data-cand-name",c.name||"");'
|
||
'btn.setAttribute("data-cand-code",c.code||"");'
|
||
'var lbl=document.createElement("span");lbl.textContent=c.name||"";'
|
||
'var code=document.createElement("span");code.className="cand-code";code.textContent=c.code||"";'
|
||
'btn.appendChild(lbl);btn.appendChild(code);'
|
||
'list.appendChild(btn);'
|
||
'});'
|
||
'}'
|
||
'openModal("candidate-modal");'
|
||
'}'
|
||
'function submitForm(form,overrideName,overrideCode,keepOpen){'
|
||
'var m=form.closest(".modal");'
|
||
'clearFeedback(m);'
|
||
'var fd=new FormData(form);'
|
||
'if(overrideName)fd.set("stock",overrideName);'
|
||
'if(overrideCode)fd.set("code",overrideCode);'
|
||
'var data=new URLSearchParams();'
|
||
'fd.forEach(function(v,k){data.append(k,v);});'
|
||
'var submitBtn=form.querySelector(\'button[type="submit"]\');'
|
||
'if(submitBtn)submitBtn.disabled=true;'
|
||
'fetch(form.action,{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:data.toString(),credentials:"same-origin"})'
|
||
'.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
|
||
'.then(function(j){'
|
||
'if(j&&j.ok){'
|
||
'if(!keepOpen){'
|
||
'form.reset();'
|
||
'closeModal(m);'
|
||
'}'
|
||
'closeModal(document.getElementById("candidate-modal"));'
|
||
'if(window.__behive_load)window.__behive_load(null,true);'
|
||
'}else if(j&&j.error==="multiple"){'
|
||
'openCandidates(j.query||"",j.candidates||[]);'
|
||
'}else{'
|
||
'showError(m,(j&&j.error)||"추가 실패");'
|
||
'}'
|
||
'})'
|
||
'.catch(function(){showError(m,"네트워크 오류");})'
|
||
'.finally(function(){if(submitBtn)submitBtn.disabled=false;});'
|
||
'}'
|
||
'function escHtml(s){return String(s==null?"":s).replace(/[&<>"\\\']/g,function(c){'
|
||
'return {"&":"&","<":"<",">":">","\\"":""","\\\'":"'"}[c];'
|
||
'});}'
|
||
'function fmtInt(n){return (n==null?0:n).toLocaleString();}'
|
||
'function fmtTradeDate(s){'
|
||
'var m=String(s==null?"":s).match(/^\\d{2}(\\d{2})-(\\d{1,2})-(\\d{1,2})$/);'
|
||
'return m?(m[1]+"/"+parseInt(m[2],10)+"/"+parseInt(m[3],10)):(s||"");'
|
||
'}'
|
||
'function setupTradePagers(root){'
|
||
'root.querySelectorAll(".trade-pager-wrap").forEach(function(wrap){'
|
||
'var listEl=wrap.querySelector(".trade-list");'
|
||
'var realRows=listEl?Array.prototype.slice.call(listEl.querySelectorAll(":scope > li")):[];'
|
||
# 종목명 헤더(ti-stock-line)가 붙은 owner 모드는 행 높이가 3줄로 늘어나 8개면 스크롤. 5개로 축소.
|
||
'var hasStockLine=listEl && listEl.querySelector(".ti-stock-line")!==null;'
|
||
'var PAGE_SIZE=hasStockLine?5:8;'
|
||
'var totalReal=realRows.length;'
|
||
'var totalPages=Math.max(1,Math.ceil(totalReal/PAGE_SIZE));'
|
||
'var padNeeded=Math.max(0,totalPages*PAGE_SIZE-totalReal);'
|
||
'for(var pi=0;pi<padNeeded && listEl;pi++){'
|
||
'var ph=document.createElement("li");'
|
||
'ph.className="trade-item placeholder";'
|
||
'ph.innerHTML=\'<div class="ti-row1"> </div><div class="ti-row2"> </div>\';'
|
||
'listEl.appendChild(ph);'
|
||
'}'
|
||
'var rows=listEl?Array.prototype.slice.call(listEl.querySelectorAll(":scope > li")):[];'
|
||
'var total=totalPages;'
|
||
'var ind=wrap.querySelector(".pg-indicator");'
|
||
'var prev=wrap.querySelector(\'[data-pg="prev"]\');'
|
||
'var next=wrap.querySelector(\'[data-pg="next"]\');'
|
||
'var page=0;'
|
||
'function render(){'
|
||
'for(var i=0;i<rows.length;i++){'
|
||
'rows[i].style.display=(i>=page*PAGE_SIZE && i<(page+1)*PAGE_SIZE)?"":"none";'
|
||
'}'
|
||
'if(ind)ind.textContent=(page+1)+" / "+total;'
|
||
'if(prev)prev.disabled=page<=0;'
|
||
'if(next)next.disabled=page>=total-1;'
|
||
'}'
|
||
'if(rows.length<=PAGE_SIZE){'
|
||
'var pg=wrap.querySelector(".trade-pager");'
|
||
'if(pg)pg.style.display="none";'
|
||
'}'
|
||
'if(prev)prev.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();if(page>0){page--;render();}});'
|
||
'if(next)next.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();if(page<total-1){page++;render();}});'
|
||
'render();'
|
||
'});'
|
||
'}'
|
||
'function fmtNum(n,d){if(n==null||n==="")return "—";var v=Number(n);if(isNaN(v))return "—";return v.toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});}'
|
||
'var INFO_DESC={'
|
||
'"현재가":"지금 시장에서 거래되는 주가입니다.",'
|
||
'"시가총액":"회사 전체의 시장 가치. 현재가 × 상장주식수로, 회사 크기를 가늠하는 기준입니다.",'
|
||
'"PER":"주가가 회사 1년 이익의 몇 배인지. 낮을수록 싼 편입니다.",'
|
||
'"PBR":"주가가 회사 순자산의 몇 배인지. 1배 미만이면 자산보다 싸게 거래되는 셈입니다.",'
|
||
'"EPS":"주식 한 주가 1년 동안 벌어들인 이익입니다.",'
|
||
'"ROE":"회사가 가진 돈으로 얼마나 이익을 냈는지. 높을수록 좋습니다.",'
|
||
'"영업이익":"회사가 본업으로 번 이익입니다. 괄호는 해당 분기(최근 확정 분기 기준)이며, 분기 데이터가 없으면 연간으로 표시됩니다.",'
|
||
'"유통비율":"전체 주식 중 시장에서 실제 사고팔 수 있는 비율. 대주주·자사주처럼 묶인 물량은 빼고 봅니다.",'
|
||
'"52주 최고/최저":"최근 1년간 가장 높았던 주가와 낮았던 주가입니다.",'
|
||
'"상장주식수":"시장에 나와 있는 전체 주식 수입니다.",'
|
||
'"외국인보유":"전체 주식 중 외국인이 가진 비율입니다.",'
|
||
'"매출증가율":"직전 연도 대비 매출액이 얼마나 늘었는지(전년대비). 출처 FnGuide.",'
|
||
'"영업이익증가율":"직전 연도 대비 영업이익 증가율(전년대비). 출처 FnGuide.",'
|
||
'"EPS증가율":"직전 연도 대비 주당순이익 증가율(전년대비). 출처 FnGuide.",'
|
||
'"목표주가":"증권사들이 제시한 목표주가의 컨센서스(평균). 출처 FnGuide.",'
|
||
'"투자의견":"증권사 컨센서스 투자의견. 1(매도)~5(적극매수) 척도. 출처 FnGuide.",'
|
||
'"추정PER":"증권사 추정(미래) 이익 기준 PER. 괄호 안 현재 PER보다 낮으면 앞으로 이익이 늘 거란 기대예요. 출처 FnGuide.",'
|
||
'"목표가 리비전":"최근 3개월간 목표주가 컨센서스가 상향(+)됐는지 하향(−)됐는지. 출처 WISEreport.",'
|
||
'"추정치 리비전":"최근 3개월간 추정 실적치 컨센서스 변화율. 상향이면 이익 전망이 좋아지는 중. 출처 WISEreport.",'
|
||
'"어닝 서프라이즈":"최신 확정연도 실적이 직전 컨센서스 대비 얼마나 상회(+)/하회(−)했는지. 출처 WISEreport."'
|
||
'};'
|
||
'function openInfoDesc(term){'
|
||
'var m=document.getElementById("info-desc-modal");if(!m)return;'
|
||
'var t=m.querySelector("[data-desc-term]");if(t)t.textContent=term;'
|
||
'var b=m.querySelector("[data-desc-body]");if(b)b.textContent=(INFO_DESC[term]||"설명이 없습니다.");'
|
||
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
|
||
'lockScroll();'
|
||
'}'
|
||
'function infoWon(n){return fmtInt(n)+"원";}'
|
||
# 키움이 억원 단위로 주는 금액 → 1조=10,000억 환산. 1조 미만(음수 포함)은 억원, 1조 이상은 조.
|
||
'function capDisp(eok){var v=Number(eok);if(isNaN(v))return "—";return Math.abs(v)>=10000?(fmtNum(v/10000,2)+"조원"):(fmtInt(v)+"억원");}'
|
||
# 증가율(%) — 빨강=증가/파랑=감소 (국내 관행). null이면 dash.
|
||
'function growthDisp(v){if(v==null||v==="")return "—";var n=Number(v);if(isNaN(n))return "—";var col=n>0?"#ef4444":(n<0?"#3b82f6":"#9ca3af");return \'<span style="color:\'+col+\';font-weight:700">\'+(n>0?"+":"")+n.toFixed(1)+"%</span>";}'
|
||
# 비교 지표 스펙. cmpOnly=true 는 비교표에서만(단일 상세 제외). fn(j)→표시문자열.
|
||
'var INFO_SPECS=['
|
||
'{k:"현재가",d:"현재가",sec:"가치지표",cmpOnly:true,fn:function(j){return infoWon(j.price);}},'
|
||
'{k:"시가총액",d:"시가총액",sec:"가치지표",cmpOnly:true,fn:function(j){return capDisp(j.market_cap);}},'
|
||
'{k:"PER",d:"PER",sec:"가치지표",fn:function(j){return fmtNum(j.per,2);}},'
|
||
'{k:"PBR",d:"PBR",sec:"가치지표",fn:function(j){return fmtNum(j.pbr,2);}},'
|
||
'{k:"EPS",d:"EPS",sec:"가치지표",fn:function(j){return infoWon(j.eps);}},'
|
||
'{k:"ROE",d:"ROE",sec:"가치지표",fn:function(j){return fmtNum(j.roe,2)+"%";}},'
|
||
'{k:"영업이익",d:"영업이익",sec:"가치지표",fn:function(j){'
|
||
'if(j.op_profit_q!=null)return capDisp(j.op_profit_q)+(j.op_profit_q_period?\' <span class="muted small">(\'+escHtml(j.op_profit_q_period)+\')</span>\':"");'
|
||
'return capDisp(j.op_profit)+\' <span class="muted small">(연간)</span>\';'
|
||
'}},'
|
||
'{k:"매출증가율",d:"매출증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.sales_yoy:null);}},'
|
||
'{k:"영업이익증가율",d:"영업이익증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.oper_profit_yoy:null);}},'
|
||
'{k:"EPS증가율",d:"EPS증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.eps_yoy:null);}},'
|
||
'{k:"목표주가",d:"목표주가",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;return (c&&c.target_price!=null)?infoWon(c.target_price):"—";}},'
|
||
'{k:"투자의견",d:"투자의견",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;return (c&&c.opinion_label)?(escHtml(c.opinion_label)+(c.opinion!=null?\' <span class="muted small">(\'+c.opinion+\'/5)</span>\':"")):"—";}},'
|
||
'{k:"추정PER",d:"추정PER",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;if(!(c&&c.per!=null))return "—";var pv=Number(j.per);var cur=(j.per!=null&&j.per!==""&&!isNaN(pv))?(\' <span style="color:#7a8493;font-size:11px">(현재 \'+fmtNum(j.per,2)+\'배)</span>\'):"";return fmtNum(c.per,2)+"배"+cur;}},'
|
||
'{k:"목표가 리비전(3M)",d:"목표가 리비전",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.target_change_pct:null);}},'
|
||
'{k:"추정치 리비전(3M)",d:"추정치 리비전",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.est_change_pct:null);}},'
|
||
'{k:"매출 서프라이즈",d:"어닝 서프라이즈",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.surprise_sales:null);}},'
|
||
'{k:"영익 서프라이즈",d:"어닝 서프라이즈",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.surprise_op:null);}},'
|
||
'{k:"상장주식수",d:"상장주식수",sec:"기본정보",fn:function(j){return fmtInt(j.listed_shares)+"주";}},'
|
||
'{k:"유통비율",d:"유통비율",sec:"기본정보",fn:function(j){return fmtNum(j.dist_rate,2)+"%";}},'
|
||
'{k:"외국인보유",d:"외국인보유",sec:"기본정보",fn:function(j){return fmtNum(j.foreign_holding_pct,2)+"%";}},'
|
||
'{k:"52주 최고",d:"52주 최고/최저",sec:"기본정보",fn:function(j){return infoWon(j.high_52w)+(j.high_52w_dt?\' <span class="muted small">(\'+escHtml(j.high_52w_dt)+\')</span>\':"");}},'
|
||
'{k:"52주 최저",d:"52주 최고/최저",sec:"기본정보",fn:function(j){return infoWon(j.low_52w)+(j.low_52w_dt?\' <span class="muted small">(\'+escHtml(j.low_52w_dt)+\')</span>\':"");}}'
|
||
'];'
|
||
'function infoQbtn(d){return d?(\' <button type="button" class="info-q" data-info-desc="\'+escHtml(d)+\'" aria-label="\'+escHtml(d)+\' 설명">?</button>\'):"";}'
|
||
# CMP: 비교 상태. items=[{code,name}] (index 0 = 기준), data=종목별 stock_info 캐시.
|
||
'var CMP={items:[],data:{},symbolsLoaded:false};'
|
||
'function infoBodyEl(){var m=document.getElementById("info-modal");return m?m.querySelector("[data-info-body]"):null;}'
|
||
# 단일 상세 — 기존 세로 레이아웃 (현재가·시총 제외).
|
||
# 최근 증권사 리포트 블록 (단일 종목 뷰 전용). 목표가 상향=▲빨강/하향=▼파랑.
|
||
'function infoReportsHtml(j){'
|
||
'var rs=j.reports;if(!rs||!rs.length)return "";'
|
||
'var h=\'<div class="info-sec-title">📰 최근 증권사 리포트 <span style="color:#5b6473;font-weight:normal;font-size:11px">(목표가·요약, 출처 WISEreport)</span></div>\';'
|
||
'rs.forEach(function(r){'
|
||
'var arrow="";'
|
||
'if(r.target_action&&r.target_action.indexOf("상향")>=0)arrow=\' <span style="color:#ef4444">▲</span>\';'
|
||
'else if(r.target_action&&r.target_action.indexOf("하향")>=0)arrow=\' <span style="color:#3b82f6">▼</span>\';'
|
||
'var tgt=r.target?(\'<span style="color:#fbbf24;font-weight:600">목표 \'+escHtml(r.target)+arrow+\'</span>\'):"";'
|
||
'var rec=r.recomm?(\'<span style="color:#9aa4b2">\'+escHtml(r.recomm)+\'</span>\'):"";'
|
||
'var anl=r.analyst?(\'<span style="color:#5b6473;font-size:11px">\'+escHtml(r.analyst)+\'</span>\'):"";'
|
||
'var sum="";if(r.summary&&r.summary.length){sum=\'<div style="font-size:11px;color:#7a8493;line-height:1.5;margin-top:3px">▶ \'+r.summary.slice(0,2).map(escHtml).join("<br>▶ ")+\'</div>\';}'
|
||
'h+=\'<div style="padding:9px 0;border-bottom:1px solid #1f2937">\'+'
|
||
'\'<div style="display:flex;gap:7px;align-items:center;flex-wrap:wrap;font-size:12px"><span style="color:#7a8493">\'+escHtml(r.date||"")+\'</span><b style="color:#cfd5df">\'+escHtml(r.broker||"")+\'</b>\'+tgt+rec+anl+\'</div>\'+'
|
||
'\'<div style="font-size:13px;color:#fff;margin:3px 0 0">\'+escHtml(r.title||"")+\'</div>\'+sum+\'</div>\';'
|
||
'});'
|
||
'return h;'
|
||
'}'
|
||
'function renderSingleInfo(b,j){'
|
||
'function row(k,v,d){return \'<dt>\'+escHtml(k)+infoQbtn(d)+\'</dt><dd>\'+v+\'</dd>\';}'
|
||
# 섹션별 info-grid 생성 (해당 sec에 항목 없으면 빈 문자열)
|
||
'function secGrid(sec){var g=\'<dl class="info-grid">\',n=0;INFO_SPECS.forEach(function(s){if(s.sec===sec&&!s.cmpOnly){g+=row(s.k,s.fn(j),s.d);n++;}});return n?(g+\'</dl>\'):"";}'
|
||
'function titled(t,inner){return inner?(\'<div class="info-sec-title">\'+t+\'</div>\'+inner):"";}'
|
||
# 요약 탭 — 현재가·목표주가·핵심 시그널·최신 리포트 1건
|
||
'function sumPanel(){'
|
||
'var fg=j.fundamentals||{},gr=fg.growth||{},fc=fg.consensus||{},cn=j.consensus||{};'
|
||
'var chg=(j.change>0?"#ef4444":(j.change<0?"#3b82f6":"#9ca3af"));'
|
||
'var pc=(j.change_pct!=null?(\' <span style="color:\'+chg+\';font-size:12px">(\'+(j.change_pct>0?"+":"")+fmtNum(j.change_pct,2)+\'%)</span>\'):"");'
|
||
'var h=\'<dl class="info-grid">\';'
|
||
'h+=row("현재가",\'<span style="color:\'+chg+\';font-weight:700">\'+infoWon(j.price)+\'</span>\'+pc,"현재가");'
|
||
'h+=row("시가총액",capDisp(j.market_cap),"시가총액");'
|
||
'if(fc.target_price!=null)h+=row("목표주가",\'<span style="color:#fbbf24;font-weight:700">\'+infoWon(fc.target_price)+\'</span>\'+(fc.opinion_label?\' <span style="color:#9aa4b2;font-size:12px">\'+escHtml(fc.opinion_label)+\'</span>\':""),"목표주가");'
|
||
'if(cn.target_change_pct!=null)h+=row("목표가 리비전(3M)",growthDisp(cn.target_change_pct),"목표가 리비전");'
|
||
'if(gr.sales_yoy!=null)h+=row("매출증가율",growthDisp(gr.sales_yoy),"매출증가율");'
|
||
'if(gr.eps_yoy!=null)h+=row("EPS증가율",growthDisp(gr.eps_yoy),"EPS증가율");'
|
||
'if(cn.surprise_sales!=null)h+=row("매출 서프라이즈",growthDisp(cn.surprise_sales),"어닝 서프라이즈");'
|
||
'h+=\'</dl>\';'
|
||
'if(j.reports&&j.reports.length){var r=j.reports[0];var ar="";if(r.target_action&&r.target_action.indexOf("상향")>=0)ar=\' <span style="color:#ef4444">▲</span>\';else if(r.target_action&&r.target_action.indexOf("하향")>=0)ar=\' <span style="color:#3b82f6">▼</span>\';h+=\'<div class="info-sec-title">최신 리포트</div><div style="font-size:12px;color:#9aa4b2"><span style="color:#7a8493">\'+escHtml(r.date||"")+\' \'+escHtml(r.broker||"")+\'</span> \'+(r.target?\'<span style="color:#fbbf24">목표 \'+escHtml(r.target)+ar+\'</span>\':"")+\'</div><div style="font-size:13px;color:#fff;margin-top:2px">\'+escHtml(r.title||"")+\'</div>\';}'
|
||
'return h;'
|
||
'}'
|
||
'var companyInner=titled("가치지표",secGrid("가치지표"))+titled("기본정보",secGrid("기본정보"));'
|
||
'var growthInner=secGrid("성장성·컨센서스");'
|
||
'var repInner=infoReportsHtml(j);'
|
||
'var TB=[["summary","요약",sumPanel()],["company","기업정보·가치",companyInner],["growth","성장성·컨센서스",growthInner],["reports","투자리포트",repInner]];'
|
||
'var bar=\'<div class="info-tabs" role="tablist">\';var pans="";'
|
||
'TB.forEach(function(t,i){'
|
||
'bar+=\'<button type="button" class="info-tabbtn\'+(i===0?" active":"")+\'" data-info-tab="\'+t[0]+\'">\'+t[1]+\'</button>\';'
|
||
'pans+=\'<div class="info-tabpanel" data-info-panel="\'+t[0]+\'"\'+(i===0?"":" hidden")+\'>\'+(t[2]||\'<div class="muted small" style="padding:12px 0;color:#7a8493">표시할 데이터가 없어요.</div>\')+\'</div>\';'
|
||
'});'
|
||
'if(b)b.innerHTML=bar+\'</div>\'+pans;'
|
||
'}'
|
||
# 모달 탭 전환 (data-info-tab/panel) — info-body 안으로 스코프, 자산탭 sub-tab과 독립.
|
||
'document.addEventListener("click",function(e){'
|
||
'var bt=e.target.closest&&e.target.closest("[data-info-tab]");if(!bt)return;'
|
||
'var wrap=bt.closest("[data-info-body]");if(!wrap)return;'
|
||
'var key=bt.getAttribute("data-info-tab");'
|
||
'wrap.querySelectorAll("[data-info-tab]").forEach(function(x){x.classList.toggle("active",x===bt);});'
|
||
'wrap.querySelectorAll("[data-info-panel]").forEach(function(p){p.hidden=(p.getAttribute("data-info-panel")!==key);});'
|
||
'});'
|
||
# 비교표 — 행=지표(현재가·시총 포함), 열=종목. 첫 열(기준) 외엔 × 제거 버튼.
|
||
'function renderCompareInfo(b){'
|
||
'var its=CMP.items;'
|
||
'var head=\'<th class="info-cmp-label"></th>\';'
|
||
'its.forEach(function(it,idx){'
|
||
'var rm=idx>0?(\'<button type="button" class="info-cmp-remove" data-cmp-remove="\'+escHtml(it.code)+\'" aria-label="비교 제거">×</button>\'):"";'
|
||
'head+=\'<th class="info-cmp-col"><span class="info-cmp-name">\'+escHtml(it.name||it.code)+\'</span>\'+rm+\'</th>\';'
|
||
'});'
|
||
'var body="";'
|
||
'INFO_SPECS.forEach(function(s){'
|
||
'var cells=its.map(function(it){var j=CMP.data[it.code];return \'<td>\'+((j&&!j.error)?s.fn(j):"—")+\'</td>\';}).join("");'
|
||
'body+=\'<tr><th class="info-cmp-label">\'+escHtml(s.k)+infoQbtn(s.d)+\'</th>\'+cells+\'</tr>\';'
|
||
'});'
|
||
'if(b)b.innerHTML=\'<div class="info-cmp-scroll"><table class="info-cmp-table"><thead><tr>\'+head+\'</tr></thead><tbody>\'+body+\'</tbody></table></div>\';'
|
||
'}'
|
||
'function infoRenderBody(){'
|
||
'var b=infoBodyEl();if(!b)return;'
|
||
'var missing=CMP.items.filter(function(it){return !CMP.data[it.code];});'
|
||
# 이미 표/그리드가 그려져 있으면 유지(비교 열 추가 시 깜빡임 방지), 빈 상태에서만 로딩 표시.
|
||
'if(missing.length&&!b.querySelector("table")&&!b.querySelector(".info-grid"))b.innerHTML=\'<div class="muted small info-loading">불러오는 중…</div>\';'
|
||
'Promise.all(missing.map(function(it){'
|
||
'return fetch("/api/stock_info?code="+encodeURIComponent(it.code),{credentials:"same-origin"})'
|
||
'.then(function(r){return r.json();})'
|
||
'.then(function(j){CMP.data[it.code]=(j||{error:"no data"});if(j&&j.name&&!it.name)it.name=j.name;})'
|
||
'.catch(function(){CMP.data[it.code]={error:"net"};});'
|
||
'})).then(function(){'
|
||
'var b2=infoBodyEl();if(!b2)return;'
|
||
'if(CMP.items.length<=1){'
|
||
'var j0=CMP.data[CMP.items[0].code];'
|
||
'if(!j0||j0.error){b2.innerHTML=\'<div class="muted small">불러오기 실패\'+((j0&&j0.error&&j0.error!=="net")?(": "+escHtml(j0.error)):"")+\'</div>\';return;}'
|
||
'renderSingleInfo(b2,j0);'
|
||
'}else{renderCompareInfo(b2);}'
|
||
'});'
|
||
'}'
|
||
'function addInfoCompare(code,name){'
|
||
'code=(code||"").trim();if(!code)return;'
|
||
'if(CMP.items.some(function(it){return it.code===code;})){'
|
||
'var rb=document.querySelector("#info-modal [data-info-search-results]");'
|
||
'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">이미 비교 중인 종목입니다.</div>\';}'
|
||
'return;'
|
||
'}'
|
||
'CMP.items.push({code:code,name:name||""});'
|
||
'infoRenderBody();'
|
||
'}'
|
||
'function removeInfoCompare(code){'
|
||
'CMP.items=CMP.items.filter(function(it){return it.code!==code;});'
|
||
'infoRenderBody();'
|
||
'}'
|
||
# 내 목록(보유·관심·감시) 드롭다운 1회 로드.
|
||
'function loadInfoSymbols(){'
|
||
'if(CMP.symbolsLoaded)return;'
|
||
'var sel=document.querySelector("#info-modal [data-info-symbols]");if(!sel)return;'
|
||
'fetch("/api/symbols/all",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
|
||
'var items=(d&&d.items)||[];if(!items.length)return;'
|
||
'var seen={};items.forEach(function(it){'
|
||
'if(!it.code||seen[it.code])return;seen[it.code]=1;'
|
||
'var o=document.createElement("option");o.value=it.code;'
|
||
'o.textContent=(it.name||it.code)+(it.source?(" · "+it.source):"");'
|
||
'o.setAttribute("data-name",it.name||"");sel.appendChild(o);'
|
||
'});'
|
||
'CMP.symbolsLoaded=true;'
|
||
'}).catch(function(){});'
|
||
'}'
|
||
# 종목 검색 — 단일이면 바로 추가, 다중이면 후보 버튼 표시.
|
||
'function doInfoSearch(){'
|
||
'var m=document.getElementById("info-modal");if(!m)return;'
|
||
'var inp=m.querySelector("[data-info-search]");var rb=m.querySelector("[data-info-search-results]");'
|
||
'var q=inp?inp.value.trim():"";if(!q){return;}'
|
||
'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">검색 중…</div>\';}'
|
||
'fetch("/api/stock_search?q="+encodeURIComponent(q),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
|
||
'var res=(d&&d.results)||[];'
|
||
'if(!res.length){if(rb)rb.innerHTML=\'<div class="muted small">\'+escHtml((d&&d.error)||"검색 결과 없음")+\'</div>\';return;}'
|
||
'if(res.length===1){addInfoCompare(res[0].code,res[0].name);if(rb){rb.classList.add("hidden");rb.innerHTML="";}if(inp)inp.value="";return;}'
|
||
'if(rb)rb.innerHTML=res.map(function(it){return \'<button type="button" class="info-search-hit" data-cmp-add="\'+escHtml(it.code)+\'" data-cmp-name="\'+escHtml(it.name||"")+\'">\'+escHtml(it.name||it.code)+\' <span class="muted small">\'+escHtml(it.code)+\'</span></button>\';}).join("");'
|
||
'}).catch(function(){if(rb)rb.innerHTML=\'<div class="muted small">검색 오류</div>\';});'
|
||
'}'
|
||
'function openInfoModal(code,name){'
|
||
'var m=document.getElementById("info-modal");if(!m)return;'
|
||
'var t=m.querySelector("[data-info-title]");'
|
||
'if(t)t.textContent=(name||"기업정보")+(code?(" ("+code+")"):"");'
|
||
'var al=m.querySelector("[data-info-analyze]");'
|
||
'if(al){if(code){al.href="/stock/"+encodeURIComponent(code);al.style.display="";}else{al.style.display="none";}}'
|
||
# 상태 초기화 — 기준 종목 1개로 시작.
|
||
'CMP.items=[{code:code,name:name||""}];CMP.data={};'
|
||
'var inp=m.querySelector("[data-info-search]");if(inp)inp.value="";'
|
||
'var rb=m.querySelector("[data-info-search-results]");if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
|
||
'var sel=m.querySelector("[data-info-symbols]");if(sel)sel.value="";'
|
||
'var b=m.querySelector("[data-info-body]");'
|
||
'if(b)b.innerHTML=\'<div class="muted small info-loading">불러오는 중…</div>\';'
|
||
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
|
||
'lockScroll();'
|
||
'loadInfoSymbols();'
|
||
'infoRenderBody();'
|
||
'}'
|
||
'function openTradeModal(code,name,owner){'
|
||
'var m=document.getElementById("trade-modal");if(!m)return;'
|
||
'var ownerMode=!code && !!owner;'
|
||
'var t=m.querySelector("[data-trade-title]");'
|
||
'if(t){t.textContent=ownerMode?(name+" 전체 거래내역"):(name+(code?(" ("+code+")"):""));}'
|
||
'var b=m.querySelector("[data-trade-body]");if(b)b.innerHTML=\'<div class="muted small">불러오는 중…</div>\';'
|
||
'var s=m.querySelector("[data-trade-summary]");if(s)s.textContent="";'
|
||
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
|
||
'lockScroll();'
|
||
'var qs=ownerMode?("owner="+encodeURIComponent(owner)):("code="+encodeURIComponent(code));'
|
||
'fetch("/api/trades?"+qs,{credentials:"same-origin"})'
|
||
'.then(function(r){return r.json();})'
|
||
'.then(function(j){'
|
||
'if(!j||j.error){if(b)b.innerHTML=\'<div class="muted small">불러오기 실패</div>\';return;}'
|
||
'if(!j.rows||!j.rows.length){if(b)b.innerHTML=\'<div class="muted small">기록이 없습니다.</div>\';return;}'
|
||
'var showStock=(j.mode==="owner");'
|
||
'function fmtAcct(a){'
|
||
'if(!showStock)return escHtml(a||"");'
|
||
'var s=String(a||"");'
|
||
'if(s.indexOf("가희_")===0)s=s.slice(3);'
|
||
'return escHtml(s);'
|
||
'}'
|
||
'function metaOf(r){'
|
||
'var d=escHtml(fmtTradeDate(r.date));'
|
||
'var acc=fmtAcct(r.account);'
|
||
'return \'<span class="ti-date">\'+d+\'</span><span class="ti-sep">·</span><span class="ti-acct">\'+acc+\'</span>\';'
|
||
'}'
|
||
'function stockLineOf(r){'
|
||
'if(!showStock)return "";'
|
||
'var nm=r.name||"";'
|
||
'return \'<div class="ti-stock-line trade-stock-col" title="\'+escHtml(nm)+\'">\'+escHtml(nm)+\'</div>\';'
|
||
'}'
|
||
'function renderRow(r){'
|
||
'var seedCls=r.seed?" seed":"";'
|
||
'var meta=metaOf(r);'
|
||
'var stockLine=stockLineOf(r);'
|
||
'var out="";'
|
||
'if(r.buy_qty){'
|
||
'out+=\'<li class="trade-item trade-item-buy\'+seedCls+\'">\'+stockLine+'
|
||
'\'<div class="ti-row1"><div class="ti-meta">\'+'
|
||
'\'<span class="ti-side side-buy">매수</span>\'+'
|
||
'\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
|
||
'\'<span class="ti-pl muted">-</span></div>\'+'
|
||
'\'<div class="ti-row2"><div class="ti-data">\'+'
|
||
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.buy_avg)+\'원</span><span class="ti-sep">·</span>\'+'
|
||
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.buy_qty)+\'주</span><span class="ti-sep">·</span>\'+'
|
||
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.buy_amt)+\'원</span></div></div>\'+'
|
||
'\'</li>\';'
|
||
'}'
|
||
'if(r.sell_qty){'
|
||
'var pl=r.pl_amt||0;'
|
||
'var plCls=pl>0?"pl-pos":(pl<0?"pl-neg":"muted");'
|
||
'var sgn=pl>0?"+":"";'
|
||
'out+=\'<li class="trade-item trade-item-sell\'+seedCls+\'">\'+stockLine+'
|
||
'\'<div class="ti-row1"><div class="ti-meta">\'+'
|
||
'\'<span class="ti-side side-sell">매도</span>\'+'
|
||
'\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
|
||
'\'<span class="ti-pl \'+plCls+\'">\'+sgn+fmtInt(pl)+\'원</span></div>\'+'
|
||
'\'<div class="ti-row2"><div class="ti-data">\'+'
|
||
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.sell_avg)+\'원</span><span class="ti-sep">·</span>\'+'
|
||
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.sell_qty)+\'주</span><span class="ti-sep">·</span>\'+'
|
||
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+'
|
||
'\'</li>\';'
|
||
'}'
|
||
'return out;'
|
||
'}'
|
||
'function renderTable(rs){'
|
||
'var list=\'<ul class="trade-list">\'+rs.map(renderRow).join("")+\'</ul>\';'
|
||
'var pager=\'<div class="trade-pager"><button type="button" class="pg-btn" data-pg="prev" aria-label="이전 페이지">◀</button><span class="pg-indicator">1 / 1</span><button type="button" class="pg-btn" data-pg="next" aria-label="다음 페이지">▶</button></div>\';'
|
||
'return \'<div class="trade-pager-wrap">\'+list+pager+\'</div>\';'
|
||
'}'
|
||
'var groups=Array.isArray(j.groups)?j.groups:[];'
|
||
# 활성 owner 탭(관리자=본인, 가희) 기준으로 처음 선택된 탭만 결정 — 탭 순서는 서버 응답(본인 먼저) 유지.
|
||
'var aoKey=window.__behive_active_owner?window.__behive_active_owner():null;'
|
||
'var prefer=aoKey==="gahee"?"가희":(aoKey==="self"?"본인":null);'
|
||
'var preferIdx=0;'
|
||
'if(prefer){'
|
||
'for(var gi=0;gi<groups.length;gi++){if((groups[gi].owner||"")===prefer){preferIdx=gi;break;}}'
|
||
'}'
|
||
'if(b){'
|
||
'if(groups.length>=2){'
|
||
'var tabsHtml=\'<div class="trade-tabs" role="tablist">\'+groups.map(function(g,idx){'
|
||
'var sel=idx===preferIdx?"true":"false";'
|
||
'return \'<button type="button" class="trade-tab" role="tab" data-trade-owner="\'+escHtml(g.owner||"")+\'" aria-selected="\'+sel+\'">\'+escHtml(g.label||g.owner||"")+\'</button>\';'
|
||
'}).join("")+\'</div>\';'
|
||
'var panelsHtml=groups.map(function(g,idx){'
|
||
'var hidden=idx===preferIdx?"":" hidden";'
|
||
'var sub=[];'
|
||
'if(g.total_buy_amt)sub.push("매수 "+fmtInt(g.total_buy_amt)+"원");'
|
||
'if(g.total_sell_amt)sub.push("매도 "+fmtInt(g.total_sell_amt)+"원");'
|
||
'if(g.total_pl_amt){var ss=g.total_pl_amt>0?"+":"";sub.push("실현 "+ss+fmtInt(g.total_pl_amt)+"원");}'
|
||
'var subHtml=sub.length?\'<div class="trade-group-sub muted small">\'+escHtml(sub.join(" · "))+\'</div>\':"";'
|
||
'return \'<div class="trade-tab-panel" data-trade-panel="\'+escHtml(g.owner||"")+\'"\'+hidden+\'>\'+subHtml+renderTable(g.rows||[])+\'</div>\';'
|
||
'}).join("");'
|
||
'b.innerHTML=tabsHtml+panelsHtml;'
|
||
'}else{'
|
||
'b.innerHTML=renderTable(groups.length===1?(groups[0].rows||[]):j.rows);'
|
||
'}'
|
||
'setupTradePagers(b);'
|
||
'}'
|
||
'if(s){'
|
||
'var parts=[];'
|
||
'if(j.total_buy_amt)parts.push("총 매수 "+fmtInt(j.total_buy_amt)+"원");'
|
||
'if(j.total_sell_amt)parts.push("총 매도 "+fmtInt(j.total_sell_amt)+"원");'
|
||
'if(j.total_pl_amt){var sgn2=j.total_pl_amt>0?"+":"";parts.push("실현손익 "+sgn2+fmtInt(j.total_pl_amt)+"원");}'
|
||
's.textContent=parts.join(" · ");'
|
||
'}'
|
||
'})'
|
||
'.catch(function(){if(b)b.innerHTML=\'<div class="muted small">네트워크 오류</div>\';});'
|
||
'}'
|
||
'function populateEditForm(trigger){'
|
||
'var em=document.getElementById("interest-edit-modal");if(!em)return;'
|
||
'var name=trigger.getAttribute("data-edit-stock")||"";'
|
||
'var code=trigger.getAttribute("data-edit-code")||"";'
|
||
'var title=em.querySelector("[data-edit-title]");'
|
||
'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
|
||
'var f=em.querySelector("#interest-edit-form");if(!f)return;'
|
||
'f.querySelector("input[name=stock]").value=name;'
|
||
'f.querySelector("input[name=buy_price]").value=trigger.getAttribute("data-edit-buy")||"";'
|
||
'f.querySelector("input[name=target_price]").value=trigger.getAttribute("data-edit-target")||"";'
|
||
'f.querySelector("input[name=stop_price]").value=trigger.getAttribute("data-edit-stop")||"";'
|
||
'f.querySelector("input[name=memo]").value=trigger.getAttribute("data-edit-memo")||"";'
|
||
'var df=em.querySelector("#interest-edit-delete-form");'
|
||
'if(df)df.querySelector("input[name=stock]").value=name;'
|
||
'}'
|
||
'function populateTagForm(trigger){'
|
||
'var tm=document.getElementById("tag-modal");if(!tm)return;'
|
||
'var name=trigger.getAttribute("data-tag-stock")||"";'
|
||
'var code=trigger.getAttribute("data-tag-code")||"";'
|
||
'var curText=trigger.getAttribute("data-tag-text")||"";'
|
||
'var curLeader=trigger.getAttribute("data-tag-leader")==="1";'
|
||
'var title=tm.querySelector("[data-tag-title]");'
|
||
'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
|
||
'var f=tm.querySelector("#tag-form");if(!f)return;'
|
||
'f.querySelector("input[name=stock]").value=name;'
|
||
'f.querySelector("input[name=code]").value=code;'
|
||
'f.querySelector("input[name=tag]").value=curText;'
|
||
'f.querySelector("input[name=leader]").value=curLeader?"1":"0";'
|
||
'tm.querySelectorAll(".btn-tag-quick[data-tag-toggle=leader]").forEach(function(b){'
|
||
'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
|
||
'});'
|
||
'}'
|
||
'document.addEventListener("click",function(e){'
|
||
'var t=e.target;'
|
||
'var closeAttr=t.closest&&t.closest("[data-modal-close]");'
|
||
'if(closeAttr){e.preventDefault();closeModal(closeAttr.closest(".modal"));return;}'
|
||
'var tradeBtn=t.closest&&t.closest(".btn-trades");'
|
||
'if(tradeBtn){'
|
||
'e.preventDefault();'
|
||
'e.stopPropagation();'
|
||
'var tc=tradeBtn.getAttribute("data-trade-code")||"";'
|
||
'var tn=tradeBtn.getAttribute("data-trade-stock")||"";'
|
||
'var to=tradeBtn.getAttribute("data-trade-owner")||"";'
|
||
'openTradeModal(tc,tn,to);'
|
||
'return;'
|
||
'}'
|
||
'var infoBtn=t.closest&&t.closest(".btn-info");'
|
||
'if(infoBtn){'
|
||
'e.preventDefault();'
|
||
'e.stopPropagation();'
|
||
'openInfoModal(infoBtn.getAttribute("data-info-code")||"",infoBtn.getAttribute("data-info-stock")||"");'
|
||
'return;'
|
||
'}'
|
||
'var infoQ=t.closest&&t.closest(".info-q");'
|
||
'if(infoQ){'
|
||
'e.preventDefault();'
|
||
'e.stopPropagation();'
|
||
'openInfoDesc(infoQ.getAttribute("data-info-desc")||"");'
|
||
'return;'
|
||
'}'
|
||
'var infoSearchBtn=t.closest&&t.closest("[data-info-search-btn]");'
|
||
'if(infoSearchBtn){e.preventDefault();e.stopPropagation();doInfoSearch();return;}'
|
||
'var infoHit=t.closest&&t.closest(".info-search-hit");'
|
||
'if(infoHit){'
|
||
'e.preventDefault();e.stopPropagation();'
|
||
'var rb=document.querySelector("#info-modal [data-info-search-results]");'
|
||
'addInfoCompare(infoHit.getAttribute("data-cmp-add")||"",infoHit.getAttribute("data-cmp-name")||"");'
|
||
'if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
|
||
'var inp=document.querySelector("#info-modal [data-info-search]");if(inp)inp.value="";'
|
||
'return;'
|
||
'}'
|
||
'var cmpRm=t.closest&&t.closest(".info-cmp-remove");'
|
||
'if(cmpRm){e.preventDefault();e.stopPropagation();removeInfoCompare(cmpRm.getAttribute("data-cmp-remove")||"");return;}'
|
||
'var tradeTab=t.closest&&t.closest(".trade-tab");'
|
||
'if(tradeTab){'
|
||
'e.preventDefault();'
|
||
'var tm=tradeTab.closest("#trade-modal");if(!tm)return;'
|
||
'var tgt=tradeTab.getAttribute("data-trade-owner")||"";'
|
||
'tm.querySelectorAll(".trade-tab").forEach(function(btn){'
|
||
'btn.setAttribute("aria-selected",btn.getAttribute("data-trade-owner")===tgt?"true":"false");'
|
||
'});'
|
||
'tm.querySelectorAll(".trade-tab-panel").forEach(function(p){'
|
||
'if(p.getAttribute("data-trade-panel")===tgt)p.removeAttribute("hidden");'
|
||
'else p.setAttribute("hidden","");'
|
||
'});'
|
||
'return;'
|
||
'}'
|
||
'var tagToggleBtn=t.closest&&t.closest("[data-tag-toggle=leader]");'
|
||
'if(tagToggleBtn){'
|
||
'e.preventDefault();'
|
||
'var ttm=document.getElementById("tag-modal");if(!ttm)return;'
|
||
'var ttf=ttm.querySelector("#tag-form");if(!ttf)return;'
|
||
'var leaderInp=ttf.querySelector("input[name=leader]");'
|
||
'var newVal=leaderInp.value==="1"?"0":"1";'
|
||
'leaderInp.value=newVal;'
|
||
'if(newVal==="1")tagToggleBtn.classList.add("active");'
|
||
'else tagToggleBtn.classList.remove("active");'
|
||
# 텍스트(name=tag) 인풋은 절대 건드리지 않음 — 사용자가 입력한 값 그대로 유지.
|
||
# 토글은 색깔(leader)만 뒤집고 즉시 서버 반영. 모달은 닫지 않음(keepOpen=true).
|
||
'submitForm(ttf,null,null,true);'
|
||
'return;'
|
||
'}'
|
||
'var tagDelBtn=t.closest&&t.closest("[data-tag-delete]");'
|
||
'if(tagDelBtn){'
|
||
'e.preventDefault();'
|
||
'var tdm=document.getElementById("tag-modal");if(!tdm)return;'
|
||
'var tdf=tdm.querySelector("#tag-form");if(!tdf)return;'
|
||
'tdf.querySelector("input[name=tag]").value="";'
|
||
'tdf.querySelector("input[name=leader]").value="0";'
|
||
'submitForm(tdf);'
|
||
'return;'
|
||
'}'
|
||
'var tagEditBtn=t.closest&&t.closest("[data-tag-edit]");'
|
||
'if(tagEditBtn){'
|
||
'e.preventDefault();'
|
||
'e.stopPropagation();'
|
||
'populateTagForm(tagEditBtn);'
|
||
'openModal("tag-modal");'
|
||
'return;'
|
||
'}'
|
||
'var openBtn=t.closest&&t.closest("[data-modal-open]");'
|
||
'if(openBtn){'
|
||
'e.preventDefault();'
|
||
'var oid=openBtn.getAttribute("data-modal-open");'
|
||
'if(oid==="interest-edit-modal"&&openBtn.hasAttribute("data-edit-stock")){'
|
||
'populateEditForm(openBtn);'
|
||
'}'
|
||
'if(oid==="interest-modal"&&openBtn.hasAttribute("data-prefill-stock")){'
|
||
'var addForm=document.getElementById("interest-add-form");'
|
||
'if(addForm){'
|
||
'var psName=openBtn.getAttribute("data-prefill-stock")||"";'
|
||
'var psCode=openBtn.getAttribute("data-prefill-code")||"";'
|
||
'var pi=addForm.querySelector("input[name=stock]");if(pi)pi.value=psName;'
|
||
'var pc=addForm.querySelector("input[name=code]");if(pc)pc.value=psCode;'
|
||
'}'
|
||
'}'
|
||
'openModal(oid);'
|
||
'return;'
|
||
'}'
|
||
'var candBtn=t.closest&&t.closest(".candidate-btn");'
|
||
'if(candBtn){'
|
||
'e.preventDefault();'
|
||
'var name=candBtn.getAttribute("data-cand-name")||"";'
|
||
'var code=candBtn.getAttribute("data-cand-code")||"";'
|
||
'var form=document.getElementById("interest-add-form");'
|
||
'if(form){'
|
||
'var ni=form.querySelector("input[name=stock]");if(ni)ni.value=name;'
|
||
'var ci=form.querySelector("input[name=code]");if(ci)ci.value=code;'
|
||
'closeModal(document.getElementById("candidate-modal"));'
|
||
'submitForm(form,name,code);'
|
||
'}'
|
||
'return;'
|
||
'}'
|
||
'});'
|
||
# 기업비교 — 내 목록 드롭다운 선택 시 추가, 검색창 Enter 시 검색.
|
||
'document.addEventListener("change",function(e){'
|
||
'var sel=e.target&&e.target.closest&&e.target.closest("[data-info-symbols]");'
|
||
'if(!sel||!sel.value)return;'
|
||
'var opt=sel.options[sel.selectedIndex];'
|
||
'addInfoCompare(sel.value,opt?(opt.getAttribute("data-name")||""):"");'
|
||
'sel.value="";'
|
||
'});'
|
||
'document.addEventListener("keydown",function(e){'
|
||
'if(e.key!=="Enter")return;'
|
||
'var inp=e.target&&e.target.closest&&e.target.closest("[data-info-search]");'
|
||
'if(!inp)return;'
|
||
'e.preventDefault();doInfoSearch();'
|
||
'});'
|
||
'document.addEventListener("submit",function(e){'
|
||
'var form=e.target;'
|
||
'if(!form)return;'
|
||
'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"){'
|
||
'e.preventDefault();'
|
||
'submitForm(form);'
|
||
'return;'
|
||
'}'
|
||
'if(form.classList&&form.classList.contains("interest-delete-form")){'
|
||
'e.preventDefault();'
|
||
'if(!confirm("관심종목에서 삭제하시겠습니까?"))return;'
|
||
'submitForm(form);'
|
||
'return;'
|
||
'}'
|
||
'});'
|
||
'document.addEventListener("keydown",function(e){'
|
||
'if(e.key==="Escape"){'
|
||
'document.querySelectorAll(".modal:not(.hidden)").forEach(closeModal);'
|
||
'}'
|
||
'});'
|
||
'})();</script>'
|
||
)
|
||
|
||
# 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide.
|
||
# 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback.
|
||
stock_name_tip_script = (
|
||
'<script>(function(){'
|
||
'var tip=document.getElementById("stock-name-tip");if(!tip)return;'
|
||
'function place(cell){'
|
||
'var r=cell.getBoundingClientRect();'
|
||
'tip.classList.remove("below");'
|
||
'tip.classList.add("show");'
|
||
'var w=tip.offsetWidth,h=tip.offsetHeight;'
|
||
'var cx=r.left+r.width/2;'
|
||
'var left=cx-w/2;'
|
||
'var maxLeft=window.innerWidth-w-8;'
|
||
'if(left<8)left=8;else if(left>maxLeft)left=maxLeft;'
|
||
'var top=r.top-h-10;'
|
||
'if(top<8){top=r.bottom+10;tip.classList.add("below");}'
|
||
'tip.style.left=left+"px";'
|
||
'tip.style.top=top+"px";'
|
||
'}'
|
||
'function show(cell){'
|
||
'tip.textContent=cell.getAttribute("title")||cell.textContent||"";'
|
||
'place(cell);'
|
||
'tip.setAttribute("aria-hidden","false");'
|
||
'}'
|
||
'function hide(){'
|
||
'tip.classList.remove("show","below");'
|
||
'tip.setAttribute("aria-hidden","true");'
|
||
'}'
|
||
'document.addEventListener("pointerdown",function(e){'
|
||
'var cell=e.target.closest&&e.target.closest(".trade-stock-col");'
|
||
'if(!cell)return;'
|
||
'e.preventDefault();'
|
||
'show(cell);'
|
||
'},{passive:false});'
|
||
'document.addEventListener("pointerup",hide);'
|
||
'document.addEventListener("pointercancel",hide);'
|
||
'document.addEventListener("pointerleave",hide);'
|
||
'window.addEventListener("scroll",hide,true);'
|
||
'})();</script>'
|
||
)
|
||
|
||
# details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음.
|
||
# capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open')
|
||
# 호출이 다시 trigger되어도 한 개만 남는다.
|
||
details_mutex_script = (
|
||
'<script>(function(){'
|
||
'document.addEventListener("toggle",function(e){'
|
||
'var d=e.target;'
|
||
'if(!d||d.nodeName!=="DETAILS")return;'
|
||
'if(!d.classList.contains("row"))return;'
|
||
'if(!d.hasAttribute("data-row-key"))return;'
|
||
'if(!d.open)return;'
|
||
'document.querySelectorAll("details.row[data-row-key][open]").forEach(function(o){'
|
||
'if(o!==d)o.removeAttribute("open");'
|
||
'});'
|
||
'},true);'
|
||
'})();</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'''<script>(function(){
|
||
var modal = document.getElementById('order-modal');
|
||
var pinModal = document.getElementById('pin-modal');
|
||
var openOrdersModal = document.getElementById('open-orders-modal');
|
||
if(!modal) return;
|
||
var ACCOUNTS = [
|
||
{label: '일반', display: '본인 일반', owner: '본인'},
|
||
{label: 'ISA', display: '본인 ISA', owner: '본인'},
|
||
{label: '가희_일반', display: '가희 일반', owner: '가희'},
|
||
{label: '가희_ISA', display: '가희 ISA', owner: '가희'}
|
||
];
|
||
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false };
|
||
function $(sel, root){ return (root||modal).querySelector(sel); }
|
||
function $$(sel, root){ return (root||modal).querySelectorAll(sel); }
|
||
function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; }
|
||
function fmt(n){ if(!isFinite(n)) return '—'; return (Math.round(n)||0).toLocaleString('ko-KR'); }
|
||
function highlightSelectedTick(){
|
||
var inp = $('[data-order-price-input]');
|
||
var cur = parseInt((inp||{}).value || '0', 10);
|
||
$$('.ob-row').forEach(function(row){
|
||
var p = parseInt(row.getAttribute('data-ob-price') || '0', 10);
|
||
row.classList.toggle('selected', p > 0 && p === cur);
|
||
});
|
||
}
|
||
var __toastEl = null, __toastTimer = null;
|
||
function showToast(text, kind, durationMs){
|
||
if(!text) return;
|
||
if(!__toastEl){
|
||
__toastEl = document.createElement('div');
|
||
__toastEl.className = 'order-toast';
|
||
document.body.appendChild(__toastEl);
|
||
}
|
||
__toastEl.textContent = text;
|
||
__toastEl.className = 'order-toast ' + (kind || 'error');
|
||
void __toastEl.offsetWidth;
|
||
__toastEl.classList.add('show');
|
||
if(__toastTimer) clearTimeout(__toastTimer);
|
||
__toastTimer = setTimeout(function(){ if(__toastEl) __toastEl.classList.remove('show'); }, durationMs || 3500);
|
||
}
|
||
function setMsg(text, kind){
|
||
// 모달 내 메시지 영역은 hide, 알림은 토스트로
|
||
var el = $('[data-order-msg]'); if(el) el.classList.add('hidden');
|
||
if(text) showToast(text, kind);
|
||
}
|
||
function setSide(side){
|
||
state.side = side;
|
||
$$('.order-side-toggle button[data-side]').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-side')===side); });
|
||
var sb = $('[data-order-submit]'); if(sb){ sb.setAttribute('data-side', side); sb.textContent = (side==='BUY'?'매수':'매도'); }
|
||
renderQtyButtons();
|
||
// 매수일 때만 금액 input 표시
|
||
var budgetRow = $('[data-order-budget-row]');
|
||
if(budgetRow) budgetRow.style.display = (side === 'BUY') ? '' : 'none';
|
||
// 토글 시 budget input 초기화 — 이전 모드 잔존 방지 (양방향 동기화 fresh 시작)
|
||
var budgetInp = $('[data-order-budget]');
|
||
if(budgetInp) budgetInp.value = '';
|
||
// 토글 시 자동 max 플래그 — updateCheck 응답에서 max로 채움
|
||
// SELL: 보유주수 100%, BUY: 매수가능액/단가
|
||
if(side === 'SELL') state.pendingMaxOnSell = true;
|
||
else {
|
||
state.pendingMaxOnBuy = true;
|
||
// BUY max 계산엔 price 필요 — 비어있으면 호가창 현재가로 즉시 채움
|
||
_resolveOrderPrice();
|
||
}
|
||
updateCheck();
|
||
}
|
||
function _resolveOrderPrice(){
|
||
// 단가 우선, 없으면 현재가 fallback. 단가 input 비어있으면 자동 채움 (disabled 아닌 경우)
|
||
var priceInp = $('[data-order-price-input]');
|
||
var price = parseInt((priceInp||{}).value || '0', 10);
|
||
if(price <= 0 && state.lastBook && state.lastBook.price){
|
||
price = state.lastBook.price;
|
||
if(priceInp && !priceInp.disabled){
|
||
priceInp.value = price;
|
||
highlightSelectedTick();
|
||
}
|
||
}
|
||
return price;
|
||
}
|
||
function recalcQtyFromBudget(){
|
||
if(state.side !== 'BUY') return;
|
||
var budgetInp = $('[data-order-budget]');
|
||
var qtyInp = $('[data-order-qty]');
|
||
if(!budgetInp || !qtyInp) return;
|
||
var budget = parseInt(budgetInp.value || '0', 10);
|
||
var price = _resolveOrderPrice();
|
||
if(budget > 0 && price > 0){
|
||
qtyInp.value = Math.floor(budget / price);
|
||
renderOrderInfo();
|
||
}
|
||
}
|
||
function recalcBudgetFromQty(){
|
||
if(state.side !== 'BUY') return;
|
||
var budgetInp = $('[data-order-budget]');
|
||
var qtyInp = $('[data-order-qty]');
|
||
if(!budgetInp || !qtyInp) return;
|
||
var qty = parseInt(qtyInp.value || '0', 10);
|
||
var price = _resolveOrderPrice();
|
||
if(qty > 0 && price > 0){
|
||
budgetInp.value = qty * price;
|
||
}
|
||
}
|
||
function renderQtyButtons(){
|
||
var container = $('[data-qty-buttons]'); if(!container) return;
|
||
var btns;
|
||
if(state.side === 'BUY'){
|
||
btns = [['clear','C'], ['-1','-1'], ['1','+1'], ['10','+10'], ['max','전부']];
|
||
} else {
|
||
btns = [['0%','0%'], ['25%','25%'], ['50%','50%'], ['75%','75%'], ['100%','100%']];
|
||
}
|
||
container.innerHTML = btns.map(function(b){
|
||
return '<button type="button" data-qty-step="'+b[0]+'">'+b[1]+'</button>';
|
||
}).join('');
|
||
}
|
||
function renderBook(book){
|
||
state.lastBook = book;
|
||
if(book.name){ var ne = $('[data-order-name]'); if(ne) ne.textContent = book.name; state.name = book.name; }
|
||
var ce = $('[data-order-cur-price]'); if(ce) ce.textContent = fmt(book.price)+'원';
|
||
var che = $('[data-order-change]');
|
||
if(che){
|
||
var sign = book.change>0?'+':'';
|
||
che.textContent = sign + fmt(book.change) + ' (' + (book.change_pct||0).toFixed(2) + '%)';
|
||
che.classList.remove('up','down');
|
||
if(book.change>0) che.classList.add('up'); else if(book.change<0) che.classList.add('down');
|
||
}
|
||
var ob = $('[data-orderbook]'); if(!ob) return;
|
||
var html = '';
|
||
var asks = (book.asks||[]).slice().reverse();
|
||
for(var i=0;i<asks.length;i++){
|
||
var a = asks[i];
|
||
html += '<div class="ob-row ask" data-ob-price="'+a.price+'"><span class="price">'+fmt(a.price)+'</span><span class="qty">'+fmt(a.qty)+'</span></div>';
|
||
}
|
||
html += '<div class="ob-divider">'+fmt(book.price)+'원</div>';
|
||
var bids = book.bids||[];
|
||
for(var j=0;j<bids.length;j++){
|
||
var b = bids[j];
|
||
html += '<div class="ob-row bid" data-ob-price="'+b.price+'"><span class="price">'+fmt(b.price)+'</span><span class="qty">'+fmt(b.qty)+'</span></div>';
|
||
}
|
||
ob.innerHTML = html;
|
||
var otSel = $('[data-order-type]');
|
||
if(otSel && otSel.value === 'MARKET' && book.price){
|
||
var inp = $('[data-order-price-input]');
|
||
if(inp && parseInt(inp.value||'0',10) !== book.price) inp.value = book.price;
|
||
}
|
||
highlightSelectedTick();
|
||
updateMarketPhaseDisplay();
|
||
if(!state.bookCentered){
|
||
var div = ob.querySelector('.ob-divider');
|
||
if(div){
|
||
ob.scrollTop = Math.max(0, div.offsetTop + div.offsetHeight/2 - ob.clientHeight/2);
|
||
state.bookCentered = true;
|
||
}
|
||
}
|
||
}
|
||
function pollOnce(){
|
||
if(document.hidden || !state.isOpen || !state.code) return;
|
||
fetch('/api/quote_book?code=' + encodeURIComponent(state.code))
|
||
.then(function(r){ return r.json(); })
|
||
.then(renderBook).catch(function(){});
|
||
}
|
||
function startPolling(){ stopPolling(); pollOnce(); state.pollTimer = setInterval(pollOnce, 1000); }
|
||
function stopPolling(){ if(state.pollTimer){ clearInterval(state.pollTimer); state.pollTimer=null; } }
|
||
function updateCheck(){
|
||
var acc = $('[data-order-account]'); if(!acc || !acc.value || !state.code) return;
|
||
var account = acc.value;
|
||
var price = parseInt(($('[data-order-price-input]')||{}).value || '0', 10);
|
||
var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
|
||
fetch('/api/order/check?code='+encodeURIComponent(state.code)+'&account='+encodeURIComponent(account)+'&side='+state.side+'&order_type='+orderType+'&price='+price)
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){
|
||
if(d.error){ state.lastCheck=null; var info=$('[data-order-info]'); if(info) info.textContent = d.error; return; }
|
||
state.lastCheck = d;
|
||
var maxEl = $('[data-order-max]');
|
||
if(maxEl) maxEl.textContent = '(최대: ' + fmt(d.max_qty||0) + '주)';
|
||
// 매도 전환 시 수량 자동 100% (max_qty) — 사용자가 직접 변경 후엔 안 덮어씀
|
||
if(state.side === 'SELL' && state.pendingMaxOnSell && (d.max_qty||0) > 0){
|
||
var qtyInp = $('[data-order-qty]');
|
||
if(qtyInp){ qtyInp.value = d.max_qty; state.pendingMaxOnSell = false; }
|
||
}
|
||
// 매수 전환 시 수량 자동 max (매수가능액/단가) — budget도 동기화
|
||
if(state.side === 'BUY' && state.pendingMaxOnBuy && (d.max_qty||0) > 0){
|
||
var qtyInpB = $('[data-order-qty]');
|
||
if(qtyInpB){ qtyInpB.value = d.max_qty; state.pendingMaxOnBuy = false; recalcBudgetFromQty(); }
|
||
}
|
||
renderOrderInfo();
|
||
}).catch(function(){});
|
||
}
|
||
function renderOrderInfo(){
|
||
var d = state.lastCheck; if(!d) return;
|
||
var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10);
|
||
var price = parseInt(($('[data-order-price-input]')||{}).value || '0', 10);
|
||
var infoEl = $('[data-order-info]'); if(!infoEl) return;
|
||
var amount = price * qty;
|
||
var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
|
||
if(state.side === 'BUY'){
|
||
var lines = [
|
||
'예수금 (D+2): <b>'+fmt(d.d2_entra||0)+'</b>원',
|
||
'매수가능액: <b>'+fmt(d.ord_alow_amt||0)+'</b>원'
|
||
];
|
||
if(orderType === 'MARKET' && d.upper_limit){
|
||
var needed = (d.upper_limit||0) * qty;
|
||
var overCls = (needed > (d.ord_alow_amt||0)) ? ' class="qty-over"' : '';
|
||
lines.push('필요증거금<span class="muted small"> (상한가 '+fmt(d.upper_limit)+'원 기준)</span>: <b'+overCls+'>'+fmt(needed)+'</b>원 ('+fmt(qty)+'주)');
|
||
} else {
|
||
lines.push('주문예상액: <b>'+fmt(amount)+'</b>원 ('+fmt(qty)+'주)');
|
||
}
|
||
infoEl.innerHTML = lines.join('<br>');
|
||
} else {
|
||
var avg = d.avg_price||0;
|
||
var pl = (price - avg) * qty;
|
||
var plCls = pl>=0?'pl-up':'pl-down';
|
||
var plSign = pl>=0?'+':'';
|
||
infoEl.innerHTML =
|
||
'보유: <b>'+fmt(d.hold_qty||0)+'</b>주 (매도가능 '+fmt(d.trde_able_qty||0)+'주)<br>'+
|
||
'평단가: <b>'+fmt(avg)+'</b>원<br>'+
|
||
'예상수익: <span class="'+plCls+'">'+plSign+fmt(pl)+'원</span> ('+fmt(qty)+'주 매도)<br>'+
|
||
'주문예상액: <b>'+fmt(amount)+'</b>원';
|
||
}
|
||
// 수량 입력 — 최대치 초과 시 글자색 빨강 (예수금/보유 부족 시각화)
|
||
var qtyInp = $('[data-order-qty]');
|
||
if(qtyInp){
|
||
var maxq = parseInt((d.max_qty||0), 10);
|
||
if(qty > maxq && qty > 0) qtyInp.classList.add('qty-over');
|
||
else qtyInp.classList.remove('qty-over');
|
||
}
|
||
}
|
||
function gotoQty(step){
|
||
var inp = $('[data-order-qty]'); if(!inp) return;
|
||
var max = parseInt((state.lastCheck && state.lastCheck.max_qty) || 0, 10);
|
||
if(step === 'clear'){
|
||
inp.value = '0';
|
||
} else if(typeof step === 'string' && step.slice(-1) === '%'){
|
||
var pct = parseInt(step.slice(0, -1), 10);
|
||
inp.value = Math.floor(max * pct / 100);
|
||
} else if(step === 'max'){
|
||
inp.value = max;
|
||
} else {
|
||
var cur = parseInt(inp.value || '0', 10);
|
||
var delta = parseInt(step, 10);
|
||
inp.value = Math.max(0, cur + (isNaN(delta)?0:delta));
|
||
}
|
||
recalcBudgetFromQty();
|
||
renderOrderInfo();
|
||
}
|
||
function refreshActiveCount(){
|
||
fetch('/api/order/active').then(function(r){ return r.json(); }).then(function(d){
|
||
state.lastActive = d;
|
||
var el = $('[data-active-count]');
|
||
if(el){
|
||
var n = d.unique_count || 0;
|
||
el.textContent = n > 0 ? ' (' + n + ')' : '';
|
||
}
|
||
}).catch(function(){});
|
||
}
|
||
function showProgressTab(){
|
||
// [📋 진행중] 탭 — 활성 카드 있으면 PIN 모달, 없고 미체결만 있으면 open-orders 모달, 둘 다 없으면 토스트
|
||
fetch('/api/order/active').then(function(r){ return r.json(); }).then(function(d){
|
||
state.lastActive = d;
|
||
var el = $('[data-active-count]');
|
||
if(el){
|
||
var n = d.unique_count || 0;
|
||
el.textContent = n > 0 ? ' (' + n + ')' : '';
|
||
}
|
||
if(d.active){
|
||
openPinModal({
|
||
card_message: d.card_message || '',
|
||
expiry_seconds: d.expiry_seconds || 0,
|
||
symbol_name: d.symbol_name || ''
|
||
});
|
||
return;
|
||
}
|
||
if((d.open_orders_count || 0) > 0){
|
||
openOpenOrdersModal();
|
||
return;
|
||
}
|
||
showToast('진행 중인 거래 없음', 'info');
|
||
}).catch(function(e){ showToast('진행중 거래 조회 실패: ' + e, 'error'); });
|
||
}
|
||
function openOpenOrdersModal(){
|
||
if(!openOrdersModal) return;
|
||
openOrdersModal.classList.remove('hidden');
|
||
openOrdersModal.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
loadOpenOrders();
|
||
}
|
||
function closeOpenOrdersModal(){
|
||
if(!openOrdersModal) return;
|
||
openOrdersModal.classList.add('hidden');
|
||
openOrdersModal.setAttribute('aria-hidden', 'true');
|
||
if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
|
||
refreshActiveCount();
|
||
}
|
||
function loadOpenOrders(){
|
||
var list = openOrdersModal.querySelector('[data-open-orders-list]');
|
||
if(!list) return;
|
||
list.innerHTML = '<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>';
|
||
fetch('/api/orders/open').then(function(r){ return r.json(); }).then(function(d){
|
||
var rows = d.rows || [];
|
||
if(!rows.length){
|
||
list.innerHTML = '<div class="empty">미체결 주문 없음</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = rows.map(function(r){
|
||
var sideCls = r.side === 'BUY' ? 'buy' : 'sell';
|
||
var sideLabel = r.side === 'BUY' ? '매수' : '매도';
|
||
var priceLabel = r.order_price ? (r.order_price.toLocaleString('ko-KR') + '원') : (r.order_type || '시장가');
|
||
var safeOrd = String(r.ord_no || '').replace(/"/g,'"');
|
||
var safeAcc = String(r.account || '').replace(/"/g,'"');
|
||
return (
|
||
'<div class="open-order-row" data-ord-no="' + safeOrd + '" data-account="' + safeAcc + '">' +
|
||
'<div class="row-info">' +
|
||
'<div class="row-line1">' +
|
||
'<span class="badge-side ' + sideCls + '">' + sideLabel + '</span>' +
|
||
'<span>' + (r.name || r.code) + '</span>' +
|
||
'<span style="color:#8b8f9a; font-weight:400; font-size:11px;">(' + r.code + ')</span>' +
|
||
'</div>' +
|
||
'<div class="row-line2">' +
|
||
'[' + r.account + '] ' + r.order_qty + '주 @ ' + priceLabel +
|
||
' · 미체결 ' + r.unfilled_qty + '주 · ' + r.status +
|
||
' · ' + r.exchange + ' · ' + (r.order_time || '') +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="row-action">' +
|
||
'<button type="button" data-cancel-open-order>취소</button>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}).join('');
|
||
}).catch(function(e){
|
||
list.innerHTML = '<div class="empty">조회 실패: ' + e + '</div>';
|
||
});
|
||
}
|
||
function cancelOpenOrder(ordNo, account, btn){
|
||
if(!confirm('주문 #' + ordNo + ' 취소하시겠습니까?')) return;
|
||
if(btn){ btn.disabled = true; btn.textContent = '취소중…'; }
|
||
var body = new URLSearchParams();
|
||
body.set('ord_no', ordNo);
|
||
body.set('account', account);
|
||
fetch('/api/orders/cancel', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
|
||
body: body.toString()
|
||
}).then(function(r){ return r.json(); })
|
||
.then(function(d){
|
||
if(d.ok){
|
||
showToast(d.message || '✅ 취소 접수됨', 'success', 4000);
|
||
loadOpenOrders(); // 모달 안 리스트 갱신
|
||
} else {
|
||
showToast(d.message || d.error || '취소 실패', 'error');
|
||
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
|
||
}
|
||
}).catch(function(e){
|
||
showToast('취소 오류: ' + e, 'error');
|
||
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
|
||
});
|
||
}
|
||
function fetchMarketPhase(){
|
||
fetch('/api/market_state').then(function(r){ return r.json(); }).then(function(d){
|
||
state.marketActive = !!d.active;
|
||
state.marketPhase = d;
|
||
updateMarketPhaseDisplay();
|
||
}).catch(function(){});
|
||
}
|
||
function updateMarketPhaseDisplay(){
|
||
var phase = state.marketPhase; if(!phase) return;
|
||
var book = state.lastBook;
|
||
var label = phase.label || '—';
|
||
var cls = phase.phase || '';
|
||
var canTrade = !!phase.active;
|
||
// NXT 시간대 + 종목 nxt_enable=false → 거래 불가 알림
|
||
if(phase.phase === 'nxt' && book && book.nxt_enable === false){
|
||
label = '📵 NXT 거래불가';
|
||
cls = 'closed';
|
||
canTrade = false;
|
||
}
|
||
var el = $('[data-market-phase]');
|
||
if(el){
|
||
el.textContent = label + ' ' + (phase.time || '');
|
||
el.className = 'order-market-phase ' + cls;
|
||
}
|
||
var sb = $('[data-order-submit]');
|
||
if(sb){
|
||
sb.disabled = !canTrade;
|
||
sb.title = canTrade ? '' : ('매매 비활성: ' + label);
|
||
}
|
||
}
|
||
function populateSymbolSelect(currentCode, currentName){
|
||
var sel = $('[data-order-symbol-select]'); if(!sel) return;
|
||
function render(items){
|
||
// 현재 종목이 리스트에 없으면 최상단에 추가
|
||
var has = items.some(function(it){ return it.code === currentCode; });
|
||
var opts = [];
|
||
if(currentCode && !has){
|
||
opts.push({code: currentCode, name: currentName || currentCode, source: '현재'});
|
||
}
|
||
// 그룹별 분리 (보유/관심/감시 순)
|
||
var groups = {'보유':[], '관심':[], '감시':[], '현재':[]};
|
||
opts.concat(items).forEach(function(it){ (groups[it.source]||groups['관심']).push(it); });
|
||
var html = '<option value="">종목 선택…</option>';
|
||
['현재','보유','관심','감시'].forEach(function(g){
|
||
if(!groups[g] || !groups[g].length) return;
|
||
html += '<optgroup label="'+g+'">';
|
||
groups[g].forEach(function(it){
|
||
var ownerTag = it.owner ? ' ['+it.owner+']' : '';
|
||
var label = (it.name||it.code) + ownerTag + ' ('+it.code+')';
|
||
var selAttr = (it.code === currentCode) ? ' selected' : '';
|
||
html += '<option value="'+it.code+'" data-name="'+(it.name||'').replace(/"/g,'"')+'"'+selAttr+'>'+label+'</option>';
|
||
});
|
||
html += '</optgroup>';
|
||
});
|
||
sel.innerHTML = html;
|
||
}
|
||
if(state.symbolsCache){ render(state.symbolsCache); return; }
|
||
fetch('/api/symbols/all').then(function(r){ return r.json(); }).then(function(d){
|
||
state.symbolsCache = d.items || [];
|
||
render(state.symbolsCache);
|
||
}).catch(function(){
|
||
render([]);
|
||
});
|
||
}
|
||
function onSymbolChange(){
|
||
var sel = $('[data-order-symbol-select]'); if(!sel) return;
|
||
var opt = sel.selectedOptions[0]; if(!opt) return;
|
||
var code = opt.value;
|
||
if(!code || code === state.code) return;
|
||
var name = opt.getAttribute('data-name') || opt.textContent.split(' [')[0].split(' (')[0];
|
||
state.code = code;
|
||
state.name = name;
|
||
state.lastBook = null;
|
||
state.lastCheck = null;
|
||
state.bookCentered = false;
|
||
// 모달 안 표시 갱신
|
||
var ne = $('[data-order-name]'); if(ne) ne.textContent = name;
|
||
var cd = $('[data-order-code-display]'); if(cd) cd.textContent = '('+code+')';
|
||
// 호가창 클리어 + 새 종목 fetch
|
||
var ob = $('[data-orderbook]');
|
||
if(ob) ob.innerHTML = '<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>';
|
||
$('[data-order-price-input]').value = '';
|
||
$('[data-order-qty]').value = '';
|
||
var budgetInp = $('[data-order-budget]');
|
||
if(budgetInp) budgetInp.value = '';
|
||
// 종목 변경 시 새 max로 자동 채움 (SELL: 보유 100%, BUY: 매수가능 max)
|
||
if(state.side === 'SELL') state.pendingMaxOnSell = true;
|
||
else state.pendingMaxOnBuy = true;
|
||
pollOnce();
|
||
updateCheck();
|
||
}
|
||
function fillAccounts(allowed){
|
||
var sel = $('[data-order-account]'); if(!sel) return;
|
||
sel.innerHTML = '';
|
||
var list = ACCOUNTS;
|
||
if(allowed && allowed.length){ list = ACCOUNTS.filter(function(a){ return allowed.indexOf(a.label) >= 0; }); }
|
||
list.forEach(function(a){ var opt = document.createElement('option'); opt.value=a.label; opt.textContent=a.display; sel.appendChild(opt); });
|
||
}
|
||
function reset(){
|
||
setMsg('');
|
||
$('[data-order-type]').value = 'LIMIT';
|
||
var priceInput = $('[data-order-price-input]');
|
||
if(priceInput){ priceInput.disabled = false; priceInput.value = ''; }
|
||
$('[data-order-qty]').value = '';
|
||
var budgetInput = $('[data-order-budget]');
|
||
if(budgetInput) budgetInput.value = '';
|
||
$('[data-order-max]').textContent = '(최대: —)';
|
||
$('[data-order-info]').textContent = '—';
|
||
}
|
||
function gotoTick(direction){
|
||
var book = state.lastBook;
|
||
if(!book || !book.asks || !book.bids) return;
|
||
var prices = [];
|
||
for(var i=book.bids.length-1; i>=0; i--) prices.push(book.bids[i].price);
|
||
for(var j=0; j<book.asks.length; j++) prices.push(book.asks[j].price);
|
||
if(!prices.length) return;
|
||
var inp = $('[data-order-price-input]');
|
||
var cur = parseInt((inp||{}).value || '0', 10);
|
||
if(!cur){
|
||
inp.value = book.bids[0] ? book.bids[0].price : prices[0];
|
||
updateCheck(); highlightSelectedTick(); return;
|
||
}
|
||
var idx = prices.indexOf(cur);
|
||
if(idx < 0){
|
||
var closestIdx = 0, closestDiff = Math.abs(prices[0] - cur);
|
||
for(var k=1; k<prices.length; k++){
|
||
var d = Math.abs(prices[k] - cur);
|
||
if(d < closestDiff){ closestIdx = k; closestDiff = d; }
|
||
}
|
||
idx = closestIdx;
|
||
}
|
||
var newIdx = Math.max(0, Math.min(prices.length-1, idx + direction));
|
||
inp.value = prices[newIdx];
|
||
updateCheck();
|
||
highlightSelectedTick();
|
||
recalcQtyFromBudget();
|
||
}
|
||
function openModal(opts){
|
||
opts = opts || {};
|
||
reset();
|
||
state.code = String(opts.code || '');
|
||
state.name = String(opts.name || '');
|
||
state.isOpen = true;
|
||
state.bookCentered = false;
|
||
var ne = $('[data-order-name]'); if(ne) ne.textContent = state.name || '—';
|
||
var cd = $('[data-order-code-display]'); if(cd) cd.textContent = state.code ? '('+state.code+')' : '';
|
||
fillAccounts(opts.accounts);
|
||
if(opts.account){ var sel = $('[data-order-account]'); if(sel) sel.value = opts.account; }
|
||
populateSymbolSelect(state.code, state.name);
|
||
setSide(opts.side === 'SELL' ? 'SELL' : 'BUY');
|
||
if(opts.price){ $('[data-order-price-input]').value = opts.price; }
|
||
modal.classList.remove('hidden');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
startPolling();
|
||
updateCheck();
|
||
fetchMarketPhase();
|
||
refreshActiveCount();
|
||
}
|
||
function closeModal(){
|
||
state.isOpen = false; stopPolling();
|
||
modal.classList.add('hidden');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
// pin-modal이 안 떠 있을 때만 body unlock
|
||
if(!pinModal || pinModal.classList.contains('hidden')) document.body.classList.remove('modal-open');
|
||
}
|
||
// ── PIN 모달 ──
|
||
function resetPinModal(){
|
||
if(!pinModal) return;
|
||
var inStep = $p('[data-pin-input-step]'); if(inStep) inStep.classList.remove('hidden');
|
||
var rStep = $p('[data-pin-result-step]'); if(rStep) rStep.classList.add('hidden');
|
||
var pinInp = $p('[data-pin-input]'); if(pinInp){ pinInp.value=''; pinInp.disabled=false; }
|
||
var verifyBtn = $p('[data-pin-verify]'); if(verifyBtn) verifyBtn.disabled=false;
|
||
var cancelBtn = $p('[data-pin-cancel]'); if(cancelBtn) cancelBtn.disabled=false;
|
||
}
|
||
function openPinModal(opts){
|
||
if(!pinModal) return;
|
||
resetPinModal();
|
||
var sym = $p('[data-pin-symbol]'); if(sym) sym.textContent = opts.symbol_name || '';
|
||
var sum = $p('[data-pin-card-summary]'); if(sum) sum.textContent = (opts.card_message || '').replace(/\*/g, '');
|
||
startCountdown(opts.expiry_seconds || 120);
|
||
pinModal.classList.remove('hidden');
|
||
pinModal.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
setTimeout(function(){ var p=$p('[data-pin-input]'); if(p) p.focus(); }, 100);
|
||
}
|
||
function closePinModal(){
|
||
if(!pinModal) return;
|
||
// verify 안 하고 닫으면 활성 카드 자동 취소 (이미 consumed면 백엔드가 무동작)
|
||
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''}).catch(function(){});
|
||
if(state.countdownTimer){ clearInterval(state.countdownTimer); state.countdownTimer=null; }
|
||
pinModal.classList.add('hidden');
|
||
pinModal.setAttribute('aria-hidden', 'true');
|
||
if(modal.classList.contains('hidden')) document.body.classList.remove('modal-open');
|
||
}
|
||
function startCountdown(seconds){
|
||
state.expiryAt = Date.now() + seconds*1000;
|
||
var cd = $p('[data-pin-countdown]');
|
||
function tick(){
|
||
var rem = Math.max(0, Math.floor((state.expiryAt - Date.now())/1000));
|
||
if(cd){
|
||
var mm = Math.floor(rem/60);
|
||
var ss = rem % 60;
|
||
cd.textContent = '⏱ ' + (mm>0?(mm+'분 '):'') + ss + '초 남음';
|
||
cd.classList.remove('warn','danger');
|
||
if(rem <= 10) cd.classList.add('danger');
|
||
else if(rem <= 30) cd.classList.add('warn');
|
||
}
|
||
if(rem <= 0){
|
||
clearInterval(state.countdownTimer); state.countdownTimer=null;
|
||
closePinModal();
|
||
showToast('⏰ PIN 만료됨 — 카드 자동 취소', 'info');
|
||
}
|
||
}
|
||
if(state.countdownTimer) clearInterval(state.countdownTimer);
|
||
tick(); state.countdownTimer = setInterval(tick, 500);
|
||
}
|
||
function doPropose(){
|
||
var account = $('[data-order-account]').value;
|
||
var orderType = $('[data-order-type]').value;
|
||
var qty = parseInt($('[data-order-qty]').value || '0', 10);
|
||
var price = parseInt($('[data-order-price-input]').value || '0', 10);
|
||
if(!account){ setMsg('계좌를 선택하세요', 'error'); return; }
|
||
if(qty <= 0){ setMsg('수량을 입력하세요', 'error'); return; }
|
||
if(orderType === 'LIMIT' && price <= 0){ setMsg('지정가는 단가가 필요합니다', 'error'); return; }
|
||
// 클라이언트 사전 차단 — 서버 왕복 없이 즉시 에러 (PIN 발급 중 토스트 깜빡임 방지)
|
||
var lc = state.lastCheck;
|
||
if(lc && typeof lc.max_qty === 'number' && lc.max_qty >= 0 && qty > lc.max_qty){
|
||
if(state.side === 'BUY'){
|
||
var basis = (orderType === 'MARKET' && lc.upper_limit) ? '상한가' : '단가';
|
||
var ref = (orderType === 'MARKET' && lc.upper_limit) ? lc.upper_limit : price;
|
||
var needed = ref * qty;
|
||
setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '원 × ' + qty + '주) / 가용 ' + (lc.ord_alow_amt||0).toLocaleString() + '원\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
|
||
} else {
|
||
setMsg('보유 부족 — 매도 ' + qty + '주 / 매도가능 ' + lc.max_qty + '주', 'error');
|
||
}
|
||
return;
|
||
}
|
||
var body = new URLSearchParams();
|
||
body.set('account', account);
|
||
body.set('side', state.side);
|
||
body.set('symbol', state.code);
|
||
body.set('symbol_name', state.name);
|
||
body.set('qty', String(qty));
|
||
body.set('order_type', orderType);
|
||
if(orderType === 'LIMIT') body.set('price', String(price));
|
||
setMsg('주문 검증 중…', 'info');
|
||
var btn = $('[data-order-submit]'); if(btn) btn.disabled = true;
|
||
// 이전 활성 카드 자동 정리 후 새 propose — "이전 카드가 아직 활성" 거부 방지
|
||
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''})
|
||
.catch(function(){})
|
||
.then(function(){
|
||
return fetch('/api/order/propose', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
|
||
body: body.toString()
|
||
});
|
||
})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){
|
||
if(btn) btn.disabled = false;
|
||
if(!d.ok){ setMsg(d.message || d.error || 'PIN 발급 실패', 'error'); return; }
|
||
setMsg('');
|
||
// 별도 PIN 모달 띄움 (order-modal은 그대로 뒤에 남음)
|
||
openPinModal({
|
||
card_message: d.card_message || '',
|
||
expiry_seconds: d.expiry_seconds || 120,
|
||
symbol_name: state.name
|
||
});
|
||
}).catch(function(e){
|
||
if(btn) btn.disabled = false;
|
||
setMsg('네트워크 오류: ' + e, 'error');
|
||
});
|
||
}
|
||
function doVerify(){
|
||
var pinInp = $p('[data-pin-input]');
|
||
var pin = (pinInp && pinInp.value || '').trim();
|
||
if(!pin){ showToast('PIN을 입력하세요', 'error'); return; }
|
||
var body = new URLSearchParams(); body.set('pin', pin);
|
||
var btn = $p('[data-pin-verify]'); if(btn) btn.disabled = true;
|
||
var cancelBtn = $p('[data-pin-cancel]'); if(cancelBtn) cancelBtn.disabled = true;
|
||
fetch('/api/order/verify', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
|
||
body: body.toString()
|
||
}).then(function(r){ return r.json(); })
|
||
.then(function(d){
|
||
if(state.countdownTimer){ clearInterval(state.countdownTimer); state.countdownTimer=null; }
|
||
if(d.ok){
|
||
// 매매등록 성공 — PIN 모달 자동 닫기 + 토스트로 결과 알림
|
||
showToast(d.message || '✅ 주문 접수됨', 'success', 5000);
|
||
closePinModal();
|
||
return;
|
||
}
|
||
// 실패 — 모달 안 결과 step swap
|
||
var resultEl = $p('[data-pin-result]');
|
||
if(resultEl){
|
||
resultEl.textContent = d.message || d.error || '주문 실패';
|
||
resultEl.classList.remove('info','success');
|
||
resultEl.classList.add('error');
|
||
}
|
||
var inStep = $p('[data-pin-input-step]'); if(inStep) inStep.classList.add('hidden');
|
||
var rStep = $p('[data-pin-result-step]'); if(rStep) rStep.classList.remove('hidden');
|
||
}).catch(function(e){
|
||
if(btn) btn.disabled = false;
|
||
if(cancelBtn) cancelBtn.disabled = false;
|
||
showToast('네트워크 오류: ' + e, 'error');
|
||
});
|
||
}
|
||
// 주문 모달 click 핸들러
|
||
modal.addEventListener('click', function(e){
|
||
var t = e.target;
|
||
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeModal(); return; }
|
||
if(t.classList && t.classList.contains('modal-close')){ closeModal(); return; }
|
||
var infoBtn = t.closest && t.closest('[data-show-active]');
|
||
if(infoBtn){ showProgressTab(); return; }
|
||
var sideBtn = t.closest && t.closest('.order-side-toggle button[data-side]');
|
||
if(sideBtn){ setSide(sideBtn.getAttribute('data-side')); return; }
|
||
if(t.matches && t.matches('[data-order-submit]')){ doPropose(); return; }
|
||
var tickBtn = t.closest && t.closest('[data-tick-dir]');
|
||
if(tickBtn){
|
||
var dir = parseInt(tickBtn.getAttribute('data-tick-dir') || '0', 10);
|
||
gotoTick(dir); return;
|
||
}
|
||
var qtyBtn = t.closest && t.closest('[data-qty-step]');
|
||
if(qtyBtn){ gotoQty(qtyBtn.getAttribute('data-qty-step')); return; }
|
||
var obRow = t.closest && t.closest('.ob-row');
|
||
if(obRow){
|
||
var p = parseInt(obRow.getAttribute('data-ob-price') || '0', 10);
|
||
if(p > 0){ var inp = $('[data-order-price-input]'); if(inp && !inp.disabled){ inp.value = p; updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); } }
|
||
}
|
||
});
|
||
// open-orders 모달 click 핸들러
|
||
if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
|
||
var t = e.target;
|
||
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeOpenOrdersModal(); return; }
|
||
if(t.classList && t.classList.contains('modal-close')){ closeOpenOrdersModal(); return; }
|
||
var refBtn = t.closest && t.closest('[data-open-orders-refresh]');
|
||
if(refBtn){ loadOpenOrders(); return; }
|
||
var cancelBtn = t.closest && t.closest('[data-cancel-open-order]');
|
||
if(cancelBtn){
|
||
var row = cancelBtn.closest('.open-order-row');
|
||
if(!row) return;
|
||
cancelOpenOrder(row.getAttribute('data-ord-no'), row.getAttribute('data-account'), cancelBtn);
|
||
return;
|
||
}
|
||
});
|
||
// PIN 모달 click 핸들러
|
||
if(pinModal) pinModal.addEventListener('click', function(e){
|
||
var t = e.target;
|
||
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closePinModal(); return; }
|
||
if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; }
|
||
if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; }
|
||
});
|
||
var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck);
|
||
var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange);
|
||
var otSel = $('[data-order-type]');
|
||
if(otSel) otSel.addEventListener('change', function(){
|
||
var isMarket = (otSel.value === 'MARKET');
|
||
var inp = $('[data-order-price-input]');
|
||
if(inp) inp.disabled = isMarket;
|
||
$$('[data-tick-dir]').forEach(function(b){ b.disabled = isMarket; });
|
||
// 주문유형 토글 시 max_qty 기준이 바뀜 (MARKET=상한가, LIMIT=단가) — 재조회
|
||
updateCheck();
|
||
});
|
||
var priceInpEl = $('[data-order-price-input]');
|
||
if(priceInpEl) priceInpEl.addEventListener('input', function(){ updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); });
|
||
var qtyInpEl = $('[data-order-qty]');
|
||
if(qtyInpEl) qtyInpEl.addEventListener('input', function(){ recalcBudgetFromQty(); renderOrderInfo(); });
|
||
var budgetInpEl = $('[data-order-budget]');
|
||
if(budgetInpEl) budgetInpEl.addEventListener('input', recalcQtyFromBudget);
|
||
document.addEventListener('visibilitychange', function(){
|
||
if(document.hidden) stopPolling();
|
||
else if(state.isOpen) startPolling();
|
||
});
|
||
// 보유종목·관심·감시종목 행의 .btn-order + 자산보기 [💰 거래] sub-tab 버튼 — panels swap 대응 위임
|
||
document.addEventListener('click', function(e){
|
||
// [💰 거래] sub-tab 버튼 — 본인 계좌 첫 보유종목 자동 선택 후 모달 열기
|
||
var subOrderBtn = e.target.closest && e.target.closest('[data-open-order-modal]');
|
||
if(subOrderBtn){
|
||
function openWithFirst(items){
|
||
var first = (items || []).find(function(it){ return it.source === '보유' && it.owner === '본인'; });
|
||
if(first){ openModal({code: first.code, name: first.name}); }
|
||
else { openModal({code: '', name: ''}); }
|
||
}
|
||
if(state.symbolsCache){ openWithFirst(state.symbolsCache); }
|
||
else {
|
||
fetch('/api/symbols/all').then(function(r){ return r.json(); }).then(function(d){
|
||
state.symbolsCache = d.items || [];
|
||
openWithFirst(state.symbolsCache);
|
||
}).catch(function(){ openModal({code: '', name: ''}); });
|
||
}
|
||
return;
|
||
}
|
||
// 보유/관심/감시 행의 거래 버튼 — 종목·side 자동 설정
|
||
var btn = e.target.closest && e.target.closest('.btn-order');
|
||
if(!btn) return;
|
||
var code = btn.getAttribute('data-order-code') || '';
|
||
var stock = btn.getAttribute('data-order-stock') || '';
|
||
var side = btn.getAttribute('data-order-side') || 'BUY';
|
||
if(code) openModal({code: code, name: stock, side: side});
|
||
});
|
||
window.openOrderModal = openModal;
|
||
window.closeOrderModal = closeModal;
|
||
window.openPinModal = openPinModal;
|
||
})();</script>'''
|
||
|
||
return f'''<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="theme-color" content="#0f1115">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="자산현황">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>자산현황</title>
|
||
<style>{_CSS}{dynamic_css}</style>
|
||
{head_script}
|
||
</head>
|
||
<body>
|
||
<div class="page" id="page">
|
||
<div class="ptr" id="ptr" aria-hidden="true"><span class="arrow">↓</span><span class="spinner"></span></div>
|
||
<div class="topbar">
|
||
<header class="top">
|
||
<div class="titles">
|
||
<h1>자산현황</h1>
|
||
<span class="meta">갱신 {now}</span>
|
||
</div>
|
||
<div class="top-actions">
|
||
<button type="button" class="account-view-toggle-btn" data-account-view-state="consolidated" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글">↔</button>
|
||
<button id="auto-toggle" class="auto-toggle" type="button" aria-pressed="false" title="자동 갱신 사이클: off → 10s → 3s (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)"><span class="auto-dot"></span><span>자동</span></button>
|
||
<a class="refresh" href="#" onclick="event.preventDefault();window.__behive_manual_refresh&&window.__behive_manual_refresh();" aria-label="새로고침" title="새로고침">↻</a>
|
||
</div>
|
||
</header>
|
||
<nav class="tabs" role="tablist">
|
||
{nav_html}
|
||
</nav>
|
||
</div>
|
||
{content_html}
|
||
</div>
|
||
<div class="market-ticker" id="market-ticker"><span class="idx-empty">지수 불러오는 중…</span></div>
|
||
{idx_info_modal_html}
|
||
{interest_modal_html}
|
||
{interest_edit_modal_html}
|
||
{tag_modal_html}
|
||
{candidate_modal_html}
|
||
{trade_modal_html}
|
||
{info_modal_html}
|
||
{info_desc_modal_html}
|
||
{order_modal_html}
|
||
{pin_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}
|
||
{details_mutex_script}
|
||
{modal_script}
|
||
{order_modal_script}
|
||
{stock_name_tip_script}
|
||
</body>
|
||
</html>'''
|
||
|
||
|
||
# ── 패널 페이로드 인메모리 캐시 ──
|
||
# /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()
|
||
|
||
# 데이터 레이어 캐시 — 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) -> 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}'
|
||
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)
|
||
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
|
||
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) -> 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}'
|
||
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)
|
||
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)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', 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/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]
|
||
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
|
||
seen: set = set()
|
||
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 or 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/orders/open':
|
||
# 4계좌 통합 미체결 주문 — open-orders-modal이 표시
|
||
try:
|
||
import kiwoom_client as kc
|
||
rows = kc.get_open_orders_all()
|
||
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()
|
||
unique_codes: set = set()
|
||
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,
|
||
}
|
||
if payload.get('symbol'):
|
||
unique_codes.add(payload['symbol'])
|
||
# 4계좌 미체결 주문 ka10075 통합
|
||
open_orders_count = 0
|
||
try:
|
||
import kiwoom_client as kc
|
||
rows = kc.get_open_orders_all()
|
||
open_orders_count = len(rows)
|
||
for r in rows:
|
||
if r.get('code'):
|
||
unique_codes.add(r['code'])
|
||
except Exception:
|
||
traceback.print_exc()
|
||
result['open_orders_count'] = open_orders_count
|
||
result['unique_count'] = len(unique_codes)
|
||
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/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
|
||
book = kc.get_quote_book(code)
|
||
quote = kc.get_stock_quote(code)
|
||
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', '')
|
||
# NXT 거래 가능 여부 (ka10099 캐시 nxtEnable) — 캐시 없거나 모르면 보수적 True
|
||
try:
|
||
meta = kc.lookup_stock_meta(code)
|
||
book['nxt_enable'] = True if (not meta) else bool(meta.get('nxt_enable', True))
|
||
except Exception:
|
||
book['nxt_enable'] = True
|
||
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/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'):
|
||
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)
|
||
ord_alow = int(bal.get('ord_alow_amt') or 0)
|
||
result['ord_alow_amt'] = ord_alow
|
||
result['d2_entra'] = int(bal.get('d2_entra') or 0)
|
||
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'] = (ord_alow // upper) if upper > 0 else 0
|
||
else:
|
||
# 키움 ord_alow_amt는 수수료 버퍼 이미 반영된 매수가능액. price>0 일 때만 max_qty.
|
||
result['max_qty'] = (ord_alow // 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:
|
||
result['hold_qty'] = pos['qty']
|
||
result['trde_able_qty'] = pos['trde_able_qty']
|
||
result['avg_price'] = pos['avg_price']
|
||
result['cur_price'] = pos['cur_price']
|
||
result['max_qty'] = pos['trde_able_qty']
|
||
# 선택 단가 기준 손익 미리보기 — 단가 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, '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/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
|
||
mins = kc.get_minute_candles(code, tic_scope=tic_scope, today_only=True)
|
||
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),
|
||
}
|
||
svg = sa.render_svg_chart(snap, range_key='1Y')
|
||
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
|
||
|
||
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': 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),
|
||
}
|
||
# 분석 페이지와 동일 패턴 — 1Y/6M/1M 3 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
|
||
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
|
||
svg_1y = sa.render_svg_chart(snap, range_key='1Y')
|
||
svg_6m = sa.render_svg_chart(snap, range_key='6M')
|
||
svg_1m = sa.render_svg_chart(snap, range_key='1M')
|
||
# 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
|
||
# subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
|
||
def _minute_panel(unit_id: str) -> str:
|
||
return (
|
||
f'<div class="chart-panel" data-range="{unit_id}" data-unit="{unit_id}">'
|
||
'<div class="chart-subtabs">'
|
||
'<button type="button" data-subrange="30min">30분</button>'
|
||
'<button type="button" data-subrange="1h" class="active">1시간</button>'
|
||
'<button type="button" data-subrange="3h">3시간</button>'
|
||
'<button type="button" data-subrange="5h">5시간</button>'
|
||
'<button type="button" data-subrange="1d">하루</button>'
|
||
'</div>'
|
||
'<div class="chart-subpanel"></div>'
|
||
'</div>'
|
||
)
|
||
html_block = (
|
||
'<div class="chart-tabs">'
|
||
'<button type="button" data-range="1Y">1년</button>'
|
||
'<button type="button" data-range="6M">6개월</button>'
|
||
'<button type="button" data-range="1M" class="active">1개월</button>'
|
||
'<button type="button" data-range="5m" data-unit="5m">5분봉</button>'
|
||
'<button type="button" data-range="1m" data-unit="1m">1분봉</button>'
|
||
'</div>'
|
||
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
|
||
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
|
||
f'<div class="chart-panel active" data-range="1M">{svg_1m}</div>'
|
||
+ _minute_panel('5m')
|
||
+ _minute_panel('1m')
|
||
)
|
||
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
|
||
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)
|
||
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/<code> — 종목 분석 상세 페이지
|
||
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
|
||
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'):
|
||
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_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 == 'LIMIT' 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
|
||
status = 200 if res.get('ok') else 400
|
||
self._send_json(status, 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
|
||
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
|
||
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
|
||
res = handler.cancel_active_card()
|
||
# cancel_active_card 반환은 PendingCard | None. dict로 변환.
|
||
if res is None:
|
||
self._send_json(200, {'ok': True, 'message': '활성 카드 없음'})
|
||
return
|
||
self._send_json(200, {'ok': True, 'message': '카드 취소됨', 'card_id': res.card_id})
|
||
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 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
|
||
|
||
# /stock/<code>/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/<code>/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)
|
||
|
||
|
||
def cmd_serve() -> int:
|
||
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:]))
|