diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx
index 6fd0694..265cb6f 100644
--- a/web/app/[code]/page.tsx
+++ b/web/app/[code]/page.tsx
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
import { AlertsPanel } from "../../components/AlertsPanel";
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
+import { InvestorCumulative } from "../../components/InvestorCumulative";
import { MetricsPanel } from "../../components/MetricsPanel";
import { PeerComparePanel } from "../../components/PeerComparePanel";
import { NewsList } from "../../components/NewsList";
@@ -170,6 +171,9 @@ export default function CodePage({ params }: { params: { code: string } }) {
diff --git a/web/components/InvestorCumulative.tsx b/web/components/InvestorCumulative.tsx
new file mode 100644
index 0000000..081326c
--- /dev/null
+++ b/web/components/InvestorCumulative.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+// 투자자별 누적 순매수 미니 차트.
+//
+// 토스 종목 상세의 "투자자별 매매" 탭에서 인상이 강한 위젯 — 외국인/기관/개인
+// 각각의 N일 누적 순매수를 작은 라인으로 보여줘서 "누가 모으고 누가 분배하고 있는가"
+// 를 한 눈에 잡게 한다.
+//
+// 데이터 소스는 ChartPayload.trading_value (이미 페이지에 있음) 그대로. 백엔드 호출 0.
+// 단위: 원 → 억원(1e8) 환산.
+//
+// 색은 한국 컬러 — 누적 +면 빨강(매수 우위), -면 파랑(매도 우위). 라인 자체 색은
+// 주체별로 고정해서 비교 가능하게 (외국인=amber, 기관=cyan, 개인=violet).
+
+import { useMemo, useState } from "react";
+import type { TradingValuePoint } from "../lib/api";
+
+type Window = 20 | 60 | 120;
+
+const WINDOW_OPTIONS: { v: Window; label: string }[] = [
+ { v: 20, label: "20일" },
+ { v: 60, label: "60일" },
+ { v: 120, label: "120일" },
+];
+
+type Subject = "foreign" | "institution" | "individual";
+
+const SUBJECTS: { key: Subject; label: string; color: string; field: keyof TradingValuePoint }[] = [
+ { key: "foreign", label: "외국인", color: "#f59e0b", field: "foreign_net" },
+ { key: "institution", label: "기관", color: "#22d3ee", field: "institution_net" },
+ { key: "individual", label: "개인", color: "#a78bfa", field: "individual_net" },
+];
+
+export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) {
+ const [win, setWin] = useState
(20);
+
+ // 데이터가 충분치 않으면 window 를 줄여서라도 그릴 수 있게.
+ const effectiveWin = Math.min(win, data?.length ?? 0);
+
+ const series = useMemo(() => {
+ if (!data || data.length === 0) return null;
+ const slice = data.slice(-effectiveWin);
+ // 각 주체별 누적 순매수 (원 단위), 0 부터 시작.
+ const out: Record = {
+ foreign: [0],
+ institution: [0],
+ individual: [0],
+ };
+ for (const p of slice) {
+ for (const s of SUBJECTS) {
+ const v = (p[s.field] as number | null) ?? 0;
+ const prev = out[s.key][out[s.key].length - 1];
+ out[s.key].push(prev + v);
+ }
+ }
+ return { slice, out };
+ }, [data, effectiveWin]);
+
+ if (!series) {
+ return (
+
+ 투자자별 누적 데이터 없음
+
+ );
+ }
+
+ return (
+
+
+
투자자별 누적 순매수
+
+ {WINDOW_OPTIONS.map((w) => {
+ const on = win === w.v;
+ return (
+ setWin(w.v)}
+ className={`rounded-full border px-2 py-0.5 transition ${
+ on
+ ? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
+ : "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
+ }`}
+ aria-pressed={on}
+ >
+ {w.label}
+
+ );
+ })}
+
+
+
+ {SUBJECTS.map((s) => (
+
+ ))}
+
+
+ 단위: 억원 · 마지막 {effectiveWin}일 누적 (시작 0)
+
+
+ );
+}
+
+// 한 주체에 대한 라벨 + 누적 라인 + 끝 값.
+// values: 길이 N+1 (시작 0 포함). viewBox 좌표는 0..100 / 0..30.
+function SubjectRow({
+ label,
+ color,
+ values,
+}: {
+ label: string;
+ color: string;
+ values: number[];
+}) {
+ if (values.length < 2) {
+ return (
+
+ {label}
+ —
+
+ );
+ }
+ const lo = Math.min(...values);
+ const hi = Math.max(...values);
+ const span = hi - lo || 1;
+ const w = 100;
+ const h = 30;
+ const n = values.length;
+ const path = values
+ .map((v, i) => {
+ const x = (i / (n - 1)) * w;
+ // 0 이 위로 가도록 y 뒤집기. 패딩 1px.
+ const y = h - 1 - ((v - lo) / span) * (h - 2);
+ return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
+ })
+ .join(" ");
+ // 기준선 (시작 0) 위치.
+ const baseY = h - 1 - ((0 - lo) / span) * (h - 2);
+ const end = values[values.length - 1];
+ const endEok = end / 1e8;
+ const tone =
+ endEok === 0
+ ? "text-zinc-400"
+ : endEok > 0
+ ? "text-rose-300"
+ : "text-sky-300";
+ const sign = endEok > 0 ? "+" : "";
+
+ return (
+
+
{label}
+
+
+
+
+
+ {sign}
+ {endEok.toLocaleString(undefined, { maximumFractionDigits: 0 })}억
+
+
+ );
+}
diff --git a/web/components/PwaRegister.tsx b/web/components/PwaRegister.tsx
index 210dd73..3896b4a 100644
--- a/web/components/PwaRegister.tsx
+++ b/web/components/PwaRegister.tsx
@@ -1,10 +1,12 @@
"use client";
// 최소 서비스워커 등록.
-// - production 빌드에서만 등록 — dev (next dev / HMR) 환경에선 SW 가 HMR 응답을
-// 이상하게 가로채는 케이스가 있어 의도적으로 우회.
-// - 등록 실패는 silently 무시 (Safari private mode, http://0.0.0.0 같은 secure context
-// 아닌 호스트, 브라우저 정책 거부 등). PWA install 자격만 못 갖출 뿐 앱 기능엔 영향 없음.
+// - dev 에서도 등록한다: 이 사이트의 sw.js 는 fetch handler 가 없어 HMR/네트워크
+// 응답을 가로채지 않음 → HMR/캐시 꼬임 위험 0. 그리고 Docker 가 next dev 로
+// 돌고 있어 NODE_ENV 게이트를 두면 실제 배포에서 install 자격 자체가 안 잡힌다.
+// - 등록 실패는 silently 무시 (Safari private mode, http://192.168.x.x 같은 secure
+// context 아닌 호스트, 브라우저 정책 거부 등). PWA install 자격만 못 갖출 뿐 앱
+// 기능엔 영향 없음.
import { useEffect } from "react";
@@ -12,7 +14,6 @@ export function PwaRegister() {
useEffect(() => {
if (typeof window === "undefined") return;
if (!("serviceWorker" in navigator)) return;
- if (process.env.NODE_ENV !== "production") return;
navigator.serviceWorker.register("/sw.js").catch(() => {
/* noop — 등록 실패 시 PWA 기능만 비활성. */
});