feat(symbol-page): intraday short-term returns + hline storage-failure guard
- 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
This commit is contained in:
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { AlertsPanel } from "../../components/AlertsPanel";
|
import { AlertsPanel } from "../../components/AlertsPanel";
|
||||||
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
||||||
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
||||||
|
import { IntradayReturns } from "../../components/IntradayReturns";
|
||||||
import { InvestmentNote } from "../../components/InvestmentNote";
|
import { InvestmentNote } from "../../components/InvestmentNote";
|
||||||
import { InvestorCumulative } from "../../components/InvestorCumulative";
|
import { InvestorCumulative } from "../../components/InvestorCumulative";
|
||||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||||
@@ -246,7 +247,12 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
<CompositeScoreCard chart={chart} />
|
<CompositeScoreCard chart={chart} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<PeriodReturns ohlcv={chart.ohlcv} />
|
{/* 1D(10분봉) 모드에선 PeriodReturns 의 거래일 라벨이 거짓이 되므로 단기 카드로 교체 */}
|
||||||
|
{isIntraday ? (
|
||||||
|
<IntradayReturns ohlcv={chart.ohlcv} />
|
||||||
|
) : (
|
||||||
|
<PeriodReturns ohlcv={chart.ohlcv} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<PeerComparePanel code={code} />
|
<PeerComparePanel code={code} />
|
||||||
|
|||||||
82
web/components/IntradayReturns.tsx
Normal file
82
web/components/IntradayReturns.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||||
|
단기 수익률 데이터 없음
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
|
<div className="mb-3 flex items-baseline justify-between">
|
||||||
|
<div className="text-sm font-medium text-zinc-200">단기 수익률</div>
|
||||||
|
<div className="text-[11px] text-zinc-500">10분봉 기준 · 60초 갱신</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||||
|
{buckets.map((b) => (
|
||||||
|
<Bucket key={b.label} label={b.label} pct={b.pct} />
|
||||||
|
))}
|
||||||
|
<Bucket label="시가대비" pct={todayPct} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
|
||||||
|
<div className="text-[11px] text-zinc-500">{label}</div>
|
||||||
|
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
|
||||||
|
{pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,18 +87,21 @@ export function addLine(code: string, price: number): HLine[] {
|
|||||||
// 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발.
|
// 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발.
|
||||||
if (cur.some((l) => nearlyEqual(l.price, price))) return cur;
|
if (cur.some((l) => nearlyEqual(l.price, price))) return cur;
|
||||||
const next = [...cur, { id: newId(), price, createdAt: new Date().toISOString() }];
|
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);
|
return next.slice(0, HLINE_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeLine(code: string, id: string): HLine[] {
|
export function removeLine(code: string, id: string): HLine[] {
|
||||||
const cur = readLines(code);
|
const cur = readLines(code);
|
||||||
const next = cur.filter((l) => l.id !== id);
|
const next = cur.filter((l) => l.id !== id);
|
||||||
writeRaw(code, next);
|
// 삭제 저장 실패 시 이전 상태 유지 — UI 에서 사라졌다가 새로고침에 부활하면 더 혼란.
|
||||||
|
if (!writeRaw(code, next)) return cur;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearLines(code: string): HLine[] {
|
export function clearLines(code: string): HLine[] {
|
||||||
writeRaw(code, []);
|
if (!writeRaw(code, [])) return readLines(code);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user