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).
This commit is contained in:
claude-owner
2026-05-23 01:34:29 +09:00
parent 928c2160f9
commit 0a5c634680
6 changed files with 503 additions and 81 deletions

View File

@@ -1,14 +1,21 @@
"""차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시). """차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시).
UI: /code 페이지 첫 로드 시 호출 → lightweight-charts 캔들 데이터로 사용. UI: /code 페이지 호출 → lightweight-charts 캔들 데이터로 사용.
첫 방문 시 ohlcv_daily 가 비어 있으면 (symbols 만 시드됨, daily_batch 아직 안 돔) interval 파라미터로 캔들 단위 선택:
즉시 pykrx 로 자동 갱신 — 사용자 입장에선 한 번의 차트 요청으로 데이터까지 충전. - "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 __future__ import annotations
import logging import logging
from datetime import date, timedelta from datetime import date, datetime, time as dtime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text from sqlalchemy import text
@@ -19,8 +26,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chart", tags=["chart"]) router = APIRouter(prefix="/api/chart", tags=["chart"])
ALLOWED_INTERVALS = ("10m", "1d", "1w", "1mo")
KST = timezone(timedelta(hours=9))
def _query_ohlcv(conn, code: str, start: date, end: date):
def _query_ohlcv_daily(conn, code: str, start: date, end: date):
return conn.execute( return conn.execute(
text( text(
""" """
@@ -34,13 +44,150 @@ def _query_ohlcv(conn, code: str, start: date, end: date):
).all() ).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 데이터가 10분 이상 오래됐으면 KIS 에서 보충.
Returns: 'fresh' | 'refreshed' | 'skipped_missing_key' | 'failed' | 'no_data'
"""
last_ts = conn.execute(
text("SELECT MAX(ts) FROM ohlcv_1m WHERE code = :c"),
{"c": code},
).scalar()
now = datetime.now(KST)
# 평일 장중 (09:00~15:30) 이 아니면 데이터가 더 들어올 일이 없으니 마지막 캐시 그대로.
market_open = dtime(9, 0)
market_close = dtime(15, 35)
in_session = (
now.weekday() < 5
and market_open <= now.time() <= market_close
)
if last_ts is not None and (now - last_ts) < timedelta(minutes=10) and in_session:
return "fresh"
try:
from app.fetch.kis import SkippedMissingKey, fetch_minute_price
except Exception: # noqa: BLE001
return "failed"
try:
rows = fetch_minute_price(code)
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}") @router.get("/{code}")
def get_chart( def get_chart(
code: str, 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_sentiment: bool = Query(default=True),
include_trading_value: bool = Query(default=True), include_trading_value: bool = Query(default=True),
) -> dict: ) -> dict:
if interval not in ALLOWED_INTERVALS:
raise HTTPException(status_code=400, detail=f"interval must be one of {ALLOWED_INTERVALS}")
eng = get_engine() eng = get_engine()
end = date.today() end = date.today()
start = end - timedelta(days=days) start = end - timedelta(days=days)
@@ -52,19 +199,44 @@ def get_chart(
if not symbol: if not symbol:
raise HTTPException(status_code=404, detail=f"unknown code: {code}") raise HTTPException(status_code=404, detail=f"unknown code: {code}")
ohlcv_rows = _query_ohlcv(conn, code, start, end) ohlcv: list[dict] = []
if not ohlcv_rows: intraday_status: str | None = None
# 첫 방문 — ohlcv_daily 가 비어있다. pykrx 로 즉시 채우고 재조회.
# refresh_code 는 별도 트랜잭션으로 ohlcv/trading_value/news 모두 commit 하므로 if interval == "10m":
# 이 conn 에서 다시 SELECT 하면 새 행이 보인다. lookback 은 차트 요청 범위 + intraday_status = _ensure_intraday_fresh(conn, code)
# 예측 모델 학습용 마진 (Chronos/LightGBM 이 충분한 과거 시계열을 요구) 으로 365 하한. 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: try:
from app.pipelines.refresh_one import refresh_code from app.pipelines.refresh_one import refresh_code
logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code) logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code)
refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365)) refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365))
ohlcv_rows = _query_ohlcv(conn, code, start, end) rows = _query_ohlcv_daily(conn, code, start, end)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.exception("chart: auto-refresh failed for %s", code) logger.exception("chart: auto-refresh failed for %s", code)
ohlcv = [ ohlcv = [
{ {
"date": str(r[0]), "date": str(r[0]),
@@ -74,11 +246,11 @@ def get_chart(
"close": float(r[4]) if r[4] 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, "volume": int(r[5]) if r[5] is not None else None,
} }
for r in ohlcv_rows for r in rows
] ]
sentiment: list[dict] = [] sentiment: list[dict] = []
if include_sentiment: if include_sentiment and interval != "10m":
try: try:
s_rows = conn.execute( s_rows = conn.execute(
text( text(
@@ -101,11 +273,10 @@ def get_chart(
for r in s_rows for r in s_rows
] ]
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
# v_sentiment_daily 뷰 아직 없을 수 있음 (마이그레이션 미실행)
sentiment = [] sentiment = []
trading: list[dict] = [] trading: list[dict] = []
if include_trading_value: if include_trading_value and interval != "10m":
tv_rows = conn.execute( tv_rows = conn.execute(
text( text(
""" """
@@ -131,7 +302,10 @@ def get_chart(
"code": symbol[0], "code": symbol[0],
"name": symbol[1], "name": symbol[1],
"market": symbol[2], "market": symbol[2],
"interval": interval,
"intraday_status": intraday_status,
"range": {"from": str(start), "to": str(end)}, "range": {"from": str(start), "to": str(end)},
"today": date.today().isoformat(),
"ohlcv": ohlcv, "ohlcv": ohlcv,
"sentiment": sentiment, "sentiment": sentiment,
"trading_value": trading, "trading_value": trading,

View File

@@ -233,6 +233,77 @@ def fetch_daily_price(
return out 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 ping() -> dict[str, Any]: def ping() -> dict[str, Any]:
"""토큰 발급만 시도해서 키 유효성 확인.""" """토큰 발급만 시도해서 키 유효성 확인."""
if not _has_keys(): if not _has_keys():

View File

@@ -8,33 +8,63 @@ import { PredictionPanel } from "../../components/PredictionPanel";
import { StockChart } from "../../components/StockChart"; import { StockChart } from "../../components/StockChart";
import { import {
api, api,
type ChartInterval,
type ChartPayload, type ChartPayload,
type LatestPredictionResponse, type LatestPredictionResponse,
} from "../../lib/api"; } from "../../lib/api";
const INTERVALS: { label: string; value: ChartInterval; defaultDays: number }[] = [
{ label: "10분", value: "10m", defaultDays: 1 },
{ label: "일", value: "1d", defaultDays: 180 },
{ label: "주", value: "1w", defaultDays: 365 * 2 },
{ label: "월", value: "1mo", defaultDays: 365 * 5 },
];
export default function CodePage({ params }: { params: { code: string } }) { export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params; const { code } = params;
const [chart, setChart] = useState<ChartPayload | null>(null); const [chart, setChart] = useState<ChartPayload | null>(null);
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null); const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [interval, setIntervalKind] = useState<ChartInterval>("1d");
const [days, setDays] = useState(180); const [days, setDays] = useState(180);
// interval 바꾸면 days 도 그 interval 에 맞는 기본값으로 (사용자가 명시적으로 다시 고를 수 있게).
function pickInterval(v: ChartInterval) {
const meta = INTERVALS.find((i) => i.value === v)!;
setIntervalKind(v);
setDays(meta.defaultDays);
}
// 초기/주기적 차트 로드. 10분봉이면 60초마다 폴링 — 백엔드가 캐시-then-fetch 로
// 10분 이내면 DB 만 읽고, 넘었으면 KIS 호출. 폴링 부담은 낮음.
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setErr(null); setErr(null);
setChart(null); setChart(null);
const load = () => {
api api
.getChart(code, days) .getChart(code, days, interval)
.then((c) => { .then((c) => {
if (alive) setChart(c); if (alive) setChart(c);
}) })
.catch((e) => { .catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e)); if (alive) setErr(e instanceof Error ? e.message : String(e));
}); });
};
load();
if (interval === "10m") {
const h = window.setInterval(load, 60_000);
return () => {
alive = false;
window.clearInterval(h);
};
}
return () => { return () => {
alive = false; alive = false;
}; };
}, [code, days]); }, [code, days, interval]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -57,6 +87,23 @@ export default function CodePage({ params }: { params: { code: string } }) {
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300"> <Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link> </Link>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-xs">
{INTERVALS.map((it) => (
<button
key={it.value}
onClick={() => pickInterval(it.value)}
className={
interval === it.value
? "bg-emerald-700 px-3 py-1 text-white"
: "bg-zinc-900 px-3 py-1 text-zinc-300 hover:bg-zinc-800"
}
>
{it.label}
</button>
))}
</div>
{interval !== "10m" && (
<select <select
value={days} value={days}
onChange={(e) => setDays(Number(e.target.value))} onChange={(e) => setDays(Number(e.target.value))}
@@ -65,18 +112,30 @@ export default function CodePage({ params }: { params: { code: string } }) {
<option value={60}> 3</option> <option value={60}> 3</option>
<option value={180}> 6</option> <option value={180}> 6</option>
<option value={365}> 1</option> <option value={365}> 1</option>
<option value={1095}> 3</option> <option value={365 * 2}> 2</option>
<option value={365 * 5}> 5</option>
<option value={365 * 10}> 10</option>
</select> </select>
)}
</div>
</div> </div>
{chart && ( {chart && (
<div className="mb-4"> <div className="mb-4 flex items-baseline justify-between">
<h1 className="text-2xl font-semibold text-zinc-100"> <h1 className="text-2xl font-semibold text-zinc-100">
{chart.name}{" "} {chart.name}{" "}
<span className="text-sm font-normal text-zinc-500"> <span className="text-sm font-normal text-zinc-500">
{chart.code} · {chart.market} {chart.code} · {chart.market}
</span> </span>
</h1> </h1>
{interval === "10m" && (
<div className="text-xs text-zinc-500">
10 · 60
{chart.intraday_status && (
<span className="ml-2 text-zinc-600">[{chart.intraday_status}]</span>
)}
</div>
)}
</div> </div>
)} )}

View File

@@ -42,16 +42,37 @@ function normalizeFromPredictResponse(
}; };
} }
const HORIZON_PRESETS: { label: string; value: number[] }[] = [
{ label: "단기 (1·3·5)", value: [1, 3, 5] },
{ label: "중기 (1·5·10)", value: [1, 5, 10] },
{ label: "장기 (5·10·20)", value: [5, 10, 20] },
];
export function PredictionPanel({ code, initial, onResult }: Props) { export function PredictionPanel({ code, initial, onResult }: Props) {
const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null); const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [presetIdx, setPresetIdx] = useState(0);
const [customRaw, setCustomRaw] = useState("");
const [useCustom, setUseCustom] = useState(false);
function effectiveHorizons(): number[] {
if (useCustom) {
const parsed = customRaw
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n >= 1 && n <= 60);
if (parsed.length > 0) return Array.from(new Set(parsed)).sort((a, b) => a - b);
}
return HORIZON_PRESETS[presetIdx].value;
}
async function runPredict() { async function runPredict() {
setLoading(true); setLoading(true);
setErr(null); setErr(null);
try { try {
const r = await api.predict(code); const horizons = effectiveHorizons();
const r = await api.predict(code, horizons);
const normalized = normalizeFromPredictResponse(code, r); const normalized = normalizeFromPredictResponse(code, r);
setPred(normalized); setPred(normalized);
onResult(normalized); onResult(normalized);
@@ -82,6 +103,49 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
</button> </button>
</div> </div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-zinc-500"> :</span>
{HORIZON_PRESETS.map((p, i) => (
<button
key={p.label}
onClick={() => {
setUseCustom(false);
setPresetIdx(i);
}}
className={
!useCustom && presetIdx === i
? "rounded-full bg-emerald-700 px-3 py-1 text-white"
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
{p.label}
</button>
))}
<label
className={
useCustom
? "flex items-center gap-1 rounded-full bg-emerald-700 px-3 py-1 text-white"
: "flex items-center gap-1 rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
<input
type="checkbox"
checked={useCustom}
onChange={(e) => setUseCustom(e.target.checked)}
className="h-3 w-3"
/>
<input
type="text"
placeholder="예: 1,2,3,7"
value={customRaw}
onChange={(e) => setCustomRaw(e.target.value)}
onFocus={() => setUseCustom(true)}
className="ml-1 w-24 rounded border border-zinc-700 bg-zinc-900 px-1 py-0.5 text-xs text-zinc-100"
/>
</label>
</div>
{err && <div className="mb-3 text-xs text-red-400">: {err}</div>} {err && <div className="mb-3 text-xs text-red-400">: {err}</div>}
{pred?.found ? ( {pred?.found ? (

View File

@@ -7,6 +7,8 @@ import {
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type LineData, type LineData,
type SeriesMarker,
type Time,
type UTCTimestamp, type UTCTimestamp,
} from "lightweight-charts"; } from "lightweight-charts";
import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
@@ -16,12 +18,26 @@ type Props = {
prediction?: LatestPredictionResponse | null; prediction?: LatestPredictionResponse | null;
}; };
function dateToUtcTs(d: string): UTCTimestamp { // 'YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS' (KST naive, 백엔드가 +09:00 시각의 wall-clock 을
// 'YYYY-MM-DD' → UTC midnight epoch seconds // 그대로 ISO 로 직렬화) 를 UTCTimestamp 로. lightweight-charts 는 timestamp 가 UTC 라고
// 가정하지만, 우리는 KST wall-clock 을 UTC 인 척 넣는다 — timeScale 의 표시도 KST 그대로
// 나와서 한국 사용자에겐 가장 직관적.
function isoToUtcTs(s: string): UTCTimestamp {
if (s.length <= 10) {
return (Date.UTC( return (Date.UTC(
Number(d.slice(0, 4)), Number(s.slice(0, 4)),
Number(d.slice(5, 7)) - 1, Number(s.slice(5, 7)) - 1,
Number(d.slice(8, 10)), Number(s.slice(8, 10)),
) / 1000) as UTCTimestamp;
}
// datetime: YYYY-MM-DDTHH:MM:SS
return (Date.UTC(
Number(s.slice(0, 4)),
Number(s.slice(5, 7)) - 1,
Number(s.slice(8, 10)),
Number(s.slice(11, 13)),
Number(s.slice(14, 16)),
Number(s.slice(17, 19) || "0"),
) / 1000) as UTCTimestamp; ) / 1000) as UTCTimestamp;
} }
@@ -33,7 +49,9 @@ export function StockChart({ chart, prediction }: Props) {
const predLowRef = useRef<ISeriesApi<"Line"> | null>(null); const predLowRef = useRef<ISeriesApi<"Line"> | null>(null);
const predHighRef = useRef<ISeriesApi<"Line"> | null>(null); const predHighRef = useRef<ISeriesApi<"Line"> | null>(null);
// create chart once const isIntraday = chart.interval === "10m";
// create chart once (interval 바뀌면 timeVisible 토글 위해 의존성에 isIntraday 포함 — 재생성)
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const c = createChart(containerRef.current, { const c = createChart(containerRef.current, {
@@ -46,7 +64,11 @@ export function StockChart({ chart, prediction }: Props) {
horzLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" },
}, },
rightPriceScale: { borderColor: "#374151" }, rightPriceScale: { borderColor: "#374151" },
timeScale: { borderColor: "#374151", timeVisible: false }, timeScale: {
borderColor: "#374151",
timeVisible: isIntraday,
secondsVisible: false,
},
autoSize: true, autoSize: true,
}); });
const candle = c.addCandlestickSeries({ const candle = c.addCandlestickSeries({
@@ -67,28 +89,49 @@ export function StockChart({ chart, prediction }: Props) {
predLowRef.current = null; predLowRef.current = null;
predHighRef.current = null; predHighRef.current = null;
}; };
}, []); }, [isIntraday]);
// push candle data // push candle data + today marker
useEffect(() => { useEffect(() => {
if (!candleRef.current) return; if (!candleRef.current) return;
const data: CandlestickData[] = chart.ohlcv const data: CandlestickData[] = chart.ohlcv
.filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null) .filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null)
.map((p) => ({ .map((p) => ({
time: dateToUtcTs(p.date), time: isoToUtcTs(p.date),
open: p.open as number, open: p.open as number,
high: p.high as number, high: p.high as number,
low: p.low as number, low: p.low as number,
close: p.close as number, close: p.close as number,
})); }));
candleRef.current.setData(data); candleRef.current.setData(data);
chartRef.current?.timeScale().fitContent();
}, [chart]);
// push prediction overlay // 오늘 날짜 마커: 일/주/월봉에서만 표시 (10분봉은 데이터 자체가 오늘 하루라 무의미).
// markers 는 데이터 포인트의 time 과 일치해야 표시되므로, 오늘 또는 가장 가까운 과거
// 거래일을 찾는다.
if (!isIntraday && chart.today) {
const todayTs = isoToUtcTs(chart.today);
// 차트의 마지막 데이터가 오늘이면 그 위에, 아니면 마지막 데이터 위에 "오늘" 표시.
const lastTs = data.length > 0 ? (data[data.length - 1].time as UTCTimestamp) : null;
const markerTime = (lastTs && lastTs <= todayTs ? lastTs : todayTs) as UTCTimestamp;
const markers: SeriesMarker<Time>[] = [
{
time: markerTime,
position: "aboveBar",
color: "#fbbf24",
shape: "arrowDown",
text: "오늘",
},
];
candleRef.current.setMarkers(markers);
} else {
candleRef.current.setMarkers([]);
}
chartRef.current?.timeScale().fitContent();
}, [chart, isIntraday]);
// push prediction overlay (10분봉에서는 표시 안 함 — 예측은 일봉 기준)
useEffect(() => { useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
// remove previous overlay
if (predRef.current) { if (predRef.current) {
chartRef.current.removeSeries(predRef.current); chartRef.current.removeSeries(predRef.current);
predRef.current = null; predRef.current = null;
@@ -101,6 +144,7 @@ export function StockChart({ chart, prediction }: Props) {
chartRef.current.removeSeries(predHighRef.current); chartRef.current.removeSeries(predHighRef.current);
predHighRef.current = null; predHighRef.current = null;
} }
if (isIntraday) return;
if (!prediction || !prediction.found || !prediction.steps?.length) return; if (!prediction || !prediction.found || !prediction.steps?.length) return;
const baseDate = prediction.base_date!; const baseDate = prediction.base_date!;
const baseClose = prediction.base_close; const baseClose = prediction.base_close;
@@ -108,22 +152,22 @@ export function StockChart({ chart, prediction }: Props) {
const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon); const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon);
const med: LineData[] = [ const med: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose }, { time: isoToUtcTs(baseDate), value: baseClose },
...sorted ...sorted
.filter((s) => s.point_close !== null) .filter((s) => s.point_close !== null)
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.point_close as number })), .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.point_close as number })),
]; ];
const lo: LineData[] = [ const lo: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose }, { time: isoToUtcTs(baseDate), value: baseClose },
...sorted ...sorted
.filter((s) => s.ci_low !== null) .filter((s) => s.ci_low !== null)
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.ci_low as number })), .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_low as number })),
]; ];
const hi: LineData[] = [ const hi: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose }, { time: isoToUtcTs(baseDate), value: baseClose },
...sorted ...sorted
.filter((s) => s.ci_high !== null) .filter((s) => s.ci_high !== null)
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.ci_high as number })), .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_high as number })),
]; ];
const medLine = chartRef.current.addLineSeries({ const medLine = chartRef.current.addLineSeries({
@@ -157,7 +201,7 @@ export function StockChart({ chart, prediction }: Props) {
predLowRef.current = loLine; predLowRef.current = loLine;
predHighRef.current = hiLine; predHighRef.current = hiLine;
chartRef.current.timeScale().fitContent(); chartRef.current.timeScale().fitContent();
}, [prediction]); }, [prediction, isIntraday]);
return ( return (
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2"> <div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">

View File

@@ -42,6 +42,7 @@ export type SymbolSearch = {
}; };
export type OhlcvPoint = { export type OhlcvPoint = {
// 1d/1w/1mo: 'YYYY-MM-DD' / 10m: 'YYYY-MM-DDTHH:MM:SS' (KST naive ISO)
date: string; date: string;
open: number | null; open: number | null;
high: number | null; high: number | null;
@@ -50,6 +51,8 @@ export type OhlcvPoint = {
volume: number | null; volume: number | null;
}; };
export type ChartInterval = "10m" | "1d" | "1w" | "1mo";
export type SentimentPoint = { export type SentimentPoint = {
date: string; date: string;
n_articles: number; n_articles: number;
@@ -68,7 +71,10 @@ export type ChartPayload = {
code: string; code: string;
name: string; name: string;
market: string; market: string;
interval: ChartInterval;
intraday_status: string | null;
range: { from: string; to: string }; range: { from: string; to: string };
today: string;
ohlcv: OhlcvPoint[]; ohlcv: OhlcvPoint[];
sentiment: SentimentPoint[]; sentiment: SentimentPoint[];
trading_value: TradingValuePoint[]; trading_value: TradingValuePoint[];
@@ -178,13 +184,17 @@ export const api = {
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`, `/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`,
), ),
getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`), getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`),
getChart: (code: string, days = 180) => getChart: (code: string, days = 180, interval: ChartInterval = "1d") =>
getJson<ChartPayload>(`/api/chart/${encodeURIComponent(code)}?days=${days}`), getJson<ChartPayload>(
predict: (code: string, horizons = "1,3,5") => `/api/chart/${encodeURIComponent(code)}?days=${days}&interval=${encodeURIComponent(interval)}`,
getJson<PredictResponse>(
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(horizons)}`,
{ method: "POST" },
), ),
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) => latestPrediction: (code: string) =>
getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`), getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`),
metrics: (code: string, windowDays = 30) => metrics: (code: string, windowDays = 30) =>