auto: 일일 백업 2026-07-18 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
|
|||||||
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
|
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
|
||||||
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
|
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
|
||||||
STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
|
STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
|
||||||
|
CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json'
|
||||||
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
|
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
|
||||||
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
||||||
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||||
@@ -1883,6 +1884,150 @@ def _note_button_html(code: str, name: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_hlines_lock():
|
||||||
|
"""CHART_HLINES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||||
|
import fcntl as _fcntl
|
||||||
|
from contextlib import contextmanager as _cm
|
||||||
|
|
||||||
|
@_cm
|
||||||
|
def _ctx():
|
||||||
|
lock_path = CHART_HLINES.with_suffix(CHART_HLINES.suffix + '.lock')
|
||||||
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
f = open(lock_path, 'a')
|
||||||
|
try:
|
||||||
|
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
return _ctx()
|
||||||
|
|
||||||
|
|
||||||
|
_HLINES_MAX = 30 # 종목당 수평선 개수 상한
|
||||||
|
|
||||||
|
|
||||||
|
def _load_chart_hlines() -> dict:
|
||||||
|
"""{code: [price, ...]}. 종목차트에 그릴 사용자 수평선(지지/저항 등) 저장소. 종목코드 단위 공용(계좌 무관)."""
|
||||||
|
if not CHART_HLINES.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
d = json.loads(CHART_HLINES.read_text())
|
||||||
|
return d if isinstance(d, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_chart_hlines(code: str | None) -> list:
|
||||||
|
"""종목의 수평선 가격 리스트(정수, 내림차순). 없으면 []."""
|
||||||
|
code = (code or '').strip()
|
||||||
|
if not code:
|
||||||
|
return []
|
||||||
|
v = _load_chart_hlines().get(code)
|
||||||
|
if not isinstance(v, list):
|
||||||
|
return []
|
||||||
|
out = set()
|
||||||
|
for x in v:
|
||||||
|
try:
|
||||||
|
p = int(round(float(x)))
|
||||||
|
if p > 0:
|
||||||
|
out.add(p)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return sorted(out, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_chart_hline(code: str | None, price) -> None:
|
||||||
|
"""수평선 가격 추가. 중복은 무시. 상한(30) 초과 시 ValueError."""
|
||||||
|
code = (code or '').strip()
|
||||||
|
if not code:
|
||||||
|
raise ValueError('종목코드가 필요합니다')
|
||||||
|
try:
|
||||||
|
p = int(round(float(price)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('가격이 올바르지 않습니다')
|
||||||
|
if p <= 0:
|
||||||
|
raise ValueError('가격은 0보다 커야 합니다')
|
||||||
|
CHART_HLINES.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _chart_hlines_lock():
|
||||||
|
d = _load_chart_hlines()
|
||||||
|
cur = _normalize_hline_list(d.get(code))
|
||||||
|
if p not in cur and len(cur) >= _HLINES_MAX:
|
||||||
|
raise ValueError(f'수평선은 최대 {_HLINES_MAX}개까지 등록할 수 있습니다')
|
||||||
|
cur.add(p)
|
||||||
|
d[code] = sorted(cur, reverse=True)
|
||||||
|
_write_chart_hlines(d)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_chart_hline(code: str | None, price) -> None:
|
||||||
|
"""수평선 가격 제거. 리스트가 비면 종목 키 자체 삭제."""
|
||||||
|
code = (code or '').strip()
|
||||||
|
if not code:
|
||||||
|
raise ValueError('종목코드가 필요합니다')
|
||||||
|
try:
|
||||||
|
p = int(round(float(price)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('가격이 올바르지 않습니다')
|
||||||
|
with _chart_hlines_lock():
|
||||||
|
d = _load_chart_hlines()
|
||||||
|
cur = _normalize_hline_list(d.get(code))
|
||||||
|
cur.discard(p)
|
||||||
|
if cur:
|
||||||
|
d[code] = sorted(cur, reverse=True)
|
||||||
|
else:
|
||||||
|
d.pop(code, None)
|
||||||
|
_write_chart_hlines(d)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_hline_list(v) -> set:
|
||||||
|
out = set()
|
||||||
|
if isinstance(v, list):
|
||||||
|
for x in v:
|
||||||
|
try:
|
||||||
|
p = int(round(float(x)))
|
||||||
|
if p > 0:
|
||||||
|
out.add(p)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _write_chart_hlines(d: dict) -> None:
|
||||||
|
tmp = CHART_HLINES.with_suffix('.json.tmp')
|
||||||
|
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||||
|
tmp.replace(CHART_HLINES)
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_hlines_ui(code: str, hlines: list) -> str:
|
||||||
|
"""차트 하단 수평선 관리 UI — 등록된 선 칩(삭제 ×) + 가격 입력 폼.
|
||||||
|
/api/chart_svg 응답 HTML 블록에 포함돼 모든 차트(보유·관심·감시)에 동일하게 노출."""
|
||||||
|
code_s = (code or '').strip()
|
||||||
|
if not code_s:
|
||||||
|
return ''
|
||||||
|
code_attr = html.escape(code_s, quote=True)
|
||||||
|
if hlines:
|
||||||
|
chips = ''.join(
|
||||||
|
f'<span class="hl-chip">{p:,}'
|
||||||
|
f'<button type="button" class="hl-chip-x" data-hl-remove="{p}" aria-label="삭제">×</button>'
|
||||||
|
f'</span>'
|
||||||
|
for p in hlines
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
chips = '<span class="hl-empty">등록된 수평선이 없어요</span>'
|
||||||
|
return (
|
||||||
|
f'<div class="chart-hlines" data-hl-code="{code_attr}">'
|
||||||
|
'<div class="hl-title">수평선</div>'
|
||||||
|
f'<div class="hl-list">{chips}</div>'
|
||||||
|
'<form class="chart-hlines-add">'
|
||||||
|
'<input type="number" name="price" inputmode="numeric" step="1" min="1" '
|
||||||
|
'placeholder="가격 입력" autocomplete="off">'
|
||||||
|
'<button type="submit" class="hl-add-btn">+ 추가</button>'
|
||||||
|
'</form>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _interest_groups_lock():
|
def _interest_groups_lock():
|
||||||
"""INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
"""INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||||
import fcntl as _fcntl
|
import fcntl as _fcntl
|
||||||
@@ -4803,6 +4948,18 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
|||||||
.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 { 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: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-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-hlines { margin-top: 8px; padding: 8px; background: #131820; border: 1px solid #2a2f3a; border-radius: 8px; }
|
||||||
|
.detail .block.chart-svg .chart-hlines .hl-title { font-size: 11px; font-weight: 700; color: #e879f9; margin-bottom: 6px; }
|
||||||
|
.detail .block.chart-svg .chart-hlines .hl-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.detail .block.chart-svg .chart-hlines .hl-empty { font-size: 11px; color: #7a8493; }
|
||||||
|
.detail .block.chart-svg .chart-hlines .hl-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px 3px 9px; background: rgba(232,121,249,0.12); color: #e879f9; border: 1px solid rgba(232,121,249,0.45); border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||||
|
.detail .block.chart-svg .chart-hlines .hl-chip-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; background: transparent; color: #e879f9; border: none; border-radius: 50%; cursor: pointer; font-size: 14px; line-height: 1; font-family: inherit; }
|
||||||
|
.detail .block.chart-svg .chart-hlines .hl-chip-x:hover { background: rgba(232,121,249,0.25); }
|
||||||
|
.detail .block.chart-svg .chart-hlines-add { display: flex; gap: 6px; }
|
||||||
|
.detail .block.chart-svg .chart-hlines-add input { flex: 1; min-width: 0; padding: 6px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 13px; font-family: inherit; }
|
||||||
|
.detail .block.chart-svg .chart-hlines-add input:focus { outline: none; border-color: #e879f9; }
|
||||||
|
.detail .block.chart-svg .chart-hlines-add .hl-add-btn { padding: 6px 14px; background: rgba(232,121,249,0.14); color: #e879f9; border: 1px solid rgba(232,121,249,0.5); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 700; font-family: inherit; white-space: nowrap; }
|
||||||
|
.detail .block.chart-svg .chart-hlines-add .hl-add-btn:hover { background: rgba(232,121,249,0.24); }
|
||||||
.detail .block.chart-svg .chart-subpanel { min-height: 200px; }
|
.detail .block.chart-svg .chart-subpanel { min-height: 200px; }
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -8501,6 +8658,42 @@ def render_html() -> str:
|
|||||||
'var subpanel=panel.querySelector(".chart-subpanel");'
|
'var subpanel=panel.querySelector(".chart-subpanel");'
|
||||||
'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
|
'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
|
||||||
'},false);'
|
'},false);'
|
||||||
|
# 수평선(hlines) 추가/제거 — 저장 후 dailyCache 무효화 + 재fetch로 차트·칩 갱신.
|
||||||
|
'function refreshChart(code){'
|
||||||
|
'if(!code)return;'
|
||||||
|
'dailyCache.delete(code);'
|
||||||
|
'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"]\').forEach(function(el){'
|
||||||
|
'if(el.closest("details[open]"))fetchDaily(code,el);'
|
||||||
|
'});'
|
||||||
|
'}'
|
||||||
|
'function postHline(url,code,price,cb){'
|
||||||
|
'fetch(url,{method:"POST",credentials:"same-origin",'
|
||||||
|
'headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},'
|
||||||
|
'body:"code="+encodeURIComponent(code)+"&price="+encodeURIComponent(price)})'
|
||||||
|
'.then(function(r){return r.json();})'
|
||||||
|
'.then(function(j){if(j&&j.ok){cb();}else{alert((j&&j.error)||"수평선 처리 실패");}})'
|
||||||
|
'.catch(function(){alert("수평선 처리 실패");});'
|
||||||
|
'}'
|
||||||
|
'document.addEventListener("submit",function(ev){'
|
||||||
|
'var f=ev.target;'
|
||||||
|
'if(!f.classList||!f.classList.contains("chart-hlines-add"))return;'
|
||||||
|
'ev.preventDefault();'
|
||||||
|
'var wrap=f.closest(".chart-hlines");if(!wrap)return;'
|
||||||
|
'var code=wrap.getAttribute("data-hl-code");'
|
||||||
|
'var inp=f.querySelector(\'input[name="price"]\');'
|
||||||
|
'var price=inp?inp.value.trim():"";'
|
||||||
|
'if(!code||!price)return;'
|
||||||
|
'postHline("/chart/hlines/add",code,price,function(){if(inp)inp.value="";refreshChart(code);});'
|
||||||
|
'});'
|
||||||
|
'document.addEventListener("click",function(ev){'
|
||||||
|
'var b=ev.target.closest("[data-hl-remove]");if(!b)return;'
|
||||||
|
'var wrap=b.closest(".chart-hlines");if(!wrap)return;'
|
||||||
|
'ev.preventDefault();'
|
||||||
|
'var code=wrap.getAttribute("data-hl-code");'
|
||||||
|
'var price=b.getAttribute("data-hl-remove");'
|
||||||
|
'if(!code||!price)return;'
|
||||||
|
'postHline("/chart/hlines/remove",code,price,function(){refreshChart(code);});'
|
||||||
|
'});'
|
||||||
# 첫 페이지 로드 시 이미 열려있는 카드 (드물지만 안전).
|
# 첫 페이지 로드 시 이미 열려있는 카드 (드물지만 안전).
|
||||||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){loadChartsInOpen();});'
|
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){loadChartsInOpen();});'
|
||||||
'else loadChartsInOpen();'
|
'else loadChartsInOpen();'
|
||||||
@@ -11789,7 +11982,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
prev_close = _pc
|
prev_close = _pc
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close, intraday=True)
|
svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close,
|
||||||
|
intraday=True, hlines=_get_chart_hlines(code))
|
||||||
body = svg.encode('utf-8')
|
body = svg.encode('utf-8')
|
||||||
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
|
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
@@ -11878,10 +12072,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
# 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
|
# 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
|
||||||
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
|
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
|
||||||
svg_1y = sa.render_svg_chart(snap, range_key='1Y')
|
hlines = _get_chart_hlines(code)
|
||||||
svg_6m = sa.render_svg_chart(snap, range_key='6M')
|
svg_1y = sa.render_svg_chart(snap, range_key='1Y', hlines=hlines)
|
||||||
svg_3m = sa.render_svg_chart(snap, range_key='3M')
|
svg_6m = sa.render_svg_chart(snap, range_key='6M', hlines=hlines)
|
||||||
svg_1m = sa.render_svg_chart(snap, range_key='1M')
|
svg_3m = sa.render_svg_chart(snap, range_key='3M', hlines=hlines)
|
||||||
|
svg_1m = sa.render_svg_chart(snap, range_key='1M', hlines=hlines)
|
||||||
# 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
|
# 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
|
||||||
# subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
|
# subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
|
||||||
def _minute_panel(unit_id: str) -> str:
|
def _minute_panel(unit_id: str) -> str:
|
||||||
@@ -11913,6 +12108,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
|
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
|
||||||
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
|
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
|
||||||
+ _turn_cap
|
+ _turn_cap
|
||||||
|
+ _chart_hlines_ui(code, hlines)
|
||||||
)
|
)
|
||||||
body = html_block.encode('utf-8')
|
body = html_block.encode('utf-8')
|
||||||
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
|
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
|
||||||
@@ -12514,6 +12710,30 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.path in ('/chart/hlines/add', '/chart/hlines/remove'):
|
||||||
|
# 종목차트 사용자 수평선 추가/제거 — code+price 필수. 응답은 JSON(갱신된 리스트 포함).
|
||||||
|
code = ''.join(ch for ch in (params.get('code') or [''])[0].strip() if ch.isalnum())
|
||||||
|
price_raw = (params.get('price') or [''])[0].strip()
|
||||||
|
if not code or not price_raw:
|
||||||
|
self._send_json(400, {'ok': False, 'error': 'code/price 필요'})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.path.endswith('/add'):
|
||||||
|
_add_chart_hline(code, price_raw)
|
||||||
|
else:
|
||||||
|
_remove_chart_hline(code, price_raw)
|
||||||
|
except ValueError as e:
|
||||||
|
self._send_json(400, {'ok': False, 'error': str(e)})
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
self._send_json(500, {'ok': False, 'error': str(e)})
|
||||||
|
return
|
||||||
|
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} price={price_raw} → ok\n')
|
||||||
|
sys.stdout.flush()
|
||||||
|
self._send_json(200, {'ok': True, 'lines': _get_chart_hlines(code)})
|
||||||
|
return
|
||||||
|
|
||||||
if self.path == '/notes/set':
|
if self.path == '/notes/set':
|
||||||
# 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수.
|
# 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수.
|
||||||
# note 가 빈 텍스트면 항목 제거.
|
# note 가 빈 텍스트면 항목 제거.
|
||||||
|
|||||||
@@ -603,11 +603,12 @@ CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '3M': 63, '1M': 21}
|
|||||||
|
|
||||||
|
|
||||||
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None,
|
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None,
|
||||||
intraday: bool = False) -> str:
|
intraday: bool = False, hlines: list | None = None) -> str:
|
||||||
"""캔들 + SMA 3선 + 거래량 막대(+일봉 20일 거래량 이동평균선) 인라인 SVG.
|
"""캔들 + SMA 3선 + 거래량 막대(+일봉 20일 거래량 이동평균선) 인라인 SVG.
|
||||||
|
|
||||||
range_key: '1Y' (250일) | '6M' (120일) | '3M' (63일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
|
range_key: '1Y' (250일) | '6M' (120일) | '3M' (63일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
|
||||||
ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
|
ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
|
||||||
|
hlines: 사용자가 등록한 수평선 가격 리스트(지지/저항 등). 표시 구간 안에 드는 값만 그린다(스케일 미확장).
|
||||||
회전율 값은 차트에 그리지 않고 caller가 turnover_today()로 별도 표시 (차트 아래).
|
회전율 값은 차트에 그리지 않고 caller가 turnover_today()로 별도 표시 (차트 아래).
|
||||||
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
|
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
|
||||||
외부 JS 의존성 0. CSS는 inline.
|
외부 JS 의존성 0. CSS는 inline.
|
||||||
@@ -872,6 +873,31 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
|||||||
f'font-size="26" font-weight="700">{int(round(cur_price)):,}</text>'
|
f'font-size="26" font-weight="700">{int(round(cur_price)):,}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- 사용자 수평선 (hlines) — 지지/저항 등 수동 등록 가격선 ----
|
||||||
|
# 표시 구간(y_min~y_max) 안에 드는 값만 그린다. 스케일은 확장하지 않음(캔들 가독성 우선).
|
||||||
|
# 좌측에 분홍 가격 태그를 둔다(우측은 가격축·현재가 태그가 이미 점유).
|
||||||
|
for hp in (hlines or []):
|
||||||
|
try:
|
||||||
|
hp = float(hp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if hp < y_min or hp > y_max:
|
||||||
|
continue
|
||||||
|
hly = price_y(hp)
|
||||||
|
parts.append(
|
||||||
|
f'<line x1="{CHART_PAD_L}" y1="{hly:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{hly:.1f}" '
|
||||||
|
f'stroke="#e879f9" stroke-width="1.6" stroke-dasharray="6 4" opacity="0.9"/>'
|
||||||
|
)
|
||||||
|
htag_y = min(max(hly, PRICE_TOP + 15), PRICE_BOT - 15)
|
||||||
|
parts.append(
|
||||||
|
f'<rect x="{CHART_PAD_L+2}" y="{htag_y-15:.1f}" width="122" height="30" '
|
||||||
|
f'fill="#e879f9" rx="3" opacity="0.95"/>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{CHART_PAD_L+8}" y="{htag_y+8:.1f}" text-anchor="start" fill="#1a0f1f" '
|
||||||
|
f'font-size="26" font-weight="700">{int(round(hp)):,}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
parts.append('</svg>')
|
parts.append('</svg>')
|
||||||
return ''.join(parts)
|
return ''.join(parts)
|
||||||
|
|
||||||
|
|||||||
@@ -434,66 +434,21 @@
|
|||||||
"origin": "interest",
|
"origin": "interest",
|
||||||
"added_at": "2026-07-07T09:00:05.632372+09:00"
|
"added_at": "2026-07-07T09:00:05.632372+09:00"
|
||||||
},
|
},
|
||||||
"064350": {
|
|
||||||
"name": "현대로템",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"089970": {
|
"089970": {
|
||||||
"name": "브이엠",
|
"name": "브이엠",
|
||||||
"origin": "auto",
|
"origin": "auto",
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
||||||
},
|
},
|
||||||
"196170": {
|
|
||||||
"name": "알테오젠",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"003230": {
|
|
||||||
"name": "삼양식품",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"080220": {
|
"080220": {
|
||||||
"name": "제주반도체",
|
"name": "제주반도체",
|
||||||
"origin": "auto",
|
"origin": "auto",
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
||||||
},
|
},
|
||||||
"352820": {
|
|
||||||
"name": "하이브",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"010950": {
|
"010950": {
|
||||||
"name": "S-Oil",
|
"name": "S-Oil",
|
||||||
"origin": "auto",
|
"origin": "auto",
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
||||||
},
|
},
|
||||||
"096770": {
|
|
||||||
"name": "SK이노베이션",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"018260": {
|
|
||||||
"name": "삼성에스디에스",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"251120": {
|
|
||||||
"name": "바이오에프디엔씨",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"004980": {
|
|
||||||
"name": "성신양회",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"403850": {
|
|
||||||
"name": "더핑크퐁컴퍼니",
|
|
||||||
"origin": "auto",
|
|
||||||
"added_at": "2026-07-07T21:02:06.405999+09:00"
|
|
||||||
},
|
|
||||||
"006400": {
|
"006400": {
|
||||||
"name": "삼성SDI",
|
"name": "삼성SDI",
|
||||||
"origin": "auto",
|
"origin": "auto",
|
||||||
|
|||||||
Reference in New Issue
Block a user