diff --git a/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index a2d2c98..7c6d317 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -42,6 +42,7 @@ WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json' INTERESTS = WORKSPACE / 'state' / 'behive_interests.json' STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json' STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json' +CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json' INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json' ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.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'{p:,}' + f'' + f'' + for p in hlines + ) + else: + chips = '등록된 수평선이 없어요' + return ( + f'
' + '
수평선
' + f'
{chips}
' + '
' + '' + '' + '
' + '
' + ) + + def _interest_groups_lock(): """INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일.""" 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: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-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; } .actions { @@ -8501,6 +8658,42 @@ def render_html() -> str: 'var subpanel=panel.querySelector(".chart-subpanel");' 'if(subpanel)fetchMinute(code,unit,sr,subpanel);' '},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();});' 'else loadChartsInOpen();' @@ -11789,7 +11982,8 @@ class Handler(BaseHTTPRequestHandler): prev_close = _pc except Exception: 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, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) @@ -11878,10 +12072,11 @@ class Handler(BaseHTTPRequestHandler): ) # 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 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_3m = sa.render_svg_chart(snap, range_key='3M') - svg_1m = sa.render_svg_chart(snap, range_key='1M') + hlines = _get_chart_hlines(code) + svg_1y = sa.render_svg_chart(snap, range_key='1Y', hlines=hlines) + svg_6m = sa.render_svg_chart(snap, range_key='6M', hlines=hlines) + svg_3m = sa.render_svg_chart(snap, range_key='3M', hlines=hlines) + svg_1m = sa.render_svg_chart(snap, range_key='1M', hlines=hlines) # 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'. # subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch. def _minute_panel(unit_id: str) -> str: @@ -11913,6 +12108,7 @@ class Handler(BaseHTTPRequestHandler): f'
{svg_6m}
' f'
{svg_1y}
' + _turn_cap + + _chart_hlines_ui(code, hlines) ) body = html_block.encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) @@ -12514,6 +12710,30 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() return + if self.path in ('/chart/hlines/add', '/chart/hlines/remove'): + # 종목차트 사용자 수평선 추가/제거 — code+price 필수. 응답은 JSON(갱신된 리스트 포함). + code = ''.join(ch for ch in (params.get('code') or [''])[0].strip() if ch.isalnum()) + price_raw = (params.get('price') or [''])[0].strip() + if not code or not price_raw: + self._send_json(400, {'ok': False, 'error': 'code/price 필요'}) + return + try: + if self.path.endswith('/add'): + _add_chart_hline(code, price_raw) + else: + _remove_chart_hline(code, price_raw) + except ValueError as e: + self._send_json(400, {'ok': False, 'error': str(e)}) + return + except Exception as e: + traceback.print_exc() + self._send_json(500, {'ok': False, 'error': str(e)}) + return + sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} price={price_raw} → ok\n') + sys.stdout.flush() + self._send_json(200, {'ok': True, 'lines': _get_chart_hlines(code)}) + return + if self.path == '/notes/set': # 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수. # note 가 빈 텍스트면 항목 제거. diff --git a/agents/stock/workspace/scripts/stock_analysis.py b/agents/stock/workspace/scripts/stock_analysis.py index 62bee06..d189663 100644 --- a/agents/stock/workspace/scripts/stock_analysis.py +++ b/agents/stock/workspace/scripts/stock_analysis.py @@ -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, - intraday: bool = False) -> str: + intraday: bool = False, hlines: list | None = None) -> str: """캔들 + SMA 3선 + 거래량 막대(+일봉 20일 거래량 이동평균선) 인라인 SVG. range_key: '1Y' (250일) | '6M' (120일) | '3M' (63일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만. ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용). + hlines: 사용자가 등록한 수평선 가격 리스트(지지/저항 등). 표시 구간 안에 드는 값만 그린다(스케일 미확장). 회전율 값은 차트에 그리지 않고 caller가 turnover_today()로 별도 표시 (차트 아래). snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용. 외부 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)):,}' ) + # ---- 사용자 수평선 (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'' + ) + htag_y = min(max(hly, PRICE_TOP + 15), PRICE_BOT - 15) + parts.append( + f'' + ) + parts.append( + f'{int(round(hp)):,}' + ) + parts.append('') return ''.join(parts) diff --git a/agents/stock/workspace/state/sim/watchlist.json b/agents/stock/workspace/state/sim/watchlist.json index fdd70eb..9ea58c7 100644 --- a/agents/stock/workspace/state/sim/watchlist.json +++ b/agents/stock/workspace/state/sim/watchlist.json @@ -434,66 +434,21 @@ "origin": "interest", "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": { "name": "브이엠", "origin": "auto", "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": { "name": "제주반도체", "origin": "auto", "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": { "name": "S-Oil", "origin": "auto", "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": { "name": "삼성SDI", "origin": "auto",