Compare commits

..

90 Commits

Author SHA1 Message Date
claude-owner
0a09540e9f a11y(prediction): add aria-pressed + type=button to period presets
PredictionPanel's 3-segment 예측 기간 control (15일/30일/1년) was the last
segmented control without aria-pressed after slice (n)'s audit. It carried
the visual "pressed" state via className branch but no ARIA attribute, so
screen-reader users got no announcement of the current selection.

Also tighten type="button" to prevent default form submission semantics —
the buttons live outside <form> here, but explicit type is canonical for
non-submit buttons.

Visual styling kept as-is. The slightly heavier active token (bg-zinc-700
solid, px-3 py-1) vs the chart-toolbar canonical (border-zinc-600 bg-zinc-
800/80, px-2 py-0.5) is intentional: these presets live in the card body
next to a primary rose-600 execute button and need more visual weight than
toolbar toggles. Comment documents the reasoning.
2026-06-01 11:17:11 +09:00
claude-owner
c172f244e3 ui(chart): unify fullscreen toggle aria-pressed visual with toolbar siblings
The fullscreen button in the chart toolbar carried aria-pressed={fullscreen}
but had no active-state visual differentiation — only its label flipped
("⛶ 전체화면" ↔ "⛶ 해제"). Its sibling toggles in the same row (SMA presets,
RSI, MACD) all use the canonical zinc token (border-zinc-600 bg-zinc-800/80
text-zinc-100 when active). Sighted users got an inconsistent press-feedback
within a single toolbar.

Switch fullscreen to the same conditional className pattern. CSV button next
to it is intentionally not a toggle so stays unchanged.

Other aria-pressed carriers audited and left as-is:
- PeriodTabs: intentionally rose-500 (primary control must stand out from
  zinc toolbar buttons, comment already documents this)
- StarButton: intentionally amber (favorite/star semantic)
- InvestorCumulative / StockChart SMA presets / RSI / MACD: already on the
  canonical zinc token
2026-06-01 11:14:27 +09:00
claude-owner
0aebdc9bdb ui(collapsible): unify subtitle text size with CardHeader (10px → 11px)
본문/사이드바 헤더 보조 텍스트 토큰 통일.

Collapsible subtitle 이 text-[10px] 였는데, 같은 페이지 좌우(좌: 본문 카드의
CardHeader, 우: 사이드바의 Collapsible) 에서 헤더 우측 metadata 슬롯이 1px 다른
크기로 노출되고 있었음. 두 슬롯은 시각적으로 같은 역할(title 옆 보조 정보) 인데
토큰이 분기돼 있어 미세한 시각 점프 발생.

해결: Collapsible subtitle 을 text-[11px] 로 bump — CardHeader subtitle 과 동일
토큰. 사이드바는 280px 로 좁지만 기존 truncate 안전망(min-w-0 truncate) 이 그대로
overflow 를 흡수하므로 1px 키워도 wrap/clip 위험 없음.

유지: 헤더 chevron(▶) 은 text-[10px] 그대로 — 순수 장식 글리프라 subtitle 텍스트
크기와 별개 토큰. CardHeader 측은 chevron 자체가 없어 비교 대상이 아님.

다른 text-[10px] 사용처는 의도된 토큰으로 유지:
- StockChart 툴바 버튼/SMA preset/수평선 chips — 액션 컨트롤군 자체 토큰.
- AlertsPanel 발화 뱃지/조용히 버튼 — 보조 액션 토큰.
- InvestorCumulative window 버튼 그룹 — aria-pressed 컨트롤 토큰.
- ShortcutsHelp kbd — 키 글리프 토큰.
- CompositeScoreCard "/ 100" / SubScore hint — footnote 캡션 토큰.

검증: tsc/next lint 통과.
2026-06-01 11:10:00 +09:00
claude-owner
2b2d1ea661 ui(collapsible): fix header hover tone inversion under sticky parent
Collapsible 헤더 button 의 hover bg 를 zinc-900/60 → zinc-800/50 으로 교체.

문제: hover 톤이 부모의 bg 불투명도에 의존해서 방향이 뒤집힘.
- 비-sticky 부모 bg-zinc-900/40 + hover bg-zinc-900/60 → hover 영역이 더 불투명
  (어두워짐) = highlight. OK.
- OrderbookPanel sticky 부모 bg-zinc-900/80 + hover bg-zinc-900/60 → hover 영역이
  덜 불투명(더 옅어짐) = hover 가 "뒤로 가는" 인상. 톤 역전.

원인: 둘 다 같은 zinc-900 색이라 hover 가 불투명도 차이만으로 highlight 를
표시. 부모 opacity 가 hover opacity 보다 높으면 신호가 뒤집힘.

해결: hover 를 zinc-800 (zinc-900 보다 한 단계 밝은 색) 으로 옮김. 색 자체가
밝아 /50 opacity 라도 두 부모 케이스 모두에서 highlight 방향이 유지됨. 사이드바
list-row hover 톤(RelatedStocks bg-zinc-800/40 · PeerList bg-zinc-800/50)과도
통일 — 클릭 가능 요소의 hover 톤이 앱 전반에서 동일 색족.

검증: tsc/next lint 통과.
2026-06-01 11:05:30 +09:00
claude-owner
be1f8870b4 refactor(card): extend CardHeader with right slot, absorb InvestorCumulative
본문 카드 헤더 단일 source of truth 한 단계 확장.

CardHeader:
- 옵셔널 right?: ReactNode 슬롯 추가. 제공되면 subtitle 대신 렌더. subtitle 은
  텍스트 케이스(text-[11px] text-zinc-500 자동) 편의 prop 으로 유지.
- 컨테이너에 gap-2 추가. 기존 8개 caller 는 justify-between 으로 충분히 떨어져
  있어 시각 변화 없고, 좁은 폭에서 title↔우측 슬롯 충돌 방지 효과.
- 주석: 적용 카드 8 → 9 (InvestorCumulative 추가). 제외 카드 PredictionPanel
  유지 — title 이 제목+설명 2줄 stack 이고 큰 button 정렬을 위해 items-center
  필요해 wrapper API 확장 비용보다 자체 헤더 유지가 surgical.

InvestorCumulative:
- 자체 헤더 markup 을 <CardHeader title=... right={...} /> 로 교체. window
  선택 button 그룹(20/60/120일 · aria-pressed) 은 right slot 으로 전달, 기존
  inline className/동작 그대로 보존.
- 시각 결과 완전 동일 — 기존 헤더가 character-by-character 로 CardHeader 와
  같은 mb-3 flex items-baseline justify-between gap-2 였음을 확인.

검증: tsc/next lint 통과.
2026-06-01 11:02:20 +09:00
claude-owner
fabc21e262 ui(chart): harmonize StockChart container tone to detail-page body-card palette
상세 페이지 본문 카드 톤 통일 마무리. StockChart 컨테이너는 본문 영역의
top-level 카드 슬롯인데 마지막 남은 분기 톤이었음:

  before:  w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2
  after:   w-full rounded-md border border-zinc-800 bg-zinc-900/40 p-2

- rounded-lg → rounded-md : 3b06b95 에서 PredictionPanel 도 같은 통일을 했고,
  같은 좌측 컬럼에서 차트 바로 아래에 PredictionPanel 이 붙어있어 corner radius
  분기는 시각적 톤 점프.
- bg-zinc-900/30 → bg-zinc-900/40 : /30 은 ReturnBucket 같은 nested sub-card
  보조 톤. top-level 본문 카드는 일관되게 /40 이어야 함.
- p-2 유지 : 차트 캔버스가 자체 inner padding 을 들이므로 p-4 는 빈 가장자리
  낭비. 이건 의도된 분기라 주석으로 근거 명시.
- fullscreen 모드는 viewport 전체 차지라 별개 — 손대지 않음.

전체화면 외 모든 종목 페이지에서 StockChart → PredictionPanel 톤 일치 확인.
tsc/next lint 통과.
2026-06-01 10:57:50 +09:00
claude-owner
9e8c32e70c docs(card): fix CardHeader comment — exclude InvestorCumulative from adopters
리뷰어 비차단 노트 후속. 959e9af 의 CardHeader.tsx 주석은 본문 카드 9개 목록에
InvestorCumulative 를 포함시켰지만, 실제로는 우측이 window 선택 button 그룹
(aria-pressed) 인 커스텀 헤더라 wrapper 적용 대상이 아님.

- 적용 카드 9 → 8 (Metrics / IntradayReturns / PeriodReturns / CompositeScoreCard
  / PeerComparePanel / TradingValuePanel / DisclosuresPanel / NewsList).
- 제외 카드 섹션에 InvestorCumulative 추가, PredictionPanel 옆에 나란히 명시.
  근거 — 둘 다 우측이 단순 subtitle 텍스트가 아닌 인터랙티브 컨트롤이라
  CardHeader 의 'subtitle 우측' 패턴에 안 맞음.

주석 한정 변경 — 런타임/타입 영향 없음. tsc/next lint 통과.
2026-06-01 10:57:37 +09:00
claude-owner
959e9afa80 refactor(card): extract shared CardHeader for detail-page body cards
상세 페이지 본문 카드 8개가 character-by-character 로 동일한 헤더
markup 을 중복 정의하고 있었음 — ReturnBucket 분리와 같은 future
drift 리스크. 단일 source of truth 로 묶음.

신규 web/components/CardHeader.tsx:
- 표준 패턴 캡슐화: mb-3 flex items-baseline justify-between 컨테이너
  + text-sm font-medium text-zinc-200 title + 옵셔널 text-[11px]
  text-zinc-500 subtitle.
- subtitle 은 옵셔널 — MetricsPanel 처럼 제목만 있는 카드도 같은
  컨테이너로 정렬돼 본문 카드 라인 높이 통일.

적용한 카드 8개:
- IntradayReturns / PeriodReturns / CompositeScoreCard /
  PeerComparePanel / TradingValuePanel / DisclosuresPanel / NewsList
  — 기존 markup 과 시각 결과 완전 동일.
- MetricsPanel — 기존 mb-2 standalone div → CardHeader 로 흡수,
  마진이 mb-2 → mb-3 으로 정렬됨. 다른 본문 카드들과 헤더 아래 공간
  통일.

적용 제외:
- PredictionPanel: 우측 슬롯이 button (예측 실행), 좌측이 title+
  subtitle 두 줄 중첩. 단순 text subtitle 모델에 안 맞음.
- InvestorCumulative: 우측 슬롯이 window 선택 button 그룹 (1M/3M/...)
  + aria-pressed. 위와 같은 이유.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:52:32 +09:00
claude-owner
232b94755a refactor(returns): extract shared ReturnBucket from Intraday/PeriodReturns
IntradayReturns(1D 10분봉 모드)와 PeriodReturns(일봉 이상)는 페이지의
같은 슬롯에서 모드 전환으로 갈아끼우는 한 쌍이다. 두 컴포넌트의 Bucket
서브컴포넌트는 className 문자열까지 완전히 동일했지만 각 파일에 별도
정의돼 있었다 — 한쪽 톤만 바꾸면 모드 전환 시 시각적 점프가 생기는
드리프트 리스크.

신규 web/components/ReturnBucket.tsx 로 단일 source of truth 분리:
- 색 규칙(rose/sky/zinc), 카드 톤(rounded-md border-zinc-800
  bg-zinc-900/30 px-2 py-2 text-center), 라벨/값 타이포 일괄 캡슐화.
- 두 패널은 각자 horizon 라벨/계산 로직만 유지하고 렌더는 ReturnBucket
  으로 위임.

기존 외부 동작/시각 결과 동일 — 동일한 className 을 한 곳으로 묶은
순수 리팩토링.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:47:44 +09:00
claude-owner
3b06b95586 ui(prediction): rounded-lg → rounded-md to match detail-page palette
종목 상세 페이지(/[code]) 본문에서 유일하게 남아 있던 rounded-lg
divergence. 다른 본문 카드(MetricsPanel / IntradayReturns /
PeriodReturns / CompositeScoreCard / PeerComparePanel /
TradingValuePanel / InvestorCumulative / DisclosuresPanel /
NewsList / SymbolSidebar) 가 모두 rounded-md border-zinc-800
bg-zinc-900/40 톤이라 PredictionPanel 만 모서리가 살짝 더 둥글어
시각적으로 튀었음.

홈 페이지 쪽(IndicesPanel / SeedTiles / RecentTiles) 의 rounded-lg
+ bg-zinc-900/30 묶음은 별도 슬라이스 — 홈 페이지는 카드가 더 크고
타일 그리드라 다른 톤 묶음을 의도적으로 유지할 수 있음. 이번 턴은
상세 페이지 본문 톤 완전 통일만 노림.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:44:29 +09:00
claude-owner
bd6e1dac6d fix(collapsible): truncate long subtitle instead of pushing title
리뷰어 b46f6f1 non-blocking 노트 반영. 기존 subtitle 은 shrink-0 이라
값이 길어지면 헤더 폭을 넘기며 title 을 두 줄로 밀어낼 수 있었음.
실제 트리거 예: PriceTargets 의 "목표 1,234,567 · 손절 987,654"
같은 큰 주가 범위.

변경:
- subtitle: shrink-0 제거 + min-w-0 truncate → 폭 부족 시 말줄임표.
- title flex span: min-w-0 추가 → flex shrink 계산이 양쪽 모두에서
  정상 동작하도록 (min-w-0 없으면 자식 텍스트의 min-content 가 부모
  shrink 를 막아 truncate 가 발동 안 됨).

title 텍스트 자체는 늘 짧음(호가/내 목표가/관련 종목/투자 메모/가격
알람) — title 쪽 truncate 는 불필요, min-w-0 만으로 충분.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:44:04 +09:00
claude-owner
b46f6f1b3a feat(targets): wrap PriceTargets in Collapsible
사이드바 5번째(이자 마지막 정보성) 패널인 PriceTargets 도 공용 Collapsible
로 통일. SymbolSidebar(상단 종목 카드 — 자체 톤 유지가 맞음) 만 남기고
나머지 사이드 패널 5개(Orderbook / Targets / Alerts / Note / Related)가
모두 같은 토글/영속 패턴.

subtitle 설계 — 접힌 상태에서도 저장값 한눈에:
- 둘 다 저장됨: "목표 12,500 · 손절 9,800"
- 한쪽만: "목표 12,500" / "손절 9,800"
- 둘 다 없음: "비공개 · 브라우저 저장" (InvestmentNote 와 동일 톤)
- dirty 입력값이 아닌 saved 기준만 표시 — 토글 사이클 사이에 안내가
  깜빡이지 않게.

storageKey="targets" → panel-open:targets 영속.
defaultOpen=true — 입력값은 Collapsible 의 hidden 본문 덕에 토글에도
보존되지만, 처음 보는 사용자에겐 input 이 펴져 있어야 "여기서 적는다"
가 자명. 입력 흐름 우선.

기존 rounded-lg 는 Collapsible 기본 rounded-md 로 흡수 — 다른 사이드
패널과 모서리 톤 통일.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:40:04 +09:00
claude-owner
70d56b14ec feat(orderbook): wrap OrderbookPanel in Collapsible (sticky-aware)
사이드바 6 패널 중 마지막 남은 비-collapsible 패널이던 OrderbookPanel 을
공용 Collapsible 로 통일. 이제 SymbolSidebar/PriceTargets 제외 모든 사이드
패널이 같은 토글/영속 패턴.

sticky 충돌 해소:
- 기존 sticky 스타일은 OrderbookPanel 자체 outer div 에 직접 박혀 있었음.
  Collapsible 로 감싸면 outer 가 바뀌므로 Collapsible 의 className prop 으로
  sticky 톤(bg-zinc-900/80 backdrop-blur)을 sticky=true 일 때만 주입.
- 접혔을 때: 헤더 한 줄(호가 + ts) 만 sticky top-4 로 상단에 떠 있음 →
  차트/지표 영역 길게 스크롤 중에도 호가 헤더 접근 가능, 한 번 클릭이면
  본문 펴짐. 펼쳤을 때: 본문 포함 전체 sticky.
- 내부 p-3 패딩은 제거 — Collapsible 의 px-4/pb-4/pt-3 본문 패딩이
  카드 안쪽 여백을 대체.

defaultOpen=true:
- 호가는 사이드바에서 정보 밀도/시의성 모두 가장 높은 패널 →
  진입 즉시 펴진 상태가 자연스러움. RelatedStocks(밀도 낮음) 만 false.

storageKey="orderbook": panel-open:orderbook 으로 영속.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:37:23 +09:00
claude-owner
d53ff7a745 feat(sidebar): RelatedStocks defaults collapsed + readOpen garbage-value fallback
readOpen() (reviewer f9027f5 non-blocking note):
- Previously returned v === '1' which collapsed '0' AND any garbage
  value (legacy markers, manual edits) into the same false branch,
  contradicting the 'fallback to default' comment. Split into three
  explicit cases: '1' → true, '0' → false, anything else →
  defaultOpen. Behavior unchanged for the writeOpen-only path; only
  garbage input is corrected.

RelatedStocks defaultOpen=false:
- Of the three collapsible sidebar panels (AlertsPanel,
  InvestmentNote, RelatedStocks), RelatedStocks has the lowest
  information density per row and the weakest direct tie to 'this
  symbol' (it's other symbols in the same market). Starting collapsed
  cuts initial mobile scroll height for first-time visitors; once the
  user toggles, localStorage pins their preference.
2026-06-01 10:31:48 +09:00
claude-owner
f9027f5365 feat(sidebar): collapsible AlertsPanel / InvestmentNote / RelatedStocks
Three lower-sidebar panels are now wrapped in a shared Collapsible so
mobile readers can fold away sections they're not actively using.

New shared pieces:
- lib/collapsible.ts — readOpen/writeOpen helpers backed by
  localStorage under 'panel-open:<name>'. Per-panel-name (not
  per-code) because the preference is a panel-level habit; users
  don't want different fold state for every symbol.
- components/Collapsible.tsx — button-as-header + chevron + body.
  aria-expanded for AT. Body uses 'hidden' instead of conditional
  unmount so textarea text, partial alert form input, and
  RelatedStocks fetch results all survive collapse cycles.
  SSR-safe: useState(defaultOpen) then useEffect-sync to avoid React
  hydration mismatch when localStorage diverges from default.

Panel refactors:
- RelatedStocks: outer rounded div + header replaced with Collapsible;
  loading/error/empty/data states all rendered inside body (header
  stays visible so user can still toggle/recognize the section even
  while loading).
- InvestmentNote: same swap. saved.updatedAt drives the subtitle.
- AlertsPanel: same swap. current price drives the subtitle.

Defaults are open=true everywhere; the surface area is the toggle
itself, not a forced collapse. User's first interaction sets the
sticky preference.
2026-06-01 10:26:33 +09:00
claude-owner
b1323373a5 feat(price-hero): 오늘의 흐름 OHLV mini strip under change line
PriceHero gains an optional flow={open,high,low,volume} prop. Rendered
as a compact 4-KPI inline strip (시/고/저/거래량) below the change
line. Wraps naturally on narrow widths via flex-wrap.

page.tsx computes flow from chart.ohlcv tail inside the existing
useMemo:
- 1D (10m): groups bars by same KST date as last bar, aggregates as
  session OHLV (open=first, high=max, low=min, volume=sum).
- 1d/1w/1mo: just the last valid bar's OHLV (each bar already
  represents one unit).

Volume formatted with the same 억/만 rule as StockChart fmtKVolume —
small enough to inline at use site instead of carving out a shared
format module. High/low labels get rose-300/sky-300 ink as a soft
directional hint while the numbers themselves stay neutral (zinc-200)
to avoid implying a misleading +/- sign.
2026-06-01 10:20:25 +09:00
claude-owner
31e7a67a0d feat(symbol-page): aria-pressed PeriodTabs + sticky orderbook in 1D mode
PeriodTabs (reviewer 488fb32 concern):
- Drop role=tablist/tab/aria-selected. These imply a full ARIA tab
  pattern (tabpanel, aria-controls, roving tabIndex, internal Arrow
  focus traversal) which we don't implement — page-level left/right keys
  drive period change instead. Switch to plain button group with
  aria-pressed={active}, which accurately describes a segmented
  toggle. Comment updated to match.

OrderbookPanel sticky (new slice):
- Add optional sticky prop. When true, panel uses 'sticky top-4' with
  a slightly heavier bg-zinc-900/80 + backdrop-blur so it stays
  legible over the panels scrolling behind it.
- page.tsx passes sticky={isIntraday} — only 1D (10m bars) mode
  benefits, since orderbook timeliness drops on daily+ charts and the
  fixed slot would harm sidebar layout stability there.
2026-05-30 20:13:44 +09:00
claude-owner
488fb32acb feat(period-tabs): rose accent + role=tab semantics for active period
The active period tab previously used `bg-zinc-700`, which blends with the
zinc-toned header buttons (비교 / 공유 / Star) right beside it — users had to
read the tab label to know which period was loaded.

Switch active style to rose-500 (한국 상승 컬러 톤 — same family as positive
returns and target lines elsewhere in the app), with bold weight and a soft
glow. Inactive tabs unchanged. Also adds tablist/tab/aria-selected semantics
so screen readers announce the active period.
2026-05-29 00:56:19 +09:00
claude-owner
900c56550c feat(chart): split toolbar into indicators row + actions row
When showSma is true, the indicator chips (MA5/20/60 + RSI + MACD) and the
action buttons (수평선 / CSV / 전체화면, plus the fullscreen symbol label) were
sharing one flex-wrap row. On narrow widths the actions wrapped into the
middle of the indicators because `ml-auto` only enforces right-pull while
everything fits on one line — once wrap kicks in the right-side group loses
its anchor.

Split into two rows so wrap in either group stays local:
- Indicators row: rendered only when showSma. Hidden in 1D (intraday) where
  none of these are meaningful, matching prior behavior.
- Actions row: always rendered. Leading fullscreen label + `ml-auto` to push
  the three action buttons right; on narrow widths they wrap among themselves
  without colliding with indicator chips.
2026-05-29 00:54:04 +09:00
claude-owner
a6cd6f3a35 feat(symbol-page): PeriodTabs overflow fix + fullscreen chart symbol label
- page.tsx: wrap PeriodTabs in overflow-x-auto + inner flex w-max min-w-full
  justify-end so the leftmost tab is reachable when content exceeds container
  (justify-end + overflow-x-auto alone clips negative direction in some browsers).
- StockChart: when fullscreen, render small "{name} {code}" leading label in the
  toolbar — PriceHero is covered by the fixed inset overlay so the chart loses
  symbol context otherwise. Toolbar (not canvas) so it doesn't overlap the
  existing CrosshairLabel at top-left of the chart area.
2026-05-29 00:51:13 +09:00
claude-owner
faa0163089 feat(symbol-page): split header into 2 rows so PeriodTabs has room on mobile
The single-row header packed nav + 비교/공유/Star + 7-tab PeriodTabs side by
side. On narrow widths the line wrapped with PeriodTabs landing mid-row, which
looked broken. Move PeriodTabs to its own row underneath, right-aligned with
overflow-x-auto fallback. Action group keeps flex-wrap as a defensive fallback.

Pure layout — no behavior change.
2026-05-29 00:46:20 +09:00
claude-owner
7312d0aadc feat(chart): Alt+Click removes nearest horizontal line
IPriceLine has no native click event, so use a coordinate-matching pattern:
  - On Alt+Click, compute candle.priceToCoordinate for each hline
  - Find the one whose y is closest to the click y
  - If within ±6px, remove it via removeLine

Complements the chips ✕ button — clicking the line itself on the chart is
more natural than aiming at a small ✕. Shift+Click for add still wins when
both modifiers are held (more frequent, checked first).

- StockChart toolbar title and ShortcutsHelp updated to surface the gesture
2026-05-29 00:42:31 +09:00
claude-owner
cab2ed13a3 feat(chart): Shift+Click adds horizontal line at clicked y-coordinate
H key and toolbar button use hover-or-last-close, so mouse users had no way
to drop a line at an arbitrary price they hadn't hovered. Shift+Click reads
the click's y via candle.coordinateToPrice — direct positional control.

- Plain click stays as native chart interaction (crosshair/drag)
- Only intercepts when shiftKey held; preventDefault to avoid text selection
- Honors the same 10-line cap as the H key / button
- ShortcutsHelp adds "Shift+클릭" entry next to H so it's discoverable
2026-05-29 00:39:45 +09:00
claude-owner
d6fc9d8a0b 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
2026-05-29 00:35:21 +09:00
claude-owner
318eae67df feat(chart): horizontal line drawing tool + modal-open shortcut guards
리뷰어 093ac86 지적 처리:
- 종목 페이지 단축키 핸들러 (←/→/S) 에 modal 가드 추가.
  `document.querySelector('[role="dialog"][aria-modal="true"]')` 가 매치되면
  return — ShortcutsHelp 같은 모달이 열린 상태에서 close 버튼이 포커스를
  잡고 있어도 페이지 단축키가 뒤에서 동작하지 않도록.
- StockChart 의 F 단축키에도 동일한 modal 가드 — 동일 문제 (모달 위에서
  F 가 차트 전체화면 토글) 차단.

신규 슬라이스 — 차트 수평선 그리기 도구:
- `web/lib/lines.ts` — `hlines:<code>` 키 localStorage. id 단위 add/remove/clear,
  최대 10 개, 같은 가격 중복 추가 방지(부동소수 4 자리 비교), crypto.randomUUID
  폴백 포함.
- StockChart 툴바: `─ 수평선 (N/10)` 버튼 — hover 가격 또는 마지막 봉 종가에
  추가. 한도 도달 / 가격 없음이면 disabled.
- 추가된 라인은 chips 형태로 toolbar 아래 한 줄에 표시 — 각각 ✕ 로 제거,
  `전체 해제` 버튼 별도.
- H 키 단축키 — modifier/editable/modal 가드 일관 적용. 가격은 ref 로 들고
  있어 keydown effect 가 재구독되지 않음.
- 차트엔 `createPriceLine` 으로 zinc-400 solid 라인 (목표/손절의 점선과 톤
  분리). 종목 전환 시 자동 reload.
- ShortcutsHelp 에 H 단축키 등록.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:29:56 +09:00
claude-owner
093ac86d1a feat(symbol-page): keyboard navigation for period tabs and watchlist
- ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap-around 안 함, 사용자가
  끝에 갇혔는지 인지 가능).
- S : 관심종목 토글 (watchlist.toggle, StarButton 자동 동기화).
- 입력 필드 포커스 시 무시 — 검색창/메모 텍스트 편집 방해 금지.
- modifier(Ctrl/Cmd/Alt) 가 눌리면 단축키 양보 — 브라우저/OS 단축키
  보존 (ex. Cmd+→ 줄 끝 이동, Alt+← 뒤로가기 등).
- ShortcutsHelp 에 새 단축키 등록 → ? 키 헬프 오버레이에서 발견 가능.
- PeriodTabs 에 `periodNeighbor(id, dir)` 헬퍼 export — 페이지가
  PERIODS 순서를 직접 알 필요 없도록.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:24:03 +09:00
claude-owner
6f71aef451 feat(shortcuts): keyboard shortcuts help overlay + F modifier guard
- 글로벌 `ShortcutsHelp` 컴포넌트 추가 — `?` 키로 토글, ESC 또는 바깥
  클릭 / 닫기 버튼 / 우하단 floating ? 버튼으로 닫음. /, Ctrl+K, F,
  ESC, ↑↓, Enter 등 현재 사이트의 모든 단축키를 scope 별로 표시.
- 입력 필드 포커스 시 단축키 무시 가드 — `?` 입력 의도를 방해하지 않음.
- 열릴 때 닫기 버튼에 포커스 → 키보드 사용자 Tab 흐름 자연스럽게.
- 모달 click-outside → setOpen(false), aria-modal/role=dialog 로 접근성.
- 리뷰어 9f215ac 지적: StockChart F 단축키가 Ctrl+F/Cmd+F/Alt+F 도
  가로채던 것을 modifier guard 로 막음. shift/ctrl/meta/alt 어느 것도
  눌리지 않은 단순 F 일 때만 toggleFullscreen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:21:07 +09:00
claude-owner
9f215acaaa feat(chart): fullscreen mode + CSV scope label clarification
- StockChart 툴바에 `⛶ 전체화면` 버튼 추가. Fullscreen API 우선,
  미지원/거부 시 fixed inset 오버레이 폴백. F 키 토글, ESC 해제.
- 입력 필드(INPUT/TEXTAREA/SELECT/contentEditable) 포커스 시 단축키 무시.
- fullscreenchange 이벤트로 native 상태 동기화 → ESC 로 빠져나와도 UI 정합.
- CSV 버튼 title 을 '현재 로드된 기간(from ~ to)' 으로 명시.
  리뷰어 지적 (1e0c917): "현재 보이는 봉" 이라는 표현은 timeScale
  visible range 와 혼동 — 실제는 PeriodTabs 가 선택한 range 전체.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:17:00 +09:00
claude-owner
1e0c917f68 feat(chart): CSV export of current chart range + fix ShareButton comment
- New lib/csv.ts: RFC 4180 escaping, UTF-8 BOM prefix (한글 Excel 호환),
  Blob + temporary anchor download, filename
  `<name>_<code>_<interval>_<from>_<to>.csv` with filesystem-illegal
  characters replaced by `_`.
- StockChart toolbar now always renders (was hidden in 10m intraday mode
  because it gated on showSma) and includes a right-aligned `⬇ CSV`
  button next to the SMA/RSI/MACD chips. Button is disabled when ohlcv
  is empty.

Also addresses the reviewer's non-blocking note on f18493f:
ShareButton.tsx header comment claimed clipboard-first, but the code
calls navigator.share() first. Comment now matches the actual order:
share → clipboard → execCommand → "복사 안됨".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:11:57 +09:00
claude-owner
f18493fad4 feat(share): symbol page share button + distinguish note delete vs failure
- New ShareButton in the page header tries navigator.share() first
  (native sheet on mobile), then falls back to navigator.clipboard, then
  to a hidden textarea + execCommand("copy") for non-secure-context LAN.
  Shows a brief "링크 복사됨" / "복사 안됨" toast; native share success
  shows none (the OS sheet is the feedback).
- Mounted between ⇄ 비교 and the Star button.

Fixes reviewer concern on 442cc38: writeNote() used to return null for
both "user emptied the textarea (intentional delete)" and "localStorage
quota failure," so clearing a note showed "저장 안됨." It now returns a
discriminated { status: "saved" | "deleted" | "failed" } and the panel
shows "저장됨" / "삭제됨" / "저장 안됨" accordingly. Failure keeps the
user's text so they can retry or shorten it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:08:37 +09:00
claude-owner
442cc38c32 feat(notes): per-code private investment note panel
- New lib/notes.ts: localStorage key `notes:<code>` storing { text, updatedAt },
  2000-char cap, blank/whitespace removes the key.
- New InvestmentNote sidebar panel: textarea + character counter, save button
  enabled only when dirty, "방금 전 / N분 전 / 오늘 HH:MM / YYYY-MM-DD"
  relative timestamp on the last save, short save-confirmation toast.
- Mounted in [code]/page.tsx sidebar after AlertsPanel.

Also tightens writeTargets() to require strictly positive finite values,
matching the UI's parseNum() guard so the lib is safe under direct external
calls (reviewer's non-blocking note on a4a4a7f).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:05:33 +09:00
claude-owner
a4a4a7f98c 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>
2026-05-29 00:01:43 +09:00
claude-owner
ecf8b9112b feat(views): anonymous daily view counter with social-proof badge
- New table symbol_views_daily(code, view_date, views) via migration 004.
- POST /api/views/{code} UPSERTs and returns today_views; GET returns
  today + last_7d + trend (last 7 days).
- Client dedupes via localStorage (views:logged map keyed by KST date) so a
  given browser counts once per day per code; refresh/extra tabs don't inflate.
- PriceHero shows "오늘 N명이 봤어요" pill when viewsToday > 0.

Read-only social proof — Toss 의 종목 상세에서 익명 카운트로 진입 신호를 주는 패턴.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:56:36 +09:00
claude-owner
ef127ae2e8 feat(chart): 메인 → RSI/MACD crosshair 동기화
토스 차트 인상 — 메인 캔들에 hover 하면 RSI/MACD 보조 패널도 같은 시점을
가리키며 가격 라벨이 뜬다. lightweight-charts setCrosshairPosition /
clearCrosshairPosition API 활용.

- rsiByTsRef / macdSignalByTsRef: 보조 시계열의 ts→value O(1) lookup.
  RSI/MACD 데이터 push 할 때 동시에 채움.
- crosshair handler: hover 시 보조 차트도 setCrosshairPosition,
  마우스가 차트 밖이면 clearCrosshairPosition 으로 양쪽 다 해제.
- MACD 는 signal 라인 기준 — 가격 라벨이 안정적으로 보이도록.
- 보조 차트 toggle off / 데이터 부족 시점은 ref guard 와 lookup miss 로 자연스럽게 noop.
2026-05-28 23:49:29 +09:00
claude-owner
759ea20daa fix(pwa)+feat(investor): SW dev 등록 + 투자자별 누적 순매수
PwaRegister:
- production 게이트 제거. sw.js 는 fetch handler 가 없어 HMR/네트워크 응답을
  가로채지 않음 → dev 에서 등록해도 안전. Docker 가 next dev 로 도는 실제
  배포에서도 install 자격이 잡힘.
- 등록 실패는 그대로 silently 무시 (secure context 아님 등).

InvestorCumulative:
- chart.trading_value 를 재사용해 외국인/기관/개인 N일 누적 순매수를 미니
  SVG 라인으로 표시. 백엔드 호출 0.
- 윈도우 토글: 20일/60일/120일. 데이터 부족하면 가용분으로 자동 축소.
- 라인 색은 주체별 고정 (외국인=amber/기관=cyan/개인=violet) — 비교 가능.
- 끝값 라벨은 한국 컬러 (양수=rose-300, 음수=sky-300), 억원 단위.
- 단위는 원 → 1e8 환산. 시작 0 기준 누적 + 점선 기준선.
- TradingValuePanel (최근 5일 표) 옆 그리드 자리에 배치, MetricsPanel 은
  아래줄로 이동.
2026-05-28 23:44:24 +09:00
claude-owner
89f3a865af feat(pwa): installable PWA (manifest + 빈 SW + SVG 아이콘)
설치 가능한 PWA 자격을 갖추는 최소 셋:
- web/public/manifest.webmanifest: 한국어 name/short_name, 다크 테마 컬러,
  standalone display, /icon.svg (any maskable, sizes any)
- web/public/icon.svg: 다크 배경 + 한국 컬러 캔들(상승=#ef4444 / 하락=#3b82f6)
  + 우상향 추세선. maskable safe-zone (중심 80%) 보존
- web/public/sw.js: install/activate 만 처리 (fetch handler 없음).
  install 자격만 충족하고 네트워크는 그대로 통과 → 캐시 stale 위험 0
- web/components/PwaRegister.tsx: production 빌드에서만 SW 등록,
  실패는 silently 무시
- web/app/layout.tsx: Metadata.manifest/icons/appleWebApp,
  viewport.themeColor/colorScheme 분리 (Next 14 권장)

dev (next dev) 에서는 SW 등록을 우회 — HMR 응답 간섭 회피.
2026-05-28 23:39:27 +09:00
claude-owner
38db4b8c24 feat(chart): crosshair 한국어 OHLC 오버레이 (토스 스타일)
차트 위에 hover 위치 봉의 날짜(+요일)/OHLC/등락률/거래량을 띄우는 좌상단
정보 박스 추가. lightweight-charts subscribeCrosshairMove 로 hover 좌표를
잡고, ohlcv 를 timestamp 기준 Map 으로 O(1) 조회. hover 가 차트 밖일 때는
마지막 봉을 기본 표시해 페이지 진입 직후에도 정보가 보임.

- 거래량 fmt: 억/만 (사이드바와 동일)
- 등락률: (close-open)/open, 한국 컬러 (상승=#ef4444 / 하락=#3b82f6)
- intraday(10분봉) 면 시:분 까지 포함
2026-05-28 19:31:35 +09:00
claude-owner
52733df235 fix(fundamentals): logger.exception → logger.error %r (UnicodeDecodeError 회귀 방지)
pykrx/KRX 비정상 응답에서 traceback 포매팅 중 UnicodeDecodeError 가 발생해
API 500 을 만든 회귀 사례가 있었음. 새 pykrx fundamentals 경로에 동일 패턴이
다시 들어가지 않도록 %r 안전 직렬화로 통일.
2026-05-28 19:29:49 +09:00
claude-owner
a79e784550 feat(fundamentals): 시가총액·PER·PBR·EPS·BPS·배당 — pykrx 스냅샷 + SymbolSidebar
backend:
- fetch/fundamentals.py: get_fundamentals(code) — pykrx 의 get_market_cap +
  get_market_fundamental 을 같은 영업일로 정렬해서 호출. 토/일/공휴일이면 KRX 가 빈
  응답을 줘서 최대 7일까지 역순 backoff. (code, today) 인메모리 daily 캐시로 중복 호출 방지.
- api/fundamentals.py: GET /api/fundamentals/{code} — 없는 코드 404, KRX 가 데이터를
  안 주는 케이스는 status="no_data" + null 들. 200 응답으로 단순화 (호출자 분기 편의).
- main.py: fundamentals_router 등록.

frontend:
- lib/api.ts: FundamentalsResponse 타입 + api.fundamentals(code).
- components/SymbolSidebar.tsx: 1Y 차트와 별도로 fundamentals 호출. ok 일 때만
  '기업 지표' 섹션 노출 — 시가총액(조/억) / 상장주식수 / PER / PBR / EPS / BPS /
  배당수익률(%) / 주당배당금. PER·PBR 음수면 '적자' 표기 (이익 음수 → 멀티플 무의미).
- 기존 시세/52주 게이지 코멘트에 fundamentals 추가됨을 반영.

pykrx 종목당 호출 1~2초 → 첫 페이지 로딩이 살짝 느려질 수 있으나 daily 캐시로
재방문은 즉시. 펀더멘털 실패는 silent — 시세 표시는 영향 없음.
2026-05-28 19:25:53 +09:00
claude-owner
59dc8c4e7a fix(nav): Cmd/Ctrl+K 단축키에도 isEditableTarget 가드 적용
리뷰어 지적: 주석엔 '다른 인풋/편집 영역에서 무시'라고 적어놨지만 실제 코드는
'/' 키에만 가드를 걸어둬서 Cmd/Ctrl+K 는 모든 input/textarea/contenteditable
에서 브라우저/편집기 단축키를 가로채는 상태였음.

수정: '/' 와 동일하게 !isEditableTarget(e.target) 조건 추가. 검색 인풋 자체에
포커스 중이면 어차피 focus()/setOpen(true) 가 noop 이라 UX 손해 없음.
2026-05-28 19:21:19 +09:00
claude-owner
7cd865c450 feat(nav): TopNav 검색 키보드 네비게이션 + 글로벌 단축키
키보드 조작:
- "/"           : 검색 인풋 포커스 (다른 인풋 포커스 중이면 무시)
- Cmd/Ctrl+K   : 동일 (OS 공통)
- ↑/↓         : 결과 목록 wrap-around 이동
- Enter        : 현재 강조 항목으로 이동 (검색 결과 또는 최근 본 종목)
- Esc          : popover 닫기 + 인풋 blur

기존 mouse hover 흐름과 충돌 없도록 onMouseEnter 에서 hi 동기화. navCodes 길이
변하면 hi 를 자동 clamp. placeholder 에 "( / )" 힌트 추가.

isEditableTarget() 가드로 다른 입력 필드 (PredictionPanel, 검색칩 등) 에 포커스
중일 때 "/" 가로채지 않음.
2026-05-28 19:19:01 +09:00
claude-owner
d83ac137a9 feat(chart): 메인 차트 하단에 거래량 막대 overlay 추가
- 캔들 priceScale 을 위 78%(top=0.05, bottom=0.22)로 좁힘
- 거래량 Histogram 시리즈를 overlay priceScale("volume")로 같은 차트에 부착,
  scaleMargins top=0.82 → 차트 영역 아래 18% 만 사용
- 상승봉=빨강(0x40 알파), 하락봉=파랑 — 한국 거래소 관행
- 가격축 라벨/마지막값/priceLine 숨김 → 캔들 시야 방해 최소화
- 시계열은 메인 timeScale 그대로 따라가서 별도 sync 불필요

cleanup 에서 volumeRef.current=null 도 같이 정리. intraday 포함 항상 ON
(거래량은 일/분봉 공통으로 의미 있음).
2026-05-28 19:17:13 +09:00
claude-owner
fed8ff287e feat(orderbook): 10단계 호가 패널 (KIS asking-price 스냅샷)
backend:
- fetch/kis.py: fetch_orderbook(code) — KIS FHKST01010200 (inquire-asking-price-exp-ccn)
  호출. output1 의 askp1~10/bidp1~10 + 잔량 + 합계, output2 의 현재가/전일대비 파싱.
- api/orderbook.py: GET /api/orderbook/{code}. symbols 매칭 안 되면 404, KIS 키 없으면
  skipped_missing_key 상태로 빈 호가 반환 (502 와 구분).
- main.py: orderbook_router 등록.

frontend:
- lib/api.ts: OrderbookResponse 타입 + api.orderbook(code).
- components/OrderbookPanel.tsx: 매도 10단계(위→아래 가격 내림차순) / 현재가(굵게, 전일대비)
  / 매수 10단계. 잔량 막대는 사이드별 max 정규화로 셀 안에서 자라남. ask=파랑, bid=빨강
  (한국 거래소 관행). 10초 폴링.
- app/[code]/page.tsx: 사이드바에 SymbolSidebar 다음으로 OrderbookPanel 마운트.

실시간 ws 가 아니라 HTTP 폴링이라 KIS rate-limit 친화적. 표시 전용 — 주문/체결 endpoint 는
일절 import 하지 않음 (kis.py 문서 정책).
2026-05-28 19:13:35 +09:00
claude-owner
1a6d953d17 feat(chart): MACD(12/26/9) 보조지표 sub-pane + 토글 추가
- EMA + MACD(line/signal/hist) 계산 함수 추가
- MACD 라인(파랑) + Signal 라인(주황) + Histogram (양수=빨강 / 음수=파랑 옅게)
- 별도 lightweight-charts 인스턴스 (RSI 와 동일 패턴, handleScroll/Scale off)
- MA chip 줄에 MACD 토글 chip 추가, 기본 OFF, intraday 에선 숨김
- timeScale 동기화 effect 를 단일 follower 리스트로 일반화 (RSI + MACD 동시 가능)

EMA 헬퍼는 null-tolerant — 결측 시점은 직전 ema 유지. Wilder 가 아닌
표준 EMA (alpha=2/(n+1)) 채택, 트레이딩뷰/Toss 와 동일.
2026-05-28 18:38:18 +09:00
claude-owner
9e43663b23 feat(symbol): 종목 페이지 헤더에 '⇄ 비교' 버튼 — /compare?codes={code} 로 비교 시작 2026-05-28 18:35:57 +09:00
claude-owner
5aeea1a559 feat(chart): RSI(14) 보조지표 sub-pane + 토글 추가
- Wilder's smoothing 기반 RSI 14일 계산 (워밍업 구간 null)
- 메인 차트 아래 110px 별도 lightweight-charts 인스턴스로 sub-pane
- 30/70 가이드 라인 (axisLabel 표시)
- 메인 ↔ RSI timeScale 단방향 동기화 (RSI scroll/scale off)
- MA chip 옆 RSI 토글 chip — 기본 OFF, intraday 에선 숨김

별도 인스턴스 방식 채택 이유: 같은 chart 에 priceScaleId/scaleMargins 로 영역 분할 시
캔들 priceScale 까지 영향받아 메인 줌이 망가짐. sub-chart 패턴이 lightweight-charts
공식 권장 + react cleanup 도 깔끔.
2026-05-28 18:35:19 +09:00
claude-owner
d5acf79e05 fix(alerts): /alerts 안내문 — 탭이 열려 있어야 동작함을 명시
리뷰어 지적: 기존 문구가 '탭이 닫혀 있어도 알람'으로 오해되어
실제 AlertsToaster 의 60초 폴링 동작(탭 열려 있을 때만)과 불일치.

- 메인 설명: '웹앱이 열려 있는 동안 60초마다 체크' + '탭 닫히면 체크 X' 명시
- NotificationHint: '다른 창을 보고 있을 때도 OS 알림 (탭 열려 있어야)'로 정정

Service Worker + Push API 미구현 — 로컬 localStorage 기반 앱이라 VAPID/푸시
백엔드는 과한 인프라. 문구로 한계를 정확히 표시하는 쪽으로 정정.
2026-05-28 18:33:07 +09:00
claude-owner
0f89b2c042 feat(compare): 종목 비교 페이지 (/compare)
- 최대 5종목 누적수익률(%) 한 차트 비교, 기간 1M/3M/6M/1Y
- URL ?codes=A,B,C 양방향 동기화 (Suspense + useSearchParams)
- 검색 칩으로 종목 추가, ✕ 로 제거. 종목별 최종 수익률 칩 표시
- TopNav 우측에 "비교" 링크
2026-05-28 18:27:27 +09:00
claude-owner
139403c133 feat(alerts): /alerts 통합 관리 페이지 + TopNav 🔔 링크
- 종목별 그룹핑 (Map by code), 활성·발사됨 분리 카운트
- 행 단위 재무장/삭제 + 전체 삭제 (confirm)
- Notification 권한이 default 일 때만 권한 요청 배너 노출
- TopNav 우측 ★ 옆에 🔔 알람 페이지 링크 추가
2026-05-28 18:25:27 +09:00
claude-owner
fd7026ad09 feat(alerts): 가격 알람 (localStorage + 60s 폴러 + 토스트)
- lib/alerts.ts: id/code/op(gte|lte)/target/triggered 저장소, watchlist/recent 와 동일 pub/sub
- AlertsPanel: 종목 사이드바에 ≥/≤ 토글 + 목표가 입력 + 활성 알람 리스트(재무장/삭제)
- AlertsToaster: layout 전역 마운트, 미발사 알람 코드만 폴링, 조건 충족시 markTriggered + 우측 하단 토스트(6s), Notification 권한 허용 시 브라우저 알림 병행
2026-05-28 15:26:10 +09:00
claude-owner
01e5eba3c1 feat(chart): SMA(5/20/60) 토글 오버레이
- lightweight-charts addLineSeries 로 종가 기반 단순이동평균 라인 추가
- 칩 토글 (기본 MA20 ON), intraday(10분봉)에선 UI/시리즈 모두 숨김
- fitContent 는 호출 안 함 — 사용자 줌 유지
2026-05-28 15:23:34 +09:00
claude-owner
645e93e7bf feat(market): 거래대금 상위 + 52주 위치 게이지
- /api/markets/movers: by_trading_value (close*volume DESC) 카테고리 추가
- MarketsOverview: 4 컬럼 그리드 (등락 상위/하위/거래대금/거래량), 거래대금은 억·조 표기
- SymbolSidebar: 52주 범위 내 현재가 위치 게이지 (그라데이션 바 + 화이트 점)
2026-05-28 15:21:27 +09:00
claude-owner
aa58761176 feat(ui): 최근 본 종목 (localStorage + 홈 카드 + TopNav popover)
- web/lib/recent.ts: stockchart.recent KEY, push/get/clear/subscribe (storage + custom event)
- [code]/page.tsx: 페이지 진입 시 recent.push(code) — 자동 트래킹
- web/components/RecentTiles.tsx: 홈 카드 그리드 (최대 10개, 2~5컬럼)
- web/app/page.tsx: SearchBox 아래에 RecentTiles 배치 (빈 리스트면 자동 숨김)
- TopNav: 검색어 비면서 포커스 시 최근 본 종목 popover 표시 + "지우기" 버튼

토스 "최근 본 종목" 톤 — 검색 빈 상태에서도 popover 가 비어 있지 않게.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:14:40 +09:00
claude-owner
300f7beef0 feat(nav): 글로벌 sticky 헤더 + 컴팩트 검색
- web/components/TopNav.tsx: sticky top header
  · 좌: Stock Chart 로고 (→ /)
  · 중: 컴팩트 검색 input + popover (debounced 200ms, with_sparkline=false 빠른 응답)
  · 우: ★ 관심 (→ /watchlist)
- 바깥 클릭/Esc 로 popover 닫힘
- 결과 클릭 시 자동 닫힘 + 검색어 클리어
- web/app/layout.tsx: <body> 최상단에 <TopNav /> 부착

토스 톱바 톤. 모든 페이지에서 검색 가능 — 홈으로 돌아갈 필요 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:10:14 +09:00
claude-owner
85da979fbe feat(peers): 동종업종(테마) 비교 패널
- backend/app/seed/themes.py: themes_for_code(code) 헬퍼 추가 (코드 → 소속 테마들)
- backend/app/api/themes.py: GET /api/themes/peer/{code} 신규
  · 종목이 속한 모든 테마의 코드 합집합을 peer 풀로
  · 단일 SQL (ROW_NUMBER OVER PARTITION BY code) 로 21영업일 누적수익률 일괄 계산
  · 본인 / peer 평균 / 상·하위 5종목 반환
- web/lib/api.ts: PeerCompare 타입 + api.peerCompare()
- web/components/PeerComparePanel.tsx: 테마 태그 + 3-칸 요약 (본인/평균/격차) + 상·하위 5
- [code]/page.tsx 에 PeriodReturns 아래 부착. 테마 무소속 종목은 패널 자동 숨김.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:07:09 +09:00
claude-owner
0b4795b03f feat(ui): AI 종합 점수 카드 (모멘텀·수급·감성·추세)
토스 "투자자 분석" 톤의 단일 카드:
- 종합 점수 반원 게이지 (conic-gradient + radial mask)
- 4개 서브 점수 (0~100, 50=중립)
  · 모멘텀: SMA5 vs SMA20 격차
  · 수급:   외국인+기관 5일 누적 net 정규화
  · 감성:   뉴스 weighted_score 7일 평균
  · 추세:   20일 종가 선형회귀 기울기
- 백엔드 호출 없음 — chart payload (ohlcv+sentiment+trading_value) 클라이언트 합성

색조: 65↑ 강세=빨강 / 35↓ 약세=파랑 / 그 사이 중립=주황.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:02:04 +09:00
claude-owner
9b25bc6970 feat(ui): 기간 수익률 + 거래주체 순매수 패널
- PeriodReturns: chart.ohlcv 종가로 1주/1개월/3개월/6개월/1년/전체 수익률 그리드
- TradingValuePanel: chart.trading_value 외국인/기관/개인 최근 5일 표 (억원 단위)
- [code]/page.tsx 레이아웃 재배치: 수익률 → 거래주체+메트릭 → 뉴스+공시

백엔드 변경 없음 — 기존 ChartPayload 의 미사용 시리즈를 재활용.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:57:40 +09:00
claude-owner
da36195cf3 feat(ui): 관심종목(localStorage) + DART 공시 패널
- web/lib/watchlist.ts: KEY=stockchart.watchlist, storage+custom event 구독
- StarButton: 종목 페이지 헤더에서 ☆/★ 토글
- /watchlist 페이지: 카드 그리드 + 현재가/전일대비 + ✕ 제거
- DisclosuresPanel: 기존 /api/news?source=dart 재사용 (백엔드 신설 없음)
- 종목 페이지 하단에 공시 패널 부착, 홈에 ★ 관심종목 링크 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:54:43 +09:00
claude-owner
ae4f07d675 feat(home+search): KOSPI/KOSDAQ 인덱스 카드 + 검색결과 미니 sparkline
- Sparkline 컴포넌트를 PriceHero 내부에서 web/components/Sparkline.tsx 로 분리·재사용
- /api/markets/indices: macro_daily 의 kospi/kosdaq N일 시계열 + 최신값/전일대비
- 홈 IndicesPanel: 두 인덱스 카드(현재값/등락/우측 sparkline)
- /api/symbols/search?with_sparkline=true: 결과 한 번에 최근 30 종가 batch 조회
- SearchBox 결과 행에 mini sparkline + 현재가/등락률 인라인 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:46:13 +09:00
claude-owner
66a28cc7ca feat(themes): 테마 인덱스/상세 (11 카테고리 · 111 종목)
- seed/themes.py: 반도체/2차전지/바이오/게임/엔터/자동차/금융/조선·방산/플랫폼·AI/유통·소비재/에너지 정적 매핑
- /api/themes (인덱스) + /api/themes/{slug} (종목 카드 + 최신 종가/등락)
- 홈에 ThemeTiles 그리드, /themes/{slug} 상세 페이지

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:29:16 +09:00
claude
21db09f65f feat(markets): /api/markets/movers + /related/{code} + 홈/종목 페이지 연동
backend:
  - 신규 라우터 app/api/markets.py
  - GET /api/markets/movers?market=ALL|KOSPI|KOSDAQ&limit=10
    → gainers / losers / by_volume 3 카테고리. ohlcv_daily 최신 2 거래일 비교.
  - GET /api/markets/related/{code}?limit=8
    → 같은 market 내 거래량 상위 (sector 컬럼은 시드 단계 NULL 다수라 제외).
  - main.py 에 라우터 include.

frontend:
  - lib/api.ts 에 Mover/MoversResponse/RelatedResponse 타입 + .movers/.related 추가.
  - components/MarketsOverview.tsx: 등락 상위 / 등락 하위 / 거래량 상위 3 컬럼.
  - components/RelatedStocks.tsx: 같은 시장 내 관련 종목 8 개.
  - 홈 페이지에 MarketsOverview 배치 (SeedTiles 아래).
  - 종목 페이지 우측 사이드바 컬럼에 RelatedStocks 추가 (SymbolSidebar 아래).

verify: py_compile clean, tsc --noEmit clean, next lint clean.
2026-05-28 01:36:13 +09:00
claude
04358469d1 feat(ui): 홈 SEED-10 타일 + 가격 헤로 sparkline
홈 페이지:
  - 텍스트 카드를 SeedTiles 컴포넌트로 교체. 10개 병렬로 /api/chart 2일치 받아
    현재가 + 전일대비 (절대값+%) 카드 그리드 (2/3/5 컬럼).
  - max-w 를 3xl → 5xl 로 확장하여 타일 5열 그리드 수용.

종목 페이지:
  - PriceHero 에 series prop 추가, 우측에 SVG sparkline (최근 30 종가) 표시.
  - 라인 색상은 전일대비 부호로 결정 (상승 빨강 / 하락 파랑).
  - lightweight-charts 인스턴스 추가 없이 인라인 SVG 만 사용 — 가벼움.

verify: tsc/next lint clean.
2026-05-28 01:27:49 +09:00
claude
832e73b5ee feat(ui): 뉴스 리스트 카드 리디자인 (감성 칩 + 출처 배지 + 상대시간)
토스 뉴스 카드 톤. 변경:
  - 감성 라벨을 chip 형식으로 (긍정=빨강, 부정=파랑, KR 관행 유지)
  - 출처를 작은 배지로 분리, 본문과 시각 구분
  - published_at 을 'N분 전 / N시간 전 / N일 전' 상대시간으로
  - 카드 hover 시 border-zinc-700 강조

썸네일은 백엔드가 og_image 미제공이라 텍스트 카드로 한정. 추후 백엔드에
컬럼 추가되면 SourceBadge 위에 16:9 썸네일 슬롯 추가.

verify: tsc/next lint clean.
2026-05-28 01:22:43 +09:00
claude
16aeba1a20 feat(ui): 우측 시세 정보 사이드바 추가 (시가/고가/저가/거래량/52주범위)
토스 종목 페이지 우측 카드와 동일 위치/구성. ChartPayload(1Y, 1d) 한 번 받아
오늘 OHLC + 1년 high/low 만 계산. 백엔드가 PER/EPS/시총 미제공이라 제외.

lg 브레이크포인트에서 chart(1fr) + sidebar(280px) 그리드.
mobile 에서는 사이드바가 chart 아래로 스택.

verify: tsc --noEmit clean, next lint clean.
2026-05-28 01:21:43 +09:00
Claude
6ab3679331 feat(seed): KRX live 실패 시 정적 주요종목 fallback (156개)
배경: 토요일/봇차단 등으로 KRX 가 LOGOUT/JSONDecodeError 를 주면
pykrx 가 빈 리스트만 돌려줘서 SEED 10 종목 외엔 검색이 안 되는
회귀가 있었다 (기아/네이버/엔비디아 같은 비-seed 검색 실패).

해결: 시총/거래대금 상위 + 친숙도 기준으로 KOSPI 104 / KOSDAQ 52
= 156 개를 정적 스냅샷 (app/seed/krx_static.py) 으로 묶었다.
seed_symbols() 가 KRX 라이브 fetch 결과가 0건이면 자동으로 이
스냅샷에서 upsert. KRX 가 정상 돌아오면 다음 호출이 라이브 데이터로
자연 덮어쓰여진다. 응답에 static_fallback_used 필드 노출.

수록 항목은 KRX 공시 종목코드 + 현재 등록명 (공개 사실).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:15:58 +09:00
Claude
b16f2b578d feat(ui): 예측 오버레이 캔들화 + 7탭 기간 셀렉터 + 가격 헤로
- 예측 오버레이: 라인 시리즈 3개 (median/q10/q90) 를 단일 캔들 시리즈
  하나로 합쳤다. 합성 OHLC: open=직전 close, close=point_close,
  high=ci_high, low=ci_low. 옅은 색 (up #fda4af / down #93c5fd) 으로
  실 캔들과 시각 톤은 같고 명도만 낮춰 "어우러지되 미래"임이 보이게.
- 예측 프리셋 단순화: 단기/중기/장기 + 직접입력 다 떼고 15일/30일/1년
  3개로. 백엔드 horizon cap 30 → 252 로 확장 (1년 = 240 거래일).
- PriceHero: 종목명/현재가/등락 큰 글씨 헤더. KR 관례대로 상승=빨강,
  하락=파랑.
- PeriodTabs: 1일/1주/1달/3달/1년/5년/최대 7탭. 기존 interval+days
  이중 컨트롤 제거. 5년·최대 는 자동으로 주봉/월봉으로 폴백.
- 차트 본체 캔들 색조도 KR 관례 (적/청) 로 통일.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:05:23 +09:00
Claude
bf898d78be chore(gitignore): backend/data/ 추가 (LGBM 학습 체크포인트)
리뷰어가 호스트에서 backend/data/ 가 untracked 로 잡힌 걸 발견. LGBM
체크포인트는 런타임 산출물이라 커밋되면 안 됨.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 17:01:41 +09:00
Claude
73593adb5c fix(seed): /api/refresh/seed/symbols 500 → 200 (logger.exception 우회)
pykrx 가 주말/장마감 시 KRX 에서 비-JSON 응답을 받아 JSONDecodeError 를
던지는데, except 블록의 logger.exception() 이 traceback 을 포매팅하다
Python 3.11 의 _byte_offset_to_character_offset 버그(pykrx 소스의 한글
주석 'df = 가...' 바이트 처리) 로 UnicodeDecodeError 가 떠서 try 밖으로
escape — 500 의 진짜 원인이었다.

- symbols_seed.py: logger.exception → logger.error(repr(e)) 로 교체.
  traceback 포매팅을 피한다.
- refresh.py: 라우트 핸들러를 try/except 로 감싸 만일 다른 경로로
  예외가 새도 200 + ok:false 로 응답. SEED 10 종목은 별도 트랜잭션이라
  KRX fetch 실패와 무관하게 보장됨.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:54:55 +09:00
claude-owner
323061df02 fix(chronos): inference RuntimeError 시 자동 CPU 폴백
ea88597 의 fp32 변경만으로는 sm_86 커널 누락 케이스를 100% 회피한다는
보장이 없음 (에러 메시지가 dtype 과 무관한 dispatch 단계에서 날 수 있어
fp32 도 같은 증상 가능). 그래서 forecast() 안에서 RuntimeError 잡아
'no kernel image' / 'CUDA error' / 'CUBLAS' 신호면 pipeline 을 CPU 로
재로드하고 한 번 더 추론. 폴백 후엔 그 세션 동안 계속 CPU 사용 (재시도
비용 회피).

이로써 사용자는 환경변수 수동 변경 없이도 GPU 비호환 시 자동으로 차트가
뜸. GPU 가 잘 도는 경우는 영향 없음.
2026-05-23 16:09:09 +09:00
claude-owner
ea885973c7 fix(chronos): cuda 도 fp32 기본 — sm_86 bf16 커널 누락 우회
torch 2.3.1+cu121 사전빌드 wheel 이 RTX 3070 Ti(sm_86) 의 일부 T5 bf16
커널을 빠뜨려서 inference 첫 호출에 'no kernel image is available for
execution on the device' 가 났음. ping/_load 까지는 통과해 진단이 까다로움.

chronos-t5-small 은 46M params 라 fp32 로도 8GB VRAM 여유 충분. cuda 도
fp32 default 로 바꾸고, CHRONOS_DTYPE=bf16|fp16 env 로 강제 가능하게 둠.

005930 h=1 예측 409 의 실제 원인이 이거였음 (LAN 으로 직접 확인).
2026-05-23 16:03:31 +09:00
claude-owner
e0edc8f1e3 feat: 예측 실패 원인 노출 + /health/models 진단 + restart-ci.bat
사금향님이 만난 409 'both chronos & lgbm failed' 에러가 원인을 안 보여줘서
디버깅 어려웠음. 세 군데 보강:

1. ensemble.py: 두 모델 다 실패 시 chronos/lgbm 각각의 실제 에러 원문
   (type:message) 을 RuntimeError 메시지에 포함. predict.py 가 409 detail
   로 그대로 노출하므로 브라우저에서 바로 원인 확인 가능. LGBM 가 None
   반환 (체크포인트 없음) 인 경우도 'model checkpoint not found' 로 명시.

2. /health/models 엔드포인트 추가:
   - chronos.ping() — lazy load 시도 + 디바이스/모델명 반환
   - LGBM_MODEL_DIR 의 *.pkl 개수와 샘플 8개 파일명 반환. cold start
     (체크포인트 0개) 면 'no_checkpoints' 상태로 알림.

3. restart-ci.bat 추가 — restart.bat 에서 pause 빼고 종료 코드로만 알리는
   SSH 비대화형 친화 버전. 일반 사용은 그대로 restart.bat.
2026-05-23 15:42:44 +09:00
claude-owner
44873ddb39 fix(chart): "오늘" 표시를 차트 본체 마커 → 시간축 아래 캡션으로 이동
lightweight-charts 의 timeScale tick 라벨에 직접 텍스트를 끼우는 공식 API 가
없어서, 차트 컨테이너 바로 아래에 작은 캡션 (• 오늘 · YYYY-MM-DD (요일)) 으로
표시. 사용자 요청 ("차트에 표시 말고 아래 날짜 표시하는 곳에").
2026-05-23 02:49:36 +09:00
claude-owner
e610599879 fix(chart): 10분봉 페이지네이션, 장외 캐시, 예측 horizon 캡 일치
reviewer 지적사항 반영:

1. KIS 분봉이 한 번에 30개만 와서 10분봉이 최대 3개만 나오던 문제 →
   fetch_minute_range() 추가. FID_INPUT_HOUR_1 을 30분씩 후퇴시키며
   페이지네이션, 중복 ts 자연 dedupe, max_pages=20 으로 무한루프 방지.
   _ensure_intraday_fresh 는 last_ts+1m ~ now 빈 구간만 채우므로 평소엔
   1~2 페이지로 끝남.

2. 장외/주말에 매번 KIS 를 때리던 문제 →
   - 주말: 'weekend' 반환, KIS 안 부름 (분봉 endpoint 는 당일만 지원)
   - 평일 장외 + 오늘 데이터 있음: 'cached_closed' 반환
   - 장중 + 10분 이내: 'fresh' 반환
   토큰/조회 제한 다시 밟지 않음.

3. 프론트 horizon 입력 60 vs 백엔드 30 불일치 →
   PredictionPanel 의 cap 을 30 으로 맞춤. 백엔드 predict.py 의 학습/검증
   범위와 일치. placeholder 도 '1~30' 으로 명시.
2026-05-23 01:41:43 +09:00
claude-owner
0a5c634680 feat(chart): 10m 실시간 / 일·주·월 토글 / 오늘 마커 / 예측 거래일 선택
- backend/app/api/chart.py: interval=10m|1d|1w|1mo. 10m 은 ohlcv_1m 을
  time_bucket(10min) 으로 집계, stale(>10분) 이면 KIS 분봉 fetch 후 재조회.
  1w/1mo 는 ohlcv_daily 를 date_trunc 로 집계. today 필드 추가.
- backend/app/fetch/kis.py: fetch_minute_price() 추가 (tr_id FHKST03010200).
  KIS 응답 KST 시각을 tz-aware datetime 으로 변환, 오름차순 정렬.
- web/lib/api.ts: ChartInterval 타입, getChart(interval), predict(horizons[]).
- web/components/StockChart.tsx: 10m 이면 timeVisible. 일·주·월에서 오늘
  화살표 마커 표시. ISO datetime 도 파싱.
- web/components/PredictionPanel.tsx: 단기/중기/장기 프리셋 + 사용자 직접
  지정 (예: 1,2,3,7). API 에 horizons 배열 전달.
- web/app/[code]/page.tsx: interval 칩 (10분/일/주/월). 10m 일 때 60초마다
  폴링. interval 별 기본 lookback (10m=1, 1d=180, 1w=730, 1mo=1825).
2026-05-23 01:34:29 +09:00
claude-owner
928c2160f9 feat(kis): 토큰 디스크 캐시 + restart.bat env 재로드
토큰 발급 1분/1일 제한 (EGW00133 등 403) 회피:
- _load_disk_cache / _save_disk_cache 로 /app/.cache/kis_token.json 영속화
  · ./backend:/app 바인드 마운트로 호스트 backend/.cache/ 에 저장
  · backend/.cache/ 는 .gitignore 에 이미 포함됨 (secret 비커밋)
  · app_key prefix 캐시 무효화 키 (.env 갱신 시 자동 폐기)
  · atomic write (tmp → rename) + 0600 권한
- get_token: 메모리 → 디스크 → 신규 발급 순으로 fallback
- 컨테이너 재기동해도 24시간 유효 토큰 재사용 → 발급 폭주 방지

restart.bat:
- restart → up -d --force-recreate --no-deps backend web
  · restart 는 env_file 재로드 안 함 (.env 의 KIS_APP_KEY 변경이 무시됨)
  · up -d 는 새 인스턴스 생성하며 env_file 다시 읽음
  · --no-deps 로 db 는 절대 건드리지 않음 (postgres_data 영속)
2026-05-23 01:22:32 +09:00
tkrmagid
78388d347e fix(web): API base 를 페이지 host 로 동적 해석 (LAN 접속 대응)
리뷰어 지적: docker-compose 가 NEXT_PUBLIC_API_BASE=http://localhost:8000 을
주입해서 클라이언트 번들에 localhost 가 inline 됨. 사금향 게임컴 (192.168.10.13)
브라우저에선 동작하지만, 같은 내부망의 다른 PC 또는 외부 검증자가
http://192.168.10.13:3000 에 접속하면 fetch 가 그 PC 의 localhost:8000 으로 가서
연결 실패. 백엔드는 정상인데도 '검색 결과 없음' 으로 보임.

해석 우선순위:
  1) NEXT_PUBLIC_API_BASE 가 비 localhost 값 → 그대로 (프로덕션 도메인 케이스)
  2) 브라우저 → window.location.hostname:8000 (LAN/localhost 자동 대응)
  3) SSR 폴백 → localhost:8000

localhost/127.0.0.1 판별은 //(localhost|127.0.0.1)(?::|$) 정규식 — 'localhost.evil.com'
같은 서브도메인 우회는 매치 안 됨. node 로 7케이스 검증 완료.

web/app 코드만이라 사금향 PC 는 restart.bat 으로 반영 (next dev hot-reload).
2026-05-21 21:52:09 +09:00
tkrmagid
659871118f fix(compose-gpu): MODEL_DEVICE 를 .env 로 덮어쓸 수 있게 변경
이전: MODEL_DEVICE: cuda (하드코딩) → .env 에 MODEL_DEVICE=cpu 두어도
compose environment 가 env_file 보다 우선이라 무시됨.

수정: MODEL_DEVICE: ${MODEL_DEVICE:-cuda} — 셸 env 또는 .env 의 값이
있으면 그 값, 없으면 기본 cuda.

용도: FinBERT/Chronos 가 GPU 에서 'no kernel image is available for
execution on the device' (PyTorch cu121 / RTX 3070 Ti 호환 이슈) 가 날 때
.env 에 MODEL_DEVICE=cpu 두고 backend 만 recreate 하면 CPU 폴백.

  echo MODEL_DEVICE=cpu>> .env
  docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d backend
2026-05-21 21:32:54 +09:00
tkrmagid
bd47198088 fix(backend): setuptools<80 핀 (pykrx 가 pkg_resources 필요)
증상 — 사금향 게임컴 (192.168.10.13) 백엔드 진단으로 확정:
  POST /api/refresh/005930 → pykrx_ohlcv: failed
    error: 'No module named pkg_resources'

root cause: setuptools 80.0.0 (2025-04) 이 pkg_resources 모듈을 제거함.
Dockerfile 의 'pip install --upgrade ... setuptools ...' 가 최신 (80+) 을
설치 → pykrx 의 'import pkg_resources' 가 import 실패.

해결:
  1) bootstrap 단계에서 'pip install setuptools<80' 명시 핀
  2) reqs.txt 설치 후에도 동일 핀 반복 (transitive upgrade 차단)
  3) 빌드 단계에서 'import pkg_resources' 확인 — 깨지면 빌드 실패시켜
     런타임에서 발견되는 일 없도록 함

이 패치 후 KIS (이미 ok 였음) 와 함께 pykrx ohlcv/trading_value 가 정상
동작 → chart 자동 충전 (fa817b3) 이 의도대로 데이터 채워 넣음.

별개 이슈 (이 커밋 범위 밖): FinBERT/Chronos 가 RTX 3070 Ti 에서
'no kernel image is available for execution on the device' 발생.
임시 회피: .env 에 MODEL_DEVICE=cpu 추가.
2026-05-21 21:30:01 +09:00
tkrmagid
fa817b31e4 feat(chart): ohlcv_daily 비어있으면 자동 refresh
문제: symbols 시드 후 첫 방문 시 ohlcv_daily 가 비어있어서
  - 차트가 빈 캔버스로 렌더링됨
  - 예상차트 보기 → 409 'no ohlcv_daily for {code}; refresh first'
daily_batch (16:00 KST) 또는 수동 POST /api/refresh/{code} 만이 OHLCV 를
채우는 구조였는데, 첫 방문 사용자는 둘 다 트리거 안 됨.

수정: GET /api/chart/{code} 가 ohlcv 0행 + symbol 존재 케이스에서
refresh_code 를 동기 호출 (pykrx OHLCV + trading_value + news + finbert)
후 재조회. idempotent 라 다음 방문부터는 캐시 히트.

lookback_days = max(days, 365) — 차트 요청 범위 + Chronos/LightGBM
학습용 과거 시계열 마진. 첫 호출 ~10-30s, 이후는 즉시.

KRX 가 도커망에서 막힌 환경이면 refresh 가 실패 status 로 끝나고
ohlcv 는 여전히 비어있음 — 기존 동작과 동일 (graceful).
2026-05-21 21:10:44 +09:00
tkrmagid
96b7afd443 fix(restart.bat): backend/web 만 재시작 (db 제외)
리뷰어 지적: docker compose restart 는 서비스명 생략시 db 까지 같이 재시작한다.
db 까지 같이 내려가면 backend lifespan 부팅 시드가 db healthcheck 와 무관하게
실행돼서 race 로 seed 가 다시 실패할 수 있다 (restart 는 depends_on.condition:
service_healthy 를 지키지 않음). db 는 postgres_data 볼륨에 상태가 영속이라
재시작할 이유도 없음.

추가: 실행중 컨테이너 체크도 backend/web 기준으로 좁힘 — db 만 떠있고
backend 가 down 인 경우 통과되던 버그 수정.
2026-05-21 21:05:57 +09:00
tkrmagid
89651251a4 feat: restart.bat 추가 (재빌드 없이 컨테이너만 재시작)
build.bat 은 이미지 재빌드 포함 — Dockerfile / pyproject.toml / package*.json /
compose 설정 같은 의존성/이미지 구성이 바뀌었을 때 사용.

restart.bat 은 docker compose restart 만 호출 — backend/app/, web/app/ 안의
코드만 바뀐 경우 사용. 바인드 마운트 (./backend:/app, ./web:/app) 덕에 새 코드가
즉시 컨테이너에서 보이고, 재시작으로 lifespan 부팅 시드도 재실행된다.

사용 가이드:
  git pull 후 변경 파일이
    - pyproject.toml / Dockerfile / package*.json → build.bat
    - app/ 코드만                                  → restart.bat
2026-05-21 21:03:16 +09:00
tkrmagid
296bd6dccd fix(seed): SEED 10종목 항상 보장 + KRX 실패 graceful degrade + 수동 reseed API
문제: 빌드/기동은 성공했는데 종목 검색 결과 빈 화면. 원인은 시드 트랜잭션
구조 — `_fetch_market_listing(KRX)` 가 `with engine.begin()` 블록 *밖*에서
호출되고, 컨테이너에서 KRX 서버 접근이 실패하거나 단일 ticker 처리 중
한 곳이라도 예외가 나면 전체 트랜잭션이 롤백되어 SEED_TICKERS 10개조차
들어가지 않음.

수정:
- `_upsert_seed_tickers()` 분리. SEED 10종목 전용 트랜잭션. 네트워크 무관.
- `seed_symbols()` 가 (1) SEED 먼저 → (2) KRX 시장별 fetch (try-per-market)
  → (3) 전 종목 bulk upsert (별도 트랜잭션) 순서로 동작. KRX fetch 실패해도
  SEED 는 살아남음.
- `main.py` lifespan bootstrap 이 SEED upsert 를 매 부팅마다 무조건 실행
  (10회, ms 단위, idempotent). count <= 10 일 때만 KRX 전 종목 fetch.
- 새 admin 엔드포인트 `POST /api/refresh/seed/symbols` — 컨테이너 재기동
  없이 시드 재시도 가능.

이번 사례 (사금향 PC) 의 복구 절차:
    curl -X POST http://localhost:8000/api/refresh/seed/symbols
이후 검색에 SEED 10 종목은 즉시 떠야 함. KRX 가 막힌 환경이면 markets={0,0}
가 응답에 찍히고, 그래도 SEED 만으로 핵심 동작은 가능.
2026-05-21 02:31:49 +09:00
tkrmagid
2c42c1151c fix(backend): accelerate 0.30.1 → 0.34.2 (chronos-forecasting 호환)
chronos-forecasting 1.4.1 의 의존성: `accelerate>=0.32,<1`.
기존 핀 `accelerate==0.30.1` 이 해당 범위 밖이라 pip 가
ResolutionImpossible 로 실패했음.

검증: python:3.11-slim 컨테이너에서
  pip install --dry-run -r reqs.txt (torch==2.3.1 포함, 30개 deps 전체)
exit 0. resolve 결과:
  chronos-forecasting 1.4.1
  transformers 4.41.2
  tokenizers 0.19.1
  accelerate 0.34.2
  torch 2.3.1
2026-05-21 01:48:13 +09:00
tkrmagid
9c7c02703a fix(backend): pip/uvicorn 을 python3.11 인터프리터에 강제 바인딩
Ubuntu 22.04 의 python3-pip 패키지는 python3.10 용이라, /usr/local/bin/python
만 3.11 로 심볼릭 링크해도 `pip`/`pip3` 명령은 여전히 3.10 의 site-packages
에 의존성을 설치한다. pyproject.toml 이 >=3.11 을 선언했으므로 런타임
인터프리터 불일치 위험.

수정:
- apt 에서 python3-pip 제거. get-pip.py 로 python3.11 전용 pip 를 부트스트랩.
- 모든 pip 호출을 `python -m pip` 로 통일.
- 직후 `RUN python -V && python -m pip -V` sanity check 추가 — 빌드 로그에
  인터프리터 버전이 박혀서 추후 불일치 의심 시 즉시 확인 가능.
- CMD 도 `python -m uvicorn` 으로 변경 — 콘솔 스크립트 shebang 이 아니라
  명시적으로 3.11 인터프리터에서 실행됨을 보장.

검증 (별도 컨테이너에서 nvidia/cuda:12.1.1 베이스로 동일 레이어 실행):
  Python 3.11.0rc1
  pip 26.1.1 from /usr/local/lib/python3.11/dist-packages/pip (python 3.11)
2026-05-20 23:53:25 +09:00
tkrmagid
e08f3b0765 fix(backend): Dockerfile editable install 제거 + PYTHONPATH
기존 `pip install -e .` 두 줄은 두 가지 이유로 실패:
1. `app/` 가 COPY 되기 전 단계라 setuptools packages.find 결과가
   비어 editable 빌드가 무의미.
2. Ubuntu 22.04 의 기본 pip 가 PEP 660 build_editable hook 을
   요구하지만, build isolation 환경의 setuptools 가 노출 안 함
   ("build backend is missing the 'build_editable' hook").

수정:
- editable install 자체를 제거. 런타임은 PYTHONPATH=/app 으로
  `app.*` import 가 동작하므로 프로젝트를 wheel 로 설치할 필요 없음.
- 의존성은 tomllib 로 pyproject.toml 에서 직접 추출해 단일 `pip
  install -r` 로 설치. ML 휠 (chronos/lgbm/transformers ...) 레이어
  캐싱이 pyproject.toml 단위로 유지됨 → app 코드 변경시 재빌드 회피.
- COPY app ./app 은 deps 설치 뒤로 유지 (캐시 분리).
2026-05-20 23:42:58 +09:00
tkrmagid
eb56025d9c chore(web): package-lock.json + next-env.d.ts 동기화
`web/package.json` 의 next/eslint-config-next ^14.2.33 상향 (6c79230) 에
맞춰 lockfile 갱신. npm 이 resolve 한 실제 버전은 14.2.35 (14.2.x 최신
patched). 자동 생성되는 next-env.d.ts 의 문서 URL 한 줄도 함께 동기화.

검증:
- `npm install --package-lock-only` 통과
- `npm run typecheck` 통과
- `npm run build` 통과 (Next.js 14.2.35, 4 페이지 정상 생성)
2026-05-20 23:37:54 +09:00
tkrmagid
6c792305a9 fix(web): next + eslint-config-next ^14.2.33 보안 패치
Next.js 2025-12-11 보안 권고에 따라 14.2.x release line 의
최신 patched 버전으로 상향. 14.2.3 → ^14.2.33.

ref: https://nextjs.org/blog/security-update-2025-12-11
2026-05-20 23:33:28 +09:00
tkrmagid
5e6ce11491 feat(weights): shadow chronos/lgbm 예측 + ensemble_weights 자동 보정
- ensemble.predict() 가 chronos_raw / lgbm_raw 를 함께 반환
- predict_and_store() 가 매 호출마다 3종 행 적재:
    model='ensemble' (user_triggered=인자)
    model='chronos'  (user_triggered=FALSE, shadow)
    model='lgbm'     (user_triggered=FALSE, shadow)
- retrain_weekly.adjust_weights(): 최근 30일 prediction_outcomes 의
  chronos vs lgbm hit_rate 로 ensemble_weights upsert
    w_chronos = clamp(0.1, hr_c/(hr_c+hr_l), 0.9), w_lgbm = 1 - w_chronos
  모델별 표본 < 10 이면 기본값(0.6/0.4) 유지
- API 응답에 saved_shadow_ids 추가 (TS 타입도 동기화)
- README: 동작 모델 메모 섹션을 실제 구현과 일치하도록 갱신

리뷰어 지적 3번 (ensemble_weights 가 영원히 갱신 안됨, upsert_weights 미호출) 해결.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:27:21 +09:00
tkrmagid
0af556396e fix(match): 주말/공휴일 이월 매칭 (target_date <= today + 최초 거래일 종가)
- match_for_date(d) → match_up_to(today) 로 시맨틱 변경: target_date == d
  대신 target_date <= today AND outcomes 미존재 전부 후보로
- 각 후보마다 ohlcv_daily 에서 target_date 이상 today 이하 범위의 최초
  거래일 행을 actual_close 로 매칭 → 주말/공휴일 자동 이월
- user_triggered 필터 제거: chronos/lgbm shadow 행도 함께 매칭됨
- prediction_outcomes.target_date 에는 실제 매칭된 거래일을 기록
- 하위 호환: match_for_date(d) 는 match_up_to(d) 별칭으로 유지

리뷰어 지적 2번 (공휴일/주말이면 target_date 일치 행이 영원히 미매칭) 해결.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:27:09 +09:00
tkrmagid
f84b460e54 fix(bootstrap): backend lifespan 에서 DB migrate + symbols 시드 자동화
- main.py 의 lifespan 시작 시 idempotent migration 적용 + symbols 비어있으면 pykrx 로 전 종목 시드
- BOOTSTRAP_DISABLED=1 / SCHEDULER_DISABLED=1 env 로 비활성 가능 (테스트 용)
- 실패해도 서버는 뜨고 /health/db 가 진단 제공

리뷰어 지적 1번 (cold-start 시 /api/refresh 404) 해결.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:26:59 +09:00
86 changed files with 9871 additions and 671 deletions

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ build/
# Models / artifacts (downloaded HF caches, trained LGBM)
backend/artifacts/
backend/.cache/
backend/data/
.huggingface/
# Node

View File

@@ -155,9 +155,13 @@ stock_chart_site/
## 동작 모델 메모
- 예측 트리거: 사용자가 "예상차트 보기" 누른 종목에 대해 즉시 inference. 결과는 `predictions(user_triggered=TRUE)` 로 저장.
- 매칭 배치: 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30, 종가 확정 후 16:00 ~ 16:30 KST 사이) `user_triggered=TRUE` 인 예측 중 `target_date == 오늘 거래일`인 행들에 대해 실제 종가/방향과 매칭 → `prediction_outcomes` 적재. 주말/공휴일이면 다음 거래일로 이월.
- 주간 02:00 (일요일): 종목/모델별 최근 30일 hit rate 기반으로 앙상블 가중치를 자동 보정. hit rate가 임계 미만이면 LGBM 재학습.
- 예측 트리거: 사용자가 "예상차트 보기" 누른 종목에 대해 즉시 inference. 결과는 세 종류 행으로 적재:
- `model='ensemble'` (user_triggered=TRUE) — UI 가 표시하는 최종 예측
- `model='chronos'` (user_triggered=FALSE, shadow) — Chronos 단독 성능 추적용
- `model='lgbm'` (user_triggered=FALSE, shadow) — LGBM 단독 성능 추적용
- 매칭 배치: 평일 16:30 KST. `target_date <= today AND outcomes 미존재` 인 모든 행에 대해 `target_date` 이상 `today` 이하 범위의 **최초 거래일 종가**를 actual_close 로 사용 → 주말/공휴일 자동 이월. shadow 행도 함께 매칭됨.
- 주간 02:00 (일요일): 시드 10종목 × horizons LGBM 재학습. 최근 30일 prediction_outcomes 의 chronos vs lgbm hit_rate 비교 → `w_chronos = clamp(0.1, hr_c/(hr_c+hr_l), 0.9)` 공식으로 `ensemble_weights` upsert. 모델별 표본이 10 미만이면 기본값(0.6/0.4) 유지.
- DB bootstrap: 백엔드 첫 부팅 시 lifespan 에서 idempotent migration + symbols 시드(비어있을 때만 pykrx 전 종목 적재) 자동 수행. `BOOTSTRAP_DISABLED=1` 로 비활성화 가능.
## 안전/한계

View File

@@ -7,27 +7,55 @@ ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONPATH=/app \
TZ=Asia/Seoul
# Ubuntu 22.04 의 python3-pip 는 python3.10 을 가리키므로 설치하지 않고,
# python3.11 + get-pip.py 로 3.11 전용 pip 를 부트스트랩한다.
# (Debian/Ubuntu 의 시스템 python 은 ensurepip 가 막혀 있어 get-pip.py 가 가장 깔끔함.)
# 이후 모든 호출은 `python -m pip` 로 통일해 인터프리터/스크립트 불일치를 차단.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 python3.11-venv python3-pip \
python3.11 python3.11-venv \
build-essential git curl ca-certificates tzdata \
libgomp1 \
&& ln -sf /usr/bin/python3.11 /usr/local/bin/python \
&& ln -sf /usr/bin/python3.11 /usr/local/bin/python3 \
&& curl -sSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py \
&& python /tmp/get-pip.py \
&& rm /tmp/get-pip.py \
&& python -m pip install --upgrade pip wheel \
&& python -m pip install "setuptools<80" \
&& rm -rf /var/lib/apt/lists/*
# setuptools 80+ 은 pkg_resources 모듈을 제거함. pykrx 가 `import pkg_resources` 를
# 하므로 80 미만으로 핀. 아래 reqs.txt 단계에서 다른 deps 가 setuptools 재upgrade 를
# 트리거하지 않도록 별도 명령으로 고정.
# Sanity check: 이 출력은 빌드 로그에 박혀서 다음에 인터프리터 불일치 의심될 때 즉시 확인 가능.
RUN python -V && python -m pip -V
WORKDIR /app
COPY pyproject.toml ./
# Install PyTorch (CUDA 12.1 wheels) first so the rest of deps don't downgrade it.
RUN pip install --extra-index-url https://download.pytorch.org/whl/cu121 \
RUN python -m pip install --extra-index-url https://download.pytorch.org/whl/cu121 \
torch==2.3.1 torchvision==0.18.1
RUN pip install --no-deps -e . || true
RUN pip install -e .
# Install runtime deps from pyproject.toml WITHOUT installing the project itself.
# - 이전 `pip install -e .` 은 app/ 가 아직 COPY 되기 전이라 packages.find 결과가 비고,
# ubuntu 22.04 기본 pip 의 PEP 660 editable hook 과 충돌해 실패했음.
# - 런타임에는 PYTHONPATH=/app 으로 `app.*` 임포트가 동작하므로 프로젝트 설치 자체가 불필요.
# - deps 만 별도 레이어로 캐시 → 코드 변경 시 ML 휠 재빌드 회피.
RUN python -c "import tomllib; \
deps = tomllib.load(open('pyproject.toml','rb'))['project']['dependencies']; \
open('/tmp/reqs.txt','w').write('\n'.join(deps))" \
&& python -m pip install -r /tmp/reqs.txt \
&& python -m pip install "setuptools<80" \
&& python -c "import pkg_resources; print('pkg_resources OK from', pkg_resources.__file__)"
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# uvicorn 콘솔 스크립트 대신 `python -m uvicorn` 으로 호출 — 3.11 인터프리터에서 실행됨을 보장.
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,26 +1,221 @@
"""차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시).
UI: /code 페이지 첫 로드 시 호출 → lightweight-charts 캔들 데이터로 사용.
UI: /code 페이지 호출 → lightweight-charts 캔들 데이터로 사용.
interval 파라미터로 캔들 단위 선택:
- "10m" : 당일 10분봉. ohlcv_1m 을 time_bucket 으로 10분 단위 집계.
stale (>10분) 이면 KIS inquire-time-itemchartprice 로 즉시 보충.
- "1d" : 일봉. ohlcv_daily 직접 조회. 비어있으면 pykrx auto-refresh.
- "1w" : 주봉. ohlcv_daily 를 date_trunc('week') 로 집계.
- "1mo" : 월봉. ohlcv_daily 를 date_trunc('month') 로 집계.
10m 외에는 date 필드가 'YYYY-MM-DD' ISO date 문자열,
10m 일 때는 'YYYY-MM-DDTHH:MM:SS' ISO datetime (KST) 으로 통일.
"""
from __future__ import annotations
from datetime import date, timedelta
import logging
from datetime import date, datetime, time as dtime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from app.db.connection import get_engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chart", tags=["chart"])
ALLOWED_INTERVALS = ("10m", "1d", "1w", "1mo")
KST = timezone(timedelta(hours=9))
def _query_ohlcv_daily(conn, code: str, start: date, end: date):
return conn.execute(
text(
"""
SELECT date, open, high, low, close, volume
FROM ohlcv_daily
WHERE code = :c AND date BETWEEN :s AND :e
ORDER BY date
"""
),
{"c": code, "s": start, "e": end},
).all()
def _query_ohlcv_bucketed(conn, code: str, start: date, end: date, trunc: str):
"""1d → 1w/1mo 집계. date_trunc 로 bucket 잡고, 첫/마지막/최고/최저/합 집계.
open=bucket 첫 거래일 시가, close=마지막 거래일 종가. PostgreSQL window 함수로 구한다.
"""
return conn.execute(
text(
f"""
WITH base AS (
SELECT date_trunc(:trunc, date)::date AS bucket,
date, open, high, low, close, volume
FROM ohlcv_daily
WHERE code = :c AND date BETWEEN :s AND :e
),
ranked AS (
SELECT bucket, date, open, high, low, close, volume,
ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY date ASC) AS rn_first,
ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY date DESC) AS rn_last
FROM base
)
SELECT bucket AS date,
MAX(open) FILTER (WHERE rn_first = 1) AS open,
MAX(high) AS high,
MIN(low) AS low,
MAX(close) FILTER (WHERE rn_last = 1) AS close,
SUM(volume) AS volume
FROM ranked
GROUP BY bucket
ORDER BY bucket
"""
),
{"c": code, "s": start, "e": end, "trunc": trunc},
).all()
def _query_ohlcv_10m(conn, code: str, start_ts: datetime, end_ts: datetime):
"""ohlcv_1m → 10분봉. TimescaleDB time_bucket 으로 10분 단위 집계.
first()/last() 는 TimescaleDB 의 집계함수.
"""
return conn.execute(
text(
"""
SELECT time_bucket(INTERVAL '10 minutes', ts) AS bucket,
first(open, ts) AS open,
MAX(high) AS high,
MIN(low) AS low,
last(close, ts) AS close,
SUM(volume) AS volume
FROM ohlcv_1m
WHERE code = :c AND ts >= :s AND ts < :e
GROUP BY bucket
ORDER BY bucket
"""
),
{"c": code, "s": start_ts, "e": end_ts},
).all()
def _upsert_ohlcv_1m(conn, code: str, rows: list[dict]) -> int:
"""KIS 분봉 응답을 ohlcv_1m 에 UPSERT. 같은 (code, ts) 는 덮어쓰기 (장중 갱신용)."""
if not rows:
return 0
conn.execute(
text(
"""
INSERT INTO ohlcv_1m (code, ts, open, high, low, close, volume)
VALUES (:code, :ts, :open, :high, :low, :close, :volume)
ON CONFLICT (code, ts) DO UPDATE SET
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
volume = EXCLUDED.volume
"""
),
[{"code": code, **r} for r in rows],
)
return len(rows)
def _intraday_window_today() -> tuple[datetime, datetime]:
"""오늘 KST 의 장 시간대 윈도우 (08:50 ~ 16:00). 토/일은 직전 영업일."""
now = datetime.now(KST)
d = now.date()
# 주말이면 직전 금요일로
while d.weekday() >= 5:
d -= timedelta(days=1)
start = datetime.combine(d, dtime(8, 50), tzinfo=KST)
end = datetime.combine(d, dtime(16, 0), tzinfo=KST)
return start, end
def _ensure_intraday_fresh(conn, code: str) -> str:
"""오늘 윈도우의 ohlcv_1m 을 필요한 만큼만 KIS 에서 보충.
분기:
- 주말: KIS 분봉 endpoint 는 "당일" 만 지원 → 호출하지 않음. 'weekend'.
- 장외 (평일 09:00 이전 또는 15:35 이후) + 이미 오늘 데이터 있음: 'cached_closed'.
(마감 후엔 데이터 늘지 않으므로 KIS 호출 의미 없음)
- 장중 + last_ts 가 10분 이내: 'fresh' (DB 만 읽음)
- 그 외 (장중 stale / 장 막 끝나서 마지막 마감 데이터 1회 필요 / 캐시 비어있음):
last_ts+1m ~ now 사이의 빈 구간을 fetch_minute_range 로 페이지네이션 채움.
DB 캐시가 그날 데이터를 이미 갖고 있으면 자연히 호출 1~2 페이지로 끝.
Returns: 'fresh' | 'refreshed' | 'cached_closed' | 'weekend' |
'skipped_missing_key' | 'failed' | 'no_data'
"""
now = datetime.now(KST)
if now.weekday() >= 5:
return "weekend"
win_start, win_end = _intraday_window_today()
last_ts = conn.execute(
text(
"SELECT MAX(ts) FROM ohlcv_1m WHERE code = :c AND ts >= :s AND ts < :e"
),
{"c": code, "s": win_start, "e": win_end},
).scalar()
market_open = dtime(9, 0)
market_close_buffer = dtime(15, 35)
in_session = market_open <= now.time() <= market_close_buffer
# 장외이고 이미 오늘 데이터 있음 → 추가 호출 불필요
if not in_session and last_ts is not None:
return "cached_closed"
# 장중 + 10분 이내 갱신 → 추가 호출 불필요
if in_session and last_ts is not None and (now - last_ts) < timedelta(minutes=10):
return "fresh"
# fetch 윈도우 = [last_ts+1m or win_start, min(now, win_end)]
fetch_to = min(now, win_end)
if last_ts is not None and last_ts >= win_start:
fetch_from = last_ts + timedelta(minutes=1)
else:
fetch_from = win_start
if fetch_from >= fetch_to:
return "fresh"
try:
from app.fetch.kis import SkippedMissingKey, fetch_minute_range
except Exception: # noqa: BLE001
return "failed"
try:
rows = fetch_minute_range(code, fetch_from, fetch_to)
except SkippedMissingKey:
return "skipped_missing_key"
except Exception: # noqa: BLE001
logger.exception("intraday refresh failed for %s", code)
return "failed"
if not rows:
return "no_data"
_upsert_ohlcv_1m(conn, code, rows)
conn.commit()
return "refreshed"
@router.get("/{code}")
def get_chart(
code: str,
days: int = Query(default=180, ge=10, le=3650),
days: int = Query(default=180, ge=1, le=3650),
interval: str = Query(default="1d"),
include_sentiment: bool = Query(default=True),
include_trading_value: bool = Query(default=True),
) -> dict:
if interval not in ALLOWED_INTERVALS:
raise HTTPException(status_code=400, detail=f"interval must be one of {ALLOWED_INTERVALS}")
eng = get_engine()
end = date.today()
start = end - timedelta(days=days)
@@ -32,31 +227,58 @@ def get_chart(
if not symbol:
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
ohlcv_rows = conn.execute(
text(
"""
SELECT date, open, high, low, close, volume
FROM ohlcv_daily
WHERE code = :c AND date BETWEEN :s AND :e
ORDER BY date
"""
),
{"c": code, "s": start, "e": end},
).all()
ohlcv = [
{
"date": str(r[0]),
"open": float(r[1]) if r[1] is not None else None,
"high": float(r[2]) if r[2] is not None else None,
"low": float(r[3]) if r[3] is not None else None,
"close": float(r[4]) if r[4] is not None else None,
"volume": int(r[5]) if r[5] is not None else None,
}
for r in ohlcv_rows
]
ohlcv: list[dict] = []
intraday_status: str | None = None
if interval == "10m":
intraday_status = _ensure_intraday_fresh(conn, code)
win_start, win_end = _intraday_window_today()
rows = _query_ohlcv_10m(conn, code, win_start, win_end)
ohlcv = [
{
# KST aware datetime → ISO datetime. 프론트에서 Date 파싱.
"date": (r[0].astimezone(KST) if r[0].tzinfo else r[0].replace(tzinfo=KST))
.strftime("%Y-%m-%dT%H:%M:%S"),
"open": float(r[1]) if r[1] is not None else None,
"high": float(r[2]) if r[2] is not None else None,
"low": float(r[3]) if r[3] is not None else None,
"close": float(r[4]) if r[4] is not None else None,
"volume": int(r[5]) if r[5] is not None else None,
}
for r in rows
]
else:
if interval == "1d":
rows = _query_ohlcv_daily(conn, code, start, end)
elif interval == "1w":
rows = _query_ohlcv_bucketed(conn, code, start, end, "week")
else: # "1mo"
rows = _query_ohlcv_bucketed(conn, code, start, end, "month")
if not rows and interval == "1d":
# 첫 방문 → pykrx auto-refresh.
try:
from app.pipelines.refresh_one import refresh_code
logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code)
refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365))
rows = _query_ohlcv_daily(conn, code, start, end)
except Exception: # noqa: BLE001
logger.exception("chart: auto-refresh failed for %s", code)
ohlcv = [
{
"date": str(r[0]),
"open": float(r[1]) if r[1] is not None else None,
"high": float(r[2]) if r[2] is not None else None,
"low": float(r[3]) if r[3] is not None else None,
"close": float(r[4]) if r[4] is not None else None,
"volume": int(r[5]) if r[5] is not None else None,
}
for r in rows
]
sentiment: list[dict] = []
if include_sentiment:
if include_sentiment and interval != "10m":
try:
s_rows = conn.execute(
text(
@@ -79,11 +301,10 @@ def get_chart(
for r in s_rows
]
except Exception: # noqa: BLE001
# v_sentiment_daily 뷰 아직 없을 수 있음 (마이그레이션 미실행)
sentiment = []
trading: list[dict] = []
if include_trading_value:
if include_trading_value and interval != "10m":
tv_rows = conn.execute(
text(
"""
@@ -109,7 +330,10 @@ def get_chart(
"code": symbol[0],
"name": symbol[1],
"market": symbol[2],
"interval": interval,
"intraday_status": intraday_status,
"range": {"from": str(start), "to": str(end)},
"today": date.today().isoformat(),
"ohlcv": ohlcv,
"sentiment": sentiment,
"trading_value": trading,

View File

@@ -0,0 +1,63 @@
"""기업 펀더멘털 (시총/PER/PBR/EPS/BPS/배당) API.
pykrx 한 번 호출 = 종목당 ~1~2초. 인메모리 daily 캐시(get_fundamentals) 가 처리.
SkippedMissingKey 같은 거 없음 — pykrx 는 무인증 KRX 스크래핑.
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, HTTPException
from sqlalchemy import text
from app.db.connection import get_engine
from app.fetch.fundamentals import get_fundamentals
router = APIRouter(prefix="/api/fundamentals", tags=["fundamentals"])
@router.get("/{code}")
def code_fundamentals(code: str) -> dict:
"""code 의 최신 영업일 fundamentals 스냅샷."""
eng = get_engine()
with eng.connect() as conn:
sym = conn.execute(
text("SELECT code, name, market FROM symbols WHERE code = :c"),
{"c": code},
).first()
if not sym:
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
f = get_fundamentals(code)
if f is None:
return {
"code": sym[0],
"name": sym[1],
"market": sym[2],
"status": "no_data",
"snapshot_date": None,
"market_cap": None,
"shares_outstanding": None,
"per": None,
"pbr": None,
"eps": None,
"bps": None,
"div": None,
"dvd_yield": None,
}
return {
"code": sym[0],
"name": sym[1],
"market": sym[2],
"status": "ok",
"snapshot_date": f.snapshot_date.isoformat(),
"market_cap": f.market_cap,
"shares_outstanding": f.shares_outstanding,
"per": f.per,
"pbr": f.pbr,
"eps": f.eps,
"bps": f.bps,
"div": f.div,
"dvd_yield": f.dvd_yield,
}

223
backend/app/api/markets.py Normal file
View File

@@ -0,0 +1,223 @@
"""시장 overview API.
GET /api/markets/movers
?market=KOSPI|KOSDAQ|ALL (기본 ALL)
?limit=10 (각 카테고리 별 상위 N)
응답 (4 카테고리):
- gainers : 등락률 상위 (전일종가 대비 상승%)
- losers : 등락률 하위 (하락%)
- by_volume : 거래량 상위 (당일 volume)
- by_trading_value: 거래대금 상위 (close × volume, 원 단위)
ohlcv_daily 에서 가장 최근 2 거래일을 잡아 비교한다. 데이터가 한 종목당
2 일치 이상 없을 때는 그 종목은 자연 누락된다.
GET /api/markets/related/{code}
?limit=8
같은 market 에서 등락률이 비슷한 (또는 가장 활발한) 종목 N 개.
sector 컬럼은 비어있는 경우가 많아, market 동질성만으로 묶는다.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from app.db.connection import get_engine
router = APIRouter(prefix="/api/markets", tags=["markets"])
def _movers_sql(where_market: str) -> str:
"""ohlcv_daily 에서 종목별 최신 2일 비교 + 카테고리 분류.
market filter 는 symbols 테이블에서 적용. WITH … AS 안에 :market 바인딩이
있는 단일 쿼리.
"""
return f"""
WITH last_dates AS (
SELECT DISTINCT date
FROM ohlcv_daily
ORDER BY date DESC
LIMIT 2
),
ordered AS (
SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last_dates
),
symb AS (
SELECT code, name, market FROM symbols
WHERE active = TRUE {where_market}
),
latest AS (
SELECT o.code, o.close AS close0, o.volume AS volume0
FROM ohlcv_daily o, ordered
WHERE o.date = ordered.d0
),
prev AS (
SELECT o.code, o.close AS close1
FROM ohlcv_daily o, ordered
WHERE o.date = ordered.d1
)
SELECT s.code, s.name, s.market,
l.close0 AS close,
p.close1 AS prev_close,
l.volume0 AS volume,
CASE WHEN p.close1 > 0
THEN (l.close0 - p.close1) / p.close1 * 100
ELSE NULL
END AS pct_change,
(l.close0 * l.volume0) AS trading_value
FROM symb s
JOIN latest l ON l.code = s.code
JOIN prev p ON p.code = s.code
WHERE l.close0 IS NOT NULL AND p.close1 IS NOT NULL
"""
@router.get("/movers")
def movers(
market: str = Query(default="ALL", pattern="^(ALL|KOSPI|KOSDAQ)$"),
limit: int = Query(default=10, ge=1, le=50),
) -> dict:
where_market = "" if market == "ALL" else "AND market = :market"
params: dict[str, object] = {"lim": limit}
if market != "ALL":
params["market"] = market
eng = get_engine()
base_sql = _movers_sql(where_market)
with eng.connect() as conn:
gainers = conn.execute(
text(base_sql + " ORDER BY pct_change DESC NULLS LAST LIMIT :lim"),
params,
).all()
losers = conn.execute(
text(base_sql + " ORDER BY pct_change ASC NULLS LAST LIMIT :lim"),
params,
).all()
by_volume = conn.execute(
text(base_sql + " ORDER BY volume DESC NULLS LAST LIMIT :lim"),
params,
).all()
by_trading_value = conn.execute(
text(base_sql + " ORDER BY trading_value DESC NULLS LAST LIMIT :lim"),
params,
).all()
def _row(r) -> dict:
return {
"code": r[0],
"name": r[1],
"market": r[2],
"close": float(r[3]) if r[3] is not None else None,
"prev_close": float(r[4]) if r[4] is not None else None,
"volume": int(r[5]) if r[5] is not None else None,
"pct_change": float(r[6]) if r[6] is not None else None,
"trading_value": float(r[7]) if r[7] is not None else None,
}
return {
"market": market,
"limit": limit,
"gainers": [_row(r) for r in gainers],
"losers": [_row(r) for r in losers],
"by_volume": [_row(r) for r in by_volume],
"by_trading_value": [_row(r) for r in by_trading_value],
}
@router.get("/indices")
def indices(days: int = Query(default=60, ge=2, le=400)) -> dict:
"""KOSPI / KOSDAQ 지수 N일 종가 시계열.
macro_daily 에 yfinance 가 채우는 'kospi','kosdaq' key 를 그대로 읽음.
채워져 있지 않으면 series 가 빈 배열로 반환되어 프런트가 '데이터 준비 중' 안내.
"""
sql = text(
"""
SELECT date, key, value
FROM macro_daily
WHERE key IN ('kospi','kosdaq')
AND date >= (CURRENT_DATE - (:d || ' day')::interval)
ORDER BY date ASC
"""
)
eng = get_engine()
with eng.connect() as conn:
rows = conn.execute(sql, {"d": days}).all()
series: dict[str, list[dict]] = {"kospi": [], "kosdaq": []}
for r in rows:
d, k, v = r[0], r[1], r[2]
if v is None:
continue
series.setdefault(k, []).append({"date": str(d), "value": float(v)})
def _summary(pts: list[dict]) -> dict:
if not pts:
return {"latest": None, "prev": None, "pct_change": None}
latest = pts[-1]["value"]
prev = pts[-2]["value"] if len(pts) >= 2 else None
pct = ((latest - prev) / prev * 100) if (prev and prev > 0) else None
return {"latest": latest, "prev": prev, "pct_change": pct}
return {
"days": days,
"indices": [
{
"key": "kospi",
"name": "KOSPI",
"points": series["kospi"],
**_summary(series["kospi"]),
},
{
"key": "kosdaq",
"name": "KOSDAQ",
"points": series["kosdaq"],
**_summary(series["kosdaq"]),
},
],
}
@router.get("/related/{code}")
def related(code: str, limit: int = Query(default=8, ge=1, le=30)) -> dict:
"""같은 market 에서 등락률 절대값 기준 가까운 종목 N 개.
sector 컬럼은 시드 단계에서 NULL 인 경우가 많아 market 만으로 동질성을 잡는다.
백엔드가 sector 분류를 채우게 되면 같은 sector 우선 정렬로 강화할 수 있음.
"""
eng = get_engine()
with eng.connect() as conn:
target = conn.execute(
text("SELECT market FROM symbols WHERE code = :c"),
{"c": code},
).first()
if not target:
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
market = target[0]
rows = conn.execute(
text(
_movers_sql("AND market = :market AND code <> :c")
+ " ORDER BY volume DESC NULLS LAST LIMIT :lim"
),
{"market": market, "c": code, "lim": limit},
).all()
return {
"code": code,
"market": market,
"items": [
{
"code": r[0],
"name": r[1],
"market": r[2],
"close": float(r[3]) if r[3] is not None else None,
"prev_close": float(r[4]) if r[4] is not None else None,
"volume": int(r[5]) if r[5] is not None else None,
"pct_change": float(r[6]) if r[6] is not None else None,
}
for r in rows
],
}

View File

@@ -0,0 +1,60 @@
"""호가(orderbook) API — KIS 10단계 호가 스냅샷.
장중에 호출하면 호가 + 잔량 + 현재가가 한 번의 KIS 호출로 따라온다. 실시간 ws 가
아니라 폴링 친화 (프론트가 5~10s 주기로 재호출) 설계 — 표시 전용.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from sqlalchemy import text
from app.db.connection import get_engine
from app.fetch.kis import SkippedMissingKey, fetch_orderbook
router = APIRouter(prefix="/api/orderbook", tags=["orderbook"])
@router.get("/{code}")
def get_orderbook(code: str) -> dict:
"""code 의 10단계 호가 스냅샷.
- symbols 에 없는 코드는 404.
- KIS 키 미설정이면 status="skipped_missing_key" + 빈 호가 (프론트에서 안내).
- KIS 호출 실패는 502.
"""
eng = get_engine()
with eng.connect() as conn:
sym = conn.execute(
text("SELECT code, name, market FROM symbols WHERE code = :c"),
{"c": code},
).first()
if not sym:
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
try:
snap = fetch_orderbook(code)
except SkippedMissingKey:
return {
"code": sym[0],
"name": sym[1],
"market": sym[2],
"status": "skipped_missing_key",
"ts": None,
"current": None,
"prev_close_diff": None,
"prev_close_pct": None,
"asks": [],
"bids": [],
"ask_total": 0,
"bid_total": 0,
}
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"kis orderbook failed: {exc}") from exc
return {
"code": sym[0],
"name": sym[1],
"market": sym[2],
"status": "ok",
**snap,
}

View File

@@ -40,8 +40,11 @@ def predict_endpoint(
try:
hs = tuple(int(x) for x in horizons.split(",") if x.strip())
if not hs or any(h < 1 or h > 30 for h in hs):
raise ValueError("invalid horizons")
# cap 을 252 거래일 (대략 1년) 까지 허용. 30 거래일 너머는 모델 학습 범위 밖이라
# 신뢰성이 떨어지지만, 프론트가 1년 프리셋을 요청하기 때문에 게이트만 풀어둔다.
# 사용자에게는 신뢰구간 폭으로 불확실성이 자연 전달됨.
if not hs or any(h < 1 or h > 252 for h in hs):
raise ValueError("invalid horizons (1..252)")
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"bad horizons: {exc}")

View File

@@ -4,6 +4,10 @@ POST /api/refresh/{code}
body: 없음
query: ?lookback_days=7 (기본)
resp: refresh_one.RefreshReport.to_dict()
POST /api/refresh/seed/symbols
symbols 테이블 강제 재시드 (SEED 10 + KRX 전 종목). 부팅 시 시드가 실패한
경우 컨테이너 재기동 없이 복구하기 위한 admin 엔드포인트.
"""
from __future__ import annotations
@@ -11,6 +15,7 @@ from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from app.db.connection import get_engine
from app.fetch.symbols_seed import seed_symbols
from app.pipelines.refresh_one import refresh_code
router = APIRouter(prefix="/api", tags=["refresh"])
@@ -33,3 +38,37 @@ def refresh_endpoint(
raise HTTPException(status_code=404, detail=f"unknown code: {code} (symbols 테이블에 없음. 시드 필요)")
report = refresh_code(code, name, lookback_days=lookback_days)
return report.to_dict()
@router.post("/refresh/seed/symbols")
def reseed_symbols() -> dict:
"""symbols 테이블 강제 재시드.
호출 예 (Windows cmd):
curl -X POST http://localhost:8000/api/refresh/seed/symbols
KRX 가 주말/장 마감 시간에 비정상 응답을 줄 때도 SEED 10 종목은 항상 보장하므로
엔드포인트는 200 을 돌려준다. 부분 성공 정보는 응답 body 에 담아 사용자가 판단.
"""
try:
report = seed_symbols()
return {
"ok": True,
"inserted": report.inserted,
"updated": report.updated,
"seed_marked": report.seed_marked,
"markets": report.markets,
"static_fallback_used": report.static_fallback_used,
}
except Exception as e: # noqa: BLE001
# seed_symbols 내부에서 다 잡지만, 만에 하나 외부로 새는 예외 (logger 포매터
# 자체 버그 등) 도 200 으로 흡수해서 SEED 10 만이라도 살리는 게 UX 목표.
return {
"ok": False,
"inserted": 0,
"updated": 0,
"seed_marked": 0,
"markets": {},
"static_fallback_used": False,
"error": repr(e)[:300],
}

View File

@@ -14,6 +14,10 @@ def search_symbols(
q: str = Query(..., min_length=1, max_length=40, description="종목명 또는 코드 prefix/부분 일치"),
limit: int = Query(default=20, ge=1, le=100),
seed_only: bool = Query(default=False, description="true 면 학습/배치 대상 10종목만"),
with_sparkline: bool = Query(
default=False,
description="true 면 각 결과에 최근 30 종가 + 전일대비 동봉 (검색 결과 미니차트용)",
),
) -> dict:
"""이름은 trigram + ILIKE, 코드는 prefix 매치.
@@ -21,6 +25,11 @@ def search_symbols(
1) 코드가 정확히 같으면 가장 위
2) 이름 prefix 매치
3) 이름 부분 매치 (trigram similarity)
with_sparkline=true 시:
- 매치된 코드들에 대해 ohlcv_daily 최근 30 거래일 종가 한 번에 조회
- items[*].sparkline (list[float]) + items[*].close + items[*].pct_change 채움
- 데이터 없는 종목은 sparkline=[], close=None
"""
q_norm = q.strip()
if not q_norm:
@@ -61,19 +70,51 @@ def search_symbols(
"lim": limit,
},
).all()
codes = [r[0] for r in rows]
spark_by_code: dict[str, list[float]] = {c: [] for c in codes}
if with_sparkline and codes:
# 최근 30 거래일 종가 — 종목별로 ORDER BY date ASC.
spark_rows = conn.execute(
text(
"""
SELECT code, date, close FROM (
SELECT code, date, close,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY date DESC) AS rn
FROM ohlcv_daily
WHERE code = ANY(:codes) AND close IS NOT NULL
) t
WHERE rn <= 30
ORDER BY code, date ASC
"""
),
{"codes": codes},
).all()
for code, _d, close in spark_rows:
spark_by_code.setdefault(code, []).append(float(close))
def _item(r) -> dict:
code = r[0]
pts = spark_by_code.get(code, []) if with_sparkline else None
out: dict[str, object] = {
"code": code,
"name": r[1],
"market": r[2],
"sector": r[3],
"is_seed": bool(r[4]),
}
if with_sparkline:
out["sparkline"] = pts or []
out["close"] = pts[-1] if pts else None
out["pct_change"] = (
(pts[-1] - pts[-2]) / pts[-2] * 100 if (pts and len(pts) >= 2 and pts[-2]) else None
)
return out
return {
"q": q_norm,
"count": len(rows),
"items": [
{
"code": r[0],
"name": r[1],
"market": r[2],
"sector": r[3],
"is_seed": bool(r[4]),
}
for r in rows
],
"items": [_item(r) for r in rows],
}

217
backend/app/api/themes.py Normal file
View File

@@ -0,0 +1,217 @@
"""테마/카테고리 API.
토스 '주식 골라보기' 의 테마 그리드와 비슷한 결.
정적 매핑(seed/themes.py)을 베이스로, DB 에서 최신 종가/등락만 채워 응답.
GET /api/themes
- 모든 테마 인덱스 (slug/name/description/code_count)
GET /api/themes/{slug}
?limit=20
- 해당 테마의 종목 카드 (종목명/시장/종가/등락률). 거래량 기준 정렬.
- ohlcv_daily 가 아직 안 채워져 있으면 close/pct_change 가 NULL.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from app.db.connection import get_engine
from app.seed.themes import THEMES, theme_by_slug, themes_for_code, themes_index
router = APIRouter(prefix="/api/themes", tags=["themes"])
@router.get("")
def list_themes() -> dict:
return {"items": themes_index(), "total": len(THEMES)}
@router.get("/peer/{code}")
def peer_compare(
code: str,
window: int = Query(default=21, ge=2, le=252),
limit: int = Query(default=5, ge=1, le=20),
) -> dict:
"""동일 테마(들) 내 동종목 비교.
- 대상 종목이 속한 모든 테마의 코드 합집합 (자기 자신 제외) 을 peer 풀로 잡고,
각 종목의 최근 거래일 종가 / window 영업일 전 종가로 누적수익률을 구한다.
- peer 평균, 대상 종목 값, 상/하위 N 개 반환.
- 테마 무소속이면 themes=[] 빈 응답 (404 아님 — UI 가 그냥 패널을 숨기게).
"""
themes = themes_for_code(code)
if not themes:
return {
"code": code,
"window": window,
"themes": [],
"self_pct": None,
"peer_avg_pct": None,
"top": [],
"bottom": [],
}
# 테마 합집합 코드 풀 (자기 자신 제외)
peer_codes: set[str] = set()
for t in themes:
peer_codes.update(t.codes)
peer_codes.discard(code)
pool = [code, *sorted(peer_codes)]
sql = text(
"""
WITH ranked AS (
SELECT code, date, close,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY date DESC) AS rn
FROM ohlcv_daily
WHERE code = ANY(:codes) AND close IS NOT NULL
),
latest AS (
SELECT code, close AS close_now, date AS date_now FROM ranked WHERE rn = 1
),
base AS (
SELECT code, close AS close_base, date AS date_base FROM ranked WHERE rn = :w
)
SELECT s.code, s.name, s.market,
l.close_now, b.close_base,
CASE WHEN b.close_base > 0
THEN (l.close_now - b.close_base) / b.close_base * 100
ELSE NULL
END AS pct
FROM symbols s
LEFT JOIN latest l ON l.code = s.code
LEFT JOIN base b ON b.code = s.code
WHERE s.code = ANY(:codes)
"""
)
eng = get_engine()
with eng.connect() as conn:
rows = conn.execute(sql, {"codes": pool, "w": window}).all()
by_code: dict[str, dict] = {}
for r in rows:
by_code[r[0]] = {
"code": r[0],
"name": r[1],
"market": r[2],
"close": float(r[3]) if r[3] is not None else None,
"base_close": float(r[4]) if r[4] is not None else None,
"pct_change": float(r[5]) if r[5] is not None else None,
}
self_row = by_code.get(code)
self_pct = self_row["pct_change"] if self_row else None
peers = [
v for k, v in by_code.items() if k != code and v["pct_change"] is not None
]
if peers:
peer_avg_pct = sum(p["pct_change"] for p in peers) / len(peers)
peers_sorted = sorted(peers, key=lambda x: x["pct_change"], reverse=True)
top = peers_sorted[:limit]
bottom = peers_sorted[-limit:][::-1]
else:
peer_avg_pct = None
top = []
bottom = []
return {
"code": code,
"name": self_row["name"] if self_row else code,
"window": window,
"themes": [
{"slug": t.slug, "name": t.name, "code_count": len(t.codes)}
for t in themes
],
"self_pct": self_pct,
"peer_avg_pct": peer_avg_pct,
"peer_count": len(peers),
"top": top,
"bottom": bottom,
}
@router.get("/{slug}")
def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict:
th = theme_by_slug(slug)
if th is None:
raise HTTPException(status_code=404, detail=f"unknown theme: {slug}")
codes = list(th.codes)[:limit]
if not codes:
return {
"slug": th.slug,
"name": th.name,
"description": th.description,
"items": [],
}
sql = text(
"""
WITH last2 AS (
SELECT DISTINCT date FROM ohlcv_daily ORDER BY date DESC LIMIT 2
),
bounds AS (SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last2),
latest AS (
SELECT o.code, o.close AS close0, o.volume AS vol0
FROM ohlcv_daily o, bounds WHERE o.date = bounds.d0
),
prev AS (
SELECT o.code, o.close AS close1
FROM ohlcv_daily o, bounds WHERE o.date = bounds.d1
)
SELECT s.code, s.name, s.market,
l.close0 AS close,
p.close1 AS prev_close,
l.vol0 AS volume,
CASE WHEN p.close1 > 0 THEN (l.close0 - p.close1) / p.close1 * 100 ELSE NULL END AS pct_change
FROM symbols s
LEFT JOIN latest l ON l.code = s.code
LEFT JOIN prev p ON p.code = s.code
WHERE s.code = ANY(:codes)
ORDER BY vol0 DESC NULLS LAST, s.name ASC
"""
)
eng = get_engine()
with eng.connect() as conn:
rows = conn.execute(sql, {"codes": codes}).all()
# symbols 테이블에 시드만 있고 codes 일부가 누락된 경우, 그래도 코드/이름 자리는 비워서 카드 표시.
by_code = {r[0]: r for r in rows}
items: list[dict] = []
for c in codes:
r = by_code.get(c)
if r is None:
items.append(
{
"code": c,
"name": c, # symbols 미등재 — 코드로 노출
"market": None,
"close": None,
"prev_close": None,
"volume": None,
"pct_change": None,
}
)
else:
items.append(
{
"code": r[0],
"name": r[1],
"market": r[2],
"close": float(r[3]) if r[3] is not None else None,
"prev_close": float(r[4]) if r[4] is not None else None,
"volume": int(r[5]) if r[5] is not None else None,
"pct_change": float(r[6]) if r[6] is not None else None,
}
)
return {
"slug": th.slug,
"name": th.name,
"description": th.description,
"items": items,
}

98
backend/app/api/views.py Normal file
View File

@@ -0,0 +1,98 @@
"""종목 페이지 익명 조회 카운터.
토스 같은 social-proof — "오늘 N명이 봤어요" 배지를 위한 가벼운 PG 카운터.
- POST /api/views/{code} : views(code, today) UPSERT +1, 새 today_views 반환
- GET /api/views/{code} : today_views + 최근 7일 시계열 반환
dedupe 는 클라이언트(localStorage) 에서 — 하루 1회만 POST. 서버는 단순 +1.
부하 들어와도 같은 코드는 하루 1 increment 라 row 하나에 hot write 가 누적되지만
유즘량 수십~수백 수준이라 lock contention 무시 가능. 진짜 늘면 그때 sharded 카운터로.
"""
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter
from sqlalchemy import text
from app.db.connection import get_engine
router = APIRouter(prefix="/api/views", tags=["views"])
def _kst_today() -> date:
# PG NOW() AT TIME ZONE 'Asia/Seoul' 과 같은 의미로 KST 기준 오늘 날짜.
# 컨테이너 TZ 가 KST 면 date.today() 가 같지만, UTC 면 안 맞음 → 명시.
from datetime import datetime, timezone
utc = datetime.now(timezone.utc)
# KST = UTC+9
return (utc + timedelta(hours=9)).date()
@router.post("/{code}")
def record_view(code: str) -> dict:
"""code 의 오늘 카운터 +1. UPSERT 후 today_views 반환.
symbols 테이블 매칭 검사 안 함 — 카운터 자체는 코드 존재 여부와 독립적으로 동작해야 함
(오타/리스트 빠진 종목도 추적). 다만 빈 code 만 거부.
"""
code = code.strip()
if not code:
return {"ok": False, "today_views": 0}
today = _kst_today()
eng = get_engine()
with eng.begin() as conn:
row = conn.execute(
text(
"""
INSERT INTO symbol_views_daily (code, view_date, views)
VALUES (:c, :d, 1)
ON CONFLICT (code, view_date)
DO UPDATE SET views = symbol_views_daily.views + 1
RETURNING views
"""
),
{"c": code, "d": today},
).first()
today_views = int(row[0]) if row else 0
return {"ok": True, "code": code, "today_views": today_views}
@router.get("/{code}")
def get_views(code: str) -> dict:
"""code 의 오늘 + 최근 7일 일별 시계열.
Returns:
{
"code": "005930",
"today_views": 42,
"last_7d_views": 320,
"trend": [{"date": "YYYY-MM-DD", "views": N}, ...] # 오래된 → 최신
}
"""
code = code.strip()
today = _kst_today()
start = today - timedelta(days=6)
eng = get_engine()
with eng.connect() as conn:
rows = conn.execute(
text(
"""
SELECT view_date, views
FROM symbol_views_daily
WHERE code = :c AND view_date BETWEEN :s AND :e
ORDER BY view_date ASC
"""
),
{"c": code, "s": start, "e": today},
).all()
trend = [{"date": r[0].isoformat(), "views": int(r[1])} for r in rows]
today_views = next((t["views"] for t in trend if t["date"] == today.isoformat()), 0)
last_7d = sum(t["views"] for t in trend)
return {
"code": code,
"today_views": today_views,
"last_7d_views": last_7d,
"trend": trend,
}

View File

@@ -0,0 +1,19 @@
-- Phase 5: 종목 페이지 익명 조회 카운터.
-- (code, view_date) PK 로 일 단위 집계. Redis/KV 없이 PG 만으로 충분 — 쓰기 빈도가
-- 낮고(페이지 진입 시 클라이언트 측에서 하루 1회로 dedupe), UPSERT 1회 비용은 무시 가능.
-- symbols(code) FK 는 일부러 안 걺 — 삭제된/오타 코드 방문에도 카운터가 죽지 않게.
\set ON_ERROR_STOP on
CREATE TABLE IF NOT EXISTS symbol_views_daily (
code TEXT NOT NULL,
view_date DATE NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (code, view_date)
);
CREATE INDEX IF NOT EXISTS symbol_views_daily_date_idx
ON symbol_views_daily (view_date DESC);
COMMENT ON TABLE symbol_views_daily IS
'Phase 5: 종목 페이지 익명 조회 카운터. 일 단위 UPSERT(+1). 클라이언트가 localStorage 로 dedupe.';

View File

@@ -0,0 +1,122 @@
"""기업 펀더멘털 (시가총액 / PER / PBR / EPS / BPS / 배당수익률) — pykrx 기반.
KRX 가 일 단위로 산정한 멀티플 + 시총 스냅샷을 가져온다. pykrx 호출은 KRX 페이지 스크래핑
이라 종목당 1~2초가 든다. 같은 (code, snapshot_date) 는 인메모리 캐시로 중복 호출을 막는다.
API 호출이 자주 들어와도 같은 날 같은 종목은 한 번만 KRX 를 때린다. 컨테이너 재기동 시
캐시는 비어 다시 채워진다 (디스크 캐시까지는 안 만든다 — KRX 는 인증/쿼터가 없어서
재발급 비용이 없음).
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
logger = logging.getLogger(__name__)
# 한국 시장 마지막 영업일 후보 — 토/일이면 금요일로, 공휴일은 KRX 가 빈 응답을 줘서
# 한 단계씩 거꾸로 시도 (최대 7일). 7일 모두 비면 fundamentals 데이터 없음.
_MAX_BACKOFF_DAYS = 7
@dataclass(frozen=True)
class Fundamentals:
code: str
snapshot_date: date
market_cap: float | None # 원
shares_outstanding: int | None # 상장주식수
per: float | None
pbr: float | None
eps: float | None
bps: float | None
div: float | None # 배당금 (원)
dvd_yield: float | None # 배당수익률 (%)
# (code, today) → Fundamentals. None 도 캐시해서 KRX 없는 종목 반복 호출 방지.
_cache: dict[tuple[str, date], Fundamentals | None] = {}
_cache_lock = threading.Lock()
def _to_pykrx(d: date) -> str:
return d.strftime("%Y%m%d")
def _fetch_one(code: str, target: date) -> Fundamentals | None:
"""target 일 기준 KRX fundamentals 스냅샷. KRX 가 그 날 빈 응답이면 직전 영업일로 backoff.
KRX 는 토/일/공휴일에 데이터를 안 주므로 target 부터 최대 _MAX_BACKOFF_DAYS 일 이전까지
역순으로 시도. 시총 / 멀티플 두 호출을 같은 target_date 로 정렬.
"""
try:
from pykrx import stock as krx
except ImportError:
logger.warning("pykrx unavailable")
return None
cur = target
for _ in range(_MAX_BACKOFF_DAYS):
ds = _to_pykrx(cur)
try:
cap_df = krx.get_market_cap(ds, ds, code)
fund_df = krx.get_market_fundamental(ds, ds, code)
except Exception as exc: # noqa: BLE001
# logger.exception 은 예전에 pykrx/KRX 비정상 응답 traceback 포매팅 중
# UnicodeDecodeError 로 API 500 을 만든 적이 있음 → %r 로 안전하게 직렬화.
logger.error("pykrx fundamentals failed code=%s date=%s err=%r", code, ds, exc)
return None
cap_ok = cap_df is not None and not cap_df.empty
fund_ok = fund_df is not None and not fund_df.empty
if cap_ok or fund_ok:
# 한쪽이라도 있으면 그 날짜로 채택. 결측은 None.
def _val(df: Any, key: str) -> float | None:
if df is None or df.empty:
return None
v = df.iloc[0].get(key)
try:
if v is None:
return None
f = float(v)
if f != f: # NaN
return None
return f
except (TypeError, ValueError):
return None
# pykrx fundamental 컬럼: ['BPS', 'PER', 'PBR', 'EPS', 'DIV', 'DPS']
# DIV = 배당수익률 (%), DPS = 주당배당금 (원).
mc = _val(cap_df, "시가총액")
shares_f = _val(cap_df, "상장주식수")
shares: int | None = int(shares_f) if shares_f is not None else None
return Fundamentals(
code=code,
snapshot_date=cur,
market_cap=mc,
shares_outstanding=shares,
per=_val(fund_df, "PER"),
pbr=_val(fund_df, "PBR"),
eps=_val(fund_df, "EPS"),
bps=_val(fund_df, "BPS"),
div=_val(fund_df, "DPS"),
dvd_yield=_val(fund_df, "DIV"),
)
cur = cur - timedelta(days=1)
return None
def get_fundamentals(code: str, *, today: date | None = None) -> Fundamentals | None:
"""code 의 최신 영업일 fundamentals. 같은 (code, today) 는 인메모리 캐시."""
t = today or date.today()
key = (code, t)
with _cache_lock:
if key in _cache:
return _cache[key]
val = _fetch_one(code, t)
with _cache_lock:
_cache[key] = val
return val

View File

@@ -13,11 +13,14 @@ status='skipped_missing_key' 처리.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
import httpx
@@ -30,6 +33,16 @@ logger = logging.getLogger(__name__)
KIS_BASE = "https://openapi.koreainvestment.com:9443"
USER_AGENT = "stock_chart_site/0.1 (+personal)"
# 토큰 디스크 캐시 경로. 기본값은 컨테이너 안 /app/.cache/kis_token.json — docker-compose
# 의 `./backend:/app` 바인드 마운트 덕에 호스트 `./backend/.cache/` 에 영속된다.
# `backend/.cache/` 는 .gitignore 에 들어있어 secrets 가 커밋되지 않는다.
#
# 왜 디스크 캐시가 필요한가:
# KIS 는 access_token 발급을 1분 1회, 하루 N회로 강하게 제한한다. 메모리만 쓰면
# `restart.bat` / `build.bat` / 컨테이너 재기동 때마다 새 발급 → 403 (EGW00133 등) 빈발.
# 토큰 자체는 24시간 유효하므로, 컨테이너 인스턴스가 바뀌어도 같은 토큰을 재사용한다.
_TOKEN_CACHE_PATH = Path(os.environ.get("KIS_TOKEN_CACHE_PATH", "/app/.cache/kis_token.json"))
class SkippedMissingKey(RuntimeError):
"""KIS 키 미설정 시 발생. 호출 측에서 skipped 로 매핑."""
@@ -49,6 +62,54 @@ def _has_keys() -> bool:
return bool(settings.kis_app_key and settings.kis_app_secret)
def _current_key_prefix() -> str:
# app_key 가 바뀌었는데 옛 키로 받은 토큰을 그대로 쓰면 401. 캐시 무효화 키로 사용.
return (settings.kis_app_key or "")[:8]
def _load_disk_cache() -> _Token | None:
try:
with _TOKEN_CACHE_PATH.open() as f:
data = json.load(f)
if data.get("key_prefix") != _current_key_prefix():
# .env 에서 app_key 가 바뀌었을 가능성 → 캐시 폐기
return None
tok = _Token(value=str(data["value"]), expires_at=float(data["expires_at"]))
if tok.expires_at <= time.time():
return None
return tok
except FileNotFoundError:
return None
except (OSError, ValueError, KeyError, TypeError) as exc:
logger.warning("kis token disk-cache read failed (%s): %s", _TOKEN_CACHE_PATH, exc)
return None
def _save_disk_cache(tok: _Token) -> None:
try:
_TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = _TOKEN_CACHE_PATH.with_suffix(".json.tmp")
# atomic write: 부분 쓰기 중 컨테이너가 죽어도 다음 시작 시 깨진 파일 안 읽음
with tmp.open("w") as f:
json.dump(
{
"value": tok.value,
"expires_at": tok.expires_at,
"key_prefix": _current_key_prefix(),
},
f,
)
os.replace(tmp, _TOKEN_CACHE_PATH)
# 토큰 파일은 키 동등의 secret. 0600 권한.
try:
os.chmod(_TOKEN_CACHE_PATH, 0o600)
except OSError:
pass
except OSError as exc:
# 캐시 쓰기 실패는 치명적이지 않음 — 메모리 캐시로만 동작 가능. 경고만.
logger.warning("kis token disk-cache write failed (%s): %s", _TOKEN_CACHE_PATH, exc)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
@@ -75,13 +136,29 @@ def _issue_token() -> _Token:
def get_token() -> str:
"""캐시된 토큰 반환. 만료 60초 전부터 재발급. 키 없으면 SkippedMissingKey."""
"""캐시된 토큰 반환. 메모리 → 디스크 → 신규 발급 순. 키 없으면 SkippedMissingKey.
디스크 캐시는 컨테이너 재기동 시 토큰 재발급 1분 제한 (EGW00133) 회피용.
"""
global _token_cache
with _token_lock:
if _token_cache and _token_cache.expires_at > time.time():
return _token_cache.value
disk = _load_disk_cache()
if disk is not None:
_token_cache = disk
logger.info(
"kis token loaded from disk, expires_at=%s",
datetime.fromtimestamp(disk.expires_at),
)
return disk.value
_token_cache = _issue_token()
logger.info("kis token issued, expires_at=%s", datetime.fromtimestamp(_token_cache.expires_at))
_save_disk_cache(_token_cache)
logger.info(
"kis token issued (and cached to %s), expires_at=%s",
_TOKEN_CACHE_PATH,
datetime.fromtimestamp(_token_cache.expires_at),
)
return _token_cache.value
@@ -156,6 +233,219 @@ def fetch_daily_price(
return out
@retry(
stop=stop_after_attempt(2),
wait=wait_exponential(multiplier=1, min=1, max=4),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
)
def fetch_minute_price(
code: str,
*,
end_hour: str | None = None,
) -> list[dict[str, Any]]:
"""당일 1분봉 시세 조회 (read-only). 최신 30개 캔들을 반환.
KIS 분봉 endpoint (`inquire-time-itemchartprice`) 는 base 시각 (FID_INPUT_HOUR_1) 부터
역순으로 최대 30개의 1분봉을 돌려준다. base 를 비우면 KIS 가 가장 최근 시각으로 해석.
즉 장중 호출 → 직전 30분 / 장 종료 후 호출 → 15:00~15:30 의 30분.
Returns: [{ts: datetime(KST aware), open, high, low, close, volume}, ...]
ts 오름차순 정렬.
Note: 이 endpoint 는 "당일" 분봉만 지원. 어제 이전 분봉은 별도 endpoint 가 필요한데,
이 사이트의 사용 패턴 (장중 라이브 차트) 에는 당일 데이터로 충분하다.
"""
if not _has_keys():
raise SkippedMissingKey("kis app_key/secret missing")
url = f"{KIS_BASE}/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
params = {
"FID_ETC_CLS_CODE": "",
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
# 비우면 KIS 가 "지금" 으로 해석. 장 마감 후엔 15:30:00 부근 데이터.
"FID_INPUT_HOUR_1": end_hour or "",
"FID_PW_DATA_INCU_YN": "Y", # 과거 데이터 포함 (장 시작 직후 빈 데이터 방지)
}
with httpx.Client(timeout=15.0) as cli:
resp = cli.get(url, headers=_headers("FHKST03010200"), params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
raise RuntimeError(f"kis error: {data.get('msg1')} (rt_cd={data.get('rt_cd')})")
# KIS 응답은 KST. tz-aware 로 변환해서 DB (TIMESTAMPTZ) 에 안전 적재.
from datetime import timedelta, timezone as _tz
KST = _tz(timedelta(hours=9))
out: list[dict[str, Any]] = []
for row in data.get("output2", []) or []:
raw_date = row.get("stck_bsop_date")
raw_hour = row.get("stck_cntg_hour")
if not raw_date or not raw_hour:
continue
try:
ts = datetime.strptime(raw_date + raw_hour.zfill(6), "%Y%m%d%H%M%S").replace(tzinfo=KST)
except ValueError:
continue
out.append(
{
"ts": ts,
"open": float(row.get("stck_oprc") or 0),
"high": float(row.get("stck_hgpr") or 0),
"low": float(row.get("stck_lwpr") or 0),
# 분봉에서는 종가가 stck_prpr (현재가) 로 옴
"close": float(row.get("stck_prpr") or row.get("stck_clpr") or 0),
"volume": int(row.get("cntg_vol") or 0),
}
)
# KIS 응답은 보통 최신→과거 역순. UI/DB 적재 편의를 위해 오름차순으로 뒤집는다.
out.sort(key=lambda r: r["ts"])
return out
def fetch_minute_range(
code: str,
from_ts: datetime,
to_ts: datetime,
*,
max_pages: int = 20,
) -> list[dict[str, Any]]:
"""[from_ts, to_ts] 윈도우의 1분봉 전체. KIS 30-bar 페이지를 역순으로 반복 호출.
KIS `inquire-time-itemchartprice` 는 한 번에 최대 30개 1분봉만 주고,
`FID_INPUT_HOUR_1` 기준 그 시각 포함 이전 30분을 반환한다. 그래서 to_ts 부터
시작해서 가장 이른 응답 시각의 -1분을 다음 cursor 로 잡아 from_ts 까지 후퇴.
중복 키 (ts) 는 dict 로 자연 dedupe. 더 이상 새 행이 안 들어오거나 max_pages 도달
하면 종료 (rate-limit/무한루프 방지).
Note: 이 endpoint 는 "당일" 만 지원. from_ts/to_ts 는 같은 날짜여야 한다.
"""
if not _has_keys():
raise SkippedMissingKey("kis app_key/secret missing")
if from_ts >= to_ts:
return []
from datetime import timedelta as _td
accumulated: dict[datetime, dict[str, Any]] = {}
cursor = to_ts
pages = 0
while cursor > from_ts and pages < max_pages:
pages += 1
rows = fetch_minute_price(code, end_hour=cursor.strftime("%H%M%S"))
if not rows:
break
added = 0
for r in rows:
ts = r["ts"]
if ts < from_ts or ts > to_ts:
continue
if ts not in accumulated:
accumulated[ts] = r
added += 1
if added == 0:
# 같은 30개를 또 받았다 — 더 과거가 없거나 KIS 가 똑같은 페이지를 반환.
break
earliest_ts = min(r["ts"] for r in rows)
next_cursor = earliest_ts - _td(minutes=1)
if next_cursor >= cursor:
break
cursor = next_cursor
return sorted(accumulated.values(), key=lambda r: r["ts"])
@retry(
stop=stop_after_attempt(2),
wait=wait_exponential(multiplier=1, min=1, max=4),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
)
def fetch_orderbook(code: str) -> dict[str, Any]:
"""10단계 호가 + 잔량 + 현재가 스냅샷 (read-only).
KIS `inquire-asking-price-exp-ccn` (TR FHKST01010200) 호출. 실시간 ws 가 아니라
HTTP 요청 시점의 호가 스냅샷이므로 프론트에서 5~10초 폴링이 자연스럽다.
Returns:
{
"ts": "HH:MM:SS" (KIS 가 준 aspr_acpt_hour),
"current": float | None, # stck_prpr
"prev_close_diff": float | None,
"prev_close_pct": float | None,
"asks": [{level, price, qty}] (level 1..10, 1=가장 낮은 매도호가),
"bids": [{level, price, qty}] (level 1..10, 1=가장 높은 매수호가),
"ask_total": int,
"bid_total": int,
}
"""
if not _has_keys():
raise SkippedMissingKey("kis app_key/secret missing")
url = f"{KIS_BASE}/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
}
with httpx.Client(timeout=10.0) as cli:
resp = cli.get(url, headers=_headers("FHKST01010200"), params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
raise RuntimeError(f"kis error: {data.get('msg1')} (rt_cd={data.get('rt_cd')})")
o1 = (data.get("output1") or {}) if isinstance(data.get("output1"), dict) else {}
o2 = (data.get("output2") or {}) if isinstance(data.get("output2"), dict) else {}
def _f(d: dict[str, Any], k: str) -> float | None:
v = d.get(k)
if v in (None, ""):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _i(d: dict[str, Any], k: str) -> int:
v = d.get(k)
if v in (None, ""):
return 0
try:
return int(float(v))
except (TypeError, ValueError):
return 0
asks = []
bids = []
for lv in range(1, 11):
ap = _f(o1, f"askp{lv}")
aq = _i(o1, f"askp_rsqn{lv}")
bp = _f(o1, f"bidp{lv}")
bq = _i(o1, f"bidp_rsqn{lv}")
if ap is not None and ap > 0:
asks.append({"level": lv, "price": ap, "qty": aq})
if bp is not None and bp > 0:
bids.append({"level": lv, "price": bp, "qty": bq})
raw_hour = o1.get("aspr_acpt_hour") or ""
if isinstance(raw_hour, str) and len(raw_hour) == 6:
ts = f"{raw_hour[0:2]}:{raw_hour[2:4]}:{raw_hour[4:6]}"
else:
ts = None
return {
"ts": ts,
"current": _f(o2, "stck_prpr"),
"prev_close_diff": _f(o2, "prdy_vrss"),
"prev_close_pct": _f(o2, "prdy_ctrt"),
"asks": asks,
"bids": bids,
"ask_total": _i(o1, "total_askp_rsqn"),
"bid_total": _i(o1, "total_bidp_rsqn"),
}
def ping() -> dict[str, Any]:
"""토큰 발급만 시도해서 키 유효성 확인."""
if not _has_keys():

View File

@@ -11,6 +11,7 @@ from dataclasses import dataclass
from sqlalchemy import text
from app.db.connection import get_engine
from app.seed.krx_static import KRX_STATIC_MAJORS
from app.seed.seed_tickers import SEED_CODES, SEED_TICKERS
logger = logging.getLogger(__name__)
@@ -22,6 +23,7 @@ class SeedReport:
updated: int
seed_marked: int
markets: dict[str, int]
static_fallback_used: bool = False
def _fetch_market_listing(market: str) -> list[tuple[str, str]]:
@@ -41,59 +43,116 @@ def _fetch_market_listing(market: str) -> list[tuple[str, str]]:
return out
def seed_symbols() -> SeedReport:
"""KOSPI + KOSDAQ 전 종목을 upsert. SEED 10 종목은 is_seed=TRUE."""
rows: list[tuple[str, str, str]] = [] # (code, name, market)
market_counts: dict[str, int] = {}
for market in ("KOSPI", "KOSDAQ"):
listing = _fetch_market_listing(market)
market_counts[market] = len(listing)
for code, name in listing:
rows.append((code, name, market))
def _upsert_seed_tickers() -> int:
"""SEED 10종목 강제 upsert. 네트워크 불필요 → KRX 실패와 무관하게 항상 성공.
별도 트랜잭션이라 KRX 시드가 나중에 실패해도 살아남는다.
"""
engine = get_engine()
inserted = updated = 0
seed_marked = 0
with engine.begin() as conn:
for code, name, market in rows:
is_seed = code in SEED_CODES
res = conn.execute(
text(
"""
INSERT INTO symbols (code, name, market, is_seed)
VALUES (:code, :name, :market, :is_seed)
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
market = EXCLUDED.market,
is_seed = symbols.is_seed OR EXCLUDED.is_seed
RETURNING (xmax = 0) AS inserted
"""
),
{"code": code, "name": name, "market": market, "is_seed": is_seed},
)
row = res.first()
if row and row[0]:
inserted += 1
else:
updated += 1
if is_seed:
seed_marked += 1
# SEED_TICKERS 중 KRX 리스팅에 없으면 (상장폐지 등) 그래도 명시적으로 시드 row 보장
for t in SEED_TICKERS:
conn.execute(
text(
"""
INSERT INTO symbols (code, name, market, is_seed)
VALUES (:code, :name, :market, TRUE)
ON CONFLICT (code) DO UPDATE SET is_seed = TRUE
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
market = EXCLUDED.market,
is_seed = TRUE
"""
),
{"code": t.code, "name": t.name, "market": t.market},
)
return len(SEED_TICKERS)
def seed_symbols() -> SeedReport:
"""KOSPI + KOSDAQ 전 종목을 upsert. SEED 10 종목은 is_seed=TRUE.
순서:
1) SEED_TICKERS 먼저 별도 트랜잭션으로 강제 upsert (KRX 실패와 무관하게 검색 가능)
2) KRX 리스팅 fetch (네트워크 의존) → 별도 트랜잭션으로 일괄 upsert.
시장별 fetch 실패 시 해당 시장만 스킵하고 나머지 진행.
"""
# 1) SEED_TICKERS — 항상 보장
try:
_upsert_seed_tickers()
seed_marked = len(SEED_TICKERS)
logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked)
except Exception as e: # noqa: BLE001
# logger.exception 은 Python 3.11 의 traceback 포매터가 pykrx 소스의 한글 주석
# 'df = 가...' 바이트를 만나면 UnicodeDecodeError 를 던지는 버그가 있어, 그 예외가
# try 밖으로 escape 해서 500 을 만든다. 그래서 traceback 안 찍는다.
logger.error("seed_symbols: seed-tickers upsert failed: %s", repr(e)[:300])
seed_marked = 0
# 2) KRX 전 종목 — fetch 실패해도 부분 성공 허용
market_counts: dict[str, int] = {}
all_rows: list[tuple[str, str, str]] = []
for market in ("KOSPI", "KOSDAQ"):
try:
listing = _fetch_market_listing(market)
market_counts[market] = len(listing)
for code, name in listing:
all_rows.append((code, name, market))
logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing))
except Exception as e: # noqa: BLE001
logger.error("seed_symbols: KRX %s fetch failed — skip market: %s", market, repr(e)[:300])
market_counts[market] = 0
# 2b) KRX 가 0건만 줬으면 정적 스냅샷 (krx_static.KRX_STATIC_MAJORS) 으로 fallback.
# KRX 가 LOGOUT/JSONDecode 로 막혀도 주요 종목 검색은 동작하게 만든다.
# KRX 가 정상으로 돌아오면 다음 호출에서 all_rows 가 채워져 자동으로 덮어쓰여진다.
static_fallback_used = False
if not all_rows:
logger.warning(
"seed_symbols: KRX live fetch empty — fallback to static snapshot (%d majors)",
len(KRX_STATIC_MAJORS),
)
all_rows = list(KRX_STATIC_MAJORS)
static_fallback_used = True
# market_counts 도 fallback 으로 채워 응답에 반영
for _c, _n, m in KRX_STATIC_MAJORS:
market_counts[m] = market_counts.get(m, 0) + 1
inserted = updated = 0
if all_rows:
engine = get_engine()
try:
with engine.begin() as conn:
for code, name, market in all_rows:
is_seed = code in SEED_CODES
res = conn.execute(
text(
"""
INSERT INTO symbols (code, name, market, is_seed)
VALUES (:code, :name, :market, :is_seed)
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
market = EXCLUDED.market,
is_seed = symbols.is_seed OR EXCLUDED.is_seed
RETURNING (xmax = 0) AS inserted
"""
),
{"code": code, "name": name, "market": market, "is_seed": is_seed},
)
row = res.first()
if row and row[0]:
inserted += 1
else:
updated += 1
except Exception as e: # noqa: BLE001
logger.error("seed_symbols: KRX bulk upsert failed (transaction rolled back): %s", repr(e)[:300])
logger.info(
"seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s",
inserted, updated, seed_marked, market_counts,
"seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s static_fallback=%s",
inserted, updated, seed_marked, market_counts, static_fallback_used,
)
return SeedReport(
inserted=inserted,
updated=updated,
seed_marked=seed_marked,
markets=market_counts,
static_fallback_used=static_fallback_used,
)
return SeedReport(inserted=inserted, updated=updated, seed_marked=seed_marked, markets=market_counts)

View File

@@ -1,19 +1,26 @@
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.api.chart import router as chart_router
from app.api.fundamentals import router as fundamentals_router
from app.api.markets import router as markets_router
from app.api.metrics import router as metrics_router
from app.api.news import router as news_router
from app.api.orderbook import router as orderbook_router
from app.api.predict import router as predict_router
from app.api.refresh import router as refresh_router
from app.api.symbols import router as symbols_router
from app.api.themes import router as themes_router
from app.api.views import router as views_router
from app.config import settings
from app.db.connection import ping as db_ping
from app.db.connection import get_engine, ping as db_ping
from app.fetch import dart as dart_mod
from app.fetch import kis as kis_mod
from app.pipelines.scheduler import shutdown_scheduler, start_scheduler
@@ -25,13 +32,62 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def _bootstrap_db() -> None:
"""첫 부팅 자동화:
1) migrations/*.sql idempotent 적용 (timescale/pgvector 확장 + 스키마)
2) symbols 테이블 비어있으면 pykrx 로 전 종목 시드 (SEED 10 마크 포함)
BOOTSTRAP_DISABLED=1 이면 스킵 (테스트/CI 용). 어떤 단계든 실패해도 서버는
뜬다 — /health/db 가 진단을 알려준다.
"""
if os.environ.get("BOOTSTRAP_DISABLED") == "1":
logger.info("bootstrap skipped (BOOTSTRAP_DISABLED=1)")
return
# 1) migrations
try:
from app.db.migrate import apply_all
res = apply_all()
logger.info("bootstrap migrate: %s", res)
except Exception: # noqa: BLE001
logger.exception("bootstrap migrate failed")
return # 스키마 없으면 시드 불가
# 2) symbols 시드
# - SEED 10종목은 매 부팅마다 무조건 upsert (10회 upsert, ms 단위, 네트워크 무관)
# → KRX 접근 실패한 환경에서도 최소 10종목 검색 보장
# - KRX 전 종목 fetch 는 symbols 가 비어있을 때만 (호출 비용 큼)
try:
from app.fetch.symbols_seed import _upsert_seed_tickers, seed_symbols
n_seed = _upsert_seed_tickers()
logger.info("bootstrap seed-tickers ensured (%d)", n_seed)
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(text("SELECT COUNT(*) FROM symbols")).first()
count = int(row[0]) if row else 0
if count <= n_seed:
# symbols 가 SEED 만큼 또는 그 이하 → KRX 전 종목 fetch 시도
logger.info("symbols sparse (count=%d) — running KRX listing seed", count)
report = seed_symbols()
logger.info("bootstrap seed_symbols: %s", report)
else:
logger.info("symbols already populated (count=%d) — skip KRX listing seed", count)
except Exception: # noqa: BLE001
logger.exception("bootstrap seed_symbols failed")
@asynccontextmanager
async def lifespan(_: FastAPI):
_bootstrap_db()
# 스케줄러는 옵션. CI/테스트에서 disable 하고 싶으면 SCHEDULER_DISABLED 같은 env 추가 가능.
try:
start_scheduler()
except Exception: # noqa: BLE001
logger.exception("scheduler start failed")
if os.environ.get("SCHEDULER_DISABLED") == "1":
logger.info("scheduler skipped (SCHEDULER_DISABLED=1)")
else:
try:
start_scheduler()
except Exception: # noqa: BLE001
logger.exception("scheduler start failed")
yield
shutdown_scheduler()
@@ -51,6 +107,11 @@ app.include_router(chart_router)
app.include_router(predict_router)
app.include_router(metrics_router)
app.include_router(news_router)
app.include_router(markets_router)
app.include_router(themes_router)
app.include_router(orderbook_router)
app.include_router(fundamentals_router)
app.include_router(views_router)
def _resolved_device() -> str:
@@ -81,3 +142,30 @@ def health_keys() -> dict[str, object]:
"dart": dart_mod.ping(),
# huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피)
}
@app.get("/health/models")
def health_models() -> dict[str, object]:
"""Chronos / LGBM 가용성 진단.
Chronos: lazy 로드 첫 호출이라 30초~수 분 걸릴 수 있음 (HuggingFace 다운로드).
LGBM: 체크포인트 디렉토리 스캔 — retrain 안 돈 cold start 에선 비어있음.
"""
from pathlib import Path
from app.models import chronos as chronos_mod
lgbm_dir = Path(os.environ.get("LGBM_MODEL_DIR", "/app/data/models"))
lgbm_files: list[str] = []
if lgbm_dir.exists():
lgbm_files = sorted(p.name for p in lgbm_dir.glob("*.pkl"))
return {
"chronos": chronos_mod.ping(),
"lgbm": {
"model_dir": str(lgbm_dir),
"checkpoint_count": len(lgbm_files),
"samples": lgbm_files[:8], # 너무 많으면 잘라서.
"status": "ok" if lgbm_files else "no_checkpoints (cold start, run retrain_weekly)",
},
}

View File

@@ -59,8 +59,22 @@ def _load() -> None:
os.environ.setdefault("HF_TOKEN", token)
device = _resolve_device()
# bf16 은 RTX 30xx 이상에서 지원. cpu 에선 fp32.
dtype = torch.bfloat16 if device == "cuda" else torch.float32
# dtype 선택:
# - 이전엔 cuda 면 무조건 bf16 으로 갔는데, torch 2.3.1+cu121 사전빌드 wheel 이
# sm_86 (RTX 3070 Ti) 의 일부 T5 커널 binary 를 빠뜨려서 inference 첫 호출에
# "no kernel image is available for execution on the device" 발생. ping/load
# 까지는 통과해서 진단이 까다로웠음 (실제 005930 케이스에서 관측).
# - chronos-t5-small 은 46M params 라 fp32 로도 8GB VRAM 에 여유 충분, 속도
# 차이도 일봉 30일 예측에선 무시 가능. 호환성 우선해 default 를 fp32 로.
# - 드라이버/torch 업그레이드 후 다시 bf16 시험하려면 .env 에
# CHRONOS_DTYPE=bf16 (또는 fp16) 두면 됨.
dtype_pref = os.environ.get("CHRONOS_DTYPE", "fp32").lower()
if device == "cuda" and dtype_pref == "bf16":
dtype = torch.bfloat16
elif device == "cuda" and dtype_pref == "fp16":
dtype = torch.float16
else:
dtype = torch.float32
logger.info("loading Chronos %s on %s (dtype=%s)", MODEL_NAME, device, dtype)
pipe = ChronosPipeline.from_pretrained(
MODEL_NAME,
@@ -70,6 +84,26 @@ def _load() -> None:
_state.update({"loaded": True, "pipe": pipe, "device": device})
def _reload_cpu() -> None:
"""현재 pipeline 을 폐기하고 CPU 로 강제 재로드.
cuda 환경에서 'no kernel image is available for execution on the device' 같이
런타임에야 드러나는 GPU 비호환 에러가 났을 때 자동 폴백용. 한 번 폴백하면
다음 호출부터는 CPU 그대로 사용 (재시도 비용 회피)."""
global _state
import torch
from chronos import ChronosPipeline
with _lock:
logger.warning("falling back to CPU for Chronos (GPU inference failed)")
_state.update({"loaded": False, "pipe": None, "device": None})
pipe = ChronosPipeline.from_pretrained(
MODEL_NAME,
device_map="cpu",
torch_dtype=torch.float32,
)
_state.update({"loaded": True, "pipe": pipe, "device": "cpu"})
def forecast(
series: list[float],
*,
@@ -88,14 +122,31 @@ def forecast(
import numpy as np
import torch
pipe = _state["pipe"]
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
with torch.no_grad():
samples = pipe.predict(
context=context,
prediction_length=horizon,
num_samples=num_samples,
)
def _do_predict():
pipe = _state["pipe"]
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
with torch.no_grad():
return pipe.predict(
context=context,
prediction_length=horizon,
num_samples=num_samples,
)
try:
samples = _do_predict()
except RuntimeError as exc:
# cuda 빌드/드라이버 미스매치는 inference 시점에야 드러나는 경우가 많음.
# 'no kernel image is available' / 'CUDA error' 같은 신호 잡아서 CPU 로 폴백.
msg = str(exc)
if _state.get("device") == "cuda" and (
"no kernel image" in msg
or "CUDA error" in msg
or "CUBLAS" in msg
):
_reload_cpu()
samples = _do_predict()
else:
raise
# samples: (1, num_samples, prediction_length)
arr = samples[0].cpu().float().numpy()
q10 = np.quantile(arr, 0.10, axis=0).tolist()

View File

@@ -19,7 +19,7 @@ Chronos 도 실패하면 LGBM 단독으로 진행.
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
import numpy as np
@@ -53,6 +53,10 @@ class EnsemblePrediction:
horizons: list[int]
steps: list[EnsembleStep]
sources_used: list[str]
# shadow 저장용 원본 출력 (predict_one.py 가 ensemble + chronos 단독 + lgbm 단독
# 3 종을 predictions 에 적재해서 retrain_weekly 가 모델별 hit_rate 비교 가능하게 함).
chronos_raw: ChronosForecast | None = None
lgbm_raw: dict[int, LgbmForecast] = field(default_factory=dict)
def _chronos_direction(samples: list[list[float]], base_close: float, horizon: int) -> tuple[float, float, float]:
@@ -83,28 +87,41 @@ def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePred
sources_used: list[str] = []
cf: ChronosForecast | None = None
chronos_err: str | None = None
try:
cf = chronos_forecast(closes, horizon=max_h, num_samples=30)
sources_used.append("chronos")
except Exception as exc: # noqa: BLE001
logger.warning("chronos forecast failed for %s: %s", code, exc)
chronos_err = f"{type(exc).__name__}: {exc}"
logger.warning("chronos forecast failed for %s: %s", code, chronos_err)
steps: list[EnsembleStep] = []
lgbm_raw: dict[int, LgbmForecast] = {}
for h in horizons:
lf: LgbmForecast | None = None
lgbm_err: str | None = None
try:
lf = lgbm_predict(code, h)
if lf is not None:
sources_used.append(f"lgbm_h{h}")
lgbm_raw[h] = lf
else:
# predict_one 이 None 반환 = 체크포인트 파일 없음 (cold start).
lgbm_err = "model checkpoint not found (run retrain_weekly)"
except Exception as exc: # noqa: BLE001
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, exc)
lgbm_err = f"{type(exc).__name__}: {exc}"
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, lgbm_err)
# 가중치 (DB 없으면 default 0.6/0.4).
w = load_weights(code, h)
wc, wl = w.w_chronos, w.w_lgbm
# 한쪽이 없으면 다른 쪽 전부.
if cf is None and lf is None:
raise RuntimeError(f"both chronos & lgbm failed for {code} h={h}")
# 사용자가 브라우저에서 바로 원인을 보게 두 에러를 그대로 노출.
raise RuntimeError(
f"both chronos & lgbm failed for {code} h={h}; "
f"chronos={chronos_err or 'unknown'}; lgbm={lgbm_err or 'unknown'}"
)
if cf is None:
wc, wl = 0.0, 1.0
if lf is None:
@@ -171,4 +188,6 @@ def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePred
horizons=list(horizons),
steps=steps,
sources_used=sources_used,
chronos_raw=cf,
lgbm_raw=lgbm_raw,
)

View File

@@ -1,13 +1,16 @@
"""prediction_outcomes 매칭 배치.
평일 16:30 KST 에 실행. 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30) 의
확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, target_date == 오늘인
user_triggered=TRUE 예측을 그 종가와 매칭.
확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, 매칭 미해결 예측을 실제
종가와 매칭한다.
cold-start / 휴장일 대비: 인자로 받은 target_date 의 ohlcv_daily 에 종가가
없으면 자연스럽게 skip. 다음 거래일 매칭 잡이 다시 시도하면 그 날짜는
여전히 매칭되지 않으므로 (매칭 sql 이 target_date 기준), 영원히 매칭 안되는
잘못된 calendar date 예측은 cleanup CLI 로 별도 정리 가능 (Phase 7).
이월/공휴일 정책:
target_date 가 calendar date 라서 비거래일이면 ohlcv_daily 에 행이 없다.
그래서 `target_date <= today` 인 미해결 행을 전부 후보로 잡고, 각 행마다
`target_date <= ohlcv_daily.date <= today` 범위의 최초 거래일 종가로
매칭한다 (=다음 거래일로 자동 이월).
shadow prediction 도 같은 방식으로 매칭한다 (user_triggered 필터 없음).
"""
from __future__ import annotations
@@ -29,7 +32,7 @@ FLAT_BAND = 0.003
@dataclass
class MatchSummary:
target_date: str
today: str
candidates: int
matched: int
skipped_no_actual: int
@@ -44,64 +47,62 @@ def _direction_label(ret: float) -> str:
return "flat"
def match_for_date(d: date) -> MatchSummary:
"""target_date == d 인 user_triggered=TRUE 예측을 매칭."""
def match_up_to(today: date) -> MatchSummary:
"""target_date <= today 인 모든 미해결 예측을 매칭.
각 행마다 ohlcv_daily 에서 target_date 이상, today 이하 범위의 최초
거래일 종가를 actual_close 로 사용 — 공휴일/주말 이월 자연 처리.
"""
eng = get_engine()
with eng.begin() as conn:
# 매칭 대상 예측 + 매칭 안 됐는지 확인.
candidate_rows = conn.execute(
text(
"""
SELECT p.id, p.code, p.base_date, p.horizon, p.point_forecast,
p.direction, p.model
SELECT p.id, p.code, p.base_date, p.target_date, p.horizon,
p.point_forecast, p.direction, p.model
FROM predictions p
LEFT JOIN prediction_outcomes po ON po.prediction_id = p.id
WHERE p.target_date = :d
AND p.user_triggered = TRUE
WHERE p.target_date <= :today
AND po.prediction_id IS NULL
"""
),
{"d": d},
{"today": today},
).all()
candidates = len(candidate_rows)
if not candidates:
return MatchSummary(str(d), 0, 0, 0, 0)
# 종목별로 actual close 조회 (한번에 batch).
codes = list({r[1] for r in candidate_rows})
actual_map: dict[tuple[str, date], float] = {}
for code in codes:
row = conn.execute(
text(
"SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"
),
{"c": code, "d": d},
).first()
if row and row[0] is not None:
actual_map[(code, d)] = float(row[0])
# base_close (각 예측의 base_date 종가) 도 필요 — direction 판정용.
base_close_map: dict[tuple[str, date], float] = {}
for pid, code, base_date, *_ in candidate_rows:
key = (code, base_date)
if key in base_close_map:
continue
row = conn.execute(
text("SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"),
{"c": code, "d": base_date},
).first()
if row and row[0] is not None:
base_close_map[key] = float(row[0])
return MatchSummary(str(today), 0, 0, 0, 0)
matched = 0
skipped = 0
already = 0
for pid, code, base_date, horizon, point_forecast, pred_dir, model in candidate_rows:
actual = actual_map.get((code, d))
base_close = base_close_map.get((code, base_date))
if actual is None or base_close is None:
for pid, code, base_date, target_date, horizon, point_forecast, pred_dir, model in candidate_rows:
# 첫 거래일 종가 (target_date <= date <= today)
actual_row = conn.execute(
text(
"""
SELECT date, close FROM ohlcv_daily
WHERE code = :c AND date >= :td AND date <= :today
ORDER BY date ASC
LIMIT 1
"""
),
{"c": code, "td": target_date, "today": today},
).first()
if not actual_row or actual_row[1] is None:
skipped += 1
continue
actual_date = actual_row[0]
actual = float(actual_row[1])
base_close_row = conn.execute(
text("SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"),
{"c": code, "d": base_date},
).first()
if not base_close_row or base_close_row[0] is None:
skipped += 1
continue
base_close = float(base_close_row[0])
actual_ret = actual / base_close - 1.0
actual_dir = _direction_label(actual_ret)
dir_hit = (pred_dir == actual_dir)
@@ -122,7 +123,8 @@ def match_for_date(d: date) -> MatchSummary:
{
"pid": pid,
"code": code,
"d": d,
# 실제 매칭된 거래일 (이월된 경우 target_date 와 다를 수 있음)
"d": actual_date,
"h": horizon,
"m": model,
"pc": float(point_forecast) if point_forecast is not None else None,
@@ -138,7 +140,7 @@ def match_for_date(d: date) -> MatchSummary:
already += 1
return MatchSummary(
target_date=str(d),
today=str(today),
candidates=candidates,
matched=matched,
skipped_no_actual=skipped,
@@ -146,13 +148,19 @@ def match_for_date(d: date) -> MatchSummary:
)
# 하위 호환 alias — 이전 시그니처를 쓰던 호출자 (예: 단일 날짜 매칭 테스트)
def match_for_date(d: date) -> MatchSummary:
"""legacy: target_date == d 만 매칭하던 동작 → 이제 target_date <= d 전체 처리."""
return match_up_to(d)
def match_today() -> dict[str, Any]:
"""평일 16:30 KST 호출용. target_date == today (KST) 인 매칭."""
"""평일 16:30 KST 호출용. target_date <= today (KST) 인 미해결 행 일괄 매칭."""
from datetime import datetime, timezone, timedelta as td
kst = timezone(td(hours=9))
today = datetime.now(kst).date()
summary = match_for_date(today)
summary = match_up_to(today)
return {
"today": str(today),
"summary": summary.__dict__,

View File

@@ -3,12 +3,16 @@
POST /api/predict/{code} 에서 호출. 사용자가 "예상차트 보기" 누른 시점.
- ensemble.predict() 로 horizons (1,3,5) 결과 계산
- base_date = 마지막 ohlcv_daily.date, target_date = base_date + horizon 영업일
(대충 calendar 일로 +h * 1.4 — KRX 영업일 추정. Phase 4 단순화: base_date + h 영업일은
ohlcv 상의 다음 h 거래일이 아닌, "거래일 카운트" 대신 단순 calendar+h 로 저장하고
매칭 잡에서 ohlcv_daily 에 그 날짜 행이 있는지로 자연 보정.)
(주말만 스킵하는 단순 카운트. 공휴일은 match_outcomes 가 "target_date 이후
최초 거래일 종가"로 자동 이월하여 보정.)
대안 정확도 위해: 매칭 잡은 "예측의 target_date 이 오늘"인 행을 그날 종가와 비교.
calendar date 가 비거래일이면 매칭이 안 되니, 매칭 잡은 매일 실행되어 모일 때 처리.
세 종류의 행을 함께 저장한다:
- model='ensemble' : 사용자에게 보여주는 최종 예측. user_triggered 플래그 따라감.
- model='chronos' : Chronos 단독 (shadow). user_triggered=FALSE 로 항상 적재.
- model='lgbm' : LGBM 단독 (shadow). user_triggered=FALSE 로 항상 적재.
shadow 행은 retrain_weekly 가 모델별 hit_rate 를 비교해 ensemble_weights 를
자동 보정하는 입력이 된다.
"""
from __future__ import annotations
@@ -27,6 +31,9 @@ logger = logging.getLogger(__name__)
KST = timezone(timedelta(hours=9))
# ±0.3% flat band — features.FLAT_BAND, match_outcomes.FLAT_BAND 와 동일.
FLAT_BAND = 0.003
def _next_trading_target(base_date: date, horizon: int) -> date:
"""base_date + horizon 거래일 (주말만 스킵, 공휴일은 무시 — 매칭잡이 자연 보정)."""
@@ -49,77 +56,194 @@ def _last_trading_date(code: str) -> date | None:
return row[0] if row and row[0] else None
def _direction_label(ret: float) -> str:
if ret > FLAT_BAND:
return "up"
if ret < -FLAT_BAND:
return "down"
return "flat"
_INSERT_PREDICTION_SQL = text(
"""
INSERT INTO predictions
(code, predicted_at, base_date, target_date, horizon, model,
direction, prob_up, prob_flat, prob_down, expected_return,
point_forecast, ci_low, ci_high, features_snapshot, user_triggered)
VALUES
(:code, :predicted_at, :base_date, :target_date, :horizon, :model,
:direction, :p_up, :p_fl, :p_dn, :exp_ret,
:point, :lo, :hi, CAST(:feats AS JSONB), :ut)
ON CONFLICT (code, base_date, target_date, horizon, model)
DO UPDATE SET
predicted_at = EXCLUDED.predicted_at,
direction = EXCLUDED.direction,
prob_up = EXCLUDED.prob_up,
prob_flat = EXCLUDED.prob_flat,
prob_down = EXCLUDED.prob_down,
expected_return = EXCLUDED.expected_return,
point_forecast = EXCLUDED.point_forecast,
ci_low = EXCLUDED.ci_low,
ci_high = EXCLUDED.ci_high,
features_snapshot = EXCLUDED.features_snapshot,
user_triggered = predictions.user_triggered OR EXCLUDED.user_triggered
RETURNING id
"""
)
def _insert_prediction(conn, *, model: str, code: str, predicted_at: datetime,
base_date: date, target_date: date, horizon: int,
direction: str, p_up: float, p_fl: float, p_dn: float,
expected_return: float, point: float | None,
lo: float | None, hi: float | None,
features_snap: dict, user_triggered: bool) -> int | None:
row = conn.execute(
_INSERT_PREDICTION_SQL,
{
"code": code,
"predicted_at": predicted_at,
"base_date": base_date,
"target_date": target_date,
"horizon": horizon,
"model": model,
"direction": direction,
"p_up": p_up,
"p_fl": p_fl,
"p_dn": p_dn,
"exp_ret": expected_return,
"point": point,
"lo": lo,
"hi": hi,
"feats": json.dumps(features_snap),
"ut": user_triggered,
},
).first()
return int(row[0]) if row else None
def predict_and_store(
code: str,
*,
horizons: tuple[int, ...] = (1, 3, 5),
user_triggered: bool = True,
) -> dict[str, Any]:
"""앙상블 예측 실행 + predictions 테이블 적재. 결과 JSON-serializable dict 반환."""
"""앙상블 예측 실행 + predictions 테이블 적재.
적재 행:
- 'ensemble' (user_triggered 인자 반영)
- 'chronos' (shadow, user_triggered=FALSE) — Chronos 가 성공했을 때만
- 'lgbm' (shadow, user_triggered=FALSE) — LGBM 이 성공한 horizon 만
"""
base_date = _last_trading_date(code)
if base_date is None:
raise RuntimeError(f"no ohlcv_daily for {code}; refresh first")
pred: EnsemblePrediction = ensemble_predict(code, horizons=horizons)
now = datetime.now(KST)
base_close = pred.base_close
eng = get_engine()
saved_ids: list[int] = []
saved_ids: dict[str, list[int]] = {"ensemble": [], "chronos": [], "lgbm": []}
with eng.begin() as conn:
for step in pred.steps:
target_date = _next_trading_target(base_date, step.horizon)
# --- ensemble row ---
features_snap = {
"base_close": pred.base_close,
"base_close": base_close,
"sources_used": pred.sources_used,
"direction": step.direction,
}
row = conn.execute(
text(
"""
INSERT INTO predictions
(code, predicted_at, base_date, target_date, horizon, model,
direction, prob_up, prob_flat, prob_down, expected_return,
point_forecast, ci_low, ci_high, features_snapshot, user_triggered)
VALUES
(:code, :predicted_at, :base_date, :target_date, :horizon, 'ensemble',
:direction, :p_up, :p_fl, :p_dn, :exp_ret,
:point, :lo, :hi, CAST(:feats AS JSONB), :ut)
ON CONFLICT (code, base_date, target_date, horizon, model)
DO UPDATE SET
predicted_at = EXCLUDED.predicted_at,
direction = EXCLUDED.direction,
prob_up = EXCLUDED.prob_up,
prob_flat = EXCLUDED.prob_flat,
prob_down = EXCLUDED.prob_down,
expected_return = EXCLUDED.expected_return,
point_forecast = EXCLUDED.point_forecast,
ci_low = EXCLUDED.ci_low,
ci_high = EXCLUDED.ci_high,
features_snapshot = EXCLUDED.features_snapshot,
user_triggered = predictions.user_triggered OR EXCLUDED.user_triggered
RETURNING id
"""
),
{
"code": code,
"predicted_at": now,
"base_date": base_date,
"target_date": target_date,
"horizon": step.horizon,
"direction": step.direction,
"p_up": step.prob_up,
"p_fl": step.prob_flat,
"p_dn": step.prob_down,
"exp_ret": step.expected_return,
"point": step.point_close,
"lo": step.ci_low,
"hi": step.ci_high,
"feats": json.dumps(features_snap),
"ut": user_triggered,
},
).first()
if row:
saved_ids.append(int(row[0]))
pid = _insert_prediction(
conn,
model="ensemble",
code=code,
predicted_at=now,
base_date=base_date,
target_date=target_date,
horizon=step.horizon,
direction=step.direction,
p_up=step.prob_up,
p_fl=step.prob_flat,
p_dn=step.prob_down,
expected_return=step.expected_return,
point=step.point_close,
lo=step.ci_low,
hi=step.ci_high,
features_snap=features_snap,
user_triggered=user_triggered,
)
if pid is not None:
saved_ids["ensemble"].append(pid)
# --- chronos shadow row ---
cf = pred.chronos_raw
if cf is not None:
c_med = float(cf.median[step.horizon - 1])
c_q10 = float(cf.q10[step.horizon - 1])
c_q90 = float(cf.q90[step.horizon - 1])
# direction prob: chronos sample 분포
try:
import numpy as np
arr = np.array(cf.samples)[:, step.horizon - 1]
ret = arr / base_close - 1.0
cp_up = float((ret > FLAT_BAND).mean())
cp_dn = float((ret < -FLAT_BAND).mean())
cp_fl = max(0.0, 1.0 - cp_up - cp_dn)
except Exception: # noqa: BLE001
cp_up = cp_fl = cp_dn = 1.0 / 3.0
exp_ret_c = c_med / base_close - 1.0
c_dir = _direction_label(exp_ret_c)
pid_c = _insert_prediction(
conn,
model="chronos",
code=code,
predicted_at=now,
base_date=base_date,
target_date=target_date,
horizon=step.horizon,
direction=c_dir,
p_up=cp_up,
p_fl=cp_fl,
p_dn=cp_dn,
expected_return=exp_ret_c,
point=c_med,
lo=c_q10,
hi=c_q90,
features_snap={"shadow": True, "base_close": base_close},
user_triggered=False,
)
if pid_c is not None:
saved_ids["chronos"].append(pid_c)
# --- lgbm shadow row ---
lf = pred.lgbm_raw.get(step.horizon)
if lf is not None:
l_close = float(lf.predicted_close)
exp_ret_l = l_close / base_close - 1.0
l_dir = _direction_label(exp_ret_l)
pid_l = _insert_prediction(
conn,
model="lgbm",
code=code,
predicted_at=now,
base_date=base_date,
target_date=target_date,
horizon=step.horizon,
direction=l_dir,
p_up=float(lf.prob_up),
p_fl=float(lf.prob_flat),
p_dn=float(lf.prob_down),
expected_return=exp_ret_l,
point=l_close,
lo=l_close * 0.97,
hi=l_close * 1.03,
features_snap={"shadow": True, "base_close": base_close},
user_triggered=False,
)
if pid_l is not None:
saved_ids["lgbm"].append(pid_l)
return {
"code": code,
@@ -133,6 +257,11 @@ def predict_and_store(
}
for s in pred.steps
],
"saved_prediction_ids": saved_ids,
# UI 는 ensemble id 만 본다. shadow 는 디버깅/검증용으로 별도 키.
"saved_prediction_ids": saved_ids["ensemble"],
"saved_shadow_ids": {
"chronos": saved_ids["chronos"],
"lgbm": saved_ids["lgbm"],
},
"user_triggered": user_triggered,
}

View File

@@ -2,16 +2,20 @@
일요일 02:00 KST 실행:
1. 시드 10종목 × horizon (1,3,5) 별로 LGBM 학습 (train_one).
2. 최근 30일 prediction_outcomes 의 model 별 hit_rate 산출, model_performance 적재.
3. 같은 30일 윈도우에서 chronos vs lgbm hit_rate 로 ensemble_weights 갱신.
(방법: w_chronos = clamp(0.1, hr_c / (hr_c + hr_l), 0.9), w_lgbm = 1 - w_chronos.
hit_rate 데이터가 부족하면 default 0.6/0.4 유지.)
2. 최근 30일 prediction_outcomes 의 (code, model, horizon) 별 hit_rate / mae
산출, model_performance 적재.
3. shadow 행 (model='chronos' / 'lgbm') 의 hit_rate 를 비교해서
ensemble_weights 자동 보정.
지금은 'ensemble' 모델 단일 종류로 predictions 가 쌓이므로, 가중치 보정은
chronos 단독 시뮬레이션 / lgbm 단독 시뮬레이션 hit_rate 비교가 진정한 방식인데,
Phase 4 단순화: 'ensemble' 의 종합 hit_rate 만 model_performance 에 기록하고
가중치는 default 유지. 진짜 비교는 Phase 7 (chronos 단독 + lgbm 단독 예측을
shadow 로 같이 적재하는 구조) 로 확장.
가중치 공식:
w_c = clamp(0.1, hr_c / (hr_c + hr_l), 0.9)
w_l = 1 - w_c
단 sample_count_c < MIN_SAMPLE 또는 sample_count_l < MIN_SAMPLE 이면
기본값 유지 (DB row 미생성). hr_c + hr_l == 0 (둘 다 0%) 이면 50:50.
predict_one 이 매 호출마다 chronos/lgbm shadow 행을 함께 적재하고
match_outcomes 가 user_triggered 무관하게 매칭하므로, hit_rate 데이터는
사용자가 예측을 한 번이라도 본 종목에 대해 자연스럽게 쌓인다.
"""
from __future__ import annotations
@@ -24,12 +28,16 @@ from sqlalchemy import text
from app.db.connection import get_engine
from app.models.lgbm import train_one
from app.models.weights import upsert_weights
from app.seed.seed_tickers import SEED_TICKERS
logger = logging.getLogger(__name__)
HORIZONS = (1, 3, 5)
WINDOW_DAYS = 30
MIN_SAMPLE = 10 # 모델당 최소 매칭 표본
W_CHRONOS_MIN = 0.1
W_CHRONOS_MAX = 0.9
def retrain_all() -> list[dict[str, Any]]:
@@ -103,6 +111,82 @@ def record_performance(as_of: date) -> list[dict[str, Any]]:
return summary
def adjust_weights(as_of: date) -> list[dict[str, Any]]:
"""shadow chronos/lgbm hit_rate 로 ensemble_weights 자동 보정.
반환: (code, horizon, w_chronos, w_lgbm, hr_c, hr_l, n_c, n_l, action) 의
리스트. action ∈ {'updated', 'skipped_insufficient', 'skipped_zero'}.
"""
eng = get_engine()
start = as_of - timedelta(days=WINDOW_DAYS)
out: list[dict[str, Any]] = []
with eng.begin() as conn:
rows = conn.execute(
text(
"""
SELECT code, horizon, model,
AVG(CASE WHEN direction_hit THEN 1.0 ELSE 0.0 END) AS hit_rate,
COUNT(*) AS n
FROM prediction_outcomes
WHERE resolved_at >= :start
AND model IN ('chronos', 'lgbm')
GROUP BY code, horizon, model
"""
),
{"start": start},
).all()
# (code, horizon) -> {'chronos': (hr, n), 'lgbm': (hr, n)}
agg: dict[tuple[str, int], dict[str, tuple[float, int]]] = {}
for code, horizon, model, hr, n in rows:
key = (code, int(horizon))
agg.setdefault(key, {})[str(model)] = (
float(hr) if hr is not None else 0.0,
int(n),
)
for (code, horizon), m in agg.items():
c = m.get("chronos")
l = m.get("lgbm")
if c is None or l is None:
out.append({
"code": code, "horizon": horizon,
"action": "skipped_missing_model",
"have": list(m.keys()),
})
continue
hr_c, n_c = c
hr_l, n_l = l
if n_c < MIN_SAMPLE or n_l < MIN_SAMPLE:
out.append({
"code": code, "horizon": horizon,
"hr_chronos": hr_c, "hr_lgbm": hr_l,
"n_chronos": n_c, "n_lgbm": n_l,
"action": "skipped_insufficient",
})
continue
total = hr_c + hr_l
if total <= 0:
w_c = 0.5
else:
w_c = hr_c / total
w_c = max(W_CHRONOS_MIN, min(W_CHRONOS_MAX, w_c))
w_l = 1.0 - w_c
upsert_weights(
code, horizon, w_c, w_l,
hit_rate_chronos=hr_c, hit_rate_lgbm=hr_l,
sample_count=min(n_c, n_l),
)
out.append({
"code": code, "horizon": horizon,
"hr_chronos": hr_c, "hr_lgbm": hr_l,
"n_chronos": n_c, "n_lgbm": n_l,
"w_chronos": w_c, "w_lgbm": w_l,
"action": "updated",
})
return out
def run_weekly() -> dict[str, Any]:
"""일요일 02:00 KST 호출 entry-point."""
from datetime import datetime, timezone
@@ -113,6 +197,7 @@ def run_weekly() -> dict[str, Any]:
"as_of": str(as_of),
"trained": retrain_all(),
"performance": record_performance(as_of),
"weights": adjust_weights(as_of),
}

View File

@@ -0,0 +1,178 @@
"""KRX 주요 종목 정적 스냅샷.
용도: pykrx → KRX 실시간 호출이 LOGOUT/JSONDecodeError 로 실패할 때
symbols 테이블 검색 universe 를 최소한 보장하기 위한 fallback.
수록 범위:
- KOSPI/KOSDAQ 시가총액 상위 + 일반 사용자가 검색할 법한 친숙한 이름 위주.
- 종목코드(KRX 공시)와 종목명(현재 등록명)은 공개된 사실 정보.
- 전 종목은 KRX 실시간이 복구되면 seed_symbols() 가 자동 덮어쓴다.
정확성 주의:
- 회사 합병/분할/사명변경/상장폐지가 잦아 일부 항목은 시간 지나면 stale.
- KRX 호출이 정상화되면 자동 upsert 로 덮어쓰므로 stale 항목은 자연 보정.
"""
from __future__ import annotations
# (code, name, market)
KRX_STATIC_MAJORS: list[tuple[str, str, str]] = [
# ---------------- KOSPI (시총 상위 + 익숙한 종목) ----------------
("005930", "삼성전자", "KOSPI"),
("005935", "삼성전자우", "KOSPI"),
("000660", "SK하이닉스", "KOSPI"),
("373220", "LG에너지솔루션", "KOSPI"),
("207940", "삼성바이오로직스", "KOSPI"),
("005380", "현대차", "KOSPI"),
("000270", "기아", "KOSPI"),
("035420", "NAVER", "KOSPI"),
("068270", "셀트리온", "KOSPI"),
("005490", "POSCO홀딩스", "KOSPI"),
("105560", "KB금융", "KOSPI"),
("035720", "카카오", "KOSPI"),
("028260", "삼성물산", "KOSPI"),
("055550", "신한지주", "KOSPI"),
("012330", "현대모비스", "KOSPI"),
("086790", "하나금융지주", "KOSPI"),
("051910", "LG화학", "KOSPI"),
("006400", "삼성SDI", "KOSPI"),
("003550", "LG", "KOSPI"),
("015760", "한국전력공사", "KOSPI"),
("032830", "삼성생명", "KOSPI"),
("003670", "포스코퓨처엠", "KOSPI"),
("017670", "SK텔레콤", "KOSPI"),
("009150", "삼성전기", "KOSPI"),
("011200", "HMM", "KOSPI"),
("138040", "메리츠금융지주", "KOSPI"),
("316140", "우리금융지주", "KOSPI"),
("018260", "삼성에스디에스", "KOSPI"),
("010130", "고려아연", "KOSPI"),
("034730", "SK", "KOSPI"),
("030200", "KT", "KOSPI"),
("066570", "LG전자", "KOSPI"),
("011070", "LG이노텍", "KOSPI"),
("010950", "S-Oil", "KOSPI"),
("047810", "한국항공우주", "KOSPI"),
("024110", "기업은행", "KOSPI"),
("138930", "BNK금융지주", "KOSPI"),
("010140", "삼성중공업", "KOSPI"),
("251270", "넷마블", "KOSPI"),
("028050", "삼성E&A", "KOSPI"),
("042660", "한화오션", "KOSPI"),
("086280", "현대글로비스", "KOSPI"),
("064350", "현대로템", "KOSPI"),
("042700", "한미반도체", "KOSPI"),
("267260", "HD현대일렉트릭", "KOSPI"),
("329180", "HD현대중공업", "KOSPI"),
("010620", "현대미포조선", "KOSPI"),
("161390", "한국타이어앤테크놀로지", "KOSPI"),
("097950", "CJ제일제당", "KOSPI"),
("271560", "오리온", "KOSPI"),
("175330", "JB금융지주", "KOSPI"),
("139480", "이마트", "KOSPI"),
("023530", "롯데쇼핑", "KOSPI"),
("088350", "한화생명", "KOSPI"),
("088980", "맥쿼리인프라", "KOSPI"),
("004020", "현대제철", "KOSPI"),
("047050", "포스코인터내셔널", "KOSPI"),
("069960", "현대백화점", "KOSPI"),
("003490", "대한항공", "KOSPI"),
("035250", "강원랜드", "KOSPI"),
("071050", "한국금융지주", "KOSPI"),
("000810", "삼성화재", "KOSPI"),
("011170", "롯데케미칼", "KOSPI"),
("000720", "현대건설", "KOSPI"),
("000150", "두산", "KOSPI"),
("034020", "두산에너빌리티", "KOSPI"),
("282330", "BGF리테일", "KOSPI"),
("007070", "GS리테일", "KOSPI"),
("078930", "GS", "KOSPI"),
("011780", "금호석유", "KOSPI"),
("002790", "아모레G", "KOSPI"),
("090430", "아모레퍼시픽", "KOSPI"),
("001040", "CJ", "KOSPI"),
("005180", "빙그레", "KOSPI"),
("049770", "동원F&B", "KOSPI"),
("005300", "롯데칠성", "KOSPI"),
("000080", "하이트진로", "KOSPI"),
("004990", "롯데지주", "KOSPI"),
("000100", "유한양행", "KOSPI"),
("009540", "HD한국조선해양", "KOSPI"),
("010060", "OCI홀딩스", "KOSPI"),
("011790", "SKC", "KOSPI"),
("004170", "신세계", "KOSPI"),
("008770", "호텔신라", "KOSPI"),
("002380", "KCC", "KOSPI"),
("014820", "동원시스템즈", "KOSPI"),
("005830", "DB손해보험", "KOSPI"),
("089860", "롯데렌탈", "KOSPI"),
("009830", "한화솔루션", "KOSPI"),
("006650", "대한유화", "KOSPI"),
("000240", "한국앤컴퍼니", "KOSPI"),
("026960", "동서", "KOSPI"),
("001450", "현대해상", "KOSPI"),
("003410", "쌍용C&E", "KOSPI"),
("003090", "대웅", "KOSPI"),
("069620", "대웅제약", "KOSPI"),
("128940", "한미약품", "KOSPI"),
("004000", "롯데정밀화학", "KOSPI"),
("000120", "CJ대한통운", "KOSPI"),
("005880", "대한해운", "KOSPI"),
("001230", "동국홀딩스", "KOSPI"),
("000990", "DB하이텍", "KOSPI"),
("036570", "엔씨소프트", "KOSPI"),
("267250", "HD현대", "KOSPI"),
# ---------------- KOSDAQ (시총/거래대금/이름 인지도) ----------------
("247540", "에코프로비엠", "KOSDAQ"),
("086520", "에코프로", "KOSDAQ"),
("035760", "CJ ENM", "KOSDAQ"),
("058470", "리노공업", "KOSDAQ"),
("196170", "알테오젠", "KOSDAQ"),
("091990", "셀트리온헬스케어", "KOSDAQ"),
("028300", "HLB", "KOSDAQ"),
("357780", "솔브레인", "KOSDAQ"),
("263750", "펄어비스", "KOSDAQ"),
("293490", "카카오게임즈", "KOSDAQ"),
("145020", "휴젤", "KOSDAQ"),
("277810", "레인보우로보틱스", "KOSDAQ"),
("240810", "원익IPS", "KOSDAQ"),
("357180", "LX세미콘", "KOSDAQ"),
("376300", "디어유", "KOSDAQ"),
("095610", "테스", "KOSDAQ"),
("067160", "아프리카TV", "KOSDAQ"),
("067310", "하나마이크론", "KOSDAQ"),
("388790", "케이엠더블유", "KOSDAQ"),
("089030", "테크윙", "KOSDAQ"),
("086900", "메디톡스", "KOSDAQ"),
("035900", "JYP Ent.", "KOSDAQ"),
("041510", "에스엠", "KOSDAQ"),
("122870", "와이지엔터테인먼트", "KOSDAQ"),
("352820", "하이브", "KOSDAQ"),
("039030", "이오테크닉스", "KOSDAQ"),
("066970", "엘앤에프", "KOSDAQ"),
("112040", "위메이드", "KOSDAQ"),
("194700", "네오위즈", "KOSDAQ"),
("095700", "제넥신", "KOSDAQ"),
("214150", "클래시스", "KOSDAQ"),
("121600", "나노신소재", "KOSDAQ"),
("064550", "바이오니아", "KOSDAQ"),
("304100", "솔트룩스", "KOSDAQ"),
("078340", "컴투스", "KOSDAQ"),
("226330", "신테카바이오", "KOSDAQ"),
("215000", "골프존", "KOSDAQ"),
("950140", "잉글우드랩", "KOSDAQ"),
("328130", "루닛", "KOSDAQ"),
("000250", "삼천당제약", "KOSDAQ"),
("237690", "에스티팜", "KOSDAQ"),
("196300", "쇼박스", "KOSDAQ"),
("215600", "신라젠", "KOSDAQ"),
("357580", "엠로", "KOSDAQ"),
("403870", "HPSP", "KOSDAQ"),
("078160", "메디포스트", "KOSDAQ"),
("141080", "리가켐바이오", "KOSDAQ"),
("306200", "세아메카닉스", "KOSDAQ"),
("213420", "덕산네오룩스", "KOSDAQ"),
("085660", "차바이오텍", "KOSDAQ"),
("950130", "엑세스바이오", "KOSDAQ"),
("950170", "JTC", "KOSDAQ"),
]

236
backend/app/seed/themes.py Normal file
View File

@@ -0,0 +1,236 @@
"""테마/카테고리 → 종목코드 정적 매핑.
토스 '주식 골라보기' 의 테마 분류와 비슷한 결을 흉내. 종목/테마 페어링은 공개
정보 기준이며, 시장 사정에 따라 합병/분할/사명변경 등으로 stale 될 수 있다.
KRX 정상화 + sector 컬럼 채우기가 끝나면 DB-driven 분류로 전환 가능.
각 테마는 KRX_STATIC_MAJORS 안의 코드 위주 — symbols 테이블에 무조건 존재해
검색/카드 표시가 깨지지 않음.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Theme:
slug: str
name: str
description: str
codes: tuple[str, ...]
THEMES: tuple[Theme, ...] = (
Theme(
slug="semiconductor",
name="반도체",
description="메모리·파운드리·장비 핵심.",
codes=(
"005930", # 삼성전자
"000660", # SK하이닉스
"042700", # 한미반도체
"240810", # 원익IPS
"357180", # LX세미콘
"403870", # HPSP
"039030", # 이오테크닉스
"089030", # 테크윙
"095610", # 테스
"067310", # 하나마이크론
"000990", # DB하이텍
),
),
Theme(
slug="battery",
name="2차전지",
description="셀·소재·장비 전반.",
codes=(
"373220", # LG에너지솔루션
"006400", # 삼성SDI
"051910", # LG화학
"247540", # 에코프로비엠
"086520", # 에코프로
"066970", # 엘앤에프
"003670", # 포스코퓨처엠
"121600", # 나노신소재
"010130", # 고려아연
"213420", # 덕산네오룩스
),
),
Theme(
slug="bio",
name="바이오·헬스케어",
description="제약·바이오시밀러·CDMO.",
codes=(
"207940", # 삼성바이오로직스
"068270", # 셀트리온
"091990", # 셀트리온헬스케어
"196170", # 알테오젠
"145020", # 휴젤
"086900", # 메디톡스
"069620", # 대웅제약
"128940", # 한미약품
"000100", # 유한양행
"328130", # 루닛
"237690", # 에스티팜
"141080", # 리가켐바이오
"000250", # 삼천당제약
"028300", # HLB
"078160", # 메디포스트
"085660", # 차바이오텍
),
),
Theme(
slug="game",
name="게임",
description="모바일·PC·콘솔 게임사.",
codes=(
"035720", # 카카오 (게임즈 모회사)
"036570", # 엔씨소프트
"251270", # 넷마블
"263750", # 펄어비스
"293490", # 카카오게임즈
"112040", # 위메이드
"194700", # 네오위즈
"078340", # 컴투스
),
),
Theme(
slug="entertainment",
name="엔터테인먼트",
description="K-팝/엔터/플랫폼.",
codes=(
"352820", # 하이브
"035900", # JYP Ent.
"041510", # 에스엠
"122870", # 와이지엔터테인먼트
"067160", # 아프리카TV
"376300", # 디어유
"035760", # CJ ENM
"196300", # 쇼박스
),
),
Theme(
slug="auto",
name="자동차",
description="완성차·부품·타이어.",
codes=(
"005380", # 현대차
"000270", # 기아
"012330", # 현대모비스
"086280", # 현대글로비스
"161390", # 한국타이어앤테크놀로지
"000240", # 한국앤컴퍼니
),
),
Theme(
slug="finance",
name="금융",
description="은행·증권·보험·지주.",
codes=(
"105560", # KB금융
"055550", # 신한지주
"086790", # 하나금융지주
"316140", # 우리금융지주
"138040", # 메리츠금융지주
"175330", # JB금융지주
"138930", # BNK금융지주
"024110", # 기업은행
"032830", # 삼성생명
"088350", # 한화생명
"000810", # 삼성화재
"005830", # DB손해보험
"001450", # 현대해상
"071050", # 한국금융지주
),
),
Theme(
slug="shipbuilding-defense",
name="조선·방산",
description="조선·중공업·항공우주·방산.",
codes=(
"329180", # HD현대중공업
"009540", # HD한국조선해양
"267260", # HD현대일렉트릭
"010620", # 현대미포조선
"042660", # 한화오션
"010140", # 삼성중공업
"012450", # 한화에어로스페이스 (SEED)
"047810", # 한국항공우주
"064350", # 현대로템
"267250", # HD현대
),
),
Theme(
slug="platform-ai",
name="플랫폼·AI",
description="검색·메신저·콘텐츠·로봇.",
codes=(
"035420", # NAVER
"035720", # 카카오
"277810", # 레인보우로보틱스
"304100", # 솔트룩스
"388790", # 케이엠더블유
),
),
Theme(
slug="consumer-retail",
name="유통·소비재",
description="대형마트·백화점·식음료·뷰티.",
codes=(
"139480", # 이마트
"023530", # 롯데쇼핑
"069960", # 현대백화점
"004170", # 신세계
"282330", # BGF리테일
"007070", # GS리테일
"097950", # CJ제일제당
"271560", # 오리온
"005180", # 빙그레
"049770", # 동원F&B
"005300", # 롯데칠성
"000080", # 하이트진로
"002790", # 아모레G
"090430", # 아모레퍼시픽
"008770", # 호텔신라
),
),
Theme(
slug="energy-utility",
name="에너지·유틸리티",
description="전력·정유·가스·풍력.",
codes=(
"015760", # 한국전력공사
"017670", # SK텔레콤 (필수통신 묶음)
"030200", # KT
"010950", # S-Oil
"047050", # 포스코인터내셔널
"036460", # 한국가스공사 (SEED)
"034020", # 두산에너빌리티 (SEED)
"088980", # 맥쿼리인프라
),
),
)
def themes_index() -> list[dict]:
return [
{
"slug": t.slug,
"name": t.name,
"description": t.description,
"code_count": len(t.codes),
}
for t in THEMES
]
def theme_by_slug(slug: str) -> Theme | None:
for t in THEMES:
if t.slug == slug:
return t
return None
def themes_for_code(code: str) -> list[Theme]:
"""주어진 종목 코드가 속한 테마들. 한 종목이 여러 테마에 동시 등재될 수 있음."""
return [t for t in THEMES if code in t.codes]

View File

@@ -31,7 +31,7 @@ dependencies = [
"transformers==4.41.2",
"tokenizers==0.19.1",
"sentencepiece==0.2.0",
"accelerate==0.30.1",
"accelerate==0.34.2",
"chronos-forecasting==1.4.1",
"scikit-learn==1.5.0",
"lightgbm==4.3.0",

View File

@@ -8,5 +8,8 @@ services:
count: all
capabilities: [gpu]
environment:
MODEL_DEVICE: cuda
# MODEL_DEVICE 는 .env 로 덮어쓰기 가능. GPU 빌드라도 PyTorch/CUDA 호환 문제 (예:
# 'no kernel image is available for execution on the device') 발생 시 .env 에
# MODEL_DEVICE=cpu 를 두고 `docker compose ... up -d backend` 로 회피.
MODEL_DEVICE: ${MODEL_DEVICE:-cuda}
NVIDIA_VISIBLE_DEVICES: all

57
restart-ci.bat Normal file
View File

@@ -0,0 +1,57 @@
@echo off
REM stock_chart_site - SSH/CI 친화 재시작 스크립트
REM
REM restart.bat 과의 차이: pause 가 없음. SSH 비대화형 (예: ssh user@host "restart-ci.bat")
REM 에서 멈추지 않고 끝까지 실행. 에러는 종료 코드로만 알린다.
REM
REM 일반 사용 시엔 restart.bat 을 쓰는게 출력 검토에 편하다.
setlocal enabledelayedexpansion
cd /d "%~dp0"
echo === stock_chart_site restart-ci ===
where docker >nul 2>&1
if errorlevel 1 (
echo [ERROR] docker not found
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo [ERROR] Docker Desktop not running
exit /b 1
)
set USE_GPU=0
where nvidia-smi >nul 2>&1
if not errorlevel 1 (
nvidia-smi >nul 2>&1
if not errorlevel 1 set USE_GPU=1
)
if "%USE_GPU%"=="1" (
echo [GPU] using GPU profile
set COMPOSE_FILES=-f docker-compose.yml -f docker-compose.gpu.yml
) else (
echo [CPU] using CPU profile
set COMPOSE_FILES=-f docker-compose.yml
)
for /f %%i in ('docker compose %COMPOSE_FILES% ps --status running --quiet backend web 2^>nul ^| find /v /c ""') do set RUN_COUNT=%%i
if "%RUN_COUNT%"=="0" (
echo [ERROR] backend/web not running. run build.bat first.
exit /b 1
)
echo === docker compose up -d --force-recreate --no-deps backend web ===
docker compose %COMPOSE_FILES% up -d --force-recreate --no-deps backend web
if errorlevel 1 (
echo [ERROR] restart failed
exit /b 1
)
echo === status ===
docker compose %COMPOSE_FILES% ps
endlocal
exit /b 0

94
restart.bat Normal file
View File

@@ -0,0 +1,94 @@
@echo off
REM stock_chart_site - Windows 재시작 스크립트
REM
REM build.bat 와의 차이:
REM - build.bat: 이미지 재빌드 포함. Dockerfile / pyproject.toml / package*.json /
REM compose 설정 등 의존성/이미지 구성이 바뀌었을 때 사용.
REM - restart.bat: 재빌드 없이 컨테이너만 재시작. backend/app/ 또는 web/app/ 안의
REM 코드만 바뀐 경우. docker-compose.yml 의 바인드 마운트 (./backend:/app,
REM ./web:/app) 덕에 새 코드가 즉시 컨테이너 안에서 보이고, 재시작으로
REM lifespan (부팅 시드 등) 도 다시 돌릴 수 있다.
REM
REM 즉 일반적으로 git pull 후:
REM - pyproject.toml / Dockerfile / package*.json 변경 있음 → build.bat
REM - app/ 코드만 변경 → restart.bat
setlocal enabledelayedexpansion
cd /d "%~dp0"
echo === stock_chart_site restart ===
REM 1) Docker 확인
where docker >nul 2>&1
if errorlevel 1 (
echo [ERROR] docker 명령을 찾을 수 없습니다. Docker Desktop 설치/실행을 확인하세요.
pause
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo [ERROR] Docker Desktop이 실행 중이 아닙니다.
pause
exit /b 1
)
REM 2) GPU 감지 (build.bat 과 동일 — compose 파일 조합 일치 위해)
set USE_GPU=0
where nvidia-smi >nul 2>&1
if not errorlevel 1 (
nvidia-smi >nul 2>&1
if not errorlevel 1 set USE_GPU=1
)
if "%USE_GPU%"=="1" (
echo [GPU] NVIDIA GPU detected. Using GPU profile.
set COMPOSE_FILES=-f docker-compose.yml -f docker-compose.gpu.yml
) else (
echo [CPU] NVIDIA GPU not detected. Using CPU profile.
set COMPOSE_FILES=-f docker-compose.yml
)
REM 3) backend/web 컨테이너 살아있는지 확인 — 없으면 build.bat 안내
REM (db 까지 포함해서 세면 db 만 떠있어도 통과돼버려서 부정확)
for /f %%i in ('docker compose %COMPOSE_FILES% ps --status running --quiet backend web 2^>nul ^| find /v /c ""') do set RUN_COUNT=%%i
if "%RUN_COUNT%"=="0" (
echo [INFO] 실행 중인 backend/web 컨테이너가 없습니다. 처음이거나 down 된 상태입니다.
echo build.bat 으로 빌드 + 기동하세요.
pause
exit /b 1
)
REM 4) backend + web 만 재기동 — db 는 건드리지 않음 (--no-deps).
REM
REM 왜 `restart` 가 아니라 `up -d --force-recreate` 인가:
REM - `docker compose restart` 는 기존 컨테이너를 stop/start 만 한다. 그래서
REM `.env` 변경 (예: KIS_APP_KEY 갱신) 이 반영되지 않는다. env_file 은
REM 컨테이너 "생성" 시점에만 읽힌다.
REM - `up -d --force-recreate` 는 새 컨테이너 인스턴스를 만들어서 env_file 을
REM 다시 읽는다. 이게 사용자가 .env 만 고치고 restart.bat 돌렸을 때 직관에 맞는다.
REM - `--no-deps` 로 db 는 절대 건드리지 않음. db 는 postgres_data 볼륨이 영속이라
REM 재기동할 이유 없고, depends_on.condition: service_healthy 와 무관하게 안전.
echo.
echo === docker compose up -d --force-recreate --no-deps backend web ===
docker compose %COMPOSE_FILES% up -d --force-recreate --no-deps backend web
if errorlevel 1 (
echo [ERROR] restart 실패.
pause
exit /b 1
)
echo.
echo === 상태 ===
docker compose %COMPOSE_FILES% ps
echo.
echo 접속:
echo Web http://localhost:3000
echo Backend http://localhost:8000/health
echo DB ext http://localhost:8000/health/db
echo.
echo 로그 보기: docker compose logs -f backend
echo 정지: docker compose down
echo.
pause
endlocal

View File

@@ -1,40 +1,150 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
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";
import { PeerComparePanel } from "../../components/PeerComparePanel";
import { NewsList } from "../../components/NewsList";
import { OrderbookPanel } from "../../components/OrderbookPanel";
import { PeriodReturns } from "../../components/PeriodReturns";
import { PeriodTabs, periodNeighbor, 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 { ShareButton } from "../../components/ShareButton";
import { StarButton } from "../../components/StarButton";
import { StockChart } from "../../components/StockChart";
import {
api,
type ChartPayload,
type LatestPredictionResponse,
} from "../../lib/api";
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 { readTargets, type Targets } from "../../lib/targets";
import { watchlist } from "../../lib/watchlist";
import { markRecorded, wasRecordedToday } from "../../lib/views";
export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params;
const [chart, setChart] = useState<ChartPayload | null>(null);
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [days, setDays] = useState(180);
const [period, setPeriod] = useState<Period>("3M");
const [viewsToday, setViewsToday] = useState<number | null>(null);
const [targets, setTargets] = useState<Targets>({});
const spec = periodSpec(period);
const isIntraday = spec.interval === "10m";
// 종목 페이지 방문 시 최근 본 종목에 push.
useEffect(() => {
recent.push(code);
}, [code]);
// 조회 카운터.
// - 같은 (code, KST today) 는 localStorage 마크로 dedupe → POST 1회.
// - dedupe 실패 (스토리지 잠금 등) 해도 GET 으로 today_views 는 조회.
// - POST 응답이 today_views 를 주므로 별도 GET 안 해도 됨 (한 라운드트립으로 끝).
// - 이미 봤던 종목이면 GET 만.
useEffect(() => {
let alive = true;
const apply = (n: number) => {
if (alive) setViewsToday(n);
};
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(() => {});
}
return () => {
alive = false;
};
}, [code]);
// 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드.
useEffect(() => {
setTargets(readTargets(code));
}, [code]);
// 종목 페이지 키보드 단축키:
// ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap 안 함, 사용자가 끝 인지)
// S : 관심종목 토글
// 입력 필드(검색창, 메모 등) 포커스 시 무시 — 텍스트 편집 방해 금지.
// modifier(Ctrl/Cmd/Alt) 가 눌리면 브라우저/OS 단축키 양보.
useEffect(() => {
if (typeof window === "undefined") return;
const isEditableTarget = (t: EventTarget | null): boolean => {
if (!(t instanceof HTMLElement)) return false;
const tag = t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (t.isContentEditable) return true;
return false;
};
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (isEditableTarget(e.target)) return;
// 모달/대화상자가 열려 있으면 뒤의 페이지 단축키를 가로채지 않음.
// ShortcutsHelp 처럼 close 버튼이 포커스 잡혀 있어도 editable 가드는 통과되므로,
// 페이지 단축키가 뒤에서 작동하는 것을 막기 위해 aria-modal 으로 추가 확인.
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
if (e.key === "ArrowLeft") {
e.preventDefault();
setPeriod((cur) => periodNeighbor(cur, -1));
} else if (e.key === "ArrowRight") {
e.preventDefault();
setPeriod((cur) => periodNeighbor(cur, 1));
} else if (e.key === "s" || e.key === "S") {
e.preventDefault();
watchlist.toggle(code);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [code]);
useEffect(() => {
let alive = true;
setErr(null);
setChart(null);
api
.getChart(code, days)
.then((c) => {
if (alive) setChart(c);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
const load = () => {
api
.getChart(code, spec.days, spec.interval)
.then((c) => {
if (alive) setChart(c);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
};
load();
// 1일(10분봉) 모드만 60s 폴링 — 백엔드가 10분 내면 DB 만 읽음.
if (isIntraday) {
const h = window.setInterval(load, 60_000);
return () => {
alive = false;
window.clearInterval(h);
};
}
return () => {
alive = false;
};
}, [code, days]);
}, [code, spec.days, spec.interval, isIntraday]);
useEffect(() => {
let alive = true;
@@ -44,53 +154,177 @@ export default function CodePage({ params }: { params: { code: string } }) {
if (alive && r.found) setPrediction(r);
})
.catch(() => {
// 예측 이력 없는 경우는 무시.
// 예측 이력 없음 — 무시
});
return () => {
alive = false;
};
}, [code]);
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="mb-4 flex items-center justify-between">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs"
>
<option value={60}> 3</option>
<option value={180}> 6</option>
<option value={365}> 1</option>
<option value={1095}> 3</option>
</select>
</div>
// 현재가/전일가 + 헤더 sparkline 시리즈 + 오늘 흐름 OHLV — ohlcv 끝부분에서 산출.
//
// flow 의 정의 (period 모드별로 "오늘" 의 의미가 다름):
// - 1D(10m): 같은 KST 날짜의 모든 봉을 묶어서 today 의 세션 OHLV 로 집계.
// open = 첫 봉 open, high = max(high), low = min(low), volume = sum(volume).
// - 1d/1w/1mo: 마지막 봉 자체가 한 단위 (일/주/월) — 그 봉의 OHLV 를 그대로 사용.
// PriceHero hero 라인 아래에 "시 / 고 / 저 / 거래량" mini KPI 로 표시 — 차트 hover 없이도
// 페이지 진입 즉시 그날 흐름이 한 줄로 보임.
const { current, prev, asOf, sparkSeries, flow } = useMemo(() => {
const empty = {
current: null as number | null,
prev: null as number | null,
asOf: null as string | null,
sparkSeries: [] as number[],
flow: null as { open: number; high: number; low: number; volume: number } | null,
};
if (!chart || !chart.ohlcv.length) return empty;
const valid = chart.ohlcv.filter((p) => p.close != null);
if (!valid.length) return empty;
const last = valid[valid.length - 1];
const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null;
// 최근 30개 종가 (없으면 가용 전체).
const recent = valid.slice(-30).map((p) => p.close as number);
{chart && (
<div className="mb-4">
<h1 className="text-2xl font-semibold text-zinc-100">
{chart.name}{" "}
<span className="text-sm font-normal text-zinc-500">
{chart.code} · {chart.market}
</span>
</h1>
// flow 집계.
const isIntra = chart.interval === "10m";
let bars: typeof valid;
if (isIntra) {
// 10m 모드는 'YYYY-MM-DDTHH:MM:SS' — 'T' 이전이 KST 날짜. 마지막 봉의 날짜와 같은
// 봉만 묶음 (intraday 세션 = 오늘).
const lastDay = last.date.split("T")[0];
bars = valid.filter((p) => p.date.split("T")[0] === lastDay);
} else {
bars = [last];
}
const opens = bars.map((p) => p.open).filter((v): v is number => v != null);
const highs = bars.map((p) => p.high).filter((v): v is number => v != null);
const lows = bars.map((p) => p.low).filter((v): v is number => v != null);
const vols = bars.map((p) => p.volume).filter((v): v is number => v != null);
const flow =
opens.length && highs.length && lows.length
? {
open: opens[0],
high: Math.max(...highs),
low: Math.min(...lows),
// volume 누락 봉은 0 로 취급 (sum 안 그러면 NaN). 1d 모드는 단일 봉이라 무관.
volume: vols.reduce((a, b) => a + b, 0),
}
: null;
return {
current: last.close,
prev: prevPt?.close ?? null,
asOf: last.date,
sparkSeries: recent,
flow,
};
}, [chart]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
{/*
헤더 액션 바 — 2 행 구조:
1행) 좌: 페이지 nav 링크 (← 검색 / 관심종목), 우: 종목 액션 (비교/공유/Star)
2행) PeriodTabs (7 탭) 단독 — 항상 우측 정렬
한 줄에 nav+액션+7탭 을 모두 넣으면 모바일에서 wrap 되며 PeriodTabs 가 줄 중간에
끼어 어색해짐. 2행 분리 + flex-wrap + overflow-x-auto 로 모든 폭에서 일관.
*/}
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<Link
href="/watchlist"
className="text-xs text-zinc-500 hover:text-zinc-300"
>
</Link>
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-3">
<Link
href={`/compare?codes=${code}`}
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
title="이 종목으로 비교 시작"
>
</Link>
<ShareButton code={code} name={chart?.name} />
<StarButton code={code} name={chart?.name} />
</div>
</div>
{/*
PeriodTabs 가 좁은 폭에서 컨테이너보다 넓어질 때:
부모에 `justify-end` 를 직접 걸면 음수 방향으로 첫 탭이 잘려서 스크롤로도 접근이 어려움.
wrapper-of-wrapper 패턴 — 바깥은 overflow-x-auto 스크롤만, 안쪽 inline flex 가 폭에 맞춰
넓으면 좌측 정렬(스크롤 노출), 좁으면 우측 정렬(공간 차지 안 함). w-max + min-w-full 이 핵심.
*/}
<div className="mb-4 overflow-x-auto">
<div className="flex w-max min-w-full justify-end">
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
</div>
</div>
{err && <div className="mb-4 text-sm text-red-400"> : {err}</div>}
{chart && (
<>
<StockChart chart={chart} prediction={prediction} />
<PriceHero
name={chart.name}
code={chart.code}
market={chart.market}
current={current}
prev={prev}
asOf={asOf}
series={sparkSeries}
viewsToday={viewsToday}
flow={flow}
/>
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<div>
<StockChart chart={chart} prediction={prediction} targets={targets} />
{isIntraday && chart.intraday_status && (
<div className="mt-2 text-right text-[11px] text-zinc-500">
10 · 60 · [{chart.intraday_status}]
</div>
)}
<div className="mt-6">
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
</div>
</div>
<div className="space-y-4">
<SymbolSidebar code={code} />
<OrderbookPanel code={code} sticky={isIntraday} />
<PriceTargets code={code} current={current} onChange={setTargets} />
<AlertsPanel code={code} name={chart?.name} current={current} />
<InvestmentNote code={code} />
<RelatedStocks code={code} />
</div>
</div>
<div className="mt-6">
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
<CompositeScoreCard chart={chart} />
</div>
<div className="mt-6">
{/* 1D(10분봉) 모드에선 PeriodReturns 의 거래일 라벨이 거짓이 되므로 단기 카드로 교체 */}
{isIntraday ? (
<IntradayReturns ohlcv={chart.ohlcv} />
) : (
<PeriodReturns ohlcv={chart.ohlcv} />
)}
</div>
<div className="mt-6">
<PeerComparePanel code={code} />
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2">
<TradingValuePanel data={chart.trading_value} />
<InvestorCumulative data={chart.trading_value} />
</div>
<div className="mt-6">
<MetricsPanel code={code} />
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2">
<NewsList code={code} />
<DisclosuresPanel code={code} />
</div>
</>
)}

212
web/app/alerts/page.tsx Normal file
View File

@@ -0,0 +1,212 @@
"use client";
// /alerts — 모든 가격 알람을 한 화면에서 관리.
// 종목별로 그룹핑, 활성/발사됨 분리, 재무장·삭제·일괄 지우기.
// AlertsToaster 가 글로벌로 폴링하므로 이 페이지는 표시/조작만 담당.
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { alerts, type Alert } from "../../lib/alerts";
export default function AlertsPage() {
const [items, setItems] = useState<Alert[]>([]);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
const refresh = () => setItems(alerts.list());
refresh();
return alerts.subscribe(refresh);
}, []);
// 종목별 그룹. 표시는 최신 등록순(prepend 된 순서) 유지.
const grouped = useMemo(() => {
const m = new Map<string, { name?: string; rows: Alert[] }>();
for (const a of items) {
const g = m.get(a.code) ?? { name: a.name, rows: [] };
if (!g.name && a.name) g.name = a.name;
g.rows.push(a);
m.set(a.code, g);
}
return Array.from(m.entries()).map(([code, g]) => ({
code,
name: g.name,
rows: g.rows,
}));
}, [items]);
const activeCount = items.filter((a) => !a.triggered).length;
const firedCount = items.length - activeCount;
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4 flex items-center justify-between gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<span className="text-[11px] text-zinc-500">
{activeCount} · {firedCount}
</span>
</div>
<div className="flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-zinc-50"> </h1>
<p className="mt-1 text-sm text-zinc-400">
. <b className="text-zinc-300"> </b>{" "}
60 + ( )
. .
</p>
</div>
{items.length > 0 && (
<button
type="button"
onClick={() => {
if (confirm("모든 알람을 삭제하시겠어요?")) alerts.clear();
}}
className="shrink-0 rounded-md border border-zinc-700 px-2 py-1 text-[11px] text-zinc-400 hover:border-rose-500/40 hover:text-rose-300"
>
</button>
)}
</div>
<div className="mt-6">
{!hydrated ? (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
.
<br />
<b className="text-amber-300"> </b> .
</div>
) : (
<NotificationHint />
)}
{hydrated && grouped.length > 0 && (
<div className="mt-4 space-y-3">
{grouped.map((g) => (
<Group key={g.code} code={g.code} name={g.name} rows={g.rows} />
))}
</div>
)}
</div>
</main>
);
}
// 알림 권한이 default 면 안내 + 요청 버튼.
function NotificationHint() {
const [perm, setPerm] = useState<NotificationPermission | "unsupported">(
"unsupported",
);
useEffect(() => {
if (typeof window === "undefined") return;
if (!("Notification" in window)) {
setPerm("unsupported");
return;
}
setPerm(Notification.permission);
}, []);
if (perm === "granted" || perm === "denied" || perm === "unsupported") return null;
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200">
<span> . OS ( ).</span>
<button
type="button"
onClick={async () => {
try {
const p = await Notification.requestPermission();
setPerm(p);
} catch {
// 무시
}
}}
className="shrink-0 rounded-md border border-amber-400/50 bg-amber-500/10 px-2 py-1 text-[11px] hover:bg-amber-500/20"
>
</button>
</div>
);
}
function Group({
code,
name,
rows,
}: {
code: string;
name?: string;
rows: Alert[];
}) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<Link
href={`/${code}`}
className="flex items-baseline gap-2 text-sm text-zinc-100 hover:text-white"
>
<span className="font-medium">{name ?? code}</span>
<span className="text-[10px] text-zinc-500">{code}</span>
</Link>
<span className="text-[10px] text-zinc-500">{rows.length}</span>
</div>
<ul className="divide-y divide-zinc-800">
{rows.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs">
<span
className={`shrink-0 rounded-sm px-1 py-0.5 text-[10px] font-semibold ${
a.op === "gte"
? "bg-rose-900/50 text-rose-300"
: "bg-sky-900/50 text-sky-300"
}`}
>
{a.op === "gte" ? "≥" : "≤"}
</span>
<span className="tabular-nums text-zinc-100">
{a.target.toLocaleString()}
</span>
<span className="ml-1 text-[10px] text-zinc-500">
{new Date(a.createdAt).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
{a.triggered && (
<span className="ml-1 rounded-sm bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-400">
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{a.triggered && (
<button
type="button"
onClick={() => alerts.reset(a.id)}
className="rounded-sm border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:text-zinc-200"
>
</button>
)}
<button
type="button"
onClick={() => alerts.remove(a.id)}
className="rounded-sm px-1 text-[10px] text-zinc-500 hover:text-rose-300"
aria-label="알람 삭제"
>
</button>
</div>
</li>
))}
</ul>
</div>
);
}

414
web/app/compare/page.tsx Normal file
View File

@@ -0,0 +1,414 @@
"use client";
// /compare?codes=005930,000660,...
// 최대 5 종목까지 같은 기간의 누적수익률(%)을 한 차트에 겹쳐서 비교.
// 토스의 "종목 비교" 화면 톤. 각 종목 첫 종가를 기준선(0%)으로 정규화.
//
// 구현:
// - URLSearchParams 의 codes (콤마구분) ↔ 내부 state 양방향
// - 기간 토글: 1M/3M/6M/1Y (각각 21/63/126/252 거래일)
// - 종목별 /api/chart 병렬 fetch → 누적수익률로 매핑 → lightweight-charts line 시리즈
// - 종목 추가는 헤더의 작은 검색바 (debounced)
// - 색상 팔레트 5색 순환
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import {
createChart,
type IChartApi,
type ISeriesApi,
type LineData,
type UTCTimestamp,
} from "lightweight-charts";
import { api, type Symbol as SymbolItem } from "../../lib/api";
const MAX_CODES = 5;
const PALETTE = ["#f43f5e", "#22d3ee", "#a78bfa", "#facc15", "#34d399"];
type Range = "1M" | "3M" | "6M" | "1Y";
const RANGE_DAYS: Record<Range, number> = { "1M": 21, "3M": 63, "6M": 126, "1Y": 252 };
type Series = {
code: string;
name: string;
points: { time: UTCTimestamp; value: number }[];
finalPct: number | null;
};
function isoToUtcTs(s: string): UTCTimestamp {
return (Date.UTC(
Number(s.slice(0, 4)),
Number(s.slice(5, 7)) - 1,
Number(s.slice(8, 10)),
) / 1000) as UTCTimestamp;
}
export default function ComparePage() {
// useSearchParams 는 Suspense 안에서 안전. 내부 컴포넌트로 분리.
return (
<Suspense fallback={null}>
<CompareInner />
</Suspense>
);
}
function CompareInner() {
const router = useRouter();
const search = useSearchParams();
const codesParam = search.get("codes") ?? "";
const [codes, setCodes] = useState<string[]>(() => parseCodes(codesParam));
const [range, setRange] = useState<Range>("3M");
const [series, setSeries] = useState<Series[]>([]);
const [err, setErr] = useState<string | null>(null);
// URL ↔ state 동기화 — 외부에서 codes= 가 바뀌면 흡수.
useEffect(() => {
setCodes(parseCodes(codesParam));
}, [codesParam]);
// codes 변경을 URL 에 반영.
const writeUrl = (next: string[]) => {
const q = next.length ? `?codes=${next.join(",")}` : "";
router.replace(`/compare${q}`, { scroll: false });
};
const add = (code: string, _name?: string) => {
if (!code) return;
if (codes.includes(code)) return;
if (codes.length >= MAX_CODES) return;
const next = [...codes, code];
setCodes(next);
writeUrl(next);
};
const remove = (code: string) => {
const next = codes.filter((c) => c !== code);
setCodes(next);
writeUrl(next);
};
// 시리즈 fetch.
useEffect(() => {
let alive = true;
setErr(null);
if (codes.length === 0) {
setSeries([]);
return;
}
const days = RANGE_DAYS[range] + 8; // 비거래일 여유.
Promise.all(
codes.map(async (code) => {
const c = await api.getChart(code, days, "1d");
const valid = c.ohlcv.filter((p) => p.close != null && p.close > 0);
if (!valid.length) {
return {
code,
name: c.name,
points: [],
finalPct: null,
} as Series;
}
const base = valid[0].close as number;
const pts = valid.map((p) => ({
time: isoToUtcTs(p.date),
value: (((p.close as number) - base) / base) * 100,
}));
const final = pts[pts.length - 1].value;
return { code, name: c.name, points: pts, finalPct: final };
}),
)
.then((r) => {
if (alive) setSeries(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [codes, range]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4 flex items-center justify-between gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<span className="text-[11px] text-zinc-500">
{codes.length} / {MAX_CODES}
</span>
</div>
<h1 className="text-2xl font-bold text-zinc-50"> </h1>
<p className="mt-1 text-sm text-zinc-400">
0% . {MAX_CODES} .
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<RangeTabs value={range} onChange={setRange} />
<SearchAdd onPick={add} disabled={codes.length >= MAX_CODES} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{series.map((s, i) => (
<Chip
key={s.code}
color={PALETTE[i % PALETTE.length]}
name={s.name}
code={s.code}
pct={s.finalPct}
onRemove={() => remove(s.code)}
/>
))}
{codes.length === 0 && (
<span className="text-xs text-zinc-500">
.
</span>
)}
</div>
{err && (
<div className="mt-4 rounded-md border border-rose-500/30 bg-rose-500/5 p-3 text-xs text-rose-200">
: {err}
</div>
)}
<div className="mt-4">
<CompareChart series={series} />
</div>
</main>
);
}
function parseCodes(raw: string): string[] {
if (!raw) return [];
return Array.from(
new Set(
raw
.split(",")
.map((s) => s.trim())
.filter((s) => /^[0-9A-Za-z]{4,12}$/.test(s)),
),
).slice(0, MAX_CODES);
}
function RangeTabs({
value,
onChange,
}: {
value: Range;
onChange: (r: Range) => void;
}) {
return (
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
{(["1M", "3M", "6M", "1Y"] as Range[]).map((r) => (
<button
key={r}
type="button"
onClick={() => onChange(r)}
className={`px-2.5 py-1 ${
value === r
? "bg-zinc-800 text-zinc-100"
: "bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{r}
</button>
))}
</div>
);
}
function SearchAdd({
onPick,
disabled,
}: {
onPick: (code: string, name: string) => void;
disabled: boolean;
}) {
const [q, setQ] = useState("");
const [items, setItems] = useState<SymbolItem[]>([]);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const term = q.trim();
if (!term) {
setItems([]);
return;
}
const h = setTimeout(async () => {
try {
const r = await api.search(term, false, 6, false);
setItems(r.items);
} catch {
setItems([]);
}
}, 200);
return () => clearTimeout(h);
}, [q]);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
return (
<div ref={wrapRef} className="relative">
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
disabled={disabled}
placeholder={disabled ? "최대치 도달" : "종목 추가 검색"}
className="w-56 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
{open && q.trim() && items.length > 0 && (
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-72 overflow-auto rounded-md border border-zinc-700 bg-zinc-950 shadow-xl">
{items.map((it) => (
<li key={it.code}>
<button
type="button"
onClick={() => {
onPick(it.code, it.name);
setQ("");
setOpen(false);
}}
className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-zinc-800/60"
>
<span className="min-w-0 truncate text-zinc-100">{it.name}</span>
<span className="shrink-0 text-zinc-500">
{it.code} · {it.market}
</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
function Chip({
color,
name,
code,
pct,
onRemove,
}: {
color: string;
name: string;
code: string;
pct: number | null;
onRemove: () => void;
}) {
const tone = pct == null ? "text-zinc-400" : pct > 0 ? "text-rose-300" : pct < 0 ? "text-sky-300" : "text-zinc-300";
return (
<div className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/50 px-2.5 py-1 text-xs">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-zinc-100">{name}</span>
<span className="text-[10px] text-zinc-500">{code}</span>
{pct != null && (
<span className={`tabular-nums ${tone}`}>
{pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%
</span>
)}
<button
type="button"
onClick={onRemove}
className="ml-1 text-zinc-500 hover:text-rose-300"
aria-label="제거"
>
</button>
</div>
);
}
function CompareChart({ series }: { series: Series[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map());
// chart 생성/파괴.
useEffect(() => {
if (!containerRef.current) return;
const c = createChart(containerRef.current, {
layout: {
background: { color: "transparent" },
textColor: "#cbd5e1",
},
grid: {
vertLines: { color: "#1f2937" },
horzLines: { color: "#1f2937" },
},
rightPriceScale: { borderColor: "#374151" },
timeScale: { borderColor: "#374151", timeVisible: false },
autoSize: true,
crosshair: { mode: 1 },
});
chartRef.current = c;
const map = seriesRef.current;
return () => {
c.remove();
chartRef.current = null;
map.clear();
};
}, []);
// 시리즈 동기화.
useEffect(() => {
const c = chartRef.current;
if (!c) return;
const wanted = new Set(series.map((s) => s.code));
// 제거된 종목 unsubscribe.
for (const [code, s] of seriesRef.current) {
if (!wanted.has(code)) {
c.removeSeries(s);
seriesRef.current.delete(code);
}
}
// 추가/갱신.
series.forEach((s, i) => {
let line = seriesRef.current.get(s.code);
if (!line) {
line = c.addLineSeries({
color: PALETTE[i % PALETTE.length],
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: false,
title: s.name,
});
seriesRef.current.set(s.code, line);
} else {
line.applyOptions({ color: PALETTE[i % PALETTE.length] });
}
const data: LineData[] = s.points.map((p) => ({ time: p.time, value: p.value }));
line.setData(data);
});
c.timeScale().fitContent();
}, [series]);
const hasAny = useMemo(() => series.some((s) => s.points.length > 0), [series]);
return (
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
<div className="h-[420px] w-full">
<div ref={containerRef} className="h-full w-full" />
</div>
{!hasAny && (
<div className="px-2 py-2 text-[11px] text-zinc-500">
.
</div>
)}
</div>
);
}

View File

@@ -1,15 +1,45 @@
import "./globals.css";
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { AlertsToaster } from "../components/AlertsToaster";
import { PwaRegister } from "../components/PwaRegister";
import { ShortcutsHelp } from "../components/ShortcutsHelp";
import { TopNav } from "../components/TopNav";
export const metadata: Metadata = {
title: "Stock Chart Site",
description: "개인용 주식 예측 차트",
title: {
default: "주식 차트 · 예측",
template: "%s · 주식 차트",
},
description: "한국 주식 차트와 단기 예측을 보는 개인용 앱.",
manifest: "/manifest.webmanifest",
// SVG 아이콘 하나로 다 처리. Chrome/Safari/iOS 모두 SVG favicon + apple-touch 수용.
icons: {
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
apple: [{ url: "/icon.svg", type: "image/svg+xml" }],
},
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "주식 차트",
},
};
// Next 14: themeColor / colorScheme 는 viewport 로 분리해야 빌드 경고가 안 뜸.
export const viewport: Viewport = {
themeColor: "#09090b",
colorScheme: "dark",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body className="min-h-screen">{children}</body>
<body className="min-h-screen">
<TopNav />
{children}
<AlertsToaster />
<ShortcutsHelp />
<PwaRegister />
</body>
</html>
);
}

View File

@@ -1,31 +1,59 @@
import Link from "next/link";
import { IndicesPanel } from "../components/IndicesPanel";
import { MarketsOverview } from "../components/MarketsOverview";
import { RecentTiles } from "../components/RecentTiles";
import { SearchBox } from "../components/SearchBox";
import { SeedTiles } from "../components/SeedTiles";
import { ThemeTiles } from "../components/ThemeTiles";
export default function HomePage() {
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-bold tracking-tight">Stock Chart Site</h1>
<p className="mt-2 text-sm text-zinc-400">
, <b> </b> Chronos + LightGBM
(1·3·5) .
</p>
<main className="mx-auto max-w-5xl px-6 py-12">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-3xl font-bold tracking-tight text-zinc-50"> </h1>
<p className="mt-2 text-sm text-zinc-400">
, <b className="text-rose-300"> </b>
15·30·1 .
</p>
</div>
<Link
href="/watchlist"
className="shrink-0 rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300 hover:bg-amber-500/20"
>
</Link>
</div>
<div className="mt-8">
<div className="mt-6">
<SearchBox autoFocus />
</div>
<div className="mt-12 grid gap-3 text-xs text-zinc-500 sm:grid-cols-2">
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
<div className="font-medium text-zinc-300"> 10</div>
<div className="mt-1">
, SK하이닉스, , , , ,
HD현대중공업, NAVER, KT&amp;G,
</div>
</div>
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
<div className="font-medium text-zinc-300">/</div>
<div className="mt-1">
16:30 KST , 02:00 KST LGBM .
</div>
<div className="mt-8">
<RecentTiles />
</div>
<div className="mt-8">
<IndicesPanel />
</div>
<div className="mt-8">
<SeedTiles />
</div>
<div className="mt-8">
<ThemeTiles />
</div>
<div className="mt-6">
<MarketsOverview />
</div>
<div className="mt-6 rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
<div className="font-medium text-zinc-300"> · </div>
<div className="mt-1">
16:30 KST , 02:00 KST LGBM .
Chronos(zero-shot) + LightGBM.
</div>
</div>
</main>

View File

@@ -0,0 +1,97 @@
"use client";
// 테마 상세 페이지: /themes/{slug}.
// 카드 그리드로 해당 테마 종목을 나열. close/pct_change 없으면 자리만 표시.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type ThemeDetailResponse } from "../../../lib/api";
export default function ThemePage({ params }: { params: { slug: string } }) {
const { slug } = params;
const [data, setData] = useState<ThemeDetailResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setErr(null);
setData(null);
api
.themeDetail(slug, 60)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [slug]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
</div>
{err && <div className="text-sm text-red-400"> : {err}</div>}
{!data && !err && <div className="text-sm text-zinc-500"> </div>}
{data && (
<>
<h1 className="text-2xl font-bold text-zinc-50">{data.name}</h1>
<p className="mt-1 text-sm text-zinc-400">{data.description}</p>
<div className="mt-1 text-[11px] text-zinc-600">
{data.items.length}
</div>
<div className="mt-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{data.items.map((m) => {
const pct = m.pct_change;
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
return (
<Link
key={m.code}
href={`/${m.code}`}
className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5 transition hover:border-zinc-700 hover:bg-zinc-800/60"
>
<div className="flex items-baseline justify-between gap-2">
<div className="min-w-0 truncate text-sm font-medium text-zinc-100">
{m.name}
</div>
<div className="shrink-0 text-[11px] text-zinc-500">
{m.market ?? "—"}
</div>
</div>
<div className="mt-1 flex items-baseline justify-between text-xs tabular-nums">
<span className="text-zinc-200">
{m.close != null
? m.close.toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: "—"}
</span>
<span className={tone}>
{pct != null
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
: ""}
</span>
</div>
<div className="mt-0.5 text-[10px] text-zinc-600">{m.code}</div>
</Link>
);
})}
</div>
</>
)}
</main>
);
}

159
web/app/watchlist/page.tsx Normal file
View File

@@ -0,0 +1,159 @@
"use client";
// /watchlist — localStorage 의 관심종목 코드들을 카드 그리드로.
// SEED 타일과 같은 톤. 각 카드는 /api/chart 2일치를 받아 현재가/전일대비를 채움.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api } from "../../lib/api";
import { watchlist } from "../../lib/watchlist";
type Tile = {
code: string;
name: string;
market: string;
current: number | null;
prev: number | null;
};
export default function WatchlistPage() {
const [codes, setCodes] = useState<string[]>([]);
const [tiles, setTiles] = useState<Tile[]>([]);
const [loading, setLoading] = useState(true);
const [hydrated, setHydrated] = useState(false);
// localStorage 구독 — 다른 탭/페이지에서 추가/제거해도 즉시 반영.
useEffect(() => {
setHydrated(true);
const refresh = () => setCodes(watchlist.get());
refresh();
return watchlist.subscribe(refresh);
}, []);
useEffect(() => {
if (!hydrated) return;
if (codes.length === 0) {
setTiles([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
Promise.allSettled(
codes.map(async (code) => {
const c = await api.getChart(code, 5, "1d");
const valid = c.ohlcv.filter((p) => p.close != null);
const last = valid[valid.length - 1] ?? null;
const prev = valid.length >= 2 ? valid[valid.length - 2] : null;
return {
code,
name: c.name,
market: c.market,
current: last?.close ?? null,
prev: prev?.close ?? null,
};
}),
).then((settled) => {
if (!alive) return;
const next: Tile[] = settled.map((r, i) => {
if (r.status === "fulfilled") return r.value;
return {
code: codes[i],
name: codes[i],
market: "—",
current: null,
prev: null,
};
});
setTiles(next);
setLoading(false);
});
return () => {
alive = false;
};
}, [codes, hydrated]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4 flex items-center justify-between gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<span className="text-[11px] text-zinc-500">{codes.length} </span>
</div>
<h1 className="text-2xl font-bold text-zinc-50"></h1>
<p className="mt-1 text-sm text-zinc-400">
. .
</p>
<div className="mt-6">
{!hydrated || loading ? (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
) : codes.length === 0 ? (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
.
<br />
<b className="text-amber-300"> </b> .
</div>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{tiles.map((t) => (
<TileCard key={t.code} t={t} />
))}
</div>
)}
</div>
</main>
);
}
function TileCard({ t }: { t: Tile }) {
const change =
t.current != null && t.prev != null ? t.current - t.prev : null;
const pct =
change != null && t.prev != null && t.prev !== 0
? (change / t.prev) * 100
: null;
const tone =
change == null || change === 0
? "text-zinc-400"
: change > 0
? "text-rose-400"
: "text-sky-400";
return (
<div className="relative">
<Link
href={`/${t.code}`}
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-3 transition hover:border-zinc-600"
>
<div className="truncate text-sm font-medium text-zinc-100">{t.name}</div>
<div className="mt-0.5 text-[11px] text-zinc-500">
{t.code} · {t.market}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-100">
{t.current != null
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
: "—"}
</div>
<div className={`text-[11px] tabular-nums ${tone}`}>
{change == null
? " "
: `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`}
</div>
</Link>
<button
type="button"
onClick={() => watchlist.remove(t.code)}
title="관심종목에서 제거"
className="absolute right-1.5 top-1.5 rounded-full bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
// 종목 페이지의 가격 알람 패널.
// 현재 종목의 활성 알람 리스트 + 새 알람 추가 폼 (≥ / ≤ + 가격).
// 발사 자체는 AlertsToaster 가 글로벌로 처리. 여기는 입력/표시만.
// Collapsible 본문은 hidden 으로만 가리므로 접어도 op/target partial 입력 유지.
import { useEffect, useState } from "react";
import { alerts, type Alert, type AlertOp } from "../lib/alerts";
import { Collapsible } from "./Collapsible";
type Props = {
code: string;
name?: string | null;
// 현재가 — 폼 placeholder 와 기본값으로 사용.
current?: number | null;
};
export function AlertsPanel({ code, name, current }: Props) {
const [items, setItems] = useState<Alert[]>([]);
const [op, setOp] = useState<AlertOp>("gte");
const [target, setTarget] = useState<string>("");
useEffect(() => {
const refresh = () => setItems(alerts.listForCode(code));
refresh();
return alerts.subscribe(refresh);
}, [code]);
const onAdd = () => {
const n = Number(target.replace(/[, ]/g, ""));
if (!Number.isFinite(n) || n <= 0) return;
alerts.add({ code, name: name ?? undefined, op, target: n });
setTarget("");
};
const subtitle =
current != null ? `현재가 ${current.toLocaleString()}` : undefined;
return (
<Collapsible title="가격 알람" subtitle={subtitle} storageKey="alerts">
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
<button
type="button"
onClick={() => setOp("gte")}
className={`px-2 py-1 ${
op === "gte"
? "bg-zinc-800 text-rose-300"
: "bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
</button>
<button
type="button"
onClick={() => setOp("lte")}
className={`px-2 py-1 ${
op === "lte"
? "bg-zinc-800 text-sky-300"
: "bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
</button>
</div>
<input
inputMode="numeric"
value={target}
onChange={(e) => setTarget(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onAdd();
}}
placeholder={current != null ? String(Math.round(current)) : "목표가"}
className="w-28 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500"
/>
<button
type="button"
onClick={onAdd}
disabled={!target.trim()}
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
{items.length === 0 ? (
<div className="mt-3 text-[11px] text-zinc-500">
. .
</div>
) : (
<ul className="mt-3 divide-y divide-zinc-800">
{items.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2 py-1.5 text-xs"
>
<div className="flex min-w-0 items-center gap-1.5">
<span
className={`shrink-0 rounded-sm px-1 py-0.5 text-[10px] font-semibold ${
a.op === "gte"
? "bg-rose-900/50 text-rose-300"
: "bg-sky-900/50 text-sky-300"
}`}
>
{a.op === "gte" ? "≥" : "≤"}
</span>
<span className="tabular-nums text-zinc-100">
{a.target.toLocaleString()}
</span>
{a.triggered && (
<span className="ml-1 rounded-sm bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-400">
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{a.triggered && (
<button
type="button"
onClick={() => alerts.reset(a.id)}
className="rounded-sm border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:text-zinc-200"
>
</button>
)}
<button
type="button"
onClick={() => alerts.remove(a.id)}
className="rounded-sm px-1 text-[10px] text-zinc-500 hover:text-rose-300"
aria-label="알람 삭제"
>
</button>
</div>
</li>
))}
</ul>
)}
</Collapsible>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
// 글로벌 가격 알람 폴러 + 인페이지 토스트.
//
// 동작:
// 1) alerts 구독 + 60s 인터벌마다 미발사(triggered=false) 알람 코드 set 수집
// 2) 각 코드별 /api/chart?days=2&interval=1d → 마지막 종가만 사용
// 3) 조건 (gte: close >= target, lte: close <= target) 만족 시 markTriggered + 토스트
//
// 알람이 0건이면 폴링 자체를 하지 않음 (네트워크 정숙).
// 토스트는 우측 하단에 쌓이며 6초 후 자동 dismiss.
//
// 브라우저 Notification 은 사용자가 허용한 경우에만 발송.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { api } from "../lib/api";
import { alerts, type Alert } from "../lib/alerts";
type Toast = {
id: string;
code: string;
name?: string;
op: Alert["op"];
target: number;
price: number;
};
const POLL_MS = 60_000;
const TOAST_TTL_MS = 6_000;
export function AlertsToaster() {
const [active, setActive] = useState<Alert[]>([]);
const [toasts, setToasts] = useState<Toast[]>([]);
const tickingRef = useRef(false);
useEffect(() => {
const refresh = () => setActive(alerts.list().filter((a) => !a.triggered));
refresh();
return alerts.subscribe(refresh);
}, []);
// 폴링.
useEffect(() => {
if (active.length === 0) return;
let alive = true;
const codes = Array.from(new Set(active.map((a) => a.code)));
const tick = async () => {
if (tickingRef.current) return;
tickingRef.current = true;
try {
// 종목별 마지막 종가 (직전 2 거래일만 받음 — 가볍게).
const prices = new Map<string, number | null>();
await Promise.allSettled(
codes.map(async (code) => {
const c = await api.getChart(code, 2, "1d");
const valid = c.ohlcv.filter((p) => p.close != null);
const last = valid[valid.length - 1] ?? null;
prices.set(code, last?.close ?? null);
}),
);
if (!alive) return;
// 알람 평가 — 최신 스냅샷(read)으로 재조회 (구독 사이 추가/삭제 반영).
const fresh = alerts.list().filter((a) => !a.triggered);
const newToasts: Toast[] = [];
for (const a of fresh) {
const p = prices.get(a.code);
if (p == null) continue;
const hit =
(a.op === "gte" && p >= a.target) ||
(a.op === "lte" && p <= a.target);
if (!hit) continue;
alerts.markTriggered(a.id);
newToasts.push({
id: a.id,
code: a.code,
name: a.name,
op: a.op,
target: a.target,
price: p,
});
// 브라우저 알림 (권한 허용 시).
if (
typeof window !== "undefined" &&
"Notification" in window &&
Notification.permission === "granted"
) {
try {
new Notification(`${a.name ?? a.code} 알람`, {
body: `${a.op === "gte" ? "≥" : "≤"} ${a.target.toLocaleString()} · 현재 ${p.toLocaleString()}`,
});
} catch {
// 무시
}
}
}
if (newToasts.length) {
setToasts((cur) => [...cur, ...newToasts]);
for (const t of newToasts) {
window.setTimeout(() => {
setToasts((cur) => cur.filter((x) => x.id !== t.id));
}, TOAST_TTL_MS);
}
}
} finally {
tickingRef.current = false;
}
};
tick();
const h = window.setInterval(tick, POLL_MS);
return () => {
alive = false;
window.clearInterval(h);
};
}, [active]);
if (toasts.length === 0) return null;
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
{toasts.map((t) => (
<Link
key={t.id}
href={`/${t.code}`}
className="pointer-events-auto block w-72 rounded-md border border-amber-500/40 bg-zinc-950/95 p-3 shadow-xl backdrop-blur"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-300">🔔 </span>
<span className="text-[10px] text-zinc-500">{t.code}</span>
</div>
<div className="mt-1 text-sm font-semibold text-zinc-100">
{t.name ?? t.code}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{t.op === "gte" ? "≥" : "≤"} {t.target.toLocaleString()} ·
<span className="tabular-nums text-zinc-100">{t.price.toLocaleString()}</span>
</div>
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,40 @@
// 상세 페이지 본문 카드의 표준 헤더 — title 좌, 옵셔널 우측 슬롯 우.
// 작은 wrapper 지만 본문 카드 9개(Metrics / IntradayReturns / PeriodReturns /
// CompositeScoreCard / PeerComparePanel / TradingValuePanel / DisclosuresPanel /
// NewsList / InvestorCumulative) 가 같은 mb-3 flex items-baseline justify-between
// 패턴을 character-by-character 로 중복 정의하고 있어, ReturnBucket 분리와 같은
// 이유로 단일 source of truth 로 묶는다 — 한쪽만 마진/폰트 바꾸면 본문 카드들
// 사이에 톤 점프가 생기는 future drift 리스크 제거.
//
// 우측 슬롯은 두 형태:
// - subtitle: 단순 보조 텍스트 (text-[11px] text-zinc-500 자동 적용). 8개 카드 사용.
// - right : 임의 ReactNode (버튼 그룹 등 인터랙티브). InvestorCumulative 가 사용.
// 둘 중 하나만 지정. 동시 지정 시 right 우선 (호출자 의도 명확화는 호출 시점에서).
//
// 제외 카드 — PredictionPanel: title 자체가 제목 + 설명 2줄 stack 이고 우측 큰
// button 이라 flex 정렬이 items-center 필요. 별도 패턴이 한 곳뿐이라 wrapper 의
// API 를 확장하기보다는 자체 헤더 유지하는 게 surgical.
import type { ReactNode } from "react";
type Props = {
title: ReactNode;
// 우측 보조 텍스트. 없으면 헤더에 title 만 남음 (이 경우에도 mb-3 flex 컨테이너
// 는 유지 — MetricsPanel 같은 단일-제목 카드도 같은 라인 높이로 정렬).
subtitle?: ReactNode;
// 우측 임의 노드 (버튼 그룹 등). 제공되면 subtitle 대신 렌더.
right?: ReactNode;
};
export function CardHeader({ title, subtitle, right }: Props) {
return (
<div className="mb-3 flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-zinc-200">{title}</div>
{right != null ? (
right
) : subtitle != null ? (
<div className="text-[11px] text-zinc-500">{subtitle}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { readOpen, writeOpen } from "../lib/collapsible";
// 사이드바 패널의 공용 collapsible 래퍼.
// - 헤더 row 가 button 으로 동작 → 클릭 시 본문 토글, aria-expanded 로 보조기술 알림.
// - 사용자 선택은 localStorage 에 panel-open:<storageKey> 로 영속.
// - 본문은 `hidden` 으로 숨겨 unmount 시키지 않음 → InvestmentNote 입력 텍스트,
// AlertsPanel 폼 partial 입력, RelatedStocks fetch 결과 등이 토글 사이클 동안
// 보존. (조건부 렌더로 children 을 끄면 re-mount → 데이터 재요청/입력 손실.)
//
// SSR 안전:
// 초기 state 를 readOpen() 으로 lazy 초기화하면 서버는 window 가 없어 defaultOpen 을
// 반환하고, 클라이언트 hydration 첫 렌더에서 localStorage 값과 다르면 React 가
// hydration mismatch 를 경고함. useState(defaultOpen) → useEffect 에서 sync 패턴으로
// 해결 — 1프레임 미세 jank 는 감수 (localStorage 값이 default 와 다를 때만, 사이드바
// 패널이라 시선 끄는 위치도 아님).
type Props = {
title: ReactNode;
subtitle?: ReactNode;
storageKey: string;
defaultOpen?: boolean;
children: ReactNode;
className?: string;
};
export function Collapsible({
title,
subtitle,
storageKey,
defaultOpen = true,
children,
className = "rounded-md border border-zinc-800 bg-zinc-900/40",
}: Props) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(readOpen(storageKey, defaultOpen));
}, [storageKey, defaultOpen]);
const toggle = () => {
setOpen((cur) => {
const next = !cur;
writeOpen(storageKey, next);
return next;
});
};
return (
<div className={className}>
<button
type="button"
onClick={toggle}
aria-expanded={open}
// hover 톤은 zinc-800/50 — zinc-900 보다 한 단계 밝은 색으로 잡아 부모의
// bg 불투명도와 무관하게 항상 highlight 방향으로 시각 신호. zinc-900/60
// 였을 땐 비-sticky 부모(/40) 에선 더 어두워져 highlight 가 됐지만,
// OrderbookPanel sticky 부모(/80) 에선 오히려 더 옅어져 hover 가 "뒤로
// 가는" 인상이 되는 톤 역전 발생. zinc-800 으로 한 단계 색을 올리면
// /50 opacity 라도 색 자체가 밝아 두 부모 모두에서 highlight 로 읽힘.
// 사이드바 list-row hover 톤(RelatedStocks/PeerList)과도 통일.
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-800/50"
>
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200">
<span
className={`inline-block text-[10px] text-zinc-500 transition-transform ${
open ? "rotate-90" : ""
}`}
aria-hidden
>
</span>
{title}
</span>
{subtitle != null && (
// min-w-0 + truncate: subtitle 이 길어지면(예: PriceTargets 의 "목표 1,234,567 ·
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
// shrink 계산이 올바르게 되도록.
//
// 크기 토큰: text-[11px] — CardHeader 의 subtitle 과 동일 토큰. 사이드바 패널
// 헤더(Collapsible) 와 본문 카드 헤더(CardHeader) 가 같은 페이지 좌우에 나란히
// 놓이므로 헤더 우측 보조 텍스트 크기를 통일. 280px 좁은 사이드바에서도 truncate
// 가 overflow 를 흡수해 1px 키워도 wrap 위험 없음.
<span className="min-w-0 truncate text-[11px] text-zinc-500">
{subtitle}
</span>
)}
</button>
<div className={open ? "border-t border-zinc-800 px-4 pb-4 pt-3" : "hidden"}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
"use client";
// AI 종합 점수 카드 — 토스 "투자자 분석" 비슷한 톤.
// chart payload (ohlcv + sentiment + trading_value) 에서 클라이언트가 직접 합성.
// 백엔드 호출 없음.
//
// 4개 서브 점수 (각 0~100, 50 = 중립):
// - 모멘텀 : SMA5 vs SMA20 격차
// - 수급 : 외국인+기관 5일 누적 net (vs |대각| 정규화)
// - 감성 : 뉴스 weighted_score 7일 평균 (-1~+1 → 0~100)
// - 추세 : 종가 20일 선형회귀 기울기 부호+강도
// 종합 = 평균. 50 미만 = 약세(파랑), 50 초과 = 강세(빨강).
import type { ChartPayload } from "../lib/api";
import { CardHeader } from "./CardHeader";
type Scores = {
momentum: number | null;
flow: number | null;
sentiment: number | null;
trend: number | null;
composite: number | null;
};
function computeScores(chart: ChartPayload): Scores {
// 모멘텀: SMA5 vs SMA20
const closes = chart.ohlcv
.map((p) => p.close)
.filter((c): c is number => c != null);
const sma = (arr: number[], n: number) => {
if (arr.length < n) return null;
const slice = arr.slice(-n);
return slice.reduce((a, b) => a + b, 0) / n;
};
const sma5 = sma(closes, 5);
const sma20 = sma(closes, 20);
const momentum =
sma5 != null && sma20 != null && sma20 > 0
? clamp01(((sma5 - sma20) / sma20) * 10) // ±10% → 0~1
: null;
// 수급: 외국인+기관 5일 net / 평균거래대금 (조잡한 정규화)
const tv = chart.trading_value.slice(-5);
const flowSum = tv.reduce(
(a, r) => a + (r.foreign_net ?? 0) + (r.institution_net ?? 0),
0,
);
const flowAbsMean =
tv.reduce(
(a, r) => a + Math.abs(r.foreign_net ?? 0) + Math.abs(r.institution_net ?? 0),
0,
) / Math.max(1, tv.length);
const flow =
tv.length >= 3 && flowAbsMean > 0
? clamp01(flowSum / (flowAbsMean * 5))
: null;
// 감성: weighted_score 평균 (최근 7개 점 기준)
const sents = chart.sentiment
.slice(-7)
.map((p) => p.weighted_score)
.filter((v): v is number => v != null);
const sentMean =
sents.length > 0 ? sents.reduce((a, b) => a + b, 0) / sents.length : null;
const sentiment = sentMean != null ? clamp01(sentMean) : null;
// 추세: 최근 20 종가 선형회귀 기울기 / 평균값 (정규화)
const last20 = closes.slice(-20);
let trend: number | null = null;
if (last20.length >= 10) {
const n = last20.length;
const xs = Array.from({ length: n }, (_, i) => i);
const xMean = (n - 1) / 2;
const yMean = last20.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - xMean) * (last20[i] - yMean);
den += (xs[i] - xMean) ** 2;
}
const slope = den > 0 ? num / den : 0;
// 하루당 yMean 의 0.5% 변동 → 점수 만점/최저
const norm = yMean > 0 ? slope / (yMean * 0.005) : 0;
trend = clamp01(norm);
}
const parts = [momentum, flow, sentiment, trend].filter(
(v): v is number => v != null,
);
const composite =
parts.length > 0 ? parts.reduce((a, b) => a + b, 0) / parts.length : null;
return {
momentum: pct(momentum),
flow: pct(flow),
sentiment: pct(sentiment),
trend: pct(trend),
composite: pct(composite),
};
}
// -1~+1 입력 → 0~1 출력 (중립 0.5).
function clamp01(x: number): number {
return Math.max(0, Math.min(1, 0.5 + x / 2));
}
function pct(v: number | null): number | null {
return v == null ? null : Math.round(v * 100);
}
export function CompositeScoreCard({ chart }: { chart: ChartPayload }) {
const s = computeScores(chart);
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader title="종합 점수 (AI)" subtitle="모멘텀·수급·감성·추세 평균" />
<div className="grid grid-cols-[auto_1fr] items-center gap-5">
<ScoreDial value={s.composite} />
<div className="grid grid-cols-2 gap-2">
<SubScore label="모멘텀" value={s.momentum} hint="SMA5 vs SMA20" />
<SubScore label="수급" value={s.flow} hint="외국인+기관 5일" />
<SubScore label="감성" value={s.sentiment} hint="뉴스 7일 가중" />
<SubScore label="추세" value={s.trend} hint="20일 회귀 기울기" />
</div>
</div>
<div className="mt-3 text-[11px] text-zinc-500">
. 50 = , 100 = .
</div>
</div>
);
}
function ScoreDial({ value }: { value: number | null }) {
// CSS conic-gradient 반원 게이지. 0~100 → 0~180°.
const v = value ?? 50;
const angle = (v / 100) * 180;
const tone =
value == null
? "text-zinc-400"
: value >= 65
? "text-rose-400"
: value <= 35
? "text-sky-400"
: "text-amber-300";
const ringColor =
value == null
? "#52525b"
: value >= 65
? "#ef4444"
: value <= 35
? "#3b82f6"
: "#f59e0b";
return (
<div className="relative h-24 w-24">
<div
className="absolute inset-0 rounded-full"
style={{
background: `conic-gradient(from 270deg, ${ringColor} 0deg, ${ringColor} ${angle}deg, #27272a ${angle}deg, #27272a 180deg, transparent 180deg)`,
mask: "radial-gradient(circle, transparent 38px, #000 39px)",
WebkitMask:
"radial-gradient(circle, transparent 38px, #000 39px)",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center pt-2">
<div className={`text-xl font-bold tabular-nums ${tone}`}>
{value == null ? "—" : value}
</div>
<div className="text-[10px] text-zinc-500">/ 100</div>
</div>
</div>
);
}
function SubScore({
label,
value,
hint,
}: {
label: string;
value: number | null;
hint: string;
}) {
const tone =
value == null
? "text-zinc-400"
: value >= 65
? "text-rose-400"
: value <= 35
? "text-sky-400"
: "text-zinc-200";
const barColor =
value == null
? "bg-zinc-700"
: value >= 65
? "bg-rose-500/70"
: value <= 35
? "bg-sky-500/70"
: "bg-amber-500/60";
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-1.5">
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-zinc-400">{label}</span>
<span className={`text-sm font-medium tabular-nums ${tone}`}>
{value == null ? "—" : value}
</span>
</div>
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
className={`h-full ${barColor}`}
style={{ width: `${value ?? 0}%` }}
/>
</div>
<div className="mt-0.5 text-[10px] text-zinc-600">{hint}</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
// 공시(DART) 패널 — news 테이블의 source='dart' 만 골라서 카드 리스트로.
// 별도 백엔드 엔드포인트는 안 만들고 기존 /api/news/{code}?source=dart 재사용.
import { useEffect, useState } from "react";
import { api, type NewsResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function DisclosuresPanel({ code }: { code: string }) {
const [data, setData] = useState<NewsResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api
.news(code, 10, "dart")
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [code]);
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader
title="공시 (DART)"
subtitle={data ? `최근 ${data.items.length}` : ""}
/>
{err && <div className="text-xs text-red-400"> : {err}</div>}
{!err && !data && <div className="text-xs text-zinc-500"> </div>}
{data && data.items.length === 0 && (
<div className="text-xs text-zinc-500"> </div>
)}
{data && data.items.length > 0 && (
<ul className="space-y-2">
{data.items.map((n, i) => (
<li key={i}>
<a
href={n.url}
target="_blank"
rel="noopener noreferrer"
className="block rounded-md border border-transparent p-2 transition hover:border-zinc-700 hover:bg-zinc-800/40"
>
<div className="text-sm leading-snug text-zinc-100 line-clamp-2">
{n.title}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="rounded-sm bg-emerald-900/50 px-1.5 py-0.5 font-semibold text-emerald-300">
DART
</span>
{n.published_at && (
<span className="text-zinc-500">
{formatRelative(n.published_at)}
</span>
)}
</div>
</a>
</li>
))}
</ul>
)}
</div>
);
}
function formatRelative(iso: string): string {
try {
const d = new Date(iso);
const now = Date.now();
const diffMin = Math.round((now - d.getTime()) / 60000);
if (diffMin < 1) return "방금";
if (diffMin < 60) return `${diffMin}분 전`;
if (diffMin < 60 * 24) return `${Math.round(diffMin / 60)}시간 전`;
if (diffMin < 60 * 24 * 7) return `${Math.round(diffMin / (60 * 24))}일 전`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
} catch {
return iso;
}
}
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}

View File

@@ -0,0 +1,90 @@
"use client";
// 홈 상단에 KOSPI / KOSDAQ 인덱스 두 칸. 우측엔 mini sparkline.
// macro_daily 가 아직 비어 있으면 안내 카드 한 줄.
import { useEffect, useState } from "react";
import { api, type IndicesResponse, type IndexSeries } from "../lib/api";
import { Sparkline } from "./Sparkline";
export function IndicesPanel() {
const [data, setData] = useState<IndicesResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api
.indices(60)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, []);
if (err)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
: {err}
</div>
);
if (!data)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
</div>
);
const allEmpty = data.indices.every((s) => s.points.length === 0);
if (allEmpty)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
. macro_daily .
</div>
);
return (
<div className="grid gap-3 md:grid-cols-2">
{data.indices.map((s) => (
<IndexCard key={s.key} s={s} />
))}
</div>
);
}
function IndexCard({ s }: { s: IndexSeries }) {
const pct = s.pct_change;
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
const stroke = pct != null && pct < 0 ? "#3b82f6" : "#ef4444";
const values = s.points.map((p) => p.value);
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
<div className="min-w-0">
<div className="text-xs text-zinc-500">{s.name}</div>
<div className="mt-0.5 text-xl font-semibold tabular-nums text-zinc-50">
{s.latest != null
? s.latest.toLocaleString(undefined, { maximumFractionDigits: 2 })
: "—"}
</div>
<div className={`mt-0.5 text-xs tabular-nums ${tone}`}>
{pct != null
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
: "—"}
</div>
</div>
{values.length >= 2 && (
<Sparkline values={values} stroke={stroke} width={120} height={40} />
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
"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";
import { CardHeader } from "./CardHeader";
import { ReturnBucket } from "./ReturnBucket";
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">
<CardHeader title="단기 수익률" subtitle="10분봉 기준 · 60초 갱신" />
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{buckets.map((b) => (
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
))}
<ReturnBucket label="시가대비" pct={todayPct} />
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
// 종목 페이지의 사이드 메모 패널.
// Toss "투자 메모" 패턴 — 진입 이유, 관전 포인트를 짧게 적는 자리.
// localStorage 만 사용, 서버 전송 없음.
// Collapsible 본문은 hidden 으로만 가리므로 접어도 textarea dirty 텍스트 유지.
import { useEffect, useMemo, useState } from "react";
import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes";
import { Collapsible } from "./Collapsible";
const FEEDBACK_TEXT: Record<"saved" | "deleted" | "failed", string> = {
saved: "저장됨",
deleted: "삭제됨",
failed: "저장 안됨",
};
type Props = {
code: string;
};
// '방금 전', '5분 전', '오늘 14:30', 'YYYY-MM-DD' 단계로 압축.
function fmtUpdatedAt(iso: string): string {
const t = Date.parse(iso);
if (!Number.isFinite(t)) return "";
const now = Date.now();
const diffSec = Math.max(0, Math.floor((now - t) / 1000));
if (diffSec < 60) return "방금 전";
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}분 전`;
const d = new Date(t);
const pad = (n: number) => String(n).padStart(2, "0");
const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
// 같은 날짜면 '오늘 HH:MM', 아니면 'YYYY-MM-DD'
const today = new Date();
if (
today.getFullYear() === d.getFullYear() &&
today.getMonth() === d.getMonth() &&
today.getDate() === d.getDate()
) {
return `오늘 ${hm}`;
}
return ymd;
}
export function InvestmentNote({ code }: Props) {
const [text, setText] = useState("");
const [saved, setSaved] = useState<Note | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
const n = readNote(code);
setSaved(n);
setText(n?.text ?? "");
setFeedback(null);
}, [code]);
const dirty = (saved?.text ?? "") !== text;
// 저장 직후 잠깐 띄우는 toast 같은 안내. dirty 또는 종목 전환 시 자동 해제.
useEffect(() => {
if (!feedback) return;
const h = window.setTimeout(() => setFeedback(null), 2500);
return () => window.clearTimeout(h);
}, [feedback]);
const remaining = useMemo(() => NOTE_MAX_LEN - text.length, [text]);
const save = () => {
const res = writeNote(code, text);
if (res.status === "saved") {
setSaved(res.note);
setText(res.note.text);
} else if (res.status === "deleted") {
setSaved(null);
setText("");
}
// failed: 입력값 유지 — 사용자가 재시도하거나 일부를 줄일 수 있도록.
setFeedback(FEEDBACK_TEXT[res.status]);
};
const subtitle = saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장";
return (
<Collapsible title="투자 메모" subtitle={subtitle} storageKey="note">
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, NOTE_MAX_LEN))}
placeholder="이 종목에 대한 관전 포인트, 진입/청산 근거 등을 적어두세요."
rows={4}
className="w-full resize-y rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1.5 text-[12px] leading-relaxed text-zinc-100 outline-none focus:border-zinc-600"
spellCheck={false}
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-[10px] tabular-nums text-zinc-500">
{remaining.toLocaleString()}
</span>
<div className="flex items-center gap-2">
{feedback && (
<span className="text-[10px] text-zinc-400" aria-live="polite">
{feedback}
</span>
)}
<button
type="button"
onClick={save}
disabled={!dirty}
className="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>
</div>
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
// 투자자별 누적 순매수 미니 차트.
//
// 토스 종목 상세의 "투자자별 매매" 탭에서 인상이 강한 위젯 — 외국인/기관/개인
// 각각의 N일 누적 순매수를 작은 라인으로 보여줘서 "누가 모으고 누가 분배하고 있는가"
// 를 한 눈에 잡게 한다.
//
// 데이터 소스는 ChartPayload.trading_value (이미 페이지에 있음) 그대로. 백엔드 호출 0.
// 단위: 원 → 억원(1e8) 환산.
//
// 색은 한국 컬러 — 누적 +면 빨강(매수 우위), -면 파랑(매도 우위). 라인 자체 색은
// 주체별로 고정해서 비교 가능하게 (외국인=amber, 기관=cyan, 개인=violet).
import { useMemo, useState } from "react";
import type { TradingValuePoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
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<Window>(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<Subject, number[]> = {
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 (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader
title="투자자별 누적 순매수"
right={
<div className="flex items-center gap-1.5 text-[10px]">
{WINDOW_OPTIONS.map((w) => {
const on = win === w.v;
return (
<button
key={w.v}
type="button"
onClick={() => 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}
</button>
);
})}
</div>
}
/>
<div className="space-y-2.5">
{SUBJECTS.map((s) => (
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />
))}
</div>
<div className="mt-3 text-[10px] text-zinc-500">
단위: 억원 · {effectiveWin} ( 0)
</div>
</div>
);
}
// 한 주체에 대한 라벨 + 누적 라인 + 끝 값.
// 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 (
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span className="w-10 text-zinc-400">{label}</span>
<span></span>
</div>
);
}
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 (
<div className="flex items-center gap-2">
<span className="w-10 shrink-0 text-[11px] text-zinc-400">{label}</span>
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
className="h-7 flex-1"
aria-hidden
>
<line
x1={0}
y1={baseY}
x2={w}
y2={baseY}
stroke="#3f3f46"
strokeDasharray="2 2"
strokeWidth={0.5}
/>
<path d={path} fill="none" stroke={color} strokeWidth={1.2} strokeLinejoin="round" />
</svg>
<span className={`w-16 shrink-0 text-right text-[11px] tabular-nums ${tone}`}>
{sign}
{endEok.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
// 시장 overview: 등락 상위 / 등락 하위 / 거래대금 상위 / 거래량 상위 4 컬럼.
// /api/markets/movers 응답을 그대로 표 형태로 렌더.
// 거래대금 = close × volume, 단위 억원. 토스 톤의 핵심 랭킹.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type Mover, type MoversResponse } from "../lib/api";
export function MarketsOverview() {
const [data, setData] = useState<MoversResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api
.movers("ALL", 10)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, []);
if (err)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
overview : {err}
</div>
);
if (!data)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
overview
</div>
);
// 아직 ohlcv_daily 가 비어 있으면 모든 리스트가 빈 배열로 옴 — 안내만.
const empty =
!data.gainers.length &&
!data.losers.length &&
!data.by_volume.length &&
!data.by_trading_value.length;
if (empty)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
. ohlcv_daily .
</div>
);
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MoverList title="등락 상위" items={data.gainers} />
<MoverList title="등락 하위" items={data.losers} />
<MoverList
title="거래대금 상위"
items={data.by_trading_value}
metric="trading_value"
/>
<MoverList title="거래량 상위" items={data.by_volume} metric="volume" />
</div>
);
}
type Metric = "price" | "volume" | "trading_value";
function MoverList({
title,
items,
metric = "price",
}: {
title: string;
items: Mover[];
metric?: Metric;
}) {
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3">
<div className="mb-2 text-xs font-medium text-zinc-300">{title}</div>
{items.length === 0 ? (
<div className="text-[11px] text-zinc-500"> </div>
) : (
<ul className="divide-y divide-zinc-800">
{items.map((m, i) => (
<MoverRow key={m.code} rank={i + 1} m={m} metric={metric} />
))}
</ul>
)}
</div>
);
}
function MoverRow({
rank,
m,
metric,
}: {
rank: number;
m: Mover;
metric: Metric;
}) {
const pct = m.pct_change;
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
let primary: string;
if (metric === "volume") {
primary = fmtVol(m.volume);
} else if (metric === "trading_value") {
primary = fmtKrw(m.trading_value ?? null);
} else {
primary =
m.close != null
? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 })
: "—";
}
return (
<li>
<Link
href={`/${m.code}`}
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
>
<div className="flex min-w-0 items-center gap-2">
<span className="w-4 shrink-0 text-zinc-500">{rank}</span>
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<div className="text-zinc-100">{primary}</div>
<div className={`text-[10px] ${tone}`}>
{pct != null ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` : ""}
</div>
</div>
</Link>
</li>
);
}
function fmtVol(n: number | null): string {
if (n == null) return "—";
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}`;
return n.toLocaleString();
}
// 거래대금 원 → 억/조 표기. 토스에서 가장 흔한 단위.
function fmtKrw(n: number | null): string {
if (n == null) return "—";
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}`;
if (n >= 1e8) return `${Math.round(n / 1e8).toLocaleString()}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}`;
return n.toLocaleString();
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { api, type MetricsResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function MetricsPanel({ code }: { code: string }) {
const [m, setM] = useState<MetricsResponse | null>(null);
@@ -36,7 +37,7 @@ export function MetricsPanel({ code }: { code: string }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-2 text-sm font-medium text-zinc-200"> 30 </div>
<CardHeader title="최근 30일 모델 성능" />
<table className="w-full text-left text-sm">
<thead className="text-xs text-zinc-500">
<tr>

View File

@@ -1,7 +1,13 @@
"use client";
// 토스증권 뉴스 카드 스타일을 흉내낸 리스트.
//
// 백엔드가 OG 썸네일은 아직 안 주므로 텍스트 카드 + 감성 칩 + 출처 배지만.
// 썸네일은 백엔드에 og_image 컬럼이 들어오면 추가.
import { useEffect, useState } from "react";
import { api, type NewsResponse } from "../lib/api";
import { api, type NewsItem, type NewsResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function NewsList({ code }: { code: string }) {
const [data, setData] = useState<NewsResponse | null>(null);
@@ -29,44 +35,80 @@ export function NewsList({ code }: { code: string }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-2 text-sm font-medium text-zinc-200"> /</div>
<ul className="divide-y divide-zinc-800">
<CardHeader title="뉴스 · 공시" subtitle={`최근 ${data.items.length}`} />
<ul className="space-y-2">
{data.items.map((n, i) => (
<li key={i} className="py-2">
<a
href={n.url}
target="_blank"
rel="noopener noreferrer"
className="block hover:bg-zinc-800/40"
>
<div className="text-sm text-zinc-100 line-clamp-2">{n.title}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-zinc-500">
<span>{n.source}</span>
{n.published_at && <span>· {formatDate(n.published_at)}</span>}
{n.sentiment_label && (
<span className={sentimentColor(n.sentiment_label)}>
· {n.sentiment_label} {n.sentiment_score != null ? `(${n.sentiment_score.toFixed(2)})` : ""}
</span>
)}
</div>
</a>
</li>
<NewsCard key={i} n={n} />
))}
</ul>
</div>
);
}
function sentimentColor(l: string): string {
if (l === "positive") return "text-emerald-400";
if (l === "negative") return "text-red-400";
return "text-zinc-400";
function NewsCard({ n }: { n: NewsItem }) {
return (
<li>
<a
href={n.url}
target="_blank"
rel="noopener noreferrer"
className="block rounded-md border border-transparent p-2 transition hover:border-zinc-700 hover:bg-zinc-800/40"
>
<div className="text-sm leading-snug text-zinc-100 line-clamp-2">{n.title}</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
<SourceBadge source={n.source} />
{n.published_at && (
<span className="text-zinc-500">{formatRelative(n.published_at)}</span>
)}
{n.sentiment_label && (
<SentimentChip label={n.sentiment_label} score={n.sentiment_score} />
)}
</div>
</a>
</li>
);
}
function formatDate(iso: string): string {
function SourceBadge({ source }: { source: string }) {
return (
<span className="rounded-sm bg-zinc-800 px-1.5 py-0.5 text-zinc-300">{source}</span>
);
}
function SentimentChip({
label,
score,
}: {
label: string;
score: number | null;
}) {
// KR 관행: 긍정=빨강, 부정=파랑 (StockChart 와 톤 일치)
const tone =
label === "positive"
? "bg-rose-500/15 text-rose-300"
: label === "negative"
? "bg-sky-500/15 text-sky-300"
: "bg-zinc-700/40 text-zinc-400";
const text =
label === "positive" ? "긍정" : label === "negative" ? "부정" : "중립";
return (
<span className={`rounded-sm px-1.5 py-0.5 ${tone}`}>
{text}
{score != null && ` ${score >= 0 ? "+" : ""}${score.toFixed(2)}`}
</span>
);
}
function formatRelative(iso: string): string {
try {
const d = new Date(iso);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = Date.now();
const diffMin = Math.round((now - d.getTime()) / 60000);
if (diffMin < 1) return "방금";
if (diffMin < 60) return `${diffMin}분 전`;
if (diffMin < 60 * 24) return `${Math.round(diffMin / 60)}시간 전`;
if (diffMin < 60 * 24 * 7) return `${Math.round(diffMin / (60 * 24))}일 전`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
} catch {
return iso;
}

View File

@@ -0,0 +1,200 @@
"use client";
// 호가창 — 10단계 매도/매수 호가 + 잔량. Toss 톤의 한 줄 한 줄 막대 그래프.
// /api/orderbook/{code} 폴링 (10s). 키 없으면 안내 카드.
//
// 표시 규칙:
// - 위쪽: 매도호가 10개 (가장 낮은 askp1 이 맨 아래, 즉 현재가에 인접)
// → 그래서 화면상 askp10 → askp1 (위에서 아래로 가격 내림차순) 으로 렌더
// - 가운데 굵게: 현재가 + 전일대비 + %
// - 아래쪽: 매수호가 10개 (가장 높은 bidp1 이 맨 위, bidp10 이 맨 아래)
// - 잔량 막대: 매도/매수 각각의 단계별 최대잔량 기준 비율로 색상 막대.
// - 색상: 매도=파랑(하락 톤), 매수=빨강(상승 톤) — 한국 거래소 관행.
import { useEffect, useState } from "react";
import { api, type OrderbookLevel, type OrderbookResponse } from "../lib/api";
import { Collapsible } from "./Collapsible";
type Props = {
code: string;
// 1D(10분봉) 모드일 때만 sticky 로 띄움 — 종목 페이지가 길어 스크롤해도 매수/매도 호가가
// 시야에 남도록. 일봉 이상에선 호가의 실시간 가치가 낮아 일반 흐름으로 두는 게 사이드바
// 다른 패널(PriceTargets/AlertsPanel/InvestmentNote/RelatedStocks) 의 위치 안정성을 해치지
// 않음.
//
// sticky + collapsible 조합: Collapsible 의 outer 래퍼 자체에 sticky 를 걸어두면 접혔을
// 때는 헤더 한 줄(호가 + ts) 만 상단에 떠 있고, 펼치면 본문 포함 전체가 시야에 남는다.
// — 사용자가 차트/지표 영역을 길게 스크롤하다 호가 보고 싶을 때 늘 접근 가능.
sticky?: boolean;
};
const POLL_MS = 10_000;
export function OrderbookPanel({ code, sticky = false }: Props) {
const [data, setData] = useState<OrderbookResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
const load = () => {
api
.orderbook(code)
.then((r) => {
if (!alive) return;
setData(r);
setErr(null);
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setErr(e instanceof Error ? e.message : String(e));
setLoading(false);
});
};
load();
const h = window.setInterval(load, POLL_MS);
return () => {
alive = false;
window.clearInterval(h);
};
}, [code]);
// 호가는 사이드바에서 정보 밀도/시의성 모두 가장 높음 → 기본 펼침 유지.
// 사용자가 접고 싶다면 헤더 클릭 한 번 + localStorage 영속.
return (
<Collapsible
title="호가"
subtitle={data?.ts ? `${data.ts} · 10초 갱신` : "10초 갱신"}
storageKey="orderbook"
className={
sticky
? "sticky top-4 rounded-md border border-zinc-800 bg-zinc-900/80 backdrop-blur"
: "rounded-md border border-zinc-800 bg-zinc-900/40"
}
>
{loading && !data && (
<div className="py-6 text-center text-[11px] text-zinc-500"> </div>
)}
{err && !data && (
<div className="rounded border border-rose-800/40 bg-rose-900/10 p-2 text-[11px] text-rose-300">
: {err}
</div>
)}
{data?.status === "skipped_missing_key" && (
<div className="rounded border border-amber-800/40 bg-amber-900/10 p-2 text-[11px] text-amber-200">
KIS API .
</div>
)}
{data && data.status === "ok" && <OrderbookTable data={data} />}
</Collapsible>
);
}
function OrderbookTable({ data }: { data: OrderbookResponse }) {
// KIS 가 항상 10단계를 다 주진 않을 수 있어 정렬 강제.
// 매도: level 오름차순 (1=가장 낮은 매도) → 화면은 level 내림차순으로 표시.
// 매수: level 오름차순 (1=가장 높은 매수) → 화면은 level 오름차순.
const asks = [...data.asks].sort((a, b) => a.level - b.level);
const bids = [...data.bids].sort((a, b) => a.level - b.level);
const asksDesc = [...asks].reverse();
// 잔량 막대 정규화 — 매도/매수 통틀어 max 로 정규화하면 한쪽이 항상 미니 막대로 나옴.
// 사이드별 max 로 각자 정규화.
const askMax = Math.max(1, ...asks.map((x) => x.qty));
const bidMax = Math.max(1, ...bids.map((x) => x.qty));
const change = data.prev_close_diff ?? 0;
const tone =
change > 0 ? "text-rose-400" : change < 0 ? "text-sky-400" : "text-zinc-400";
return (
<div className="space-y-1">
<ul className="space-y-0.5">
{asksDesc.map((lv) => (
<Row key={`a${lv.level}`} lv={lv} max={askMax} side="ask" />
))}
</ul>
<div className="my-1 flex items-center justify-between rounded bg-zinc-800/60 px-2 py-1.5">
<span className="text-[10px] text-zinc-400"></span>
<div className="flex items-baseline gap-2">
<span className="text-base font-semibold tabular-nums text-zinc-50">
{data.current != null ? data.current.toLocaleString() : "—"}
</span>
{data.prev_close_diff != null && (
<span className={`text-[11px] tabular-nums ${tone}`}>
{change > 0 ? "+" : ""}
{change.toLocaleString()}
{data.prev_close_pct != null && (
<span className="ml-0.5">
({data.prev_close_pct > 0 ? "+" : ""}
{data.prev_close_pct.toFixed(2)}%)
</span>
)}
</span>
)}
</div>
</div>
<ul className="space-y-0.5">
{bids.map((lv) => (
<Row key={`b${lv.level}`} lv={lv} max={bidMax} side="bid" />
))}
</ul>
<div className="mt-1.5 flex items-center justify-between border-t border-zinc-800 pt-1.5 text-[10px] text-zinc-500">
<span> {data.ask_total.toLocaleString()}</span>
<span> {data.bid_total.toLocaleString()}</span>
</div>
</div>
);
}
function Row({
lv,
max,
side,
}: {
lv: OrderbookLevel;
max: number;
side: "ask" | "bid";
}) {
const pct = Math.min(100, (lv.qty / max) * 100);
// ask=파랑(하락 톤), bid=빨강(상승 톤). 잔량 막대는 잔량 셀 안에서 자라남:
// - ask 행: 가격(좌) · 잔량(우) — 막대는 잔량 셀 오른쪽 → 왼쪽 (수치쪽으로) 자라남
// - bid 행: 잔량(좌) · 가격(우) — 막대는 잔량 셀 왼쪽 → 오른쪽 자라남
const barColor = side === "ask" ? "bg-sky-500/20" : "bg-rose-500/20";
const priceColor = side === "ask" ? "text-sky-300" : "text-rose-300";
const priceCell = (
<span className={`tabular-nums ${priceColor}`}>{lv.price.toLocaleString()}</span>
);
const qtyCell = (
<span className="relative inline-block w-full overflow-hidden rounded-sm py-0.5">
<span
className={`absolute inset-y-0 ${side === "ask" ? "right-0" : "left-0"} ${barColor}`}
style={{ width: `${pct}%` }}
aria-hidden
/>
<span
className={`relative z-10 block tabular-nums text-zinc-300 ${
side === "ask" ? "text-right" : "text-left"
} px-1.5`}
>
{lv.qty.toLocaleString()}
</span>
</span>
);
return (
<li className="grid grid-cols-[12px_64px_1fr] items-center gap-1.5 text-[11px]">
<span className="text-[9px] text-zinc-600">{lv.level}</span>
<span className="text-right">{priceCell}</span>
{qtyCell}
</li>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
// 동종업종(테마) 비교 패널 — 토스의 "동일업종 비교" 톤.
// /api/themes/peer/{code} 한 번 호출로 (테마 라벨, 본인 수익률, 평균, 상/하위 N) 모두 받음.
// 테마 무소속 종목은 패널을 통째로 숨김.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function PeerComparePanel({ code }: { code: string }) {
const [data, setData] = useState<PeerCompareResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api
.peerCompare(code, 21, 5)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [code]);
if (err) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
: {err}
</div>
);
}
if (!data) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
// 테마 무소속 → 패널 숨김
if (data.themes.length === 0) return null;
const self = data.self_pct;
const avg = data.peer_avg_pct;
const diff = self != null && avg != null ? self - avg : null;
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader
title="동종업종 비교"
subtitle={`최근 ${data.window}거래일 · peer ${data.peer_count ?? 0}종목`}
/>
<div className="mb-3 flex flex-wrap gap-1.5">
{data.themes.map((t) => (
<Link
key={t.slug}
href={`/themes/${t.slug}`}
className="rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[11px] text-zinc-300 hover:border-zinc-500"
>
#{t.name}
</Link>
))}
</div>
<div className="mb-4 grid grid-cols-3 gap-2 text-center">
<CompareTile label="이 종목" pct={self} bold />
<CompareTile label="업종 평균" pct={avg} />
<CompareTile label="격차" pct={diff} sign />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<PeerList title="상위 5종목" items={data.top} />
<PeerList title="하위 5종목" items={data.bottom} />
</div>
</div>
);
}
function CompareTile({
label,
pct,
bold = false,
sign = false,
}: {
label: string;
pct: number | null;
bold?: boolean;
sign?: boolean;
}) {
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
const text =
pct == null
? "—"
: sign
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%p`
: `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2">
<div className="text-[11px] text-zinc-500">{label}</div>
<div
className={`mt-1 tabular-nums ${tone} ${
bold ? "text-base font-semibold" : "text-sm font-medium"
}`}
>
{text}
</div>
</div>
);
}
function PeerList({
title,
items,
}: {
title: string;
items: PeerCompareItem[];
}) {
return (
<div>
<div className="mb-1.5 text-[11px] text-zinc-500">{title}</div>
<ul className="divide-y divide-zinc-800 rounded-md border border-zinc-800">
{items.length === 0 && (
<li className="px-2 py-2 text-xs text-zinc-500"> </li>
)}
{items.map((it) => {
const tone =
it.pct_change == null || it.pct_change === 0
? "text-zinc-400"
: it.pct_change > 0
? "text-rose-400"
: "text-sky-400";
return (
<li key={it.code}>
<Link
href={`/${it.code}`}
className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs hover:bg-zinc-800/50"
>
<span className="truncate text-zinc-200">{it.name}</span>
<span className={`shrink-0 tabular-nums ${tone}`}>
{it.pct_change == null
? "—"
: `${it.pct_change >= 0 ? "+" : ""}${it.pct_change.toFixed(2)}%`}
</span>
</Link>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
// 기간별 수익률 패널 — chart.ohlcv 에서 직접 계산.
// 토스 종목 페이지의 "기간 수익률" 박스 모사. 1주/1개월/3개월/6개월/1년/전체.
// 거래일 기준 근사: 1주≈5, 1개월≈21, 3개월≈63, 6개월≈126, 1년≈252.
import type { OhlcvPoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
import { ReturnBucket } from "./ReturnBucket";
const BUCKETS: { label: string; days: number }[] = [
{ label: "1주", days: 5 },
{ label: "1개월", days: 21 },
{ label: "3개월", days: 63 },
{ label: "6개월", days: 126 },
{ label: "1년", days: 252 },
];
export function PeriodReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
const closes = ohlcv
.map((p) => p.close)
.filter((c): c is number => c != null);
if (closes.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 = closes[closes.length - 1];
const buckets = BUCKETS.map((b) => {
const idx = closes.length - 1 - b.days;
if (idx < 0) return { ...b, pct: null as number | null };
const base = closes[idx];
return { ...b, pct: base ? ((last - base) / base) * 100 : null };
});
const all =
closes[0] != null && closes[0] !== 0
? ((last - closes[0]) / closes[0]) * 100
: null;
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader title="기간 수익률" subtitle="차트 구간 기준" />
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{buckets.map((b) => (
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
))}
<ReturnBucket label="전체" pct={all} />
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import type { ChartInterval } from "../lib/api";
// 7-탭 기간 셀렉터. 각 탭이 (interval, days) 쌍으로 풀린다.
// 1D 만 10분봉, 5Y/MAX 는 자동으로 주봉/월봉 사용 (포인트 수 폭주 방지).
export type Period = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y" | "ALL";
type PeriodSpec = { id: Period; label: string; interval: ChartInterval; days: number };
const PERIODS: PeriodSpec[] = [
{ id: "1D", label: "1일", interval: "10m", days: 1 },
{ id: "1W", label: "1주", interval: "1d", days: 7 },
{ id: "1M", label: "1달", interval: "1d", days: 30 },
{ id: "3M", label: "3달", interval: "1d", days: 90 },
{ id: "1Y", label: "1년", interval: "1d", days: 365 },
{ id: "5Y", label: "5년", interval: "1w", days: 365 * 5 },
{ id: "ALL", label: "최대", interval: "1mo", days: 365 * 20 },
];
export function periodSpec(id: Period): PeriodSpec {
return PERIODS.find((p) => p.id === id) ?? PERIODS[2];
}
// ←/→ 키 네비게이션용 — 양끝 clamp (wrap-around 안 함, 사용자가 끝에 갇혔는지 인지 가능).
export function periodNeighbor(id: Period, dir: -1 | 1): Period {
const idx = PERIODS.findIndex((p) => p.id === id);
if (idx < 0) return id;
const next = idx + dir;
if (next < 0 || next >= PERIODS.length) return id;
return PERIODS[next].id;
}
type Props = {
value: Period;
onChange: (id: Period, interval: ChartInterval, days: number) => void;
};
export function PeriodTabs({ value, onChange }: Props) {
// 활성 탭 스타일:
// 이전엔 `bg-zinc-700` 만 사용해 헤더의 다른 zinc-톤 버튼(비교/공유/Star) 과 색이 거의 동일,
// 페이지에서 "지금 어느 기간이 선택돼 있는지" 한눈에 안 들어왔음.
// 한국 컬러 상승 톤(rose-500) 으로 칠해서 zinc 버튼군 사이에서 즉시 식별. 다른 모듈의 활성/positive
// 상태(목표가 라인, 등락 양수 등) 도 같은 rose 계열이라 톤 일관성 유지.
// ARIA: 이건 tabpanel 을 가진 진짜 탭이 아니라 차트 기간 segmented control 임.
// role=tablist/tab 을 붙이면 스크린리더가 aria-controls·roving tabIndex·Arrow focus 이동 같은
// ARIA 탭 패턴을 기대하는데 여기엔 그 인프라가 없음 (←/→ 는 페이지 전역 단축키로 처리). 단순
// 버튼 그룹 + aria-pressed 가 정확한 표현.
return (
<div
aria-label="기간 선택"
className="inline-flex rounded-full border border-zinc-800 bg-zinc-900 p-1 text-xs"
>
{PERIODS.map((p) => {
const active = value === p.id;
return (
<button
key={p.id}
type="button"
aria-pressed={active}
onClick={() => onChange(p.id, p.interval, p.days)}
className={
active
? "rounded-full bg-rose-500 px-3 py-1 font-semibold text-white shadow-sm shadow-rose-500/40"
: "rounded-full px-3 py-1 text-zinc-400 hover:text-zinc-100"
}
>
{p.label}
</button>
);
})}
</div>
);
}

View File

@@ -42,16 +42,26 @@ function normalizeFromPredictResponse(
};
}
// 15일/30일/1년 세 프리셋. 각각 그 구간 안에 6~7 step 을 깔아서 차트 캔들이
// 너무 듬성하지 않게 한다. 백엔드는 1..252 거래일 horizon 을 허용 (predict.py 참조).
const PREDICT_PRESETS: { id: string; label: string; horizons: number[] }[] = [
{ id: "15d", label: "15일", horizons: [1, 3, 5, 7, 10, 15] },
{ id: "30d", label: "30일", horizons: [1, 5, 10, 15, 20, 25, 30] },
{ id: "1y", label: "1년", horizons: [5, 15, 30, 60, 120, 180, 240] },
];
export function PredictionPanel({ code, initial, onResult }: Props) {
const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [presetIdx, setPresetIdx] = useState(1); // 30일 기본
async function runPredict() {
setLoading(true);
setErr(null);
try {
const r = await api.predict(code);
const horizons = PREDICT_PRESETS[presetIdx].horizons;
const r = await api.predict(code, horizons);
const normalized = normalizeFromPredictResponse(code, r);
setPred(normalized);
onResult(normalized);
@@ -66,22 +76,55 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-zinc-200"> (Chronos + LightGBM )</div>
<div className="text-xs text-zinc-500">
.
, .
</div>
</div>
<button
onClick={runPredict}
disabled={loading}
className="rounded-md bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 disabled:opacity-50"
>
{loading ? "예측 중…" : pred?.found ? "다시 예측" : "예상차트 보기"}
</button>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-zinc-500"> :</span>
{PREDICT_PRESETS.map((p, i) => {
const on = presetIdx === i;
return (
// segmented control — PeriodTabs / InvestorCumulative window / SMA·RSI·MACD 토글과
// 같은 패턴. type="button" 으로 폼 submit 기본값 사고 차단, aria-pressed 로 스크린
// 리더에 현재 선택 노출. 시각 토큰은 PredictionPanel 자체 톤(bg-zinc-700 solid)
// 유지 — 본문 카드 영역 안 primary 보조 컨트롤이라 툴바용 zinc-800/80 보다 한
// 단계 도드라지는 게 의도. 패딩(px-3 py-1) 도 옆 rose-600 실행 버튼과 시각
// 무게 균형을 위해 툴바 토글(px-2 py-0.5) 보다 큼.
<button
key={p.id}
type="button"
onClick={() => setPresetIdx(i)}
aria-pressed={on}
className={
on
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
{p.label}
</button>
);
})}
{presetIdx === 2 && (
<span className="ml-1 text-[11px] text-amber-400">
30
</span>
)}
</div>
{err && <div className="mb-3 text-xs text-red-400">: {err}</div>}
{pred?.found ? (
@@ -90,54 +133,56 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
{pred.base_date} · {" "}
{pred.base_close != null ? pred.base_close.toLocaleString() : "-"}
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs text-zinc-500">
<tr>
<th className="py-1">+</th>
<th></th>
<th></th>
<th>P(up/flat/down)</th>
<th></th>
<th> </th>
<th>q10~q90</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{steps.map((s) => (
<tr key={s.horizon}>
<td className="py-2">+{s.horizon}</td>
<td className="text-xs text-zinc-400">{s.target_date}</td>
<td>
<span
className={
s.direction === "up"
? "text-emerald-400"
: s.direction === "down"
? "text-red-400"
: "text-zinc-300"
}
>
{s.direction}
</span>
</td>
<td className="text-xs text-zinc-300">
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
</td>
<td>{fmtSignedPct(s.expected_return)}</td>
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
<td className="text-xs text-zinc-400">
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
</td>
<div className="max-h-60 overflow-y-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-zinc-900/95 text-xs text-zinc-500">
<tr>
<th className="py-1">+</th>
<th></th>
<th></th>
<th>P(up/flat/down)</th>
<th></th>
<th> </th>
<th>q10~q90</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-zinc-800">
{steps.map((s) => (
<tr key={s.horizon}>
<td className="py-2">+{s.horizon}</td>
<td className="text-xs text-zinc-400">{s.target_date}</td>
<td>
<span
className={
s.direction === "up"
? "text-rose-400"
: s.direction === "down"
? "text-sky-400"
: "text-zinc-300"
}
>
{s.direction}
</span>
</td>
<td className="text-xs text-zinc-300">
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
</td>
<td>{fmtSignedPct(s.expected_return)}</td>
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
<td className="text-xs text-zinc-400">
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="text-xs text-zinc-500">
. <b> </b> 1·3·5
.
. <b> </b>
.
</div>
)}
</div>

View File

@@ -0,0 +1,128 @@
"use client";
// 종목 페이지 상단 가격 헤더.
// 표시: 종목명 / (코드·시장) / 현재가 / 전일대비 + 우측에 sparkline.
// 한국 거래소 관행대로 상승=빨강, 하락=파랑.
import { Sparkline } from "./Sparkline";
type Props = {
name: string;
code: string;
market: string;
current: number | null;
prev: number | null;
asOf?: string | null;
// 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시.
series?: number[];
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
viewsToday?: number | null;
// 오늘(또는 마지막 봉) 흐름 요약. page.tsx 에서 ohlcv 끝부분으로 계산해 전달.
// null 이면 strip 자체를 숨김 (데이터 없음 + 레이아웃 jitter 방지).
flow?: { open: number; high: number; low: number; volume: number } | null;
};
// 거래량 한국식 단위 (억/만) — StockChart 의 fmtKVolume 과 같은 규칙. 둘 다 작아서
// 별도 모듈로 빼지 않고 사용처에 인라인 유지.
function fmtVolume(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "-";
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}`;
return n.toLocaleString();
}
export function PriceHero({
name,
code,
market,
current,
prev,
asOf,
series,
viewsToday,
flow,
}: Props) {
const change = current != null && prev != null ? current - prev : null;
const pct = change != null && prev ? (change / prev) * 100 : null;
const tone =
change == null || change === 0
? "text-zinc-400"
: change > 0
? "text-rose-400"
: "text-sky-400";
const arrow = change == null || change === 0 ? "—" : change > 0 ? "▲" : "▼";
return (
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<div className="text-xs text-zinc-500">
{code} · {market}
</div>
<h1 className="mt-0.5 text-2xl font-bold tracking-tight text-zinc-50">
{name}
</h1>
<div className="mt-3 flex items-baseline gap-3">
<span className="text-4xl font-semibold tabular-nums text-zinc-50">
{current != null ? current.toLocaleString() : "-"}
</span>
{change != null && pct != null && (
<span className={`text-base font-medium tabular-nums ${tone}`}>
{arrow} {Math.abs(change).toLocaleString()}{" "}
({pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%)
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-zinc-500">
{asOf && <span> {asOf}</span>}
{viewsToday != null && viewsToday > 0 && (
<span className="rounded-full border border-zinc-700/70 bg-zinc-900/60 px-2 py-0.5 text-[11px] text-zinc-300">
<span className="tabular-nums text-zinc-100">
{viewsToday.toLocaleString()}
</span>
</span>
)}
</div>
{flow && (
// 오늘의 흐름 요약 — 시/고/저/거래량 4-KPI mini strip. 차트 hover 없이도 진입 즉시 보임.
// 고/저 가격 자체에는 색을 칠하지 않음 (양수/음수 의미가 없으므로 톤이 거짓 신호가 됨).
// 대신 라벨만 sky-300(저) / rose-300(고) 으로 살짝 칠해 직관 보조.
<div className="mt-3 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[11px] text-zinc-400">
<span>
{" "}
<span className="tabular-nums text-zinc-200">
{flow.open.toLocaleString()}
</span>
</span>
<span>
<span className="text-rose-300"></span>{" "}
<span className="tabular-nums text-zinc-200">
{flow.high.toLocaleString()}
</span>
</span>
<span>
<span className="text-sky-300"></span>{" "}
<span className="tabular-nums text-zinc-200">
{flow.low.toLocaleString()}
</span>
</span>
<span>
{" "}
<span className="tabular-nums text-zinc-200">
{fmtVolume(flow.volume)}
</span>
</span>
</div>
)}
</div>
{series && series.length >= 2 && (
<Sparkline
values={series}
stroke={change != null && change < 0 ? "#3b82f6" : "#ef4444"}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
// 목표가 / 손절가 시뮬레이터 (Toss "내 목표가" read-only 클론).
// - 현재가 기준 변동률 미리 계산해서 표시
// - 저장 시 localStorage 에만 — 차트 오버레이는 부모 page 가 같은 상태를 읽어 StockChart 에 전달
// - 알림은 별도 AlertsPanel 담당, 여기는 시각화 + 손익 프리뷰만
import { useEffect, useMemo, useState } from "react";
import { readTargets, writeTargets, type Targets } from "../lib/targets";
import { Collapsible } from "./Collapsible";
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";
// 접힌 상태에서도 유저가 어떤 값을 저장해뒀는지 한눈에 — 둘 다 없으면 InvestmentNote 와
// 같은 톤의 "비공개 · 브라우저 저장" 안내. 저장값(savedN)이 아닌 dirty 입력값을 보여주면
// 토글 사이클 사이에 안내가 깜빡여 혼란. saved 기준만 표시.
const fmtNum = (n: number): string =>
n.toLocaleString(undefined, { maximumFractionDigits: 0 });
const subtitle =
saved.target != null && saved.stop != null
? `목표 ${fmtNum(saved.target)} · 손절 ${fmtNum(saved.stop)}`
: saved.target != null
? `목표 ${fmtNum(saved.target)}`
: saved.stop != null
? `손절 ${fmtNum(saved.stop)}`
: "비공개 · 브라우저 저장";
return (
// defaultOpen=true — 입력 중인 텍스트는 Collapsible 의 hidden 본문 덕에 토글로 사라지지
// 않지만, 처음 보는 사용자에겐 input 이 펴져 있어야 "여기서 목표가를 적는다" 가 자명.
<Collapsible title="내 목표가" subtitle={subtitle} storageKey="targets">
<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>
)}
</Collapsible>
);
}
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

@@ -0,0 +1,22 @@
"use client";
// 최소 서비스워커 등록.
// - 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";
export function PwaRegister() {
useEffect(() => {
if (typeof window === "undefined") return;
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js").catch(() => {
/* noop — 등록 실패 시 PWA 기능만 비활성. */
});
}, []);
return null;
}

View File

@@ -0,0 +1,129 @@
"use client";
// 최근 본 종목 카드 그리드. localStorage 의 stockchart.recent 를 구독.
// 각 코드별로 /api/chart 2일치 받아서 현재가/전일대비 표시.
// 빈 리스트면 패널 자체를 안 그림.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { recent } from "../lib/recent";
type Tile = {
code: string;
name: string;
market: string;
current: number | null;
prev: number | null;
};
export function RecentTiles() {
const [codes, setCodes] = useState<string[]>([]);
const [tiles, setTiles] = useState<Tile[]>([]);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
const refresh = () => setCodes(recent.get());
refresh();
return recent.subscribe(refresh);
}, []);
useEffect(() => {
if (!hydrated || codes.length === 0) {
setTiles([]);
return;
}
let alive = true;
Promise.allSettled(
codes.map(async (code) => {
const c = await api.getChart(code, 5, "1d");
const valid = c.ohlcv.filter((p) => p.close != null);
const last = valid[valid.length - 1] ?? null;
const prev = valid.length >= 2 ? valid[valid.length - 2] : null;
return {
code,
name: c.name,
market: c.market,
current: last?.close ?? null,
prev: prev?.close ?? null,
};
}),
).then((settled) => {
if (!alive) return;
setTiles(
settled.map((r, i) => {
if (r.status === "fulfilled") return r.value;
return {
code: codes[i],
name: codes[i],
market: "—",
current: null,
prev: null,
};
}),
);
});
return () => {
alive = false;
};
}, [codes, hydrated]);
if (!hydrated || codes.length === 0) return null;
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> </div>
<button
type="button"
onClick={() => recent.clear()}
className="text-[11px] text-zinc-500 hover:text-zinc-300"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
{tiles.map((t) => (
<Tile key={t.code} t={t} />
))}
</div>
</div>
);
}
function Tile({ t }: { t: Tile }) {
const change =
t.current != null && t.prev != null ? t.current - t.prev : null;
const pct =
change != null && t.prev != null && t.prev !== 0
? (change / t.prev) * 100
: null;
const tone =
change == null || change === 0
? "text-zinc-400"
: change > 0
? "text-rose-400"
: "text-sky-400";
return (
<Link
href={`/${t.code}`}
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-2.5 transition hover:border-zinc-600"
>
<div className="truncate text-xs font-medium text-zinc-100">{t.name}</div>
<div className="mt-0.5 text-[10px] text-zinc-500">
{t.code} · {t.market}
</div>
<div className="mt-1.5 text-xs tabular-nums text-zinc-100">
{t.current != null
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
: "—"}
</div>
<div className={`text-[10px] tabular-nums ${tone}`}>
{pct == null
? " "
: `${pct > 0 ? "+" : ""}${pct.toFixed(2)}%`}
</div>
</Link>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
// 같은 시장 내 거래량/유동성 기준 관련 종목 8개.
// /api/markets/related/{code} 응답을 좁은 list 카드로 렌더.
// Collapsible 로 감싸 — 모바일에서 사이드바 길이가 부담되면 사용자가 접어 둠.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type RelatedResponse } from "../lib/api";
import { Collapsible } from "./Collapsible";
export function RelatedStocks({ code }: { code: string }) {
const [data, setData] = useState<RelatedResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setErr(null);
setData(null);
api
.related(code, 8)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [code]);
const subtitle = data?.market ? `${data.market} · 거래량 기준` : undefined;
return (
// defaultOpen=false — 사이드바의 세 collapsible 중 정보 밀도가 가장 낮고 "이 종목 자체"
// 와의 직접 연관도 약함(같은 시장의 다른 종목 리스트). 진입 즉시 접혀 있다가 사용자가
// 필요할 때 펴는 게 자연스러움. 첫 토글 이후엔 localStorage 가 사용자 선호로 굳음.
<Collapsible
title="관련 종목"
subtitle={subtitle}
storageKey="related"
defaultOpen={false}
>
{err && (
<div className="text-xs text-zinc-500"> : {err}</div>
)}
{!err && !data && (
<div className="text-xs text-zinc-500"> </div>
)}
{!err && data && !data.items.length && (
<div className="text-xs text-zinc-500">
({data.market}) .
</div>
)}
{!err && data && data.items.length > 0 && (
<ul className="divide-y divide-zinc-800">
{data.items.map((m) => {
const pct = m.pct_change;
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
return (
<li key={m.code}>
<Link
href={`/${m.code}`}
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
>
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
<span className="shrink-0 text-right tabular-nums">
<span className="text-zinc-100">
{m.close != null
? m.close.toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: "—"}
</span>
<span className={`ml-2 ${tone}`}>
{pct != null
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
: ""}
</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</Collapsible>
);
}

View File

@@ -0,0 +1,28 @@
// 수익률 미니 셀 — IntradayReturns(1D 10분봉 모드) 와 PeriodReturns(일봉 이상) 가 같은
// 슬롯에서 모드 전환으로 갈아끼우므로 두 패널의 Bucket 톤/색 규칙은 반드시 동일해야 한다.
// 예전엔 각 파일에 동일한 Bucket 이 중복 정의돼 있어 한쪽만 톤을 바꾸면 모드 전환 시
// 시각적 점프가 생길 위험이 있었다. 한 곳으로 묶어 단일 source of truth 로 둔다.
//
// 한국식 컬러 규칙: 상승 = rose, 하락 = sky, 0/null = zinc (Toss 톤).
type Props = {
label: string;
pct: number | null;
};
export function ReturnBucket({ label, pct }: Props) {
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>
);
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type Symbol } from "../lib/api";
import { Sparkline } from "./Sparkline";
export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
const [q, setQ] = useState("");
@@ -22,7 +23,8 @@ export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
setErr(null);
const handle = setTimeout(async () => {
try {
const r = await api.search(term, seedOnly, 15);
// with_sparkline=true: 백엔드가 최근 30 종가 + close + pct_change 동봉.
const r = await api.search(term, seedOnly, 15, true);
setItems(r.items);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
@@ -63,31 +65,61 @@ export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
{items.length > 0 && (
<ul className="mt-2 divide-y divide-zinc-800 rounded-md border border-zinc-800 bg-zinc-900/40">
{items.map((it) => (
<li key={it.code}>
<Link
href={`/${it.code}`}
className="flex items-center justify-between px-4 py-3 text-sm hover:bg-zinc-800/70"
>
<div>
<div className="font-medium text-zinc-100">
{it.name}
{it.is_seed && (
<span className="ml-2 rounded-full bg-emerald-900/60 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
SEED
</span>
)}
</div>
<div className="text-xs text-zinc-500">
{it.code} · {it.market}
{it.sector ? ` · ${it.sector}` : ""}
</div>
</div>
<span className="text-zinc-500"></span>
</Link>
</li>
<SearchRow key={it.code} it={it} />
))}
</ul>
)}
</div>
);
}
function SearchRow({ it }: { it: Symbol }) {
const pct = it.pct_change ?? null;
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
const stroke = pct != null && pct < 0 ? "#3b82f6" : "#ef4444";
const series = it.sparkline ?? [];
return (
<li>
<Link
href={`/${it.code}`}
className="flex items-center justify-between gap-3 px-4 py-3 text-sm hover:bg-zinc-800/70"
>
<div className="min-w-0">
<div className="font-medium text-zinc-100">
{it.name}
{it.is_seed && (
<span className="ml-2 rounded-full bg-emerald-900/60 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
SEED
</span>
)}
</div>
<div className="text-xs text-zinc-500">
{it.code} · {it.market}
{it.sector ? ` · ${it.sector}` : ""}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{series.length >= 2 && (
<Sparkline values={series} stroke={stroke} width={80} height={28} />
)}
<div className="text-right tabular-nums">
<div className="text-xs text-zinc-100">
{it.close != null
? it.close.toLocaleString(undefined, { maximumFractionDigits: 0 })
: "—"}
</div>
<div className={`text-[11px] ${tone}`}>
{pct != null ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` : ""}
</div>
</div>
</div>
</Link>
</li>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
// 학습 대상 SEED 10종목 quick-access 타일.
//
// 토스 홈의 '인기 종목' 카드 톤. 각 타일이 자체적으로 /api/chart 를 2일치 받아
// 현재가/전일대비 를 계산해서 표시. 10개 병렬 호출 — 백엔드는 캐시 친화적이므로 OK.
//
// 백엔드 신규 엔드포인트가 추가되면 (예: /api/markets/top-volume) 그쪽으로 갈아끼움.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api } from "../lib/api";
type Tile = {
code: string;
name: string;
market: string;
current: number | null;
prev: number | null;
};
const SEED_TICKERS: Array<Omit<Tile, "current" | "prev">> = [
{ code: "005930", name: "삼성전자", market: "KOSPI" },
{ code: "000660", name: "SK하이닉스", market: "KOSPI" },
{ code: "247540", name: "에코프로비엠", market: "KOSDAQ" },
{ code: "042700", name: "한미반도체", market: "KOSPI" },
{ code: "034020", name: "두산에너빌리티", market: "KOSPI" },
{ code: "012450", name: "한화에어로스페이스", market: "KOSPI" },
{ code: "329180", name: "HD현대중공업", market: "KOSPI" },
{ code: "035420", name: "NAVER", market: "KOSPI" },
{ code: "033780", name: "KT&G", market: "KOSPI" },
{ code: "036460", name: "한국가스공사", market: "KOSPI" },
];
export function SeedTiles() {
const [tiles, setTiles] = useState<Tile[]>(
SEED_TICKERS.map((t) => ({ ...t, current: null, prev: null })),
);
useEffect(() => {
let alive = true;
// 10 병렬 fetch. 한 종목 실패해도 다른 타일은 살린다.
Promise.allSettled(
SEED_TICKERS.map(async (t) => {
const c = await api.getChart(t.code, 5, "1d");
const valid = c.ohlcv.filter((p) => p.close != null);
const last = valid[valid.length - 1] ?? null;
const prev = valid.length >= 2 ? valid[valid.length - 2] : null;
return {
code: t.code,
name: t.name,
market: t.market,
current: last?.close ?? null,
prev: prev?.close ?? null,
};
}),
).then((settled) => {
if (!alive) return;
const next: Tile[] = settled.map((r, i) => {
if (r.status === "fulfilled") return r.value;
return { ...SEED_TICKERS[i], current: null, prev: null };
});
setTiles(next);
});
return () => {
alive = false;
};
}, []);
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> 10</div>
<div className="text-[11px] text-zinc-500"> · </div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
{tiles.map((t) => (
<TileCard key={t.code} t={t} />
))}
</div>
</div>
);
}
function TileCard({ t }: { t: Tile }) {
const change =
t.current != null && t.prev != null ? t.current - t.prev : null;
const pct =
change != null && t.prev != null && t.prev !== 0
? (change / t.prev) * 100
: null;
const tone =
change == null || change === 0
? "text-zinc-400"
: change > 0
? "text-rose-400"
: "text-sky-400";
return (
<Link
href={`/${t.code}`}
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-3 transition hover:border-zinc-600"
>
<div className="truncate text-sm font-medium text-zinc-100">{t.name}</div>
<div className="mt-0.5 text-[11px] text-zinc-500">
{t.code} · {t.market}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-100">
{t.current != null
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
: "—"}
</div>
<div className={`text-[11px] tabular-nums ${tone}`}>
{change == null
? " "
: `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`}
</div>
</Link>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
// 종목 페이지 공유 버튼 — 현재 URL 을 공유 / 클립보드에 복사.
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 시도 + 짧은 토스트).
//
// 동작 우선순위:
// 1) navigator.share (모바일 native sheet — 성공 시 OS 시트가 피드백, 토스트 안 띄움)
// 2) navigator.clipboard.writeText (HTTPS or localhost — 클립보드에 복사 + "링크 복사됨")
// 3) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
// 4) 모두 실패면 "복사 안됨" 안내
import { useEffect, useState } from "react";
type Props = {
code: string;
name?: string;
};
async function copyToClipboard(text: string): Promise<boolean> {
// Modern path — secure context 에서만 사용 가능.
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 권한 거부 등 — fallback 시도.
}
}
// Legacy fallback. document.execCommand 는 deprecated 지만 비-HTTPS LAN 환경 호환용.
if (typeof document === "undefined") return false;
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
export function ShareButton({ code, name }: Props) {
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
if (!feedback) return;
const h = window.setTimeout(() => setFeedback(null), 2000);
return () => window.clearTimeout(h);
}, [feedback]);
const onClick = async () => {
if (typeof window === "undefined") return;
const url = window.location.href;
// Web Share API 우선 — 모바일에서 native sheet 뜸. 데스크탑/실패 시 클립보드로 폴백.
const shareData = {
title: name ? `${name} (${code})` : code,
text: name ? `${name} 종목 정보` : code,
url,
};
if (
typeof navigator !== "undefined" &&
typeof navigator.share === "function"
) {
try {
await navigator.share(shareData);
return; // 사용자가 native sheet 에서 처리. 토스트 안 띄움.
} catch {
// AbortError(사용자 취소) 또는 미지원 — 클립보드로 폴백.
}
}
const ok = await copyToClipboard(url);
setFeedback(ok ? "링크 복사됨" : "복사 안됨");
};
return (
<div className="relative">
<button
type="button"
onClick={onClick}
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 transition hover:border-zinc-500 hover:text-zinc-200"
title="이 종목 페이지 공유"
aria-label="이 종목 페이지 공유"
>
</button>
{feedback && (
<span
aria-live="polite"
className="pointer-events-none absolute right-0 top-full z-20 mt-1 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-950/90 px-2 py-0.5 text-[10px] text-zinc-200 shadow"
>
{feedback}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
// 글로벌 키보드 단축키 헬프 오버레이.
// `?` (Shift+/) 로 열고 ESC 또는 바깥 클릭 / 닫기 버튼으로 닫음.
// 다른 슬라이스에서 키보드 단축키를 추가해도 발견 가능성을 유지하기 위한 공용 패널.
//
// 등록되는 단축키는 여기 한 곳에서 정의 — 실제 핸들러는 각 컴포넌트가 가지고 있고,
// 이 컴포넌트는 단순 표시 + `?` 토글만 담당. 단일 출처(Single Source of Truth)는 아니지만,
// 이 사이트가 사용하는 단축키 수가 적어 수동 동기화로 충분.
import { useEffect, useRef, useState } from "react";
type Shortcut = {
keys: string[]; // ['Ctrl', 'K'] 같은 표시용 토큰. 시각적으로 + 로 join.
label: string;
scope?: string; // '전역' / '종목 페이지' 등 — group 헤더 라벨.
};
const SHORTCUTS: Shortcut[] = [
{ scope: "전역", keys: ["/"], label: "종목 검색창 포커스" },
{ scope: "전역", keys: ["Ctrl", "K"], label: "종목 검색창 포커스 (Mac: ⌘K)" },
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
{ scope: "종목 페이지", keys: ["H"], label: "현재 가격에 수평선 추가" },
{ scope: "종목 페이지", keys: ["Shift", "클릭"], label: "차트 위 클릭 위치에 수평선 추가" },
{ scope: "종목 페이지", keys: ["Alt", "클릭"], label: "차트의 가장 가까운 수평선 제거" },
{ scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" },
{ scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" },
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
{ scope: "검색 팝오버", keys: ["Enter"], label: "선택한 종목으로 이동" },
];
function isEditableTarget(t: EventTarget | null): boolean {
if (!(t instanceof HTMLElement)) return false;
const tag = t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (t.isContentEditable) return true;
return false;
}
export function ShortcutsHelp() {
const [open, setOpen] = useState(false);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
// `?` 키 = 토글. 입력 중에는 무시 (사용자가 실제 ? 입력하려는 의도).
// ESC 는 모달 안에서 onKeyDown 으로도 처리하지만, 글로벌 핸들러로도 닫음 — 포커스가
// 모달 밖에 있어도 닫히도록.
useEffect(() => {
if (typeof window === "undefined") return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "?" && !isEditableTarget(e.target)) {
e.preventDefault();
setOpen((v) => !v);
return;
}
if (e.key === "Escape" && open) {
setOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// 열릴 때 닫기 버튼에 포커스 — 키보드 사용자가 바로 Tab 흐름을 잡을 수 있게.
useEffect(() => {
if (open) closeBtnRef.current?.focus();
}, [open]);
// 화면 우하단 floating 버튼 — 모바일/마우스 사용자가 키보드 없이도 발견 가능.
// 모달이 열려 있으면 같은 버튼이 닫기 역할.
return (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="fixed bottom-4 right-4 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-xs text-zinc-300 shadow-lg backdrop-blur transition hover:border-zinc-500 hover:text-zinc-100"
title="키보드 단축키 (?)"
aria-label="키보드 단축키 도움말"
aria-expanded={open}
>
?
</button>
{open && (
<div
role="dialog"
aria-modal="true"
aria-label="키보드 단축키"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
>
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-5 shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-100"> </h2>
<button
ref={closeBtnRef}
type="button"
onClick={() => setOpen(false)}
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
aria-label="닫기"
>
(Esc)
</button>
</div>
<ShortcutTable />
<p className="mt-3 text-[10px] text-zinc-500">
.
</p>
</div>
</div>
)}
</>
);
}
// scope 별로 묶어 한 줄에 [키] — 설명 형태로 표시. 같은 scope 가 연속되면 헤더 생략해서
// 시각적 노이즈 줄임.
function ShortcutTable() {
let prevScope: string | undefined;
return (
<ul className="space-y-1.5">
{SHORTCUTS.map((s, i) => {
const showScope = s.scope && s.scope !== prevScope;
prevScope = s.scope;
return (
<li key={i}>
{showScope && (
<div className="mb-0.5 mt-2 first:mt-0 text-[10px] uppercase tracking-wide text-zinc-500">
{s.scope}
</div>
)}
<div className="flex items-center justify-between gap-3 text-[12px]">
<span className="text-zinc-300">{s.label}</span>
<span className="flex shrink-0 items-center gap-1">
{s.keys.map((k, j) => (
<span key={j} className="flex items-center gap-1">
{j > 0 && <span className="text-[10px] text-zinc-600">+</span>}
<kbd className="rounded border border-zinc-700 bg-zinc-900 px-1.5 py-0.5 font-mono text-[10px] text-zinc-200">
{k}
</kbd>
</span>
))}
</span>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,49 @@
// 가벼운 SVG sparkline. lightweight-charts 인스턴스를 또 만들 필요 없음.
// 종가 시계열을 받아 polyline 으로 그린다. 색상은 호출부에서 결정 (KR 관행: 상승=빨강, 하락=파랑).
type Props = {
values: number[];
stroke: string;
width?: number;
height?: number;
className?: string;
};
export function Sparkline({
values,
stroke,
width = 140,
height = 44,
className,
}: Props) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const stepX = width / (values.length - 1);
const points = values
.map((v, i) => {
const x = (i * stepX).toFixed(1);
const y = (height - ((v - min) / span) * height).toFixed(1);
return `${x},${y}`;
})
.join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={`shrink-0 opacity-80 ${className ?? ""}`}
aria-hidden
>
<polyline
points={points}
fill="none"
stroke={stroke}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
// 관심종목 토글 버튼. 종목 페이지 PriceHero 옆에 들어감.
// localStorage 만 건드리므로 백엔드 호출 없음.
import { useEffect, useState } from "react";
import { watchlist } from "../lib/watchlist";
export function StarButton({ code, name }: { code: string; name?: string }) {
const [active, setActive] = useState(false);
useEffect(() => {
setActive(watchlist.has(code));
return watchlist.subscribe(() => setActive(watchlist.has(code)));
}, [code]);
const toggle = () => {
const now = watchlist.toggle(code);
setActive(now);
};
return (
<button
type="button"
onClick={toggle}
aria-pressed={active}
title={active ? "관심종목에서 제거" : "관심종목에 추가"}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition ${
active
? "border-amber-500/60 bg-amber-500/10 text-amber-300"
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
}`}
>
<span aria-hidden>{active ? "★" : "☆"}</span>
<span>{active ? "관심" : "관심"}</span>
{name && <span className="sr-only">{name}</span>}
</button>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
"use client";
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
//
// 표시 항목:
// - 오늘: 시가 / 고가 / 저가 / 거래량
// - 52주: 최고 / 최저 (1Y 일봉에서 계산) + 게이지
// - 펀더멘털: 시가총액 / PER / PBR / EPS / BPS / 배당수익률 (pykrx KRX 스냅샷)
//
// 차트 폴링과 독립적으로 1Y 일봉 + 펀더멘털 스냅샷을 한 번씩 받는다.
import { useEffect, useMemo, useState } from "react";
import { api, type ChartPayload, type FundamentalsResponse } from "../lib/api";
export function SymbolSidebar({ code }: { code: string }) {
const [chart, setChart] = useState<ChartPayload | null>(null);
const [fund, setFund] = useState<FundamentalsResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setErr(null);
setChart(null);
setFund(null);
api
.getChart(code, 365, "1d")
.then((c) => {
if (alive) setChart(c);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
// 펀더멘털은 차트와 독립 — 실패해도 시세 표시는 유지.
api
.fundamentals(code)
.then((f) => {
if (alive) setFund(f);
})
.catch(() => {
// 무시: 펀더멘털 없으면 그 섹션만 숨김.
});
return () => {
alive = false;
};
}, [code]);
const stats = useMemo(() => {
if (!chart) return null;
const points = chart.ohlcv.filter((p) => p.close != null);
if (!points.length) return null;
const last = points[points.length - 1];
let yearHigh = -Infinity;
let yearLow = Infinity;
for (const p of points) {
if (p.high != null && p.high > yearHigh) yearHigh = p.high;
if (p.low != null && p.low < yearLow) yearLow = p.low;
}
const yh = yearHigh === -Infinity ? null : yearHigh;
const yl = yearLow === Infinity ? null : yearLow;
// 52주 위치 (0~1). 종가 기준, 분모 0 방어.
let pos: number | null = null;
if (yh != null && yl != null && yh > yl && last.close != null) {
pos = Math.min(1, Math.max(0, (last.close - yl) / (yh - yl)));
}
return {
open: last.open,
high: last.high,
low: last.low,
close: last.close,
volume: last.volume,
yearHigh: yh,
yearLow: yl,
yearPos: pos,
};
}, [chart]);
if (err) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
: {err}
</div>
);
}
if (!stats) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 text-sm font-medium text-zinc-200"> </div>
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<Row label="시가" value={fmt(stats.open)} />
<Row label="고가" value={fmt(stats.high)} tone="up" />
<Row label="저가" value={fmt(stats.low)} tone="down" />
<Row label="거래량" value={fmtCompact(stats.volume)} />
<Row label="52주 최고" value={fmt(stats.yearHigh)} tone="up" />
<Row label="52주 최저" value={fmt(stats.yearLow)} tone="down" />
</dl>
{stats.yearPos != null && (
<YearRangeBar
low={stats.yearLow}
high={stats.yearHigh}
pos={stats.yearPos}
/>
)}
{fund && fund.status === "ok" && <FundamentalsBlock f={fund} />}
</div>
);
}
// 시가총액 / PER / PBR / EPS / BPS / 배당수익률. KRX 스냅샷 일자 표시.
// 값 미존재 (e.g. 신규 상장으로 EPS 안 나옴) 항목은 자동으로 "-".
function FundamentalsBlock({ f }: { f: FundamentalsResponse }) {
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
<div className="mb-2 flex items-center justify-between text-[10px] text-zinc-500">
<span> </span>
{f.snapshot_date && (
<span className="tabular-nums">{f.snapshot_date} </span>
)}
</div>
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<Row label="시가총액" value={fmtKrw(f.market_cap)} />
<Row label="상장주식수" value={fmtShares(f.shares_outstanding)} />
<Row label="PER" value={fmtMultiple(f.per)} />
<Row label="PBR" value={fmtMultiple(f.pbr)} />
<Row label="EPS" value={fmt(f.eps)} />
<Row label="BPS" value={fmt(f.bps)} />
<Row label="배당수익률" value={fmtPct(f.dvd_yield)} />
<Row label="주당배당금" value={fmt(f.div)} />
</dl>
</div>
);
}
// 52주 범위 내 현재가 위치 — 토스 종목 상세에 있는 가로 게이지를 흉내냄.
function YearRangeBar({
low,
high,
pos,
}: {
low: number | null;
high: number | null;
pos: number;
}) {
const pct = pos * 100;
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
<div className="mb-1.5 flex items-center justify-between text-[10px] text-zinc-500">
<span>52 </span>
<span className="tabular-nums text-zinc-300">{pct.toFixed(0)}%</span>
</div>
<div className="relative h-1.5 w-full rounded-full bg-zinc-800">
<div
className="absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-sky-500/70 via-amber-400/70 to-rose-500/70"
style={{ width: "100%" }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-zinc-950 bg-zinc-100 shadow"
style={{ left: `${pct}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-zinc-500">
<span>{fmt(low)}</span>
<span>{fmt(high)}</span>
</div>
</div>
);
}
function Row({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "up" | "down";
}) {
const toneCls =
tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : "text-zinc-100";
return (
<>
<dt className="text-zinc-500">{label}</dt>
<dd className={`text-right tabular-nums ${toneCls}`}>{value}</dd>
</>
);
}
function fmt(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
function fmtCompact(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}`;
return n.toLocaleString();
}
// 시가총액은 단위가 매우 큼 — 조/억 까지 단순화.
function fmtKrw(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}`;
if (n >= 1e8) return `${(n / 1e8).toFixed(0)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}`;
return n.toLocaleString();
}
function fmtShares(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e8) return `${(n / 1e8).toFixed(2)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}`;
return n.toLocaleString();
}
// PER/PBR — 음수면 "적자" 표기 (이익 음수 = PER 의미 없음).
function fmtMultiple(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n <= 0) return "적자";
return `${n.toFixed(2)}`;
}
function fmtPct(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n === 0) return "-";
return `${n.toFixed(2)}%`;
}

View File

@@ -0,0 +1,69 @@
"use client";
// 홈에 들어가는 '테마로 골라보기' 그리드.
// /api/themes 인덱스를 받아 카드로 펼치고, 카드 클릭 시 /themes/{slug} 로 이동.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type ThemesIndexResponse } from "../lib/api";
export function ThemeTiles() {
const [data, setData] = useState<ThemesIndexResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api
.themesIndex()
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, []);
if (err)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
: {err}
</div>
);
if (!data)
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
</div>
);
return (
<div>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-zinc-200"> </h2>
<span className="text-[11px] text-zinc-500">{data.total} </span>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{data.items.map((t) => (
<Link
key={t.slug}
href={`/themes/${t.slug}`}
className="group rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5 transition hover:border-zinc-700 hover:bg-zinc-800/60"
>
<div className="text-sm font-medium text-zinc-100 group-hover:text-rose-300">
{t.name}
</div>
<div className="mt-0.5 truncate text-[11px] text-zinc-500">
{t.description}
</div>
<div className="mt-1 text-[10px] text-zinc-600">
{t.code_count}
</div>
</Link>
))}
</div>
</div>
);
}

257
web/components/TopNav.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
// 글로벌 sticky 헤더 — 모든 페이지 상단에 고정. 토스 톱바 톤.
// 좌: 로고/타이틀, 중: 컴팩트 검색 (debounced + popover), 우: 관심종목 링크.
//
// 홈의 큰 SearchBox 와 별개로 동작. 결과 클릭 시 popover 가 닫히고 페이지 이동.
// Esc 또는 바깥 클릭으로 popover 닫힘.
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { api, type Symbol } from "../lib/api";
import { recent } from "../lib/recent";
export function TopNav() {
const router = useRouter();
const [q, setQ] = useState("");
const [items, setItems] = useState<Symbol[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [recentCodes, setRecentCodes] = useState<string[]>([]);
const [hi, setHi] = useState(0); // popover 선택 인덱스 (검색 결과 또는 recent)
const wrapRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// 최근 본 종목 구독 — popover 빈 상태에서 보여줌.
useEffect(() => {
const refresh = () => setRecentCodes(recent.get());
refresh();
return recent.subscribe(refresh);
}, []);
// 디바운스 검색. 입력 비면 결과 클리어.
useEffect(() => {
const term = q.trim();
if (!term) {
setItems([]);
return;
}
setLoading(true);
const h = setTimeout(async () => {
try {
const r = await api.search(term, false, 8, false);
setItems(r.items);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, 200);
return () => clearTimeout(h);
}, [q]);
// 바깥 클릭으로 popover 닫기 + 글로벌 단축키.
// - "/" 또는 Cmd/Ctrl+K → 검색 인풋 포커스 + popover 열기
// (단, 다른 input/textarea/contenteditable 에 포커스 중이면 무시)
// - Esc → popover 닫기
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const isEditableTarget = (t: EventTarget | null): boolean => {
if (!(t instanceof HTMLElement)) return false;
const tag = t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (t.isContentEditable) return true;
return false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
return;
}
// Cmd/Ctrl+K — OS 공통 검색 단축키. 단, 다른 인풋/편집 영역에서 입력 중이면
// 브라우저/편집기 단축키를 뺏지 않도록 동일하게 무시.
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "k" &&
!isEditableTarget(e.target)
) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
return;
}
// "/" — 다른 인풋에 포커스 없을 때만.
if (e.key === "/" && !isEditableTarget(e.target)) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, []);
// 현재 선택 가능한 코드 목록 — 검색 모드면 items, 빈 모드면 recent.
const navCodes = useMemo<string[]>(() => {
if (q.trim()) return items.map((it) => it.code);
return recentCodes;
}, [q, items, recentCodes]);
// 목록 길이가 바뀌면 hi 를 clamp.
useEffect(() => {
setHi((h) => (navCodes.length === 0 ? 0 : Math.min(h, navCodes.length - 1)));
}, [navCodes]);
// 인풋 키 이벤트 — ↑↓ 이동, Enter 이동, Esc 닫기.
const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
if (navCodes.length === 0) return;
e.preventDefault();
setOpen(true);
setHi((h) => (h + 1) % navCodes.length);
} else if (e.key === "ArrowUp") {
if (navCodes.length === 0) return;
e.preventDefault();
setOpen(true);
setHi((h) => (h - 1 + navCodes.length) % navCodes.length);
} else if (e.key === "Enter") {
if (navCodes.length === 0) return;
e.preventDefault();
const code = navCodes[hi] ?? navCodes[0];
router.push(`/${code}`);
setOpen(false);
setQ("");
inputRef.current?.blur();
}
};
return (
<header className="sticky top-0 z-40 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
<div className="mx-auto flex h-12 max-w-5xl items-center gap-4 px-6">
<Link
href="/"
className="shrink-0 text-sm font-semibold tracking-tight text-zinc-100 hover:text-white"
>
Stock Chart
</Link>
<div ref={wrapRef} className="relative flex-1 max-w-md">
<input
ref={inputRef}
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
setHi(0);
}}
onFocus={() => setOpen(true)}
onKeyDown={onInputKey}
placeholder="종목 검색 ( / )"
aria-label="종목 검색"
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 outline-none focus:border-zinc-600"
/>
{open && q.trim() && (
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
{loading && (
<div className="px-3 py-2 text-xs text-zinc-500"></div>
)}
{!loading && items.length === 0 && (
<div className="px-3 py-2 text-xs text-zinc-500"> </div>
)}
<ul className="divide-y divide-zinc-800">
{items.map((it, i) => (
<li key={it.code}>
<Link
href={`/${it.code}`}
onMouseEnter={() => setHi(i)}
onClick={() => {
setOpen(false);
setQ("");
}}
className={`flex items-center justify-between gap-2 px-3 py-2 text-xs ${
i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60"
}`}
>
<span className="min-w-0 truncate text-zinc-100">
{it.name}
{it.is_seed && (
<span className="ml-1.5 rounded-sm bg-emerald-900/60 px-1 py-0.5 text-[9px] font-semibold text-emerald-300">
SEED
</span>
)}
</span>
<span className="shrink-0 text-zinc-500">
{it.code} · {it.market}
</span>
</Link>
</li>
))}
</ul>
</div>
)}
{open && !q.trim() && recentCodes.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-zinc-500">
<span> </span>
<button
type="button"
onClick={() => recent.clear()}
className="hover:text-zinc-300"
>
</button>
</div>
<ul className="divide-y divide-zinc-800">
{recentCodes.map((c, i) => (
<li key={c}>
<Link
href={`/${c}`}
onMouseEnter={() => setHi(i)}
onClick={() => setOpen(false)}
className={`block px-3 py-2 text-xs text-zinc-200 ${
i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60"
}`}
>
{c}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
<Link
href="/compare"
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
title="종목 비교"
>
</Link>
<Link
href="/alerts"
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
title="가격 알람"
>
🔔
</Link>
<Link
href="/watchlist"
className="shrink-0 text-xs text-amber-300 hover:text-amber-200"
>
</Link>
</div>
</header>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
// 거래주체 패널 — chart.trading_value (외국인/기관/개인 순매수) 를 최근 5일 표로.
// 별도 백엔드 호출 없음. ChartPayload 에 이미 들어있는 시리즈를 재사용.
// 단위: 백엔드는 원(KRW) 단위. UI 는 억원(억원=1e8)으로 환산.
// 색: KR 관행 — 매수 우위(+) 빨강, 매도 우위(-) 파랑.
import type { TradingValuePoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function TradingValuePanel({
data,
}: {
data: TradingValuePoint[];
}) {
if (!data || data.length === 0) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
// 최근 5일만 (입력은 오래된→최신 순), 표시는 최신이 위.
const recent = data.slice(-5).reverse();
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader
title="거래주체 순매수"
subtitle={`최근 ${recent.length}일 · 단위: 억원`}
/>
<div className="overflow-hidden rounded-md border border-zinc-800">
<table className="w-full text-xs tabular-nums">
<thead className="bg-zinc-900/60 text-[11px] text-zinc-500">
<tr>
<th className="px-2 py-1.5 text-left font-normal"></th>
<th className="px-2 py-1.5 text-right font-normal"></th>
<th className="px-2 py-1.5 text-right font-normal"></th>
<th className="px-2 py-1.5 text-right font-normal"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{recent.map((row) => (
<tr key={row.date}>
<td className="px-2 py-1.5 text-left text-zinc-400">
{shortDate(row.date)}
</td>
<NetCell value={row.foreign_net} />
<NetCell value={row.institution_net} />
<NetCell value={row.individual_net} />
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function NetCell({ value }: { value: number | null }) {
if (value == null) {
return <td className="px-2 py-1.5 text-right text-zinc-600"></td>;
}
const eok = value / 1e8;
const tone =
eok === 0
? "text-zinc-400"
: eok > 0
? "text-rose-400"
: "text-sky-400";
const sign = eok > 0 ? "+" : "";
return (
<td className={`px-2 py-1.5 text-right ${tone}`}>
{sign}
{eok.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</td>
);
}
function shortDate(iso: string): string {
// 'YYYY-MM-DD' → 'MM-DD'
const m = /^\d{4}-(\d{2})-(\d{2})/.exec(iso);
return m ? `${m[1]}-${m[2]}` : iso;
}

104
web/lib/alerts.ts Normal file
View File

@@ -0,0 +1,104 @@
// 가격 알람 — localStorage 기반. 단순 임계값 알람.
//
// 스키마 (Alert):
// id 랜덤 문자열
// code 종목코드
// name 등록 시점의 종목명 (표시용)
// op "gte" = 현재가 ≥ target, "lte" = 현재가 ≤ target
// target 임계값 (원)
// triggered 한 번 발사되면 true. 사용자가 명시적으로 다시 켜기 전까지 침묵.
// createdAt epoch ms
//
// 발사는 AlertsToaster (글로벌 폴러) 가 처리. 이 모듈은 저장소+pub/sub 만 담당.
const KEY = "stockchart.alerts";
export type AlertOp = "gte" | "lte";
export type Alert = {
id: string;
code: string;
name?: string;
op: AlertOp;
target: number;
triggered: boolean;
createdAt: number;
};
function read(): Alert[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return [];
const v = JSON.parse(raw);
if (!Array.isArray(v)) return [];
return v.filter(
(x): x is Alert =>
x &&
typeof x.id === "string" &&
typeof x.code === "string" &&
(x.op === "gte" || x.op === "lte") &&
typeof x.target === "number" &&
typeof x.triggered === "boolean",
);
} catch {
return [];
}
}
function write(items: Alert[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(KEY, JSON.stringify(items));
window.dispatchEvent(new CustomEvent("alerts:change"));
}
function genId(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
export const alerts = {
list(): Alert[] {
return read();
},
listForCode(code: string): Alert[] {
return read().filter((a) => a.code === code);
},
add(input: Omit<Alert, "id" | "triggered" | "createdAt"> & { triggered?: boolean }) {
const cur = read();
cur.unshift({
id: genId(),
triggered: input.triggered ?? false,
createdAt: Date.now(),
code: input.code,
name: input.name,
op: input.op,
target: input.target,
});
write(cur);
},
remove(id: string) {
write(read().filter((a) => a.id !== id));
},
markTriggered(id: string) {
write(read().map((a) => (a.id === id ? { ...a, triggered: true } : a)));
},
reset(id: string) {
write(read().map((a) => (a.id === id ? { ...a, triggered: false } : a)));
},
clear() {
write([]);
},
subscribe(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) cb();
};
const onCustom = () => cb();
window.addEventListener("storage", onStorage);
window.addEventListener("alerts:change", onCustom as EventListener);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener("alerts:change", onCustom as EventListener);
};
},
};

View File

@@ -1,8 +1,31 @@
// Backend API client.
// NEXT_PUBLIC_API_BASE 는 docker-compose 에서 http://localhost:8000 으로 주입됨.
//
// API 베이스 해석 우선순위:
// 1) NEXT_PUBLIC_API_BASE 가 localhost/127.0.0.1 이 아닌 명시값 → 그대로 사용
// (예: 프로덕션 https://api.example.com)
// 2) 브라우저 환경 → window.location.hostname:8000 (LAN 접속도 자동 대응)
// 3) SSR 폴백 → http://localhost:8000
//
// docker-compose 가 NEXT_PUBLIC_API_BASE=http://localhost:8000 을 주입하는 경우가 흔한데,
// LAN 의 다른 PC 에서 http://<host>:3000 으로 접속하면 inline 된 localhost 가 그쪽 PC 의
// localhost 를 가리켜 깨진다. 그래서 localhost/127.0.0.1 값은 신뢰하지 않고 페이지 host 로
// 폴백.
const RAW_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
export const API_BASE = RAW_BASE.replace(/\/$/, "");
function resolveApiBase(): string {
const raw = process.env.NEXT_PUBLIC_API_BASE;
const env = raw && raw.length > 0 ? raw.replace(/\/$/, "") : "";
const envIsLocal = !env || /\/\/(localhost|127\.0\.0\.1)(?::|$)/.test(env);
if (typeof window !== "undefined") {
if (envIsLocal) {
return `${window.location.protocol}//${window.location.hostname}:8000`;
}
return env;
}
// SSR
return env || "http://localhost:8000";
}
export const API_BASE = resolveApiBase();
export type Symbol = {
code: string;
@@ -10,6 +33,10 @@ export type Symbol = {
market: string;
sector: string | null;
is_seed: boolean;
// with_sparkline=true 일 때만 채워짐.
sparkline?: number[];
close?: number | null;
pct_change?: number | null;
};
export type SymbolSearch = {
@@ -19,6 +46,7 @@ export type SymbolSearch = {
};
export type OhlcvPoint = {
// 1d/1w/1mo: 'YYYY-MM-DD' / 10m: 'YYYY-MM-DDTHH:MM:SS' (KST naive ISO)
date: string;
open: number | null;
high: number | null;
@@ -27,6 +55,8 @@ export type OhlcvPoint = {
volume: number | null;
};
export type ChartInterval = "10m" | "1d" | "1w" | "1mo";
export type SentimentPoint = {
date: string;
n_articles: number;
@@ -45,7 +75,10 @@ export type ChartPayload = {
code: string;
name: string;
market: string;
interval: ChartInterval;
intraday_status: string | null;
range: { from: string; to: string };
today: string;
ohlcv: OhlcvPoint[];
sentiment: SentimentPoint[];
trading_value: TradingValuePoint[];
@@ -72,6 +105,7 @@ export type PredictResponse = {
sources_used: string[];
steps: PredictionStep[];
saved_prediction_ids: number[];
saved_shadow_ids?: { chronos: number[]; lgbm: number[] };
user_triggered: boolean;
};
@@ -132,6 +166,125 @@ export type NewsResponse = {
items: NewsItem[];
};
export type Mover = {
code: string;
name: string;
market: string;
close: number | null;
prev_close: number | null;
volume: number | null;
pct_change: number | null;
trading_value?: number | null;
};
export type MoversResponse = {
market: string;
limit: number;
gainers: Mover[];
losers: Mover[];
by_volume: Mover[];
by_trading_value: Mover[];
};
export type RelatedResponse = {
code: string;
market: string;
items: Mover[];
};
export type ThemeIndex = {
slug: string;
name: string;
description: string;
code_count: number;
};
export type ThemesIndexResponse = {
items: ThemeIndex[];
total: number;
};
export type ThemeDetailResponse = {
slug: string;
name: string;
description: string;
items: Mover[];
};
export type PeerCompareItem = {
code: string;
name: string;
market: string | null;
close: number | null;
base_close: number | null;
pct_change: number | null;
};
export type PeerCompareResponse = {
code: string;
name?: string;
window: number;
themes: { slug: string; name: string; code_count: number }[];
self_pct: number | null;
peer_avg_pct: number | null;
peer_count?: number;
top: PeerCompareItem[];
bottom: PeerCompareItem[];
};
export type IndexPoint = { date: string; value: number };
export type IndexSeries = {
key: "kospi" | "kosdaq";
name: string;
points: IndexPoint[];
latest: number | null;
prev: number | null;
pct_change: number | null;
};
export type IndicesResponse = {
days: number;
indices: IndexSeries[];
};
export type OrderbookLevel = {
level: number;
price: number;
qty: number;
};
export type FundamentalsResponse = {
code: string;
name: string;
market: string;
status: "ok" | "no_data";
snapshot_date: string | null;
market_cap: number | null;
shares_outstanding: number | null;
per: number | null;
pbr: number | null;
eps: number | null;
bps: number | null;
div: number | null; // 주당배당금 (원)
dvd_yield: number | null; // 배당수익률 (%)
};
export type OrderbookResponse = {
code: string;
name: string;
market: string;
status: "ok" | "skipped_missing_key";
ts: string | null;
current: number | null;
prev_close_diff: number | null;
prev_close_pct: number | null;
asks: OrderbookLevel[];
bids: OrderbookLevel[];
ask_total: number;
bid_total: number;
};
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
@@ -149,18 +302,22 @@ async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
search: (q: string, seedOnly = false, limit = 20) =>
search: (q: string, seedOnly = false, limit = 20, withSparkline = false) =>
getJson<SymbolSearch>(
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`,
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}&with_sparkline=${withSparkline}`,
),
getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`),
getChart: (code: string, days = 180) =>
getJson<ChartPayload>(`/api/chart/${encodeURIComponent(code)}?days=${days}`),
predict: (code: string, horizons = "1,3,5") =>
getJson<PredictResponse>(
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(horizons)}`,
{ method: "POST" },
getChart: (code: string, days = 180, interval: ChartInterval = "1d") =>
getJson<ChartPayload>(
`/api/chart/${encodeURIComponent(code)}?days=${days}&interval=${encodeURIComponent(interval)}`,
),
predict: (code: string, horizons: string | number[] = "1,3,5") => {
const h = Array.isArray(horizons) ? horizons.join(",") : horizons;
return getJson<PredictResponse>(
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(h)}`,
{ method: "POST" },
);
},
latestPrediction: (code: string) =>
getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`),
metrics: (code: string, windowDays = 30) =>
@@ -175,4 +332,48 @@ export const api = {
source ? `&source=${encodeURIComponent(source)}` : ""
}`,
),
movers: (market: "ALL" | "KOSPI" | "KOSDAQ" = "ALL", limit = 10) =>
getJson<MoversResponse>(`/api/markets/movers?market=${market}&limit=${limit}`),
related: (code: string, limit = 8) =>
getJson<RelatedResponse>(
`/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`,
),
indices: (days = 60) => getJson<IndicesResponse>(`/api/markets/indices?days=${days}`),
themesIndex: () => getJson<ThemesIndexResponse>(`/api/themes`),
themeDetail: (slug: string, limit = 30) =>
getJson<ThemeDetailResponse>(
`/api/themes/${encodeURIComponent(slug)}?limit=${limit}`,
),
peerCompare: (code: string, window = 21, limit = 5) =>
getJson<PeerCompareResponse>(
`/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`,
),
orderbook: (code: string) =>
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
fundamentals: (code: string) =>
getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`),
recordView: (code: string) =>
getJson<RecordViewResponse>(`/api/views/${encodeURIComponent(code)}`, {
method: "POST",
}),
views: (code: string) =>
getJson<ViewsResponse>(`/api/views/${encodeURIComponent(code)}`),
};
export type RecordViewResponse = {
ok: boolean;
code?: string;
today_views: number;
};
export type ViewsTrendPoint = {
date: string;
views: number;
};
export type ViewsResponse = {
code: string;
today_views: number;
last_7d_views: number;
trend: ViewsTrendPoint[];
};

30
web/lib/collapsible.ts Normal file
View File

@@ -0,0 +1,30 @@
// 사이드바 collapsible 패널의 펼침/접힘 상태 영속 저장.
// 키 형식: `panel-open:<name>` — 종목별이 아닌 panel-name 단위. 사용자는 "관련 종목은 늘
// 접어 둔다" 같은 panel 단위 습관이 있지 종목별로 다르게 설정하지 않음. 모든 종목 페이지에서
// 같은 상태로 보이는 것이 직관적.
//
// 값 형식: '1' = open, '0' = closed. 다른 값(수동 편집·legacy 등) 은 default 로 폴백.
// 쿼타/잠금 환경에선 silently fail — 그 세션 동안 default 상태 유지.
const STORAGE_PREFIX = "panel-open:";
export function readOpen(key: string, defaultOpen: boolean): boolean {
if (typeof window === "undefined") return defaultOpen;
try {
const v = window.localStorage.getItem(STORAGE_PREFIX + key);
if (v === "1") return true;
if (v === "0") return false;
return defaultOpen;
} catch {
return defaultOpen;
}
}
export function writeOpen(key: string, open: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_PREFIX + key, open ? "1" : "0");
} catch {
// quota / private mode — 다음 페이지 진입엔 default 로 되돌아감.
}
}

69
web/lib/csv.ts Normal file
View File

@@ -0,0 +1,69 @@
// OHLCV → CSV 변환 + 다운로드 트리거.
// 차트 데이터를 분석가 친화 CSV (UTF-8 BOM, Excel 호환) 로 내려받게 해줌.
import type { ChartInterval, OhlcvPoint } from "./api";
function escapeField(v: string): string {
// 표준 RFC 4180 — 쉼표/큰따옴표/개행 포함 시 큰따옴표로 감싸고 내부 큰따옴표는 이중화.
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
return `"${v.replace(/"/g, '""')}"`;
}
return v;
}
function num(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "";
return String(n);
}
export function ohlcvToCsv(ohlcv: OhlcvPoint[]): string {
const header = ["date", "open", "high", "low", "close", "volume"];
const lines: string[] = [header.join(",")];
for (const p of ohlcv) {
lines.push(
[
escapeField(p.date),
num(p.open),
num(p.high),
num(p.low),
num(p.close),
num(p.volume),
].join(","),
);
}
return lines.join("\n");
}
export function downloadCsv(filename: string, csv: string): boolean {
if (typeof window === "undefined" || typeof document === "undefined") return false;
// Excel BOM — 한글 Excel 이 UTF-8 로 추론하도록.
const BOM = "\uFEFF";
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return true;
} catch {
return false;
} finally {
setTimeout(() => URL.revokeObjectURL(url), 0);
}
}
// `삼성전자_005930_1d_2025-08-01_2025-11-28.csv` — 파일시스템 금지 문자만 `_` 치환.
export function csvFilename(
name: string,
code: string,
interval: ChartInterval,
from: string,
to: string,
): string {
const safe = (s: string) => s.replace(/[\\/:*?"<>|]/g, "_").trim() || "data";
return `${safe(name)}_${code}_${interval}_${from}_${to}.csv`;
}

107
web/lib/lines.ts Normal file
View File

@@ -0,0 +1,107 @@
// 종목별 사용자 수평선(horizontal price lines) 저장소.
// TradingView/Toss 식 그리기 도구의 가장 가벼운 형태 — 가격 라인만, per-code, localStorage.
// 차트 자체는 라인 ID 를 모르고 좌표만 받음. 추가/삭제는 이 모듈을 통해서만.
const KEY = (code: string) => `hlines:${code}`;
export const HLINE_MAX = 10;
export type HLine = {
id: string;
price: number;
// 작성 시각 — 디버그/정렬용. UI 에선 노출 안 해도 됨.
createdAt: string;
};
function safeStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function readLines(code: string): HLine[] {
const s = safeStorage();
if (!s) return [];
try {
const raw = s.getItem(KEY(code));
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
const out: HLine[] = [];
for (const item of parsed) {
if (
item &&
typeof item === "object" &&
typeof (item as HLine).id === "string" &&
typeof (item as HLine).price === "number" &&
Number.isFinite((item as HLine).price) &&
(item as HLine).price > 0
) {
out.push({
id: (item as HLine).id,
price: (item as HLine).price,
createdAt:
typeof (item as HLine).createdAt === "string"
? (item as HLine).createdAt
: new Date().toISOString(),
});
}
}
return out.slice(0, HLINE_MAX);
} catch {
return [];
}
}
function writeRaw(code: string, lines: HLine[]): boolean {
const s = safeStorage();
if (!s) return false;
try {
if (lines.length === 0) s.removeItem(KEY(code));
else s.setItem(KEY(code), JSON.stringify(lines.slice(0, HLINE_MAX)));
return true;
} catch {
return false;
}
}
// id 충돌 가능성이 거의 없는 short id. crypto.randomUUID 미지원 환경 폴백.
function newId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
// price 같음 검사용 — 유효숫자 4 자리 정도 비교로 부동소수 오차 무시.
function nearlyEqual(a: number, b: number): boolean {
const denom = Math.max(Math.abs(a), Math.abs(b), 1);
return Math.abs(a - b) / denom < 1e-4;
}
export function addLine(code: string, price: number): HLine[] {
if (!Number.isFinite(price) || price <= 0) return readLines(code);
const cur = readLines(code);
// 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발.
if (cur.some((l) => nearlyEqual(l.price, price))) return cur;
const next = [...cur, { id: newId(), price, createdAt: new Date().toISOString() }];
// 저장 실패 시 이전 상태 반환 — 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);
// 삭제 저장 실패 시 이전 상태 유지 — UI 에서 사라졌다가 새로고침에 부활하면 더 혼란.
if (!writeRaw(code, next)) return cur;
return next;
}
export function clearLines(code: string): HLine[] {
if (!writeRaw(code, [])) return readLines(code);
return [];
}

58
web/lib/notes.ts Normal file
View File

@@ -0,0 +1,58 @@
// 종목별 사용자 메모 (브라우저 한정 — 비로그인 앱).
//
// 키: `notes:<code>` → { text, updatedAt }
// 투자 결정의 근거/관전 포인트를 짧게 적어두는 용도. 다른 사용자에게는 보이지 않음.
const PREFIX = "notes:";
const MAX_LEN = 2000; // 너무 큰 입력 방어 — localStorage quota 보호.
export type Note = {
text: string;
updatedAt: string; // ISO
};
export function readNote(code: string): Note | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(`${PREFIX}${code}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Note>;
if (typeof parsed.text !== "string" || typeof parsed.updatedAt !== "string") return null;
return { text: parsed.text, updatedAt: parsed.updatedAt };
} catch {
return null;
}
}
export type WriteResult =
| { status: "saved"; note: Note }
| { status: "deleted" }
| { status: "failed" };
export function writeNote(code: string, text: string): WriteResult {
if (typeof window === "undefined") return { status: "failed" };
const trimmed = text.slice(0, MAX_LEN);
// 공백만이면 키 자체 제거 → 빈 잔여물 방지. 의도된 삭제이므로 failed 와 구분.
if (!trimmed.trim()) {
clearNote(code);
return { status: "deleted" };
}
const next: Note = { text: trimmed, updatedAt: new Date().toISOString() };
try {
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(next));
return { status: "saved", note: next };
} catch {
return { status: "failed" }; // quota 등 — UI 가 저장 실패 처리
}
}
export function clearNote(code: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(`${PREFIX}${code}`);
} catch {
/* ignore */
}
}
export const NOTE_MAX_LEN = MAX_LEN;

52
web/lib/recent.ts Normal file
View File

@@ -0,0 +1,52 @@
// 최근 본 종목 — localStorage 기반. 종목 페이지 진입 시 자동으로 push.
// 단순 string[] 직렬화. 최신이 앞쪽, 최대 MAX 개로 캡.
const KEY = "stockchart.recent";
const MAX = 10;
function read(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return [];
const v = JSON.parse(raw);
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
} catch {
return [];
}
}
function write(codes: string[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(KEY, JSON.stringify(codes));
// 같은 탭은 storage 이벤트가 안 떠서 수동 dispatch.
window.dispatchEvent(new CustomEvent("recent:change"));
}
export const recent = {
get(): string[] {
return read();
},
push(code: string): void {
if (!code) return;
const cur = read().filter((c) => c !== code);
cur.unshift(code);
write(cur.slice(0, MAX));
},
clear(): void {
write([]);
},
subscribe(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) cb();
};
const onCustom = () => cb();
window.addEventListener("storage", onStorage);
window.addEventListener("recent:change", onCustom as EventListener);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener("recent:change", onCustom as EventListener);
};
},
};

50
web/lib/targets.ts Normal file
View File

@@ -0,0 +1,50 @@
// 종목별 목표가/손절가 저장 (브라우저 한정 — 비로그인 앱).
//
// 키: `targets:<code>` → { 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;
// 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거.
// 양수 유한값만 통과. UI 가 이미 가드하지만 lib 자체도 방어 — 외부에서 직접 호출돼도 안전.
const cleaned: Targets = {};
if (t.target != null && Number.isFinite(t.target) && t.target > 0) cleaned.target = t.target;
if (t.stop != null && Number.isFinite(t.stop) && t.stop > 0) 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 */
}
}

61
web/lib/views.ts Normal file
View File

@@ -0,0 +1,61 @@
// 종목 조회 카운터 클라이언트 dedupe.
//
// 서버는 받은 POST 횟수만큼 +1. 같은 사용자가 페이지를 새로고침할 때마다 카운트되면
// 인플레됨 → 클라이언트가 localStorage 에 (code, KST today) 마크를 남겨 하루 1회만 POST.
// 새 탭/시크릿 모드는 새 사용자로 간주 (브라우저 한정 dedupe — 비로그인 앱이라 한계).
const STORAGE_KEY = "views:logged";
type LoggedMap = Record<string, string>; // code → "YYYY-MM-DD"
function kstToday(): string {
// KST = UTC+9. naive: Date 가 시스템 TZ 영향을 받으므로 UTC 기준 +9 로 직접 계산.
const now = new Date();
const utcMs = now.getTime() + now.getTimezoneOffset() * 60_000;
const kst = new Date(utcMs + 9 * 60 * 60_000);
const y = kst.getFullYear();
const m = String(kst.getMonth() + 1).padStart(2, "0");
const d = String(kst.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function readLogged(): LoggedMap {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as LoggedMap) : {};
} catch {
return {};
}
}
function writeLogged(m: LoggedMap): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
} catch {
/* quota 초과 등 — 무시. dedupe 실패 시 최악으로 한 사용자가 N번 카운트될 뿐. */
}
}
/** 오늘 이미 카운트된 종목인지. 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();
logged[code] = today;
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
const entries = Object.entries(logged);
if (entries.length > 100) {
const trimmed: LoggedMap = Object.fromEntries(entries.slice(-100));
writeLogged(trimmed);
} else {
writeLogged(logged);
}
}

64
web/lib/watchlist.ts Normal file
View File

@@ -0,0 +1,64 @@
// 관심종목 — localStorage 기반. 로그인 없이 토스의 '내 종목' 느낌.
// 단순 string[] 직렬화. 다중 탭 동기화는 'storage' 이벤트로 처리.
const KEY = "stockchart.watchlist";
function read(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return [];
const v = JSON.parse(raw);
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
} catch {
return [];
}
}
function write(codes: string[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(KEY, JSON.stringify(codes));
// 같은 탭은 storage 이벤트가 안 떠서 수동 dispatch.
window.dispatchEvent(new CustomEvent("watchlist:change"));
}
export const watchlist = {
get(): string[] {
return read();
},
has(code: string): boolean {
return read().includes(code);
},
add(code: string): void {
const cur = read();
if (cur.includes(code)) return;
write([code, ...cur]);
},
remove(code: string): void {
const cur = read();
write(cur.filter((c) => c !== code));
},
toggle(code: string): boolean {
const cur = read();
if (cur.includes(code)) {
write(cur.filter((c) => c !== code));
return false;
}
write([code, ...cur]);
return true;
},
// 컴포넌트가 변경 구독. unsubscribe 함수 반환.
subscribe(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) cb();
};
const onCustom = () => cb();
window.addEventListener("storage", onStorage);
window.addEventListener("watchlist:change", onCustom as EventListener);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener("watchlist:change", onCustom as EventListener);
};
},
};

2
web/next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

433
web/package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.0.1",
"dependencies": {
"lightweight-charts": "4.1.7",
"next": "14.2.3",
"next": "^14.2.33",
"react": "18.3.1",
"react-dom": "18.3.1"
},
@@ -19,7 +19,7 @@
"@types/react-dom": "18.3.0",
"autoprefixer": "10.4.19",
"eslint": "8.57.0",
"eslint-config-next": "14.2.3",
"eslint-config-next": "^14.2.33",
"postcss": "8.4.38",
"tailwindcss": "3.4.4",
"typescript": "5.4.5"
@@ -260,23 +260,23 @@
}
},
"node_modules/@next/env": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz",
"integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA=="
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ=="
},
"node_modules/@next/eslint-plugin-next": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.3.tgz",
"integrity": "sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz",
"integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==",
"dev": true,
"dependencies": {
"glob": "10.3.10"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz",
"integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
@@ -289,9 +289,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
"integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
"integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
@@ -319,9 +319,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
"integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
@@ -334,9 +334,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
"integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
@@ -349,9 +349,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
"integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
@@ -364,9 +364,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
"integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
@@ -379,9 +379,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
"integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
@@ -394,9 +394,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
"integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
@@ -538,58 +538,152 @@
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
"integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==",
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"debug": "^4.3.4"
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/type-utils": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz",
"integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz",
"integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==",
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
@@ -597,72 +691,118 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz",
"integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz",
"integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==",
"node_modules/@typescript-eslint/utils": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"eslint-visitor-keys": "^3.4.1"
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@ungap/structured-clone": {
@@ -1101,15 +1241,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
@@ -1731,18 +1862,6 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2040,14 +2159,15 @@
}
},
"node_modules/eslint-config-next": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.3.tgz",
"integrity": "sha512-ZkNztm3Q7hjqvB1rRlOX8P9E/cXRL9ajRcs8jufEtwMfTVYRqnmtnaSu57QqHyBlovMuiB8LEzfLBkh5RYV6Fg==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz",
"integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==",
"dev": true,
"dependencies": {
"@next/eslint-plugin-next": "14.2.3",
"@next/eslint-plugin-next": "14.2.35",
"@rushstack/eslint-patch": "^1.3.3",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.28.1",
@@ -2776,26 +2896,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3713,12 +3813,11 @@
"dev": true
},
"node_modules/next": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
"integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"dependencies": {
"@next/env": "14.2.3",
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -3733,15 +3832,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.3",
"@next/swc-darwin-x64": "14.2.3",
"@next/swc-linux-arm64-gnu": "14.2.3",
"@next/swc-linux-arm64-musl": "14.2.3",
"@next/swc-linux-x64-gnu": "14.2.3",
"@next/swc-linux-x64-musl": "14.2.3",
"@next/swc-win32-arm64-msvc": "14.2.3",
"@next/swc-win32-ia32-msvc": "14.2.3",
"@next/swc-win32-x64-msvc": "14.2.3"
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -4098,15 +4197,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4761,15 +4851,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5281,15 +5362,15 @@
}
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
"integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"engines": {
"node": ">=16"
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.2.0"
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": {

View File

@@ -11,7 +11,7 @@
"check": "npm run typecheck && npm run lint"
},
"dependencies": {
"next": "14.2.3",
"next": "^14.2.33",
"react": "18.3.1",
"react-dom": "18.3.1",
"lightweight-charts": "4.1.7"
@@ -25,6 +25,6 @@
"postcss": "8.4.38",
"autoprefixer": "10.4.19",
"eslint": "8.57.0",
"eslint-config-next": "14.2.3"
"eslint-config-next": "^14.2.33"
}
}

28
web/public/icon.svg Normal file
View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="주식 차트">
<!-- maskable safe-zone: 중심 80% (102.4 ~ 409.6) 안에 핵심 요소를 둠. -->
<rect width="512" height="512" rx="96" fill="#09090b"/>
<!-- 격자 가이드 (옅게) -->
<g stroke="#27272a" stroke-width="2">
<line x1="64" y1="160" x2="448" y2="160"/>
<line x1="64" y1="256" x2="448" y2="256"/>
<line x1="64" y1="352" x2="448" y2="352"/>
</g>
<!-- 캔들 (한국 컬러: 상승=빨강 / 하락=파랑) -->
<g>
<!-- 하락 -->
<line x1="144" y1="120" x2="144" y2="300" stroke="#3b82f6" stroke-width="6"/>
<rect x="124" y="180" width="40" height="100" fill="#3b82f6"/>
<!-- 상승 -->
<line x1="224" y1="160" x2="224" y2="360" stroke="#ef4444" stroke-width="6"/>
<rect x="204" y="200" width="40" height="140" fill="#ef4444"/>
<!-- 하락 -->
<line x1="304" y1="200" x2="304" y2="380" stroke="#3b82f6" stroke-width="6"/>
<rect x="284" y="240" width="40" height="120" fill="#3b82f6"/>
<!-- 상승 (가장 큼 — 우상향 인상) -->
<line x1="384" y1="100" x2="384" y2="340" stroke="#ef4444" stroke-width="6"/>
<rect x="364" y="140" width="40" height="180" fill="#ef4444"/>
</g>
<!-- 추세선 (사선 우상향) -->
<polyline points="80,380 160,330 240,280 320,260 400,180 448,140"
fill="none" stroke="#fbbf24" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "주식 차트 · 예측",
"short_name": "주식 차트",
"description": "한국 주식 차트와 단기 예측을 보는 개인용 앱.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#09090b",
"theme_color": "#09090b",
"lang": "ko",
"categories": ["finance", "productivity"],
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

13
web/public/sw.js Normal file
View File

@@ -0,0 +1,13 @@
// 최소 서비스워커 — PWA install 자격 (manifest + SW) 만 충족시킨다.
// fetch handler 가 없어 네트워크 요청을 가로채지 않음 → 정상 동작은 그대로,
// 캐시 stale 위험 없음. 향후 오프라인 셸/사진 캐싱이 필요하면 여기에 추가.
self.addEventListener("install", () => {
// 이전 버전 SW 를 즉시 교체. 한 페이지 새로고침이면 새 SW 가 active 가 됨.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
// 모든 열린 탭의 client 를 즉시 control 하도록.
event.waitUntil(self.clients.claim());
});