Files
kyu 97cf5aecb0
trading/pipeline/head This commit looks good
chore: 누적 미커밋 작업분 일괄 커밋
이번 세션 외 그간 쌓인 변경 일괄 저장:
- bareunaapi: finance_dict 금융용어 / stock_loader 종목 로더 보강
- kis-api: 키움 토큰·수집 로직
- us-market / dart-collector: 수집 보강
- docker-compose: GEMINI_API_KEY 등 환경변수 추가
- score-engine/news-collector requirements, CLAUDE.md
- 신규: PROJECT.md, news-collector/sentiment_rules.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:24:16 +09:00

951 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
US Market Sync Service (port 8383, 172.30.0.24)
미국증시 → 한국증시 동조 시그널 생성:
A) 섹터 ETF 동조 (SOXX, XBI, LIT 등 14개) → 한국 같은 섹터에 ±5점
B) 개별 페어 (NVDA↔SK하이닉스 등) 60일 회귀 베타 → ±10점
D) 자동 페어 발굴 (코스피200 × S&P500 60일 상관계수)
매일 KST 07:30 미국 정규장 마감 후 수집, 08:00 시그널 계산.
"""
import os
import asyncio
import json
from datetime import date, datetime, timedelta
from typing import Optional, List, Dict
import asyncpg
import orjson
import structlog
from fastapi import FastAPI, Query, BackgroundTasks
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from pytz import timezone
import httpx
import pandas as pd
import numpy as np
from scipy import stats
# ─────────────────────────────────────────────────────────────
# 설정
# ─────────────────────────────────────────────────────────────
PG = {
"host": os.getenv("POSTGRES_HOST", "postgres"),
"port": int(os.getenv("POSTGRES_PORT", 5432)),
"database": os.getenv("POSTGRES_DB", "trading_ai"),
"user": os.getenv("POSTGRES_USER", "kyu"),
"password": os.getenv("POSTGRES_PASSWORD", ""),
}
KST = timezone("Asia/Seoul")
FINNHUB_KEY = os.getenv("FINNHUB_API_KEY", "")
FINNHUB_BASE = "https://finnhub.io/api/v1"
AV_KEY = os.getenv("ALPHAVANTAGE_API_KEY", "")
AV_BASE = "https://www.alphavantage.co/query"
AV_DAILY_LIMIT = 25 # free tier: 25 calls/day
logger = structlog.get_logger()
app = FastAPI(title="US Market Sync")
pg_pool: Optional[asyncpg.Pool] = None
scheduler = AsyncIOScheduler(timezone=KST)
# ─────────────────────────────────────────────────────────────
# ETF → 한국 섹터 키워드 매핑 (dart_corps.sector LIKE 매칭용)
# 섹터 컬럼은 KSIC 한글 (예: "반도체 및 전자집적회로 제조업")
# ─────────────────────────────────────────────────────────────
SECTOR_ETFS: Dict[str, Dict] = {
"SOXX": {"keywords": ["반도체", "전자집적", "전자부품"],
"desc": "iShares Semiconductor"},
"SMH": {"keywords": ["반도체", "전자집적"],
"desc": "VanEck Semiconductor"},
"XLK": {"keywords": ["소프트웨어", "정보서비스", "컴퓨터"],
"desc": "Tech Select Sector"},
"QQQ": {"keywords": ["소프트웨어", "인터넷", "포털"],
"desc": "Nasdaq 100"},
"XBI": {"keywords": ["바이오", "생물의약", "의약품"],
"desc": "S&P Biotech"},
"IBB": {"keywords": ["바이오", "생물의약"],
"desc": "Nasdaq Biotech"},
"LIT": {"keywords": ["전지", "축전지", "이차전지"],
"desc": "Global Lithium"},
"XLE": {"keywords": ["석유", "정제", "가스", "원유"],
"desc": "Energy Select"},
"XLF": {"keywords": ["은행", "보험", "증권", "금융"],
"desc": "Financial Select"},
"XLV": {"keywords": ["의료", "병원", "의료용기기"],
"desc": "Health Care Select"},
"XLI": {"keywords": ["기계", "산업용", "건설"],
"desc": "Industrials Select"},
"XLP": {"keywords": ["식품", "음료", "가공식품"],
"desc": "Consumer Staples"},
"XLY": {"keywords": ["자동차", "여가", "의류", "백화점"],
"desc": "Consumer Discretionary"},
"ITA": {"keywords": ["항공", "방위", "조선"],
"desc": "Aerospace & Defense"},
}
# ─────────────────────────────────────────────────────────────
# 검증된 핵심 페어 (seed) — 학술/시장 통념상 강한 동조
# (us_ticker, kr_code, kr_name_hint) kr_name_hint는 검증용 코멘트
# ─────────────────────────────────────────────────────────────
SEED_PAIRS: List = [
# 반도체
("NVDA", "000660"), # SK하이닉스
("NVDA", "005930"), # 삼성전자
("AMD", "000660"),
("AMD", "005930"),
("MU", "000660"),
("INTC", "005930"),
("TSM", "000660"),
("TSM", "005930"),
# 2차전지
("TSLA", "373220"), # LG에너지솔루션
("TSLA", "247540"), # 에코프로비엠
("TSLA", "006400"), # 삼성SDI
("ALB", "006400"), # 알버말 (리튬)
("ALB", "373220"),
# 자동차
("F", "005380"), # 현대차
("GM", "000270"), # 기아
("TSLA", "012330"), # 현대모비스
("TM", "005380"), # 도요타→현대차
# 인터넷/IT
("GOOGL","035420"), # NAVER
("META", "035720"), # 카카오
("AAPL", "011070"), # LG이노텍
("AAPL", "009150"), # 삼성전기
# 바이오/제약
("PFE", "068270"), # 셀트리온
("MRK", "207940"), # 삼성바이오로직스
# 조선/방산
("LMT", "079550"), # LIG넥스원
("LMT", "329180"), # HD현대중공업
# 철강/소재
("NUE", "005490"), # POSCO홀딩스
# 화학
("DOW", "051910"), # LG화학
("LYB", "011170"), # 롯데케미칼
# 게임/엔터
("NTES", "036570"), # 엔씨소프트
("NTES", "251270"), # 넷마블
("DIS", "035250"), # 강원랜드 (엔터)
# 유통
("AMZN", "139480"), # 이마트
]
# 자동 발굴용 미국 종목 후보 (S&P500 대표 + 한국 영향 큰 종목)
DISCOVERY_US_TICKERS = [
# 반도체
"NVDA", "AMD", "MU", "INTC", "TSM", "AVGO", "QCOM", "TXN", "AMAT", "LRCX", "KLAC",
# 빅테크
"AAPL", "MSFT", "GOOGL", "AMZN", "META", "TSLA", "NFLX", "ORCL", "ADBE", "CRM",
# 금융
"JPM", "BAC", "GS", "MS", "WFC", "C", "BLK",
# 에너지/소재
"XOM", "CVX", "COP", "NUE", "FCX",
# 헬스/바이오
"JNJ", "PFE", "MRK", "ABBV", "LLY", "BMY", "GILD",
# 소비재/유통
"WMT", "COST", "HD", "MCD", "NKE", "SBUX", "DIS",
# 산업/방산
"BA", "LMT", "RTX", "GD", "CAT", "DE",
# 자동차
"F", "GM", "TM",
# 2차전지/리튬
"ALB", "RIVN",
# 화학
"DOW", "LYB",
]
# ─────────────────────────────────────────────────────────────
# DB 초기화
# ─────────────────────────────────────────────────────────────
DDL = """
CREATE TABLE IF NOT EXISTS us_market_daily (
ticker VARCHAR(20),
trade_date DATE,
open_price DOUBLE PRECISION,
close_price DOUBLE PRECISION,
prev_close DOUBLE PRECISION,
change_pct DOUBLE PRECISION,
volume BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (ticker, trade_date)
);
CREATE INDEX IF NOT EXISTS idx_us_daily_ticker ON us_market_daily(ticker, trade_date DESC);
CREATE TABLE IF NOT EXISTS us_sector_etf_map (
etf_ticker VARCHAR(20) PRIMARY KEY,
sector_keywords TEXT[],
description TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS us_kr_pairs (
us_ticker VARCHAR(20),
kr_code VARCHAR(10),
beta_60d DOUBLE PRECISION,
correlation_60d DOUBLE PRECISION,
sample_size INTEGER,
source VARCHAR(20) DEFAULT 'seed',
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (us_ticker, kr_code)
);
CREATE INDEX IF NOT EXISTS idx_pairs_kr ON us_kr_pairs(kr_code);
CREATE TABLE IF NOT EXISTS us_overnight_signal (
kr_code VARCHAR(10),
signal_date DATE,
sector_adj DOUBLE PRECISION DEFAULT 0,
pair_adj DOUBLE PRECISION DEFAULT 0,
total_adj DOUBLE PRECISION DEFAULT 0,
contributing_pairs JSONB,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (kr_code, signal_date)
);
CREATE INDEX IF NOT EXISTS idx_overnight_date ON us_overnight_signal(signal_date DESC);
"""
# ─────────────────────────────────────────────────────────────
# 시작/종료
# ─────────────────────────────────────────────────────────────
@app.on_event("startup")
async def on_start():
global pg_pool
pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=10)
async with pg_pool.acquire() as conn:
await conn.execute(DDL)
await seed_etfs_and_pairs()
scheduler.add_job(
collect_us_daily, CronTrigger(hour=7, minute=30),
id="us_collect", replace_existing=True)
scheduler.add_job(
calc_overnight_signals_all, CronTrigger(hour=8, minute=0),
id="overnight_calc", replace_existing=True)
scheduler.add_job(
recalc_pair_betas, CronTrigger(day_of_week="sun", hour=2, minute=0),
id="pair_beta", replace_existing=True)
scheduler.add_job(
discover_new_pairs, CronTrigger(day_of_week="sun", hour=3, minute=0),
id="pair_discover", replace_existing=True)
# 매일 06:00 Alpha Vantage 백필 (25 ticker/일, 60일+ 채워질 때까지)
scheduler.add_job(
backfill_yfinance, CronTrigger(hour=6, minute=0),
id="av_backfill", replace_existing=True)
scheduler.start()
logger.info("us-market.started")
@app.on_event("shutdown")
async def on_stop():
if scheduler.running:
scheduler.shutdown()
if pg_pool:
await pg_pool.close()
# ─────────────────────────────────────────────────────────────
# Seed: ETF + 핵심 페어 등록
# ─────────────────────────────────────────────────────────────
async def seed_etfs_and_pairs():
async with pg_pool.acquire() as conn:
for etf, meta in SECTOR_ETFS.items():
await conn.execute("""
INSERT INTO us_sector_etf_map (etf_ticker, sector_keywords, description)
VALUES ($1, $2, $3)
ON CONFLICT (etf_ticker) DO UPDATE
SET sector_keywords=$2, description=$3, updated_at=NOW()
""", etf, meta["keywords"], meta["desc"])
for us, kr in SEED_PAIRS:
await conn.execute("""
INSERT INTO us_kr_pairs (us_ticker, kr_code, source)
VALUES ($1, $2, 'seed')
ON CONFLICT (us_ticker, kr_code) DO NOTHING
""", us, kr)
logger.info("seed.done", etfs=len(SECTOR_ETFS), pairs=len(SEED_PAIRS))
# ─────────────────────────────────────────────────────────────
# Finnhub 헬퍼 — /quote (무료 OK, /stock/candle은 2024년부터 유료)
# 응답: c(current), pc(prev_close), dp(percent), o(open), h, l, t
# Free tier: 60 calls/분
# ─────────────────────────────────────────────────────────────
async def fetch_finnhub_quote(client: httpx.AsyncClient, ticker: str
) -> Optional[dict]:
"""Finnhub /quote 호출. 실패 시 None."""
if not FINNHUB_KEY:
return None
try:
r = await client.get(f"{FINNHUB_BASE}/quote", params={
"symbol": ticker, "token": FINNHUB_KEY,
}, timeout=15)
except Exception as e:
logger.warning("finnhub.req_err", ticker=ticker, err=str(e))
return None
if r.status_code == 429:
await asyncio.sleep(5)
return None
if r.status_code != 200:
return None
try:
j = r.json()
except Exception:
return None
# 휴장/잘못된 ticker 시 c=0
if not j or j.get("c", 0) <= 0:
return None
return j
# ─────────────────────────────────────────────────────────────
# 수집: /quote으로 미국 일간 종가 누적
# 매일 호출하면 us_market_daily에 시계열 점진 누적 → 베타 학습 가능
# ─────────────────────────────────────────────────────────────
async def collect_us_daily(days: int = 1):
"""매일 KST 07:30 호출. days 인자는 호환용(무시) — /quote은 단일 시점."""
if not FINNHUB_KEY:
logger.error("us.no_api_key")
return {"saved": 0, "err": "FINNHUB_API_KEY missing — set in .env"}
tickers = sorted(
set(SECTOR_ETFS.keys())
| {t for t, _ in SEED_PAIRS}
| set(DISCOVERY_US_TICKERS)
)
saved = 0
failed: List[str] = []
async with httpx.AsyncClient() as client:
for i, ticker in enumerate(tickers):
q = await fetch_finnhub_quote(client, ticker)
if not q:
failed.append(ticker)
else:
# t 타임스탬프(미국 ET 종가 시점) → trade_date
ts = q.get("t", 0)
trade_dt = (datetime.fromtimestamp(ts).date() if ts
else date.today() - timedelta(days=1))
async with pg_pool.acquire() as conn:
await conn.execute("""
INSERT INTO us_market_daily
(ticker, trade_date, open_price, close_price,
prev_close, change_pct, volume)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (ticker, trade_date) DO UPDATE
SET open_price=$3, close_price=$4, prev_close=$5,
change_pct=$6
""", ticker, trade_dt,
float(q.get("o", 0) or 0), float(q["c"]),
float(q.get("pc", 0) or 0), float(q.get("dp", 0) or 0),
0)
saved += 1
# Rate limit: 60/분 → 1.1초 간격
if i < len(tickers) - 1:
await asyncio.sleep(1.1)
logger.info("us.collected", rows=saved, ok=len(tickers) - len(failed),
failed=len(failed))
return {"saved": saved, "tickers": len(tickers),
"ok": len(tickers) - len(failed), "failed": failed[:10]}
# ─────────────────────────────────────────────────────────────
# Alpha Vantage 백필 — TIME_SERIES_DAILY로 ticker당 100일 히스토리.
# Free tier 25 calls/day → 자동으로 매일 25개씩 분할 처리 (75 ticker × 3일).
# ─────────────────────────────────────────────────────────────
async def fetch_av_daily(client: httpx.AsyncClient, ticker: str
) -> Optional[List[dict]]:
"""Alpha Vantage TIME_SERIES_DAILY 호출. 실패/한도초과 시 None."""
try:
r = await client.get(AV_BASE, params={
"function": "TIME_SERIES_DAILY",
"symbol": ticker,
"outputsize": "compact", # 100일치 (full=20+년)
"apikey": AV_KEY,
}, timeout=20)
except Exception as e:
logger.warning("av.req_err", ticker=ticker, err=str(e))
return None
if r.status_code != 200:
return None
try:
j = r.json()
except Exception:
return None
if "Note" in j or "Information" in j or "Error Message" in j:
logger.warning("av.limit_or_err",
ticker=ticker, msg=str(j)[:200])
return None
ts = j.get("Time Series (Daily)")
if not ts:
return None
rows = []
for dt_str, ohlcv in ts.items():
try:
rows.append({
"trade_date": date.fromisoformat(dt_str),
"open": float(ohlcv["1. open"]),
"high": float(ohlcv["2. high"]),
"low": float(ohlcv["3. low"]),
"close": float(ohlcv["4. close"]),
"volume": int(float(ohlcv["5. volume"])),
})
except (KeyError, ValueError):
continue
rows.sort(key=lambda x: x["trade_date"])
return rows
async def backfill_yfinance(days: int = 180, max_tickers: int = 0):
"""Alpha Vantage로 히스토리 백필.
- max_tickers=0 (기본): AV_DAILY_LIMIT(=25)개만 처리 → 일일 한도 자동 준수
- max_tickers>0: 명시값 사용
- 이미 60일+ 데이터 있는 ticker는 건너뜀 → 3일치 분산 자동 진행
"""
if not AV_KEY:
return {"saved": 0, "err": "ALPHAVANTAGE_API_KEY missing — set in .env"}
all_tickers = sorted(
set(SECTOR_ETFS.keys())
| {t for t, _ in SEED_PAIRS}
| set(DISCOVERY_US_TICKERS)
)
# 이미 60일+ 누적된 ticker는 스킵
async with pg_pool.acquire() as conn:
rows = await conn.fetch(
"SELECT ticker, COUNT(*) AS n FROM us_market_daily "
"GROUP BY ticker HAVING COUNT(*) >= 60")
done = {r["ticker"] for r in rows}
pending = [t for t in all_tickers if t not in done]
limit = max_tickers if max_tickers > 0 else AV_DAILY_LIMIT
targets = pending[:limit]
saved = 0
failed: List[str] = []
async with httpx.AsyncClient() as client:
for i, ticker in enumerate(targets):
rows = await fetch_av_daily(client, ticker)
if not rows:
failed.append(ticker)
# 한도 초과면 즉시 중단
if i > 0 and len(failed) > 3 and len(failed) > i // 2:
logger.warning("av.likely_quota", processed=i)
break
else:
prev_close = None
async with pg_pool.acquire() as conn:
async with conn.transaction():
for row in rows:
pc = prev_close if prev_close is not None else 0.0
dp = ((row["close"] - pc) / pc * 100.0) if pc > 0 else 0.0
await conn.execute("""
INSERT INTO us_market_daily
(ticker, trade_date, open_price, close_price,
prev_close, change_pct, volume)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (ticker, trade_date) DO UPDATE
SET open_price=$3, close_price=$4, prev_close=$5,
change_pct=$6, volume=$7
""", ticker, row["trade_date"], row["open"],
row["close"], pc, dp, row["volume"])
saved += 1
prev_close = row["close"]
# AV free tier 5 calls/min → 12초 간격
if i < len(targets) - 1:
await asyncio.sleep(12.5)
logger.info("us.av_backfill", saved=saved,
processed=len(targets) - len(failed),
failed=len(failed),
pending_remaining=len(pending) - len(targets) + len(failed))
return {"saved": saved, "processed": len(targets),
"ok": len(targets) - len(failed),
"failed": failed[:10],
"pending_after": max(0, len(pending) - len(targets) + len(failed)),
"source": "alphavantage"}
# ─────────────────────────────────────────────────────────────
# 시그널 계산: 한국 종목별 overnight 보정 점수
# ─────────────────────────────────────────────────────────────
async def calc_overnight_signals_all(target_date: Optional[date] = None):
"""오늘 자(=어제 미국장 마감) 보정 점수 계산.
A. 섹터 ETF 동조: ETF change_pct → 같은 sector 한국 종목에 ±5점
B. 페어 동조: 페어별 미국주 change_pct × beta → ±10점 (집계)
"""
target = target_date or date.today()
async with pg_pool.acquire() as conn:
# 1) 최신 미국 거래일의 ETF/주식 change_pct
us_rows = await conn.fetch("""
SELECT DISTINCT ON (ticker)
ticker, trade_date, change_pct
FROM us_market_daily
WHERE trade_date <= $1 AND change_pct IS NOT NULL
ORDER BY ticker, trade_date DESC
""", target)
us_chg = {r["ticker"]: float(r["change_pct"]) for r in us_rows}
if not us_chg:
logger.warning("overnight.no_us_data")
return {"saved": 0, "err": "no us data"}
# 2) 활성 한국 종목 + 섹터
kr_rows = await conn.fetch("""
SELECT stock_code, sector FROM dart_corps WHERE is_active=true
""")
# 3) 페어 매핑 (us_ticker → list of (kr_code, beta))
pair_rows = await conn.fetch("""
SELECT us_ticker, kr_code, beta_60d, correlation_60d
FROM us_kr_pairs
""")
pairs_by_kr: Dict[str, List] = {}
for r in pair_rows:
kr = r["kr_code"]
pairs_by_kr.setdefault(kr, []).append({
"us": r["us_ticker"],
"beta": float(r["beta_60d"]) if r["beta_60d"] else 1.0,
"corr": float(r["correlation_60d"]) if r["correlation_60d"] else 0.0,
})
# 4) ETF 매핑
etf_rows = await conn.fetch("SELECT etf_ticker, sector_keywords FROM us_sector_etf_map")
saved = 0
for kr in kr_rows:
code = kr["stock_code"]
sector = (kr["sector"] or "")
# A. 섹터 ETF 동조
sector_adj = 0.0
matched_etfs = []
for er in etf_rows:
kws = er["sector_keywords"] or []
if not sector or not kws:
continue
if any(kw and kw in sector for kw in kws):
pct = us_chg.get(er["etf_ticker"])
if pct is None:
continue
matched_etfs.append({"etf": er["etf_ticker"], "pct": pct})
if matched_etfs:
# 매칭된 ETF 평균 변동률 → ±5 클램프
avg_pct = sum(m["pct"] for m in matched_etfs) / len(matched_etfs)
sector_adj = max(-5.0, min(5.0, avg_pct * 1.5))
# B. 페어 베타 기반
pair_adj = 0.0
contributing = []
kr_pairs = pairs_by_kr.get(code, [])
for p in kr_pairs:
pct = us_chg.get(p["us"])
if pct is None:
continue
# 예상 갭 = 미국주 변동률 × 베타
exp_gap = pct * p["beta"]
# 상관계수 가중 (|corr|이 낮으면 신뢰도 ↓)
weight = max(0.3, abs(p["corr"])) if p["corr"] else 0.5
contrib = exp_gap * weight
pair_adj += contrib
contributing.append({
"us": p["us"], "pct": round(pct, 2),
"beta": round(p["beta"], 2),
"corr": round(p["corr"], 2),
"contribution": round(contrib, 2),
})
if contributing:
# 다중 페어 평균 + 클램프
pair_adj = max(-10.0, min(10.0, pair_adj / len(contributing) * 2.0))
total_adj = sector_adj + pair_adj
if abs(total_adj) < 0.1 and not matched_etfs and not contributing:
continue # 영향 없는 종목은 저장 스킵
await conn.execute("""
INSERT INTO us_overnight_signal
(kr_code, signal_date, sector_adj, pair_adj, total_adj, contributing_pairs)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (kr_code, signal_date) DO UPDATE
SET sector_adj=$3, pair_adj=$4, total_adj=$5,
contributing_pairs=$6, created_at=NOW()
""", code, target, sector_adj, pair_adj, total_adj,
json.dumps({"etfs": matched_etfs, "pairs": contributing}))
saved += 1
logger.info("overnight.calculated", saved=saved, date=str(target))
return {"saved": saved, "date": str(target)}
# ─────────────────────────────────────────────────────────────
# 페어 베타 재계산 (주 1회 일요일)
# ─────────────────────────────────────────────────────────────
async def recalc_pair_betas(window_days: int = 60):
"""등록된 페어에 대해 최근 N일 일간수익률로 회귀 → beta, correlation 갱신."""
since = date.today() - timedelta(days=window_days * 2) # 거래일 여유
async with pg_pool.acquire() as conn:
pairs = await conn.fetch("SELECT us_ticker, kr_code FROM us_kr_pairs")
updated, skipped = 0, 0
for p in pairs:
us_t, kr_c = p["us_ticker"], p["kr_code"]
# 미국 시계열
us_rows = await conn.fetch("""
SELECT trade_date, close_price FROM us_market_daily
WHERE ticker=$1 AND trade_date >= $2 ORDER BY trade_date
""", us_t, since)
if len(us_rows) < 30:
skipped += 1
continue
# 한국 시계열 (stock_ohlcv 우선 — 일봉 80일+ / 폴백: stock_prices)
kr_rows = await conn.fetch("""
SELECT dt, close_price::float AS close
FROM stock_ohlcv
WHERE stock_code=$1 AND dt >= $2 AND close_price > 0
ORDER BY dt
""", kr_c, since)
if len(kr_rows) < 30:
kr_rows = await conn.fetch("""
SELECT collected_at::date AS dt,
AVG(price)::float AS close
FROM stock_prices
WHERE stock_code=$1 AND collected_at::date >= $2
GROUP BY collected_at::date ORDER BY dt
""", kr_c, since)
if len(kr_rows) < 30:
skipped += 1
continue
us_df = pd.DataFrame([(r["trade_date"], r["close_price"]) for r in us_rows],
columns=["dt", "us"])
kr_df = pd.DataFrame([(r["dt"], r["close"]) for r in kr_rows],
columns=["dt", "kr"])
us_df["dt"] = pd.to_datetime(us_df["dt"])
kr_df["dt"] = pd.to_datetime(kr_df["dt"])
# 한국 종가는 미국 다음날에 영향 받음 → 미국 시계열을 +1일 시프트해서 매칭
us_df["dt"] = us_df["dt"] + pd.Timedelta(days=1)
merged = pd.merge(us_df, kr_df, on="dt", how="inner")
if len(merged) < window_days // 2:
skipped += 1
continue
us_ret = merged["us"].pct_change().dropna()
kr_ret = merged["kr"].pct_change().dropna()
n = min(len(us_ret), len(kr_ret))
if n < 20:
skipped += 1
continue
us_ret, kr_ret = us_ret.iloc[-n:].values, kr_ret.iloc[-n:].values
# 회귀: kr_ret = beta * us_ret + intercept
slope, _, r_val, _, _ = stats.linregress(us_ret, kr_ret)
beta = float(slope)
corr = float(r_val)
await conn.execute("""
UPDATE us_kr_pairs
SET beta_60d=$1, correlation_60d=$2, sample_size=$3, updated_at=NOW()
WHERE us_ticker=$4 AND kr_code=$5
""", beta, corr, n, us_t, kr_c)
updated += 1
logger.info("pair_beta.recalc", updated=updated, skipped=skipped)
return {"updated": updated, "skipped": skipped}
# ─────────────────────────────────────────────────────────────
# 자동 페어 발굴 (월 1회)
# 코스피200 시총상위 50개 × DISCOVERY_US 후보 → |corr|≥0.5인 것만 등록
# ─────────────────────────────────────────────────────────────
async def discover_new_pairs(min_abs_corr: float = 0.5, top_kr: int = 50,
window_days: int = 60):
since = date.today() - timedelta(days=window_days * 2)
async with pg_pool.acquire() as conn:
# 시총상위 한국 종목
top_rows = await conn.fetch("""
SELECT DISTINCT ON (stock_code) stock_code, market_cap
FROM stock_prices
WHERE collected_at::date >= CURRENT_DATE - INTERVAL '7 days'
AND market_cap > 0
ORDER BY stock_code, collected_at DESC
""")
kr_top = sorted(top_rows, key=lambda r: -(r["market_cap"] or 0))[:top_kr]
added = 0
for kr in kr_top:
kr_c = kr["stock_code"]
kr_rows = await conn.fetch("""
SELECT collected_at::date AS dt, AVG(price)::float AS close
FROM stock_prices
WHERE stock_code=$1 AND collected_at::date >= $2
GROUP BY collected_at::date ORDER BY dt
""", kr_c, since)
if len(kr_rows) < 30:
continue
kr_df = pd.DataFrame([(r["dt"], r["close"]) for r in kr_rows],
columns=["dt", "kr"])
kr_df["dt"] = pd.to_datetime(kr_df["dt"])
for us_t in DISCOVERY_US_TICKERS:
us_rows = await conn.fetch("""
SELECT trade_date, close_price FROM us_market_daily
WHERE ticker=$1 AND trade_date >= $2 ORDER BY trade_date
""", us_t, since)
if len(us_rows) < 30:
continue
us_df = pd.DataFrame([(r["trade_date"], r["close_price"]) for r in us_rows],
columns=["dt", "us"])
us_df["dt"] = pd.to_datetime(us_df["dt"]) + pd.Timedelta(days=1)
merged = pd.merge(us_df, kr_df, on="dt", how="inner")
if len(merged) < window_days // 2:
continue
us_ret = merged["us"].pct_change().dropna()
kr_ret = merged["kr"].pct_change().dropna()
n = min(len(us_ret), len(kr_ret))
if n < 20:
continue
slope, _, r_val, _, _ = stats.linregress(
us_ret.iloc[-n:].values, kr_ret.iloc[-n:].values)
if abs(r_val) < min_abs_corr:
continue
await conn.execute("""
INSERT INTO us_kr_pairs
(us_ticker, kr_code, beta_60d, correlation_60d, sample_size, source)
VALUES ($1, $2, $3, $4, $5, 'discovered')
ON CONFLICT (us_ticker, kr_code) DO UPDATE
SET beta_60d=$3, correlation_60d=$4, sample_size=$5, updated_at=NOW()
""", us_t, kr_c, float(slope), float(r_val), n)
added += 1
logger.info("pair_discover.done", added=added)
return {"added": added}
# ─────────────────────────────────────────────────────────────
# REST API
# ─────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"ok": True, "service": "us-market", "ts": datetime.now(KST).isoformat()}
@app.post("/collect")
async def manual_collect(days: int = Query(default=7, ge=1, le=365),
bg: BackgroundTasks = None):
"""수동 수집. days=7 일상, 백필은 180 권장."""
if bg:
bg.add_task(collect_us_daily, days)
return {"status": "queued", "days": days}
return await collect_us_daily(days)
@app.post("/collect/backfill")
async def backfill(days: int = Query(default=180, ge=30, le=365),
bg: BackgroundTasks = None):
"""대규모 백필 — 페어 베타 학습용 180일 권장. 76 ticker × 1.1초 ≈ 85초."""
if bg:
bg.add_task(collect_us_daily, days)
return {"status": "queued", "days": days}
return await collect_us_daily(days)
@app.post("/collect/yfinance-backfill")
async def yfinance_backfill_ep(days: int = Query(default=180, ge=30, le=730),
bg: BackgroundTasks = None):
"""yfinance로 일괄 히스토리 다운로드 (페어 베타 학습 초기 시드용)."""
if bg:
bg.add_task(backfill_yfinance, days)
return {"status": "queued", "days": days}
return await backfill_yfinance(days)
@app.post("/signal/calculate")
async def manual_signal(target: Optional[str] = None):
d = date.fromisoformat(target) if target else date.today()
return await calc_overnight_signals_all(d)
@app.get("/signal/latest")
async def latest_signals(limit: int = Query(default=50, le=500)):
"""주의: /signal/{kr_code}보다 먼저 선언해야 함 — 안 그러면 'latest'를 kr_code로 매칭."""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT DISTINCT ON (kr_code)
kr_code, signal_date, sector_adj, pair_adj, total_adj
FROM us_overnight_signal
ORDER BY kr_code, signal_date DESC
LIMIT $1
""", limit)
return [dict(r) for r in rows]
@app.get("/signal/{kr_code}")
async def get_signal(kr_code: str, days: int = Query(default=7, ge=1, le=90)):
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT signal_date, sector_adj, pair_adj, total_adj, contributing_pairs
FROM us_overnight_signal
WHERE kr_code=$1 AND signal_date >= CURRENT_DATE - $2::int
ORDER BY signal_date DESC
""", kr_code, days)
return [dict(r) for r in rows]
@app.get("/pairs")
async def list_pairs(kr_code: Optional[str] = None, us_ticker: Optional[str] = None):
async with pg_pool.acquire() as conn:
if kr_code:
rows = await conn.fetch(
"SELECT * FROM us_kr_pairs WHERE kr_code=$1 ORDER BY ABS(correlation_60d) DESC NULLS LAST",
kr_code)
elif us_ticker:
rows = await conn.fetch(
"SELECT * FROM us_kr_pairs WHERE us_ticker=$1 ORDER BY ABS(correlation_60d) DESC NULLS LAST",
us_ticker)
else:
rows = await conn.fetch(
"SELECT * FROM us_kr_pairs ORDER BY ABS(correlation_60d) DESC NULLS LAST LIMIT 200")
return [dict(r) for r in rows]
@app.post("/pairs/recalc-beta")
async def manual_recalc(window: int = Query(default=60, ge=20, le=250)):
return await recalc_pair_betas(window)
@app.post("/pairs/discover")
async def manual_discover(min_corr: float = Query(default=0.5, ge=0.1, le=0.9),
top_kr: int = Query(default=50, ge=10, le=200)):
return await discover_new_pairs(min_corr, top_kr)
@app.get("/etfs")
async def list_etfs():
async with pg_pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM us_sector_etf_map ORDER BY etf_ticker")
return [dict(r) for r in rows]
@app.get("/etfs/{etf}/latest")
async def etf_latest(etf: str):
async with pg_pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT * FROM us_market_daily WHERE ticker=$1
ORDER BY trade_date DESC LIMIT 1
""", etf.upper())
return dict(row) if row else {"err": "no data"}
# ─────────────────────────────────────────────────────────────
# 미국 티커 → 한국어 라벨 (브리핑 표시용 — 비전문가도 알아보게)
# ─────────────────────────────────────────────────────────────
US_TICKER_LABEL = {
"NVDA":"엔비디아 (AI반도체)","AMD":"AMD (반도체)","MU":"마이크론 (메모리반도체)",
"INTC":"인텔 (반도체)","TSM":"TSMC (파운드리)","AVGO":"브로드컴 (반도체)",
"QCOM":"퀄컴 (모바일칩)","TXN":"텍사스인스트루먼트 (반도체)",
"AMAT":"어플라이드머티어리얼즈 (반도체장비)","LRCX":"램리서치 (반도체장비)",
"KLAC":"KLA (반도체장비)",
"AAPL":"애플","MSFT":"마이크로소프트","GOOGL":"구글(알파벳)","AMZN":"아마존",
"META":"메타 (페이스북)","TSLA":"테슬라 (전기차)","NFLX":"넷플릭스",
"ORCL":"오라클 (소프트웨어)","ADBE":"어도비 (소프트웨어)","CRM":"세일즈포스 (소프트웨어)",
"JPM":"JP모건 (은행)","BAC":"뱅크오브아메리카 (은행)","GS":"골드만삭스 (증권)",
"MS":"모건스탠리 (증권)","WFC":"웰스파고 (은행)","C":"씨티그룹 (은행)",
"BLK":"블랙록 (자산운용)",
"XOM":"엑슨모빌 (석유)","CVX":"셰브론 (석유)","COP":"코노코필립스 (석유)",
"NUE":"뉴코어 (철강)","FCX":"프리포트맥모란 (구리)",
"JNJ":"존슨앤존슨 (제약)","PFE":"화이자 (제약)","MRK":"머크 (제약)",
"ABBV":"애브비 (제약)","LLY":"일라이릴리 (제약)","BMY":"브리스톨마이어스 (제약)",
"GILD":"길리어드 (바이오)",
"WMT":"월마트 (유통)","COST":"코스트코 (유통)","HD":"홈디포 (유통)",
"MCD":"맥도날드","NKE":"나이키","SBUX":"스타벅스","DIS":"디즈니 (엔터)",
"BA":"보잉 (항공)","LMT":"록히드마틴 (방산)","RTX":"RTX (방산)",
"GD":"제너럴다이내믹스 (방산)","CAT":"캐터필러 (중장비)","DE":"디어 (중장비)",
"F":"포드 (자동차)","GM":"제너럴모터스 (자동차)","TM":"도요타 (자동차)",
"ALB":"앨버말 (리튬)","RIVN":"리비안 (전기차)",
"DOW":"다우 (화학)","LYB":"라이온델바젤 (화학)",
}
ETF_KR_LABEL = {
"SOXX":"미국 반도체 ETF","SMH":"미국 반도체 ETF","XLK":"미국 기술주 ETF",
"QQQ":"나스닥100 ETF","XBI":"미국 바이오 ETF","IBB":"미국 바이오 ETF",
"LIT":"글로벌 리튬·2차전지 ETF","XLE":"미국 에너지 ETF","XLF":"미국 금융 ETF",
"XLV":"미국 헬스케어 ETF","XLI":"미국 산업재 ETF","XLP":"미국 필수소비재 ETF",
"XLY":"미국 경기소비재 ETF","ITA":"미국 항공·방산 ETF",
}
@app.get("/overnight-briefing")
async def overnight_briefing(top: int = Query(default=6, ge=3, le=15)):
"""오늘 새벽(=어젯밤 미국장) 핫/저조 종목 + 관련 KOSPI 종목 추천.
한국어 라벨로 비전문가도 바로 이해하게 구성."""
etf_set = set(SECTOR_ETFS.keys())
async with pg_pool.acquire() as conn:
latest = await conn.fetchval("SELECT MAX(trade_date) FROM us_market_daily")
if not latest:
return {"err": "미국 시장 데이터 없음", "hot": [], "cold": [], "sector_heat": []}
rows = await conn.fetch("""
SELECT ticker, change_pct FROM us_market_daily
WHERE trade_date=$1 AND change_pct IS NOT NULL
""", latest)
stocks = sorted([(r["ticker"], float(r["change_pct"])) for r in rows
if r["ticker"] not in etf_set], key=lambda x: -x[1])
hot = [s for s in stocks if s[1] > 0.5][:top]
cold = sorted([s for s in stocks if s[1] < -0.5], key=lambda x: x[1])[:top]
pair_rows = await conn.fetch("""
SELECT p.us_ticker, p.kr_code, p.beta_60d, d.corp_name,
s.total_score, s.recommendation
FROM us_kr_pairs p
JOIN dart_corps d ON d.stock_code = p.kr_code AND d.is_active = true
LEFT JOIN (
SELECT DISTINCT ON (stock_code) stock_code, total_score, recommendation
FROM stock_scores ORDER BY stock_code, score_date DESC
) s ON s.stock_code = p.kr_code
""")
etf_rows = await conn.fetch("""
SELECT ticker, change_pct FROM us_market_daily
WHERE trade_date=$1 AND ticker = ANY($2) AND change_pct IS NOT NULL
""", latest, list(etf_set))
pairs_by_us: Dict[str, List] = {}
for r in pair_rows:
pairs_by_us.setdefault(r["us_ticker"], []).append(r)
def related(tk: str) -> list:
out, seen = [], set()
for r in pairs_by_us.get(tk, []):
if r["kr_code"] in seen:
continue
seen.add(r["kr_code"])
out.append({
"kr_code": r["kr_code"],
"kr_name": r["corp_name"] or r["kr_code"],
"beta": round(float(r["beta_60d"]), 2) if r["beta_60d"] else None,
"ai_score": round(float(r["total_score"]), 1) if r["total_score"] is not None else None,
"recommendation": r["recommendation"] or "-",
})
out.sort(key=lambda x: x["ai_score"] if x["ai_score"] is not None else -999,
reverse=True)
return out[:4]
def pack(lst: list) -> list:
return [{"ticker": t, "label": US_TICKER_LABEL.get(t, t),
"change_pct": round(c, 2), "related_kr": related(t)} for t, c in lst]
sector_heat = sorted([
{"etf": r["ticker"], "label": ETF_KR_LABEL.get(r["ticker"], r["ticker"]),
"change_pct": round(float(r["change_pct"]), 2)} for r in etf_rows
], key=lambda x: -x["change_pct"])
return {"trade_date": str(latest), "hot": pack(hot), "cold": pack(cold),
"sector_heat": sector_heat}
@app.get("/stats")
async def stats_endpoint():
async with pg_pool.acquire() as conn:
r1 = await conn.fetchrow(
"SELECT COUNT(*) AS rows, COUNT(DISTINCT ticker) AS tickers,"
" MIN(trade_date) AS earliest, MAX(trade_date) AS latest FROM us_market_daily")
r2 = await conn.fetchrow(
"SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE source='seed') AS seed,"
" COUNT(*) FILTER (WHERE source='discovered') AS discovered,"
" COUNT(*) FILTER (WHERE beta_60d IS NOT NULL) AS with_beta"
" FROM us_kr_pairs")
r3 = await conn.fetchrow(
"SELECT COUNT(*) AS rows, COUNT(DISTINCT kr_code) AS codes,"
" MAX(signal_date) AS latest FROM us_overnight_signal")
return {"us_daily": dict(r1), "pairs": dict(r2), "signals": dict(r3)}