feat(phase-0): scaffold backend + web + docker + DB schema

- docker-compose.yml: timescaledb-ha (timescaledb 2.27 + vectorscale + pgvector + pgai)
  + backend (FastAPI, CUDA 12.1) + web (Next.js 14)
- docker-compose.gpu.yml: GPU profile overlay for RTX 3070 Ti
- build.bat: Windows bootstrap, auto-detects nvidia-smi and selects GPU/CPU compose
- backend: Dockerfile, pyproject.toml, FastAPI skeleton with /health and /health/db
- DB migration 001_init.sql: symbols (with trigram search), ohlcv_daily/1m (hypertables),
  macro_daily, trading_value_daily, news (vector embedding), predictions
  (with user_triggered flag for on-demand UX), prediction_outcomes, model_performance
- web: Next.js 14 + Tailwind + lightweight-charts placeholder
- README: KIS/DART/HuggingFace token issuance guides + 10 seed tickers + run instructions
This commit is contained in:
tkrmagid
2026-05-20 14:37:35 +09:00
parent 619dc7811b
commit cacddf5adf
30 changed files with 852 additions and 0 deletions

9
backend/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
__pycache__
*.pyc
.venv
.pytest_cache
.mypy_cache
.ruff_cache
tests
artifacts
.cache

33
backend/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# CUDA 12.1 + Python 3.11. CPU 환경에서는 torch.cuda.is_available()==False 가 되어 자동 폴백.
# Windows + Docker Desktop + WSL2 GPU 패스스루로 RTX 3070 Ti 에서 동작.
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04 AS base
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=Asia/Seoul
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 python3.11-venv python3-pip \
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 \
&& rm -rf /var/lib/apt/lists/*
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 \
torch==2.3.1 torchvision==0.18.1
RUN pip install --no-deps -e . || true
RUN pip install -e .
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

0
backend/app/__init__.py Normal file
View File

View File

24
backend/app/config.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str = "postgresql+psycopg://stock:stockpw@db:5432/stock"
tz: str = "Asia/Seoul"
log_level: str = "INFO"
# 모델 디바이스 선택. 'auto'는 torch.cuda.is_available() 기반
model_device: str = "auto"
# External keys (옵션)
kis_app_key: str | None = None
kis_app_secret: str | None = None
kis_account_no: str | None = None
dart_api_key: str | None = None
huggingface_token: str | None = None
settings = Settings()

View File

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from app.config import settings
_engine: Engine | None = None
def get_engine() -> Engine:
global _engine
if _engine is None:
_engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
return _engine
def ping() -> dict[str, object]:
"""Smoke-test: DB 연결 + 확장 확인."""
eng = get_engine()
with eng.connect() as conn:
version = conn.execute(text("SELECT version()")).scalar()
exts = conn.execute(
text(
"SELECT extname FROM pg_extension "
"WHERE extname IN ('timescaledb','vector','pg_trgm') ORDER BY extname"
)
).scalars().all()
return {"server": version, "extensions": list(exts)}

View File

@@ -0,0 +1,139 @@
-- Init schema for stock_chart_site
-- Loaded automatically on first DB container start via /docker-entrypoint-initdb.d
\set ON_ERROR_STOP on
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 종목 마스터 (Phase 1 에서 KRX 전체 종목 시드. 검색은 name 또는 code 둘 다 지원)
CREATE TABLE IF NOT EXISTS symbols (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL, -- 'KOSPI' / 'KOSDAQ' / 'NASDAQ'
sector TEXT,
active BOOLEAN DEFAULT TRUE,
is_seed BOOLEAN DEFAULT FALSE, -- 학습/배치 대상 10종목 여부
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS symbols_name_trgm ON symbols USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS symbols_active ON symbols(active);
-- 일별 시세
CREATE TABLE IF NOT EXISTS ohlcv_daily (
code TEXT NOT NULL REFERENCES symbols(code),
date DATE NOT NULL,
open NUMERIC,
high NUMERIC,
low NUMERIC,
close NUMERIC,
volume BIGINT,
PRIMARY KEY (code, date)
);
SELECT create_hypertable('ohlcv_daily', 'date', if_not_exists => TRUE);
CREATE INDEX IF NOT EXISTS ohlcv_daily_code_date ON ohlcv_daily(code, date DESC);
-- 분봉 (M8 인트라데이용, 스키마만 미리 둔다)
CREATE TABLE IF NOT EXISTS ohlcv_1m (
code TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
open NUMERIC,
high NUMERIC,
low NUMERIC,
close NUMERIC,
volume BIGINT,
PRIMARY KEY (code, ts)
);
SELECT create_hypertable('ohlcv_1m', 'ts', if_not_exists => TRUE);
-- 거시 / 환율 / 지수
CREATE TABLE IF NOT EXISTS macro_daily (
date DATE NOT NULL,
key TEXT NOT NULL, -- 'kospi','kosdaq','usdkrw','us10y',...
value NUMERIC,
PRIMARY KEY (date, key)
);
-- 외인 / 기관 순매수 (KRW 기준 거래대금)
CREATE TABLE IF NOT EXISTS trading_value_daily (
code TEXT NOT NULL REFERENCES symbols(code),
date DATE NOT NULL,
foreign_net NUMERIC,
institution_net NUMERIC,
individual_net NUMERIC,
PRIMARY KEY (code, date)
);
-- 뉴스 / 공시
CREATE TABLE IF NOT EXISTS news (
id BIGSERIAL PRIMARY KEY,
code TEXT REFERENCES symbols(code),
source TEXT NOT NULL, -- 'naver_finance' / 'dart' / 'google_rss'
published_at TIMESTAMPTZ NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
body TEXT,
sentiment_score REAL, -- KR-FinBERT 출력 -1..+1
sentiment_label TEXT, -- 'positive' / 'neutral' / 'negative'
embedding VECTOR(768),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS news_code_pub ON news(code, published_at DESC);
CREATE INDEX IF NOT EXISTS news_pub ON news(published_at DESC);
-- 모델 예측 이력
-- user_triggered=TRUE 인 행만 다음날 outcomes 매칭/오차수정 학습에 사용
CREATE TABLE IF NOT EXISTS predictions (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL REFERENCES symbols(code),
predicted_at TIMESTAMPTZ NOT NULL,
base_date DATE NOT NULL, -- 예측 기준일(=마지막 관측일)
target_date DATE NOT NULL,
horizon INT NOT NULL, -- 1, 3, 5
model TEXT NOT NULL, -- 'chronos2' / 'lgbm' / 'ensemble'
direction TEXT, -- 'up' / 'flat' / 'down'
prob_up REAL,
prob_flat REAL,
prob_down REAL,
expected_return REAL,
point_forecast NUMERIC, -- median 가격
ci_low NUMERIC, -- quantile 10
ci_high NUMERIC, -- quantile 90
features_snapshot JSONB,
user_triggered BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (code, base_date, target_date, horizon, model)
);
CREATE INDEX IF NOT EXISTS predictions_code_target ON predictions(code, target_date DESC);
CREATE INDEX IF NOT EXISTS predictions_user_triggered ON predictions(user_triggered) WHERE user_triggered = TRUE;
-- 예측 vs 실제 결과 (오차 수정 / 메트릭 / 가중치 튜닝의 입력)
CREATE TABLE IF NOT EXISTS prediction_outcomes (
prediction_id BIGINT PRIMARY KEY REFERENCES predictions(id) ON DELETE CASCADE,
code TEXT NOT NULL REFERENCES symbols(code),
target_date DATE NOT NULL,
horizon INT NOT NULL,
model TEXT NOT NULL,
predicted_close NUMERIC,
actual_close NUMERIC,
actual_return REAL,
direction_hit BOOLEAN, -- 방향성 적중 여부
abs_error REAL,
resolved_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS po_code_target ON prediction_outcomes(code, target_date DESC);
CREATE INDEX IF NOT EXISTS po_model ON prediction_outcomes(model);
-- 모델별 롤링 성능 (앙상블 가중치 튜닝에 사용)
CREATE TABLE IF NOT EXISTS model_performance (
code TEXT NOT NULL REFERENCES symbols(code),
model TEXT NOT NULL,
window_days INT NOT NULL, -- 7, 30 등
as_of DATE NOT NULL,
hit_rate REAL,
mae REAL,
brier REAL,
sample_count INT,
PRIMARY KEY (code, model, window_days, as_of)
);

View File

46
backend/app/main.py Normal file
View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db.connection import ping as db_ping
logging.basicConfig(level=settings.log_level)
logger = logging.getLogger(__name__)
app = FastAPI(title="stock_chart_site", version="0.0.1")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def _resolved_device() -> str:
if settings.model_device != "auto":
return settings.model_device
try:
import torch # noqa: WPS433
return "cuda" if torch.cuda.is_available() else "cpu"
except Exception: # noqa: BLE001
return "cpu"
@app.get("/health")
def health() -> dict[str, object]:
return {
"ok": True,
"device": _resolved_device(),
"version": "0.0.1",
}
@app.get("/health/db")
def health_db() -> dict[str, object]:
return {"ok": True, **db_ping()}

View File

View File

65
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,65 @@
[project]
name = "stock_chart_site_backend"
version = "0.0.1"
description = "Stock chart + prediction backend (FastAPI + Chronos + LightGBM + KR-FinBERT)"
requires-python = ">=3.11,<3.13"
dependencies = [
# web
"fastapi==0.111.0",
"uvicorn[standard]==0.30.0",
"pydantic==2.7.1",
"pydantic-settings==2.3.0",
# db
"sqlalchemy==2.0.30",
"psycopg[binary]==3.1.19",
"alembic==1.13.1",
# data
"pandas==2.2.2",
"numpy==1.26.4",
"pykrx==1.0.45",
"yfinance==0.2.40",
"feedparser==6.0.11",
"requests==2.32.3",
"httpx==0.27.0",
"beautifulsoup4==4.12.3",
"lxml==5.2.2",
# ml
"transformers==4.41.2",
"tokenizers==0.19.1",
"sentencepiece==0.2.0",
"scikit-learn==1.5.0",
"lightgbm==4.3.0",
"ta==0.11.0",
# scheduler
"apscheduler==3.10.4",
"pytz==2024.1",
# utils
"python-dotenv==1.0.1",
"loguru==0.7.2",
"tenacity==8.3.0",
]
[project.optional-dependencies]
dev = [
"pytest==8.2.1",
"ruff==0.4.7",
"mypy==1.10.0",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
[tool.ruff]
line-length = 100
target-version = "py311"