diff --git a/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index b16c7be..5083e28 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -3152,7 +3152,31 @@ def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: l return out -def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False): +def _available_chart_periods() -> dict: + """스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞. + 반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}""" + empty = {'years': [], 'months': {}} + if not SNAPSHOT_FILE.exists(): + return empty + try: + snap = json.loads(SNAPSHOT_FILE.read_text()) + except Exception: + return empty + years: set = set() + months: dict = {} + for date_key in snap.keys(): + if len(date_key) < 7 or date_key[4] != '-': + continue + y, m = date_key[:4], date_key[5:7] + years.add(y) + months.setdefault(y, set()).add(m) + return { + 'years': sorted(years, reverse=True), + 'months': {y: sorted(ms, reverse=True) for y, ms in months.items()}, + } + + +def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''): """portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출. 각 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리. unit으로 일/주/월/연 단위 집계 (각 구간 마지막 영업일 점). 기간(days) 필터는 단위 집계 후 적용. @@ -3188,7 +3212,10 @@ def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: s # filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김 baseline_prepended = False - if days == -2: # 이번달 — KST 기준 이번 달 1일 이후만 + if period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출. + range_start = (period + '-01-01') if len(period) == 4 else (period + '-01') + filtered = [(d, v) for d, v in aggregated if d.startswith(period)] + elif days == -2: # 이번달 — KST 기준 이번 달 1일 이후만 prefix = datetime.now(KST).strftime('%Y-%m') range_start = f'{prefix}-01' filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)] @@ -4954,6 +4981,7 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; } /* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 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-ym[hidden] { display: none; } /* 연도별/월별 아닐 때 연·월 드롭다운 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */ .chart-controls .cc-label { color: #8b8f9a; font-size: 11px; } .chart-controls .nw-select { appearance: none; -webkit-appearance: none; @@ -5111,17 +5139,42 @@ def _owner_full_title(owner: str) -> str: 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세트만 노출, 본인·가희 차트 둘 다 동시 컨트롤. +def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str: + """차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select. + 기간에 '연도별'(연도 선택→월별집계)·'월별'(연도+월 선택→일별) 추가. period(chart_ym) 활성 시 단위는 강제·비활성. 의미없는 단위(이번주+월별 등)는 disabled.""" + avail = avail or {'years': [], 'months': {}} + period = (chart_ym or '').strip() + is_year = len(period) == 4 + is_month = len(period) == 7 + period_active = is_year or is_month + years = avail.get('years') or [] + months_map = avail.get('months') or {} + allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get( selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS) ) range_opts = ''.join( - f'' + f'' for lbl, d in NET_WORTH_CHART_PRESETS ) + range_opts += f'' + range_opts += f'' + + cur_year = period[:4] if period_active else (years[0] if years else '') + cur_month = period[5:7] if is_month else '' + year_opts = ''.join( + f'' + for y in years + ) + month_opts = ''.join( + f'' + for m in months_map.get(cur_year, []) + ) + _yhide = '' if period_active else ' hidden' + _mhide = '' if is_month else ' hidden' + months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True) + unit_opts = ''.join( f'' for lbl, m in NET_WORTH_MODE_PRESETS ) + _unit_dis = ' disabled' if period_active else '' return ( - '
' + f'
' f'' - # 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치. + # 연도별/월별 선택 시 노출되는 연·월 드롭다운 (데이터 있는 기간만). + f'' + f'' + # 단위·모드 한 cc-field 묶음. period 활성 시 단위 강제(disabled). f'' '
' ) @@ -5685,7 +5744,7 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str: ) -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: +def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str: """자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리. - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글) - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글) @@ -5697,9 +5756,13 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: balances = balances or {} journal_by_label = journal_by_label or {} + # 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터. + period = (chart_ym or '').strip() + eff_unit = ('day' if len(period) > 4 else 'month') if period else chart_unit + consolidated_parts: list[str] = [] by_account_parts: list[str] = [] - chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)] + chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())] for owner in ordered_owners: d = owner_data.get(owner) if not d: @@ -5718,11 +5781,11 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: 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) + chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=eff_unit, with_prev_baseline=True, period=period) if chart_series: # 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum. daily_cf = _load_cash_flow_by_date(owner) - cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], chart_unit) if daily_cf else {} + cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], eff_unit) if daily_cf else {} # 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음. today_cf = None if d.get('cash_in', 0) or d.get('cash_out', 0): @@ -5748,7 +5811,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: pnl_series, current_net=current_pnl, selected_days=chart_days, - selected_unit=chart_unit, + selected_unit=eff_unit, cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김 today_cash_flow=None, mode=chart_mode, @@ -5761,7 +5824,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: chart_series, current_net=d.get('total_net'), selected_days=chart_days, - selected_unit=chart_unit, + selected_unit=eff_unit, cash_flow_by_date=cf_by_date, today_cash_flow=today_cf, mode='net', @@ -6190,7 +6253,7 @@ def _render_interests_panel(cards: list[dict]) -> tuple[str, int]: 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: +def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> dict: """fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위. owner 지정 시: 해당 owner 계좌만 호출하고 응답 tabs는 owner 탭 하나만 (summary·wl 제외). 클라이언트가 partial swap으로 사용 — 보지 않는 owner 호출 폭주 방지. @@ -6220,7 +6283,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_ # 전체 — 모든 탭 채움. 모두 동일 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) + summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym) tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms}) for o in ordered_owners: d = owner_data[o] @@ -6487,10 +6550,21 @@ def render_html() -> str: 'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;' 'return v;' '}' + # chart_ym — 연도별('YYYY')/월별('YYYY-MM') 절대 선택. 빈값이면 프리셋(chartDays) 사용. + 'function chartYM(){' + 'var v=localStorage.getItem("behive.chartYM")||"";' + 'var p=v.split("-");' + 'if(p.length===1&&/^\\d{4}$/.test(p[0]))return v;' + 'if(p.length===2&&/^\\d{4}$/.test(p[0])&&/^\\d{2}$/.test(p[1]))return v;' + 'return "";' + '}' + 'function setYM(v){try{if(v)localStorage.setItem("behive.chartYM",v);else localStorage.removeItem("behive.chartYM");}catch(_){}}' 'window.__behive_chart_days=chartDays;' 'window.__behive_chart_unit=chartUnit;' 'window.__behive_chart_mode=chartMode;' 'window.__behive_adr_days=adrDays;' + 'window.__behive_chart_ym=chartYM;' + 'window.__behive_set_ym=setYM;' 'function load(owner,fresh){' # 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap). 'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}' @@ -6501,6 +6575,7 @@ def render_html() -> str: '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 ym=chartYM();if(ym)qs.push("chart_ym="+encodeURIComponent(ym));' '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();})' @@ -6849,16 +6924,34 @@ def render_html() -> str: 'document.addEventListener("change",function(e){' 'var sel=e.target;' 'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;' + 'function ymFirstMonth(y){var cc=document.querySelector(".chart-controls");try{var mp=JSON.parse(cc.getAttribute("data-ym-months")||"{}");var a=mp[y]||[];return a.length?a[0]:"";}catch(_){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(_){}' + 'var rv=sel.value;' + 'if(rv==="year"||rv==="month"){' + # 연도별/월별 진입 — 현재 연도 드롭다운 값(기본=최신연도)으로 chartYM 세팅. + 'var yEl=document.querySelector(\'select.nw-select[data-nw-year]\');' + 'var y=yEl&&yEl.value?yEl.value:"";' + 'if(!y)return;' # 데이터 없음 + 'if(rv==="year"){window.__behive_set_ym(y);}' + 'else{var mm=ymFirstMonth(y);window.__behive_set_ym(mm?(y+"-"+mm):y);}' + '}else{' + 'window.__behive_set_ym("");' # 프리셋 → 절대선택 해제 + 'try{localStorage.setItem("behive.chartDaysV2",rv);}catch(_){}' + # 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치). + 'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};' + 'var allowed=allowedMap[rv]||["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-year")){' + 'var rEl=document.querySelector(\'select.nw-select[data-nw-range]\');' + 'var mode=rEl?rEl.value:"year";var yy=sel.value;' + 'if(mode==="month"){var m2=ymFirstMonth(yy);window.__behive_set_ym(m2?(yy+"-"+m2):yy);}' + 'else{window.__behive_set_ym(yy);}' + '}else if(sel.hasAttribute("data-nw-month")){' + 'var ry=document.querySelector(\'select.nw-select[data-nw-year]\');' + 'window.__behive_set_ym((ry?ry.value:"")+"-"+sel.value);' '}else if(sel.hasAttribute("data-nw-unit")){' 'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}' '}else if(sel.hasAttribute("data-nw-mode")){' @@ -9329,11 +9422,11 @@ def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict } -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: +def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> None: """partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다. 다른 owner와 watchlist/interests 슬라이스는 cached 'all'에 있던 그대로 — TTL 만료 시 full fetch로 동기화. 핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록.""" - all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}' + all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}' all_lock = _panels_lock_for(all_key) with all_lock: slot = _panels_cache.get(all_key) @@ -9354,7 +9447,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY 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) + new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym) entry = _owner_data_cache.get(owner) if not entry: return @@ -9378,7 +9471,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY _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: +def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> bytes: """fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출. 응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 그 후 자동 갱신은 새 캐시를 본다. @@ -9388,7 +9481,7 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart 갈아끼운다(_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}' + key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}' if not fresh: slot = _panels_cache.get(key) if slot and slot['expires_at'] > time.time(): @@ -9399,15 +9492,15 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart 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) + payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym) body = json.dumps(payload, ensure_ascii=False).encode('utf-8') _panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL} if owner is not None: try: - _patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) + _patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym) except Exception: traceback.print_exc() - _panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', None) + _panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', None) return body @@ -10082,8 +10175,17 @@ class Handler(BaseHTTPRequestHandler): adr_days = ADR_TREND_DAYS if adr_days not in {d for _, d in ADR_TREND_PRESETS}: adr_days = ADR_TREND_DAYS + # chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋). + chart_ym = (qs.get('chart_ym') or [''])[0] + _ymp = (chart_ym or '').split('-') + _ym_ok = ( + (len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit()) + or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit()) + ) + if not _ym_ok: + chart_ym = '' try: - body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) + body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym) except Exception: traceback.print_exc() self.send_error(500, 'panels failed')