From d6fc9d8a0baadaf544dc2510394d637f2136f603 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Fri, 29 May 2026 00:35:21 +0900 Subject: [PATCH] feat(symbol-page): intraday short-term returns + hline storage-failure guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntradayReturns: 10분/30분/1시간/3시간/시가대비 mini cards for 1D mode (PeriodReturns labels lie when interval is 10m; swap them out by isIntraday) - addLine/removeLine/clearLines now return previous state when localStorage write fails — UI no longer diverges from storage in locked-storage env addresses reviewer 318eae6 non-blocking caveat about write-failure divergence --- web/app/[code]/page.tsx | 8 ++- web/components/IntradayReturns.tsx | 82 ++++++++++++++++++++++++++++++ web/lib/lines.ts | 9 ++-- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 web/components/IntradayReturns.tsx diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 9275b70..4ce88e0 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 { IntradayReturns } from "../../components/IntradayReturns"; import { InvestmentNote } from "../../components/InvestmentNote"; import { InvestorCumulative } from "../../components/InvestorCumulative"; import { MetricsPanel } from "../../components/MetricsPanel"; @@ -246,7 +247,12 @@ export default function CodePage({ params }: { params: { code: string } }) {
- + {/* 1D(10분봉) 모드에선 PeriodReturns 의 거래일 라벨이 거짓이 되므로 단기 카드로 교체 */} + {isIntraday ? ( + + ) : ( + + )}
diff --git a/web/components/IntradayReturns.tsx b/web/components/IntradayReturns.tsx new file mode 100644 index 0000000..32d00ff --- /dev/null +++ b/web/components/IntradayReturns.tsx @@ -0,0 +1,82 @@ +"use client"; + +// 1D (10분봉) 모드 전용 단기 수익률 미니 카드. +// PeriodReturns 는 거래일 단위 (1주/1개월/...) 계산이라 10m 데이터에선 라벨이 거짓이 됨 +// (1주=5봉=50분). 그래서 intraday 일 때는 이 컴포넌트로 갈아끼움. +// +// 정의: +// - "현재가" = 마지막 유효 close (null 제외 후 가장 뒤). 폴링되면 자동 갱신. +// - 10분/30분/1시간/3시간 = 마지막에서 N봉 전 close 와 비교. +// - 시가대비 = 가장 첫 봉의 open(없으면 close) 과 비교 — 당일 손익률. +// - 데이터 부족하면 "—" 표시. +// +// 추가 백엔드 호출 없음 — chart.ohlcv 슬라이싱만. + +import type { OhlcvPoint } from "../lib/api"; + +const BUCKETS: { label: string; bars: number }[] = [ + { label: "10분", bars: 1 }, + { label: "30분", bars: 3 }, + { label: "1시간", bars: 6 }, + { label: "3시간", bars: 18 }, +]; + +export function IntradayReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) { + // close == null 인 휴장/결측 봉은 비교에서 제외. + const valid = ohlcv.filter((p): p is OhlcvPoint & { close: number } => p.close != null); + if (valid.length < 2) { + return ( +
+ 단기 수익률 데이터 없음 +
+ ); + } + + const last = valid[valid.length - 1].close; + const buckets = BUCKETS.map((b) => { + const idx = valid.length - 1 - b.bars; + if (idx < 0) return { label: b.label, pct: null as number | null }; + const base = valid[idx].close; + return { label: b.label, pct: base ? ((last - base) / base) * 100 : null }; + }); + + // 시가대비 — 당일 첫 봉의 open 우선, 없으면 close. + const first = valid[0]; + const openBase = + first.open != null && first.open > 0 ? first.open : first.close; + const todayPct = + openBase != null && openBase > 0 ? ((last - openBase) / openBase) * 100 : null; + + return ( +
+
+
단기 수익률
+
10분봉 기준 · 60초 갱신
+
+
+ {buckets.map((b) => ( + + ))} + +
+
+ ); +} + +function Bucket({ label, pct }: { label: string; pct: number | null }) { + // 한국식 컬러 — 상승=rose, 하락=sky, 0/null=zinc. + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( +
+
{label}
+
+ {pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`} +
+
+ ); +} diff --git a/web/lib/lines.ts b/web/lib/lines.ts index 789019c..24cb9ec 100644 --- a/web/lib/lines.ts +++ b/web/lib/lines.ts @@ -87,18 +87,21 @@ export function addLine(code: string, price: number): HLine[] { // 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발. if (cur.some((l) => nearlyEqual(l.price, price))) return cur; const next = [...cur, { id: newId(), price, createdAt: new Date().toISOString() }]; - writeRaw(code, next); + // 저장 실패 시 이전 상태 반환 — UI 가 보여주는 라인과 스토리지가 어긋나 + // 새로고침 후 사라지는 혼란을 막음 (시크릿 탭/스토리지 잠금 환경 대비). + if (!writeRaw(code, next)) return cur; return next.slice(0, HLINE_MAX); } export function removeLine(code: string, id: string): HLine[] { const cur = readLines(code); const next = cur.filter((l) => l.id !== id); - writeRaw(code, next); + // 삭제 저장 실패 시 이전 상태 유지 — UI 에서 사라졌다가 새로고침에 부활하면 더 혼란. + if (!writeRaw(code, next)) return cur; return next; } export function clearLines(code: string): HLine[] { - writeRaw(code, []); + if (!writeRaw(code, [])) return readLines(code); return []; }