Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector, aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등) - 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/ 모멘텀/Beneish/GP-A/G-Score/Amihud/BAB) - 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인 - 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
EXPOSE 8282
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8282", "--workers", "1", "--log-level", "info"]
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Aux Signal Service (port 8282, 172.30.0.25)
|
||||
|
||||
외부 보조 데이터 수집:
|
||||
- 네이버 종목 integration API → 컨센서스 + 기관/외국인 일별 수급
|
||||
- 한국은행 ECOS API → USD/KRW 환율, 국고채 10년 금리
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
import asyncpg
|
||||
import structlog
|
||||
import httpx
|
||||
from fastapi import FastAPI, Query, BackgroundTasks
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from pytz import timezone
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 설정
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
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")
|
||||
ECOS_KEY = os.getenv("ECOS_API_KEY", "")
|
||||
|
||||
logger = structlog.get_logger()
|
||||
app = FastAPI(title="Aux Signal")
|
||||
pg_pool: Optional[asyncpg.Pool] = None
|
||||
scheduler = AsyncIOScheduler(timezone=KST)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DDL
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DDL = """
|
||||
CREATE TABLE IF NOT EXISTS analyst_consensus (
|
||||
stock_code VARCHAR(10) PRIMARY KEY,
|
||||
target_price BIGINT DEFAULT 0,
|
||||
recomm_mean DOUBLE PRECISION DEFAULT 0,
|
||||
create_date DATE,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inst_daily_flow (
|
||||
stock_code VARCHAR(10),
|
||||
trade_date DATE,
|
||||
foreign_net BIGINT DEFAULT 0,
|
||||
inst_net BIGINT DEFAULT 0,
|
||||
individual_net BIGINT DEFAULT 0,
|
||||
foreign_hold_ratio DOUBLE PRECISION DEFAULT 0,
|
||||
close_price BIGINT DEFAULT 0,
|
||||
change_amount BIGINT DEFAULT 0,
|
||||
PRIMARY KEY (stock_code, trade_date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_stock ON inst_daily_flow(stock_code, trade_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS macro_daily (
|
||||
indicator VARCHAR(20),
|
||||
trade_date DATE,
|
||||
value DOUBLE PRECISION,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (indicator, trade_date)
|
||||
);
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def _parse_int(v) -> int:
|
||||
if not v or v in ("-", ""):
|
||||
return 0
|
||||
s = str(v).replace(",", "").replace("+", "").replace(" ", "").strip()
|
||||
try: return int(s)
|
||||
except Exception: return 0
|
||||
|
||||
|
||||
def _parse_float(v) -> float:
|
||||
if not v or v in ("-", ""):
|
||||
return 0.0
|
||||
s = str(v).replace(",", "").replace("%", "").replace("+", "").replace(" ", "").strip()
|
||||
try: return float(s)
|
||||
except Exception: return 0.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 네이버 종목 integration API
|
||||
# 응답: consensusInfo + dealTrendInfos (60일치 일별 매매동향)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
async def fetch_naver(client: httpx.AsyncClient, code: str) -> Optional[dict]:
|
||||
url = f"https://m.stock.naver.com/api/stock/{code}/integration"
|
||||
try:
|
||||
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.debug("naver.req_err", code=code, err=str(e))
|
||||
return None
|
||||
|
||||
|
||||
async def save_consensus(conn, code: str, ci: dict):
|
||||
if not ci:
|
||||
return False
|
||||
tp = _parse_int(ci.get("priceTargetMean"))
|
||||
rm = _parse_float(ci.get("recommMean"))
|
||||
cd = ci.get("createDate")
|
||||
cd_date = None
|
||||
if cd:
|
||||
try:
|
||||
cd_date = datetime.strptime(cd, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
cd_date = None
|
||||
if tp == 0 and rm == 0:
|
||||
return False
|
||||
await conn.execute("""
|
||||
INSERT INTO analyst_consensus (stock_code, target_price, recomm_mean, create_date)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (stock_code) DO UPDATE
|
||||
SET target_price=$2, recomm_mean=$3, create_date=$4, updated_at=NOW()
|
||||
""", code, tp, rm, cd_date)
|
||||
return True
|
||||
|
||||
|
||||
async def save_flow(conn, code: str, dt_infos: list) -> int:
|
||||
saved = 0
|
||||
for r in dt_infos:
|
||||
try:
|
||||
bz = r.get("bizdate")
|
||||
if not bz: continue
|
||||
trade_dt = datetime.strptime(bz, "%Y%m%d").date()
|
||||
await conn.execute("""
|
||||
INSERT INTO inst_daily_flow
|
||||
(stock_code, trade_date, foreign_net, inst_net,
|
||||
individual_net, foreign_hold_ratio,
|
||||
close_price, change_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (stock_code, trade_date) DO UPDATE
|
||||
SET foreign_net=$3, inst_net=$4, individual_net=$5,
|
||||
foreign_hold_ratio=$6, close_price=$7, change_amount=$8
|
||||
""", code, trade_dt,
|
||||
_parse_int(r.get("foreignerPureBuyQuant")),
|
||||
_parse_int(r.get("organPureBuyQuant")),
|
||||
_parse_int(r.get("individualPureBuyQuant")),
|
||||
_parse_float(r.get("foreignerHoldRatio")),
|
||||
_parse_int(r.get("closePrice")),
|
||||
_parse_int(r.get("compareToPreviousClosePrice")))
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
logger.debug("flow.save_err", code=code, err=str(e))
|
||||
return saved
|
||||
|
||||
|
||||
async def collect_naver_data(count: int = 500):
|
||||
"""시총 상위 N개 종목 컨센서스 + 일별 수급 수집"""
|
||||
async with pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT DISTINCT ON (d.stock_code) d.stock_code,
|
||||
COALESCE(p.market_cap, 0) AS mc
|
||||
FROM dart_corps d
|
||||
LEFT JOIN stock_prices p ON p.stock_code=d.stock_code
|
||||
WHERE d.is_active=true
|
||||
ORDER BY d.stock_code, p.collected_at DESC NULLS LAST
|
||||
""")
|
||||
top = sorted(rows, key=lambda r: -r["mc"])[:count]
|
||||
logger.info("naver.collect_start", count=len(top))
|
||||
|
||||
cons_saved, flow_saved = 0, 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
for i, row in enumerate(top):
|
||||
code = row["stock_code"]
|
||||
j = await fetch_naver(client, code)
|
||||
if j:
|
||||
async with pg_pool.acquire() as c2:
|
||||
if await save_consensus(c2, code, j.get("consensusInfo") or {}):
|
||||
cons_saved += 1
|
||||
s = await save_flow(c2, code, j.get("dealTrendInfos") or [])
|
||||
flow_saved += s
|
||||
# 네이버 rate-limit 회피
|
||||
if i < len(top) - 1:
|
||||
await asyncio.sleep(0.3)
|
||||
logger.info("naver.collect_done", consensus=cons_saved, flow_rows=flow_saved)
|
||||
return {"consensus": cons_saved, "flow_rows": flow_saved}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ECOS 매크로
|
||||
# 731Y001 → 환율 (0000001=USD/KRW)
|
||||
# 817Y002 → 시장금리 (010195000=국고채 10년)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
ECOS_BASE = "https://ecos.bok.or.kr/api/StatisticSearch"
|
||||
|
||||
ECOS_SERIES = [
|
||||
# (indicator_name, stat_code, item_code1, freq)
|
||||
("usdkrw", "731Y001", "0000001", "D"),
|
||||
("kor_10y", "817Y002", "010195000", "D"),
|
||||
("kor_3y", "817Y002", "010190000", "D"),
|
||||
("kospi", "802Y001", "0001000", "D"),
|
||||
]
|
||||
|
||||
|
||||
async def fetch_ecos_series(client: httpx.AsyncClient, stat: str, item: str,
|
||||
freq: str = "D", days: int = 90) -> list:
|
||||
if not ECOS_KEY:
|
||||
return []
|
||||
end = date.today().strftime("%Y%m%d")
|
||||
start = (date.today() - timedelta(days=days)).strftime("%Y%m%d")
|
||||
url = f"{ECOS_BASE}/{ECOS_KEY}/json/kr/1/200/{stat}/{freq}/{start}/{end}/{item}"
|
||||
try:
|
||||
r = await client.get(url, timeout=15)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
j = r.json()
|
||||
return (j.get("StatisticSearch") or {}).get("row", []) or []
|
||||
except Exception as e:
|
||||
logger.warning("ecos.req_err", stat=stat, item=item, err=str(e))
|
||||
return []
|
||||
|
||||
|
||||
async def collect_ecos_macro(days: int = 90):
|
||||
if not ECOS_KEY:
|
||||
logger.error("ecos.no_key")
|
||||
return {"saved": 0, "err": "ECOS_API_KEY missing"}
|
||||
saved = 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
for name, stat, item, freq in ECOS_SERIES:
|
||||
rows = await fetch_ecos_series(client, stat, item, freq, days)
|
||||
async with pg_pool.acquire() as conn:
|
||||
for r in rows:
|
||||
try:
|
||||
t = r.get("TIME")
|
||||
v = r.get("DATA_VALUE")
|
||||
if not t or not v:
|
||||
continue
|
||||
dt = datetime.strptime(t, "%Y%m%d").date()
|
||||
await conn.execute("""
|
||||
INSERT INTO macro_daily (indicator, trade_date, value)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (indicator, trade_date) DO UPDATE
|
||||
SET value=$3
|
||||
""", name, dt, float(v))
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
logger.debug("ecos.save_err", err=str(e))
|
||||
await asyncio.sleep(0.2)
|
||||
logger.info("ecos.collect_done", saved=saved)
|
||||
return {"saved": saved}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 시작/종료
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@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)
|
||||
|
||||
# 네이버 컨센서스/수급: 매일 평일 16:30 (장 마감 후)
|
||||
scheduler.add_job(collect_naver_data, CronTrigger(
|
||||
day_of_week="mon-fri", hour=16, minute=30),
|
||||
id="naver_collect", replace_existing=True)
|
||||
# ECOS 매크로: 매일 새벽 5시
|
||||
scheduler.add_job(collect_ecos_macro, CronTrigger(hour=5),
|
||||
id="ecos_macro", replace_existing=True)
|
||||
scheduler.start()
|
||||
logger.info("aux-signal.started")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_stop():
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
if pg_pool:
|
||||
await pg_pool.close()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# REST API
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True, "service": "aux-signal",
|
||||
"ts": datetime.now(KST).isoformat()}
|
||||
|
||||
|
||||
@app.post("/collect/naver")
|
||||
async def manual_naver(count: int = Query(default=500, ge=10, le=2000),
|
||||
bg: BackgroundTasks = None):
|
||||
if bg:
|
||||
bg.add_task(collect_naver_data, count)
|
||||
return {"status": "queued", "count": count}
|
||||
return await collect_naver_data(count)
|
||||
|
||||
|
||||
@app.post("/collect/macro")
|
||||
async def manual_macro(days: int = Query(default=90, ge=7, le=365)):
|
||||
return await collect_ecos_macro(days)
|
||||
|
||||
|
||||
@app.get("/consensus/{code}")
|
||||
async def get_consensus(code: str):
|
||||
async with pg_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM analyst_consensus WHERE stock_code=$1", code)
|
||||
return dict(row) if row else {"err": "no data"}
|
||||
|
||||
|
||||
@app.get("/flow/{code}")
|
||||
async def get_flow(code: str, days: int = Query(default=30, ge=1, le=90)):
|
||||
async with pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT trade_date, foreign_net, inst_net, individual_net,
|
||||
foreign_hold_ratio, close_price, change_amount
|
||||
FROM inst_daily_flow
|
||||
WHERE stock_code=$1 AND trade_date >= CURRENT_DATE - $2::int
|
||||
ORDER BY trade_date DESC
|
||||
""", code, days)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/macro/latest")
|
||||
async def macro_latest():
|
||||
async with pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT DISTINCT ON (indicator) indicator, trade_date, value
|
||||
FROM macro_daily
|
||||
ORDER BY indicator, trade_date DESC
|
||||
""")
|
||||
return {r["indicator"]: {"date": str(r["trade_date"]),
|
||||
"value": float(r["value"])}
|
||||
for r in rows}
|
||||
|
||||
|
||||
@app.get("/macro/{indicator}")
|
||||
async def macro_series(indicator: str, days: int = Query(default=30, ge=1, le=365)):
|
||||
async with pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT trade_date, value FROM macro_daily
|
||||
WHERE indicator=$1 AND trade_date >= CURRENT_DATE - $2::int
|
||||
ORDER BY trade_date DESC
|
||||
""", indicator, days)
|
||||
return [{"date": str(r["trade_date"]), "value": float(r["value"])}
|
||||
for r in rows]
|
||||
|
||||
|
||||
@app.get("/stats")
|
||||
async def stats():
|
||||
async with pg_pool.acquire() as conn:
|
||||
c = await conn.fetchval("SELECT COUNT(*) FROM analyst_consensus")
|
||||
f = await conn.fetchrow(
|
||||
"SELECT COUNT(*) AS rows, COUNT(DISTINCT stock_code) AS codes,"
|
||||
" MAX(trade_date) AS latest FROM inst_daily_flow")
|
||||
m = await conn.fetchrow(
|
||||
"SELECT COUNT(*) AS rows, COUNT(DISTINCT indicator) AS indicators,"
|
||||
" MAX(trade_date) AS latest FROM macro_daily")
|
||||
return {"consensus_stocks": c,
|
||||
"flow": dict(f) if f else {},
|
||||
"macro": dict(m) if m else {}}
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
asyncpg==0.29.0
|
||||
apscheduler==3.10.4
|
||||
structlog==24.2.0
|
||||
httpx==0.27.0
|
||||
orjson==3.10.3
|
||||
pytz==2024.1
|
||||
Reference in New Issue
Block a user