From a4a4a7f98cdd7ef510e14210d9e66419bcc1ea49 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Fri, 29 May 2026 00:01:43 +0900 Subject: [PATCH] feat(targets): price target/stop simulator with chart overlay lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/targets.ts: per-code localStorage (key `targets:`) 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 --- web/app/[code]/page.tsx | 29 ++++-- web/components/PriceTargets.tsx | 154 ++++++++++++++++++++++++++++++++ web/components/StockChart.tsx | 46 +++++++++- web/lib/targets.ts | 49 ++++++++++ web/lib/views.ts | 13 ++- 5 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 web/components/PriceTargets.tsx create mode 100644 web/lib/targets.ts diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index e48e8fd..7bbea2d 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -14,6 +14,7 @@ import { PeriodReturns } from "../../components/PeriodReturns"; import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs"; import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; +import { PriceTargets } from "../../components/PriceTargets"; import { RelatedStocks } from "../../components/RelatedStocks"; import { StarButton } from "../../components/StarButton"; import { StockChart } from "../../components/StockChart"; @@ -21,7 +22,8 @@ import { SymbolSidebar } from "../../components/SymbolSidebar"; import { TradingValuePanel } from "../../components/TradingValuePanel"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; import { recent } from "../../lib/recent"; -import { shouldRecordView } from "../../lib/views"; +import { readTargets, type Targets } from "../../lib/targets"; +import { markRecorded, wasRecordedToday } from "../../lib/views"; export default function CodePage({ params }: { params: { code: string } }) { const { code } = params; @@ -30,6 +32,7 @@ export default function CodePage({ params }: { params: { code: string } }) { const [err, setErr] = useState(null); const [period, setPeriod] = useState("3M"); const [viewsToday, setViewsToday] = useState(null); + const [targets, setTargets] = useState({}); const spec = periodSpec(period); const isIntraday = spec.interval === "10m"; @@ -49,11 +52,17 @@ export default function CodePage({ params }: { params: { code: string } }) { const apply = (n: number) => { if (alive) setViewsToday(n); }; - if (shouldRecordView(code)) { - api.recordView(code).then((r) => apply(r.today_views)).catch(() => { - // POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌). - api.views(code).then((r) => apply(r.today_views)).catch(() => {}); - }); + if (!wasRecordedToday(code)) { + api.recordView(code) + .then((r) => { + // POST 성공 후에만 mark — 실패 시 다음 방문에 다시 시도 가능. + markRecorded(code); + apply(r.today_views); + }) + .catch(() => { + // POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌). + api.views(code).then((r) => apply(r.today_views)).catch(() => {}); + }); } else { api.views(code).then((r) => apply(r.today_views)).catch(() => {}); } @@ -62,6 +71,11 @@ export default function CodePage({ params }: { params: { code: string } }) { }; }, [code]); + // 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드. + useEffect(() => { + setTargets(readTargets(code)); + }, [code]); + useEffect(() => { let alive = true; setErr(null); @@ -169,7 +183,7 @@ export default function CodePage({ params }: { params: { code: string } }) { />
- + {isIntraday && chart.intraday_status && (
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}] @@ -182,6 +196,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
+
diff --git a/web/components/PriceTargets.tsx b/web/components/PriceTargets.tsx new file mode 100644 index 0000000..1b65863 --- /dev/null +++ b/web/components/PriceTargets.tsx @@ -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({}); + + // 종목 바뀌면 새로 로드. 입력창도 저장값으로 초기화. + 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 ( +
+
+

내 목표가

+ 브라우저 저장 · 비공개 +
+
+ + +
+
+ + +
+ {current == null && ( +
현재가 로딩 중 — 변동률은 잠시 후 표시
+ )} +
+ ); +} + +function Row({ + label, + color, + value, + onChange, + deltaText, + deltaCls, +}: { + label: string; + color: string; + value: string; + onChange: (s: string) => void; + deltaText: string; + deltaCls: string; +}) { + return ( + + ); +} diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index 8205df1..122e888 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -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(null); const chartRef = useRef(null); const candleRef = useRef | null>(null); @@ -215,6 +219,9 @@ export function StockChart({ chart, prediction }: Props) { const macdLineRef = useRef | null>(null); const macdSignalRef = useRef | null>(null); const macdHistRef = useRef | null>(null); + // 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착. + const targetLineRef = useRef(null); + const stopLineRef = useRef(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(() => { diff --git a/web/lib/targets.ts b/web/lib/targets.ts new file mode 100644 index 0000000..0b0f618 --- /dev/null +++ b/web/lib/targets.ts @@ -0,0 +1,49 @@ +// 종목별 목표가/손절가 저장 (브라우저 한정 — 비로그인 앱). +// +// 키: `targets:` → { target?: number, stop?: number, savedAt: ISO } +// 차트 오버레이 라인과 손익 미리보기에 사용. 알림 없음 (그건 AlertsPanel 담당). + +const PREFIX = "targets:"; + +export type Targets = { + target?: number | null; // 익절가 (선택) + stop?: number | null; // 손절가 (선택) + savedAt?: string; +}; + +export function readTargets(code: string): Targets { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(`${PREFIX}${code}`); + return raw ? (JSON.parse(raw) as Targets) : {}; + } catch { + return {}; + } +} + +export function writeTargets(code: string, t: Targets): void { + if (typeof window === "undefined") return; + // 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거. + const cleaned: Targets = {}; + if (t.target != null && Number.isFinite(t.target)) cleaned.target = t.target; + if (t.stop != null && Number.isFinite(t.stop)) cleaned.stop = t.stop; + try { + if (cleaned.target == null && cleaned.stop == null) { + window.localStorage.removeItem(`${PREFIX}${code}`); + return; + } + cleaned.savedAt = new Date().toISOString(); + window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(cleaned)); + } catch { + /* quota 초과 등 — 무시 */ + } +} + +export function clearTargets(code: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(`${PREFIX}${code}`); + } catch { + /* ignore */ + } +} diff --git a/web/lib/views.ts b/web/lib/views.ts index 29b15bb..5039845 100644 --- a/web/lib/views.ts +++ b/web/lib/views.ts @@ -38,11 +38,17 @@ function writeLogged(m: LoggedMap): void { } } -/** 오늘 처음 보는 code 면 true, 이미 봤으면 false. true 인 경우 mark 까지 수행. */ -export function shouldRecordView(code: string): boolean { +/** 오늘 이미 카운트된 종목인지. POST 호출 전 dedupe 게이트. */ +export function wasRecordedToday(code: string): boolean { + const today = kstToday(); + const logged = readLogged(); + return logged[code] === today; +} + +/** POST 성공 이후 호출. 실패 시 마크하지 않아 다음 방문에 재시도 가능. */ +export function markRecorded(code: string): void { const today = kstToday(); const logged = readLogged(); - if (logged[code] === today) return false; logged[code] = today; // 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄. const entries = Object.entries(logged); @@ -52,5 +58,4 @@ export function shouldRecordView(code: string): boolean { } else { writeLogged(logged); } - return true; }