feat(targets): price target/stop simulator with chart overlay lines

- New lib/targets.ts: per-code localStorage (key `targets:<code>`) with
  read/write/clear; rejects non-positive/non-finite values.
- New PriceTargets sidebar panel: 목표가/손절가 inputs, live %-from-current
  preview (rose=up, sky=down), save/clear actions.
- StockChart accepts optional `targets` prop and draws dashed horizontal
  price lines on the candle series (rose=target, sky=stop) with axis labels.
- Page loads saved targets on mount/code-change and re-renders chart on save.

Also fixes view-counter dedupe caveat from reviewer: split into
wasRecordedToday() + markRecorded(); the page now marks localStorage only
after POST /api/views succeeds, so a failed POST stays retryable on the
next visit instead of being silently swallowed for the day.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-owner
2026-05-29 00:01:43 +09:00
parent ecf8b9112b
commit a4a4a7f98c
5 changed files with 279 additions and 12 deletions

View File

@@ -0,0 +1,154 @@
"use client";
// 목표가 / 손절가 시뮬레이터 (Toss "내 목표가" read-only 클론).
// - 현재가 기준 변동률 미리 계산해서 표시
// - 저장 시 localStorage 에만 — 차트 오버레이는 부모 page 가 같은 상태를 읽어 StockChart 에 전달
// - 알림은 별도 AlertsPanel 담당, 여기는 시각화 + 손익 프리뷰만
import { useEffect, useMemo, useState } from "react";
import { readTargets, writeTargets, type Targets } from "../lib/targets";
type Props = {
code: string;
current: number | null;
// 저장/해제 시 부모에 변경을 전달 — 차트에 즉시 반영하도록.
onChange?: (t: Targets) => void;
};
function parseNum(s: string): number | null {
const cleaned = s.replace(/,/g, "").trim();
if (!cleaned) return null;
const n = Number(cleaned);
return Number.isFinite(n) && n > 0 ? n : null;
}
function pct(price: number | null, current: number | null): number | null {
if (price == null || current == null || current <= 0) return null;
return ((price - current) / current) * 100;
}
export function PriceTargets({ code, current, onChange }: Props) {
const [target, setTarget] = useState("");
const [stop, setStop] = useState("");
const [saved, setSaved] = useState<Targets>({});
// 종목 바뀌면 새로 로드. 입력창도 저장값으로 초기화.
useEffect(() => {
const t = readTargets(code);
setSaved(t);
setTarget(t.target != null ? String(t.target) : "");
setStop(t.stop != null ? String(t.stop) : "");
}, [code]);
const targetN = useMemo(() => parseNum(target), [target]);
const stopN = useMemo(() => parseNum(stop), [stop]);
const targetPct = pct(targetN, current);
const stopPct = pct(stopN, current);
const dirty =
(targetN ?? null) !== (saved.target ?? null) ||
(stopN ?? null) !== (saved.stop ?? null);
const save = () => {
const next: Targets = { target: targetN, stop: stopN };
writeTargets(code, next);
setSaved(next);
onChange?.(next);
};
const clear = () => {
setTarget("");
setStop("");
const next: Targets = {};
writeTargets(code, next);
setSaved(next);
onChange?.(next);
};
const fmtPct = (p: number | null): string =>
p == null ? "" : `${p >= 0 ? "+" : ""}${p.toFixed(2)}%`;
const pctCls = (p: number | null): string =>
p == null ? "text-zinc-500" : p >= 0 ? "text-rose-400" : "text-sky-400";
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-xs font-semibold text-zinc-300"> </h3>
<span className="text-[10px] text-zinc-500"> · </span>
</div>
<div className="space-y-2">
<Row
label="목표가"
color="bg-rose-400/80"
value={target}
onChange={setTarget}
deltaText={fmtPct(targetPct)}
deltaCls={pctCls(targetPct)}
/>
<Row
label="손절가"
color="bg-sky-400/80"
value={stop}
onChange={setStop}
deltaText={fmtPct(stopPct)}
deltaCls={pctCls(stopPct)}
/>
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={save}
disabled={!dirty}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/80 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
<button
type="button"
onClick={clear}
disabled={saved.target == null && saved.stop == null && !target && !stop}
className="rounded-md border border-zinc-800 px-2 py-1 text-[11px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
{current == null && (
<div className="mt-1 text-[10px] text-zinc-500"> </div>
)}
</div>
);
}
function Row({
label,
color,
value,
onChange,
deltaText,
deltaCls,
}: {
label: string;
color: string;
value: string;
onChange: (s: string) => void;
deltaText: string;
deltaCls: string;
}) {
return (
<label className="flex items-center gap-2 text-[11px]">
<span className={`inline-block h-2 w-2 rounded-sm ${color}`} aria-hidden />
<span className="w-12 shrink-0 text-zinc-400">{label}</span>
<input
type="text"
inputMode="numeric"
autoComplete="off"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="0"
className="min-w-0 flex-1 rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1 text-right tabular-nums text-zinc-100 outline-none focus:border-zinc-600"
/>
<span className={`w-16 shrink-0 text-right tabular-nums ${deltaCls}`}>
{deltaText}
</span>
</label>
);
}

View File

@@ -6,6 +6,7 @@ import {
type CandlestickData,
type HistogramData,
type IChartApi,
type IPriceLine,
type ISeriesApi,
type LineData,
type LogicalRange,
@@ -13,10 +14,13 @@ import {
type UTCTimestamp,
} from "lightweight-charts";
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
import type { Targets } from "../lib/targets";
type Props = {
chart: ChartPayload;
prediction?: LatestPredictionResponse | null;
// 사용자가 저장한 목표가/손절가 — 메인 캔들 위 가로 라인 오버레이.
targets?: Targets | null;
};
// KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로.
@@ -198,7 +202,7 @@ function macd(
return { macd: m, signal: sig, hist };
}
export function StockChart({ chart, prediction }: Props) {
export function StockChart({ chart, prediction, targets }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
@@ -215,6 +219,9 @@ export function StockChart({ chart, prediction }: Props) {
const macdLineRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdHistRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착.
const targetLineRef = useRef<IPriceLine | null>(null);
const stopLineRef = useRef<IPriceLine | null>(null);
// 보조 차트 crosshair 동기화용 time→value lookup.
// 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에
@@ -430,6 +437,43 @@ export function StockChart({ chart, prediction }: Props) {
chartRef.current.timeScale().fitContent();
}, [prediction, isIntraday]);
// 목표가/손절가 가로 라인 — targets prop 또는 캔들 시리즈 재생성 시 동기화.
// candleRef 가 바뀌면 이전 라인 ref 는 이미 해제된 상태라 그냥 새로 만들면 됨.
useEffect(() => {
const candle = candleRef.current;
if (!candle) return;
// 이전 라인 제거 후 새로 그림 — 입력 바뀔 때마다 가격값 변경하지 않고 그냥 재생성.
if (targetLineRef.current) {
try { candle.removePriceLine(targetLineRef.current); } catch { /* 이미 해제됨 */ }
targetLineRef.current = null;
}
if (stopLineRef.current) {
try { candle.removePriceLine(stopLineRef.current); } catch { /* 이미 해제됨 */ }
stopLineRef.current = null;
}
if (!targets) return;
if (targets.target != null && Number.isFinite(targets.target)) {
targetLineRef.current = candle.createPriceLine({
price: targets.target,
color: "#fb7185", // rose-400 (한국 상승색 톤)
lineWidth: 1,
lineStyle: 2, // dashed
axisLabelVisible: true,
title: "목표",
});
}
if (targets.stop != null && Number.isFinite(targets.stop)) {
stopLineRef.current = candle.createPriceLine({
price: targets.stop,
color: "#60a5fa", // sky-400 (한국 하락색 톤)
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "손절",
});
}
}, [targets, isIntraday]);
// RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스.
// priceScale fix (0-100) + 30/70 가이드 라인.
useEffect(() => {