Files
trading/kis-api/main.py
T
kyu 6d3b0bacc0 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>
2026-05-20 21:33:56 +09:00

903 lines
40 KiB
Python

"""
키움증권 REST API 기반 주가·수급·공매도 수집 서비스
- ka10001: 현재가 + 재무지표 (PER/PBR/ROE/EPS/BPS/외국인비중/시가총액)
- ka10005: 일봉 OHLCV + 외국인·기관 순매수
- ka10008: 외국인 종목별 매매동향 (일자별 보유비중 변화)
- ka10014: 공매도 추이 (잔고수량·거래비중)
- Redis db=3 price:{code} 형식 유지 (score-engine·ta-engine 호환)
"""
import asyncio, json, os, re
from datetime import datetime, timedelta
from typing import Optional
import asyncpg, httpx, redis.asyncio as aioredis, structlog
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
structlog.configure(processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
])
logger = structlog.get_logger()
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
PG_HOST = os.getenv("POSTGRES_HOST", "postgres")
PG_PORT = int(os.getenv("POSTGRES_PORT", "5432"))
PG_DB = os.getenv("POSTGRES_DB", "trading_ai")
PG_USER = os.getenv("POSTGRES_USER", "kyu")
PG_PASS = os.getenv("POSTGRES_PASSWORD", "")
KIWOOM_APP_KEY = os.getenv("KIWOOM_APP_KEY", "")
KIWOOM_SECRET_KEY = os.getenv("KIWOOM_SECRET_KEY", "")
KIWOOM_BASE_URL = os.getenv("KIWOOM_BASE_URL", "https://api.kiwoom.com")
pg_pool: Optional[asyncpg.Pool] = None
redis_cl: Optional[aioredis.Redis] = None
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# ── 키움 토큰 관리 ────────────────────────────────────────
class KiwoomToken:
token: str = ""
expires_at: datetime = datetime.min
async def get(self, client: httpx.AsyncClient) -> str:
if self.token and datetime.now() < self.expires_at:
return self.token
resp = await client.post(
f"{KIWOOM_BASE_URL}/oauth2/token",
json={"grant_type": "client_credentials",
"appkey": KIWOOM_APP_KEY,
"secretkey": KIWOOM_SECRET_KEY},
headers={"Content-Type": "application/json;charset=UTF-8"},
timeout=10)
data = resp.json()
if data.get("return_code", -1) != 0:
raise RuntimeError(f"토큰 발급 실패: {data.get('return_msg')}")
self.token = data["token"]
# expires_dt: "20260508151325" 형식
exp_str = data.get("expires_dt", "")
try:
self.expires_at = datetime.strptime(exp_str, "%Y%m%d%H%M%S") - timedelta(minutes=5)
except Exception:
self.expires_at = datetime.now() + timedelta(hours=23)
logger.info("kiwoom.token.refreshed", expires=exp_str)
return self.token
kiwoom_token = KiwoomToken()
# ── 키움 API 호출 헬퍼 ────────────────────────────────────
async def kiwoom_post(client: httpx.AsyncClient, endpoint: str, api_id: str,
body: dict, cont_yn: str = "N", next_key: str = "",
return_headers: bool = False):
token = await kiwoom_token.get(client)
headers = {
"Content-Type": "application/json;charset=UTF-8",
"Authorization": f"Bearer {token}",
"api-id": api_id,
"cont-yn": cont_yn,
}
if next_key:
headers["next-key"] = next_key
r = await client.post(f"{KIWOOM_BASE_URL}{endpoint}",
headers=headers, json=body, timeout=15)
if return_headers: # 연속조회용 cont-yn/next-key 응답헤더 필요
return r.json(), r.headers
return r.json()
def to_int(v: str) -> int:
try:
return int(str(v).replace(",", "").lstrip("+").replace(" ", "") or 0)
except:
return 0
def to_float(v: str) -> float:
try:
return float(str(v).replace(",", "").lstrip("+").replace(" ", "") or 0)
except:
return 0.0
# ── DB 초기화 ─────────────────────────────────────────────
async def init_db():
async with pg_pool.acquire() as c:
await c.execute("""
CREATE TABLE IF NOT EXISTS stock_prices (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
price INTEGER DEFAULT 0,
change_pct FLOAT DEFAULT 0,
change_amount INTEGER DEFAULT 0,
volume BIGINT DEFAULT 0,
high INTEGER DEFAULT 0,
low INTEGER DEFAULT 0,
open_price INTEGER DEFAULT 0,
market_cap BIGINT DEFAULT 0,
per FLOAT DEFAULT 0,
pbr FLOAT DEFAULT 0,
eps FLOAT DEFAULT 0,
bps FLOAT DEFAULT 0,
roe FLOAT DEFAULT 0,
ev FLOAT DEFAULT 0,
high_52w INTEGER DEFAULT 0,
low_52w INTEGER DEFAULT 0,
foreign_ratio FLOAT DEFAULT 0,
credit_ratio FLOAT DEFAULT 0,
float_shares BIGINT DEFAULT 0,
collected_at TIMESTAMP DEFAULT NOW()
)""")
await c.execute("CREATE INDEX IF NOT EXISTS idx_sp_code ON stock_prices(stock_code)")
await c.execute("CREATE INDEX IF NOT EXISTS idx_sp_time ON stock_prices(collected_at DESC)")
await c.execute("""
CREATE TABLE IF NOT EXISTS stock_ohlcv (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
dt DATE NOT NULL,
open_price INTEGER DEFAULT 0,
high_price INTEGER DEFAULT 0,
low_price INTEGER DEFAULT 0,
close_price INTEGER DEFAULT 0,
volume BIGINT DEFAULT 0,
trade_amount BIGINT DEFAULT 0,
foreign_ratio FLOAT DEFAULT 0,
foreign_net BIGINT DEFAULT 0,
institution_net BIGINT DEFAULT 0,
individual_net BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stock_code, dt)
)""")
await c.execute("CREATE INDEX IF NOT EXISTS idx_ohlcv_code_dt ON stock_ohlcv(stock_code, dt DESC)")
await c.execute("""
CREATE TABLE IF NOT EXISTS stock_foreign_flow (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
dt DATE NOT NULL,
close_price INTEGER DEFAULT 0,
change_qty BIGINT DEFAULT 0,
hold_qty BIGINT DEFAULT 0,
hold_ratio FLOAT DEFAULT 0,
limit_ratio FLOAT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stock_code, dt)
)""")
await c.execute("CREATE INDEX IF NOT EXISTS idx_ff_code_dt ON stock_foreign_flow(stock_code, dt DESC)")
await c.execute("""
CREATE TABLE IF NOT EXISTS stock_short_sale (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
dt DATE NOT NULL,
close_price INTEGER DEFAULT 0,
short_qty BIGINT DEFAULT 0,
short_balance_qty BIGINT DEFAULT 0,
trade_weight FLOAT DEFAULT 0,
short_avg_price INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stock_code, dt)
)""")
await c.execute("CREATE INDEX IF NOT EXISTS idx_ss_code_dt ON stock_short_sale(stock_code, dt DESC)")
await c.execute("""
CREATE TABLE IF NOT EXISTS trade_signals (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
signal_type VARCHAR(10) NOT NULL,
current_price INTEGER DEFAULT 0,
target_price INTEGER DEFAULT 0,
stop_loss INTEGER DEFAULT 0,
expected_return_pct FLOAT DEFAULT 0,
risk_reward_ratio FLOAT DEFAULT 0,
confidence FLOAT DEFAULT 0,
reason TEXT DEFAULT '',
news_score FLOAT DEFAULT 0,
dart_score FLOAT DEFAULT 0,
price_momentum FLOAT DEFAULT 0,
foreign_net_5d BIGINT DEFAULT 0,
short_weight FLOAT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)""")
await c.execute("CREATE INDEX IF NOT EXISTS idx_ts_code ON trade_signals(stock_code)")
await c.execute("CREATE INDEX IF NOT EXISTS idx_ts_time ON trade_signals(created_at DESC)")
logger.info("kiwoom.db.initialized")
# ── 수집 함수 ─────────────────────────────────────────────
async def fetch_basic_info(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore) -> Optional[dict]:
"""ka10001: 현재가 + 재무지표 (PER/PBR/ROE/EPS/BPS/외국인비중/시가총액)"""
async with sem:
try:
d = await kiwoom_post(client, "/api/dostk/stkinfo", "ka10001", {"stk_cd": code})
if d.get("return_code", -1) != 0:
return None
return {
"code": code,
"name": d.get("stk_nm", ""),
"price": abs(to_int(d.get("cur_prc", "0"))),
"change_amount": to_int(d.get("pred_pre", "0")),
"change_pct": to_float(d.get("flu_rt", "0")),
"volume": to_int(d.get("trde_qty", "0")),
"open_price": abs(to_int(d.get("open_pric", "0"))),
"high": abs(to_int(d.get("high_pric", "0"))),
"low": abs(to_int(d.get("low_pric", "0"))),
"market_cap": to_int(d.get("mac", "0")) * 100_000_000, # 억 → 원
"per": to_float(d.get("per", "0")),
"pbr": to_float(d.get("pbr", "0")),
"eps": to_float(d.get("eps", "0")),
"bps": to_float(d.get("bps", "0")),
"roe": to_float(d.get("roe", "0")),
"ev": to_float(d.get("ev", "0")),
"high_52w": abs(to_int(d.get("250hgst", "0"))),
"low_52w": abs(to_int(d.get("250lwst", "0"))),
"foreign_ratio": to_float(d.get("for_exh_rt", "0")),
"credit_ratio": to_float(d.get("crd_rt", "0")),
"float_shares": to_int(d.get("flo_stk", "0")),
"sale_amt": to_int(d.get("sale_amt", "0")),
"operating_profit": to_int(d.get("bus_pro", "0")),
"net_income": to_int(d.get("cup_nga", "0")),
"timestamp": datetime.now().isoformat(),
}
except Exception as e:
logger.debug("ka10001.err", code=code, error=str(e))
return None
async def fetch_ohlcv(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore, days: int = 365) -> list:
"""ka10005: 일봉 OHLCV + 외국인·기관 순매수"""
async with sem:
try:
today = datetime.now().strftime("%Y%m%d")
result = []
cont_yn, next_key = "N", ""
for _ in range(20): # 안전 상한(20p ≈ 365봉 충분)
d, hdr = await kiwoom_post(
client, "/api/dostk/mrkcond", "ka10005",
{"stk_cd": code, "dt": today, "req_cnt": days},
cont_yn=cont_yn, next_key=next_key, return_headers=True)
for r in d.get("stk_ddwkmm", []):
cp = abs(to_int(r.get("close_pric", "0")))
if cp <= 0:
continue
result.append({
"dt": r.get("date", ""),
"open": abs(to_int(r.get("open_pric", "0"))),
"high": abs(to_int(r.get("high_pric", "0"))),
"low": abs(to_int(r.get("low_pric", "0"))),
"close": cp,
"volume": to_int(r.get("trde_qty", "0")),
"trade_amount": to_int(r.get("trde_prica", "0")),
"foreign_ratio": to_float(r.get("for_poss", "0")),
"foreign_net": to_int(r.get("for_netprps", "0")),
"institution_net": to_int(r.get("orgn_netprps", "0")),
"individual_net": to_int(r.get("ind_netprps", "0")),
})
# 키움 연속조회: 응답 cont-yn=Y + next-key 있으면 다음 페이지
if (len(result) >= days or hdr.get("cont-yn") != "Y"
or not hdr.get("next-key")):
break
cont_yn, next_key = "Y", hdr.get("next-key", "")
return result
except Exception as e:
logger.debug("ka10005.err", code=code, error=str(e))
return []
async def fetch_foreign_flow(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore) -> list:
"""ka10008: 외국인 종목별 매매동향"""
async with sem:
try:
d = await kiwoom_post(client, "/api/dostk/frgnistt", "ka10008", {"stk_cd": code})
rows = d.get("stk_frgnr", [])
result = []
for r in rows:
result.append({
"dt": r.get("dt", ""),
"close_price": abs(to_int(r.get("close_pric", "0"))),
"change_qty": to_int(r.get("chg_qty", "0")),
"hold_qty": to_int(r.get("poss_stkcnt", "0")),
"hold_ratio": to_float(r.get("wght", "0")),
"limit_ratio": to_float(r.get("limit_exh_rt", "0")),
})
return result
except Exception as e:
logger.debug("ka10008.err", code=code, error=str(e))
return []
async def fetch_minute_chart(client: httpx.AsyncClient, code: str, tic_scope: str = "1") -> list:
"""ka10080: 주식분봉차트조회. tic_scope: 1, 3, 5, 10, 15, 30, 45, 60 분"""
try:
d = await kiwoom_post(client, "/api/dostk/chart", "ka10080",
{"stk_cd": code, "tic_scope": tic_scope, "upd_stkpc_tp": "1"})
if d.get("return_code", -1) != 0:
return []
rows = d.get("stk_min_pole_chart_qry", []) or d.get("stk_min_pole", []) or []
result = []
for r in rows:
result.append({
"dt": r.get("cntr_tm", r.get("dt", "")),
"open": abs(to_int(r.get("open_pric", "0"))),
"high": abs(to_int(r.get("high_pric", "0"))),
"low": abs(to_int(r.get("low_pric", "0"))),
"close": abs(to_int(r.get("cur_prc", r.get("close_pric", "0")))),
"volume": to_int(r.get("trde_qty", "0")),
})
return result
except Exception as e:
logger.debug("ka10080.err", code=code, error=str(e))
return []
async def fetch_orderbook(client: httpx.AsyncClient, code: str) -> dict:
"""ka10004: 호가잔량 (10단계 매수/매도). path=/api/dostk/mrkcond"""
try:
d = await kiwoom_post(client, "/api/dostk/mrkcond", "ka10004", {"stk_cd": code})
if d.get("return_code", -1) != 0:
return {}
bid, ask = [], []
# 매도 호가 1~10 (sel_1th_pre_bid = 가격, sel_1th_pre_req = 잔량)
for i in range(1, 11):
p = abs(to_int(d.get(f"sel_{i}th_pre_bid", "0")))
q = to_int(d.get(f"sel_{i}th_pre_req", "0"))
if p > 0:
ask.append({"price": p, "qty": q})
# 매수 호가 1~10
for i in range(1, 11):
p = abs(to_int(d.get(f"buy_{i}th_pre_bid", "0")))
q = to_int(d.get(f"buy_{i}th_pre_req", "0"))
if p > 0:
bid.append({"price": p, "qty": q})
return {
"code": code,
"ask": ask, # 매도 (가격 낮은 것부터)
"bid": bid, # 매수 (가격 높은 것부터)
"ask_total": to_int(d.get("tot_sel_req", "0")),
"bid_total": to_int(d.get("tot_buy_req", "0")),
"base_time": d.get("bid_req_base_tm", ""),
"timestamp": datetime.now().isoformat(),
}
except Exception as e:
logger.debug("ka10004.err", code=code, error=str(e))
return {}
async def fetch_volume_surge() -> list:
"""ka10023: 거래량급증 종목. path=/api/dostk/rkinfo"""
try:
async with httpx.AsyncClient() as c:
d = await kiwoom_post(c, "/api/dostk/rkinfo", "ka10023",
{"mrkt_tp": "000", # 000=전체, 001=코스피, 101=코스닥
"sort_tp": "1", # 1=급증량, 2=급증률
"tm_tp": "2", # 1=분, 2=전일
"trde_qty_tp": "5", # 1=5천주, 2=1만주, 5=5만주
"tm": "",
"stk_cnd": "0", # 0=전체
"pric_tp": "0", # 0=전체
"stex_tp": "3"}) # 3=통합
if d.get("return_code", -1) != 0:
return []
rows = d.get("trde_qty_sdnin", []) or []
result = []
for r in rows[:50]:
raw_code = r.get("stk_cd", "")
# NXT 등 접미사 제거 ('_AL', '_NX' 등)
clean_code = raw_code.split("_")[0] if raw_code else ""
result.append({
"code": clean_code,
"name": r.get("stk_nm", ""),
"price": abs(to_int(r.get("cur_prc", "0"))),
"change_pct": to_float(r.get("flu_rt", "0")),
"volume": to_int(r.get("now_trde_qty", r.get("trde_qty", "0"))),
"prev_volume":to_int(r.get("pred_trde_qty", "0")),
"surge_rate": to_float(r.get("sdnin_rt", r.get("sdnin_qty", "0"))),
})
return result
except Exception as e:
logger.debug("ka10023.err", error=str(e))
return []
async def fetch_short_sale(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore) -> list:
"""ka10014: 공매도 추이"""
async with sem:
try:
today = datetime.now().strftime("%Y%m%d")
month_ago = (datetime.now() - timedelta(days=30)).strftime("%Y%m%d")
d = await kiwoom_post(client, "/api/dostk/shsa", "ka10014",
{"stk_cd": code, "strt_dt": month_ago, "end_dt": today})
rows = d.get("shrts_trnsn", [])
result = []
for r in rows:
result.append({
"dt": r.get("dt", ""),
"close_price": abs(to_int(r.get("close_pric", "0"))),
"short_qty": to_int(r.get("shrts_qty", "0")),
"short_balance_qty": to_int(r.get("ovr_shrts_qty", "0")),
"trade_weight": to_float(r.get("trde_wght", "0")),
"short_avg_price": to_int(r.get("shrts_avg_pric", "0")),
})
return result
except Exception as e:
logger.debug("ka10014.err", code=code, error=str(e))
return []
# ── 종목 코드 로드 ────────────────────────────────────────
async def get_stock_codes(limit: int = 0) -> list:
"""is_active 종목 전체 (limit=0이면 제한 없음)"""
if pg_pool:
try:
if limit > 0:
rows = await pg_pool.fetch(
"SELECT stock_code FROM dart_corps WHERE is_active=TRUE ORDER BY stock_code LIMIT $1", limit)
else:
rows = await pg_pool.fetch(
"SELECT stock_code FROM dart_corps WHERE is_active=TRUE ORDER BY stock_code")
codes = [r["stock_code"] for r in rows if r["stock_code"]]
if len(codes) >= 50:
return codes
except Exception:
pass
return []
# ── 저장 함수 ─────────────────────────────────────────────
async def save_price(info: dict):
if redis_cl:
redis_data = {
"code": info["code"], "name": info["name"],
"price": info["price"], "change_pct": info["change_pct"],
"change_amount": info["change_amount"],
"volume": info["volume"], "high": info["high"], "low": info["low"],
"open_price": info["open_price"],
"market_cap": info["market_cap"],
"per": info["per"], "pbr": info["pbr"],
"eps": info["eps"], "bps": info["bps"],
"roe": info["roe"], "ev": info["ev"],
"high_52w": info["high_52w"], "low_52w": info["low_52w"],
"foreign_ratio": info["foreign_ratio"],
"credit_ratio": info["credit_ratio"],
"timestamp": info["timestamp"],
}
await redis_cl.set(f"price:{info['code']}", json.dumps(redis_data, ensure_ascii=False), ex=600)
if pg_pool:
try:
async with pg_pool.acquire() as c:
await c.execute("""
INSERT INTO stock_prices (
stock_code, stock_name, price, change_pct, change_amount,
volume, high, low, open_price, market_cap,
per, pbr, eps, bps, roe, ev,
high_52w, low_52w, foreign_ratio, credit_ratio, float_shares, collected_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
""", info["code"], info["name"], info["price"], info["change_pct"], info["change_amount"],
info["volume"], info["high"], info["low"], info["open_price"], info["market_cap"],
info["per"], info["pbr"], info["eps"], info["bps"], info["roe"], info["ev"],
info["high_52w"], info["low_52w"], info["foreign_ratio"], info["credit_ratio"],
info["float_shares"], datetime.now())
except Exception as e:
logger.debug("save_price.err", code=info["code"], error=str(e))
async def save_ohlcv(code: str, rows: list):
if not rows or not pg_pool:
return
# Redis에 최근 60일 일봉 저장 (ta-engine 보조)
if redis_cl:
await redis_cl.set(f"ohlcv:{code}", json.dumps(rows[:60], ensure_ascii=False), ex=86400)
async with pg_pool.acquire() as c:
for r in rows:
try:
dt = datetime.strptime(r["dt"], "%Y%m%d").date()
await c.execute("""
INSERT INTO stock_ohlcv (
stock_code, dt, open_price, high_price, low_price, close_price,
volume, trade_amount, foreign_ratio, foreign_net, institution_net, individual_net)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (stock_code, dt) DO UPDATE SET
close_price=$6, volume=$7, foreign_ratio=$9,
foreign_net=$10, institution_net=$11, individual_net=$12
""", code, dt, r["open"], r["high"], r["low"], r["close"],
r["volume"], r["trade_amount"], r["foreign_ratio"],
r["foreign_net"], r["institution_net"], r["individual_net"])
except Exception:
pass
async def save_foreign_flow(code: str, rows: list):
if not rows or not pg_pool:
return
if redis_cl and rows:
await redis_cl.set(f"foreign:{code}", json.dumps(rows[:20], ensure_ascii=False), ex=86400)
async with pg_pool.acquire() as c:
for r in rows:
try:
dt = datetime.strptime(r["dt"], "%Y%m%d").date()
await c.execute("""
INSERT INTO stock_foreign_flow (
stock_code, dt, close_price, change_qty, hold_qty, hold_ratio, limit_ratio)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (stock_code, dt) DO UPDATE SET
change_qty=$4, hold_qty=$5, hold_ratio=$6, limit_ratio=$7
""", code, dt, r["close_price"], r["change_qty"],
r["hold_qty"], r["hold_ratio"], r["limit_ratio"])
except Exception:
pass
async def save_short_sale(code: str, rows: list):
if not rows or not pg_pool:
return
if redis_cl and rows:
await redis_cl.set(f"short:{code}", json.dumps(rows[:20], ensure_ascii=False), ex=86400)
async with pg_pool.acquire() as c:
for r in rows:
try:
dt = datetime.strptime(r["dt"], "%Y%m%d").date()
await c.execute("""
INSERT INTO stock_short_sale (
stock_code, dt, close_price, short_qty, short_balance_qty,
trade_weight, short_avg_price)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (stock_code, dt) DO UPDATE SET
short_qty=$4, short_balance_qty=$5, trade_weight=$6, short_avg_price=$7
""", code, dt, r["close_price"], r["short_qty"],
r["short_balance_qty"], r["trade_weight"], r["short_avg_price"])
except Exception:
pass
# ── 수집 작업 ─────────────────────────────────────────────
class Stats:
collected = 0; errors = 0; last_run = ""; last_full_run = ""
stats = Stats()
async def job_price():
"""평일 9~16시 5분마다: 현재가·재무지표 수집 (ka10001)"""
codes = await get_stock_codes(0)
if not codes:
logger.warning("job_price.no_codes")
return
sem = asyncio.Semaphore(10) # 동시 10개 제한
ok = 0
async with httpx.AsyncClient() as client:
tasks = [fetch_basic_info(client, code, sem) for code in codes]
results = await asyncio.gather(*tasks, return_exceptions=True)
for info in results:
if isinstance(info, dict) and info.get("price", 0) > 0:
await save_price(info)
ok += 1
else:
stats.errors += 1
await gen_signals()
stats.collected += ok
stats.last_run = datetime.now().isoformat()
if redis_cl:
await redis_cl.set("prices:last_update", datetime.now().isoformat())
logger.info("job_price.done", ok=ok, total=len(codes))
async def job_full(days: int = 10):
"""평일 17:00: 일봉·외국인·공매도 전체 수집 (ka10005·ka10008·ka10014).
일별은 days=10(경량, ON CONFLICT 갱신). 백필은 /collect/full?days=400."""
codes = await get_stock_codes(0)
if not codes:
return
sem = asyncio.Semaphore(5) # 야간 수집 느리게
ok = 0
async with httpx.AsyncClient() as client:
for code in codes:
try:
ohlcv, foreign, short = await asyncio.gather(
fetch_ohlcv(client, code, sem, days),
fetch_foreign_flow(client, code, sem),
fetch_short_sale(client, code, sem),
return_exceptions=True
)
if isinstance(ohlcv, list):
await save_ohlcv(code, ohlcv)
if isinstance(foreign, list):
await save_foreign_flow(code, foreign)
if isinstance(short, list):
await save_short_sale(code, short)
ok += 1
except Exception as e:
logger.debug("job_full.err", code=code, error=str(e))
stats.last_full_run = datetime.now().isoformat()
logger.info("job_full.done", ok=ok, total=len(codes))
# ── 시그널 생성 ───────────────────────────────────────────
def calc_signal(info: dict, news_sc: float, dart_sc: float,
foreign_net_5d: int, short_weight: float) -> Optional[dict]:
price = info["price"]
if price <= 0:
return None
h52 = info.get("high_52w", 0)
l52 = info.get("low_52w", 0)
cpct = info.get("change_pct", 0)
foreign_ratio = info.get("foreign_ratio", 0)
# 외국인 수급 보너스 (5일 누적 순매수 양수면 +10, 음수면 -10)
foreign_bonus = min(10, max(-10, foreign_net_5d / 500_000)) if foreign_net_5d else 0
# 공매도 패널티 (비중 5% 이상이면 -5)
short_penalty = -5 if short_weight >= 5 else 0
total = (news_sc * 0.35 + dart_sc * 0.25
+ (cpct * 10) * 0.25 + foreign_bonus * 0.10 + short_penalty * 0.05)
total = max(-100, min(100, total))
pos = (price - l52) / (h52 - l52) if h52 > l52 > 0 else 0.5
if total >= 30:
sig = "매수"
tp = int(price + (h52 - price) * (0.3 + min(total, 100) / 200)) if h52 > price else int(price * 1.10)
sl = int(price * (0.92 + pos * 0.03))
conf = min(95, 50 + total * 0.3 + (1 - pos) * 20)
elif total <= -30:
sig = "매도"
tp = int(price - (price - l52) * (0.3 + min(abs(total), 100) / 200)) if l52 > 0 and l52 < price else int(price * 0.90)
sl = int(price * 1.05)
conf = min(95, 50 + abs(total) * 0.3 + pos * 20)
else:
return None
er = ((tp - price) / price) * 100 if sig == "매수" else ((price - tp) / price) * 100
risk = abs(sl - price)
reward = abs(tp - price)
rr = reward / risk if risk > 0 else 0
return {
"signal_type": sig, "current_price": price, "target_price": tp, "stop_loss": sl,
"expected_return_pct": round(er, 2), "risk_reward_ratio": round(rr, 2),
"confidence": round(conf, 1), "price_momentum": round(cpct, 2),
"news_score": round(news_sc, 1), "dart_score": round(dart_sc, 1),
"foreign_net_5d": foreign_net_5d, "short_weight": short_weight,
}
async def gen_signals():
if not pg_pool:
return
async with pg_pool.acquire() as c:
scores = await c.fetch("""
SELECT stock_code, stock_name, news_score, dart_score, total_score
FROM stock_scores
WHERE score_date = (SELECT MAX(score_date) FROM stock_scores)
AND (total_score >= 30 OR total_score <= -30)
""")
n = 0
for row in scores:
code = row["stock_code"]
if not code or len(code) != 6:
continue
if not redis_cl:
continue
cached = await redis_cl.get(f"price:{code}")
if not cached:
continue
info = json.loads(cached)
# 외국인 5일 순매수 합산
foreign_net_5d = 0
f_cached = await redis_cl.get(f"foreign:{code}")
if f_cached:
f_rows = json.loads(f_cached)
foreign_net_5d = sum(r.get("change_qty", 0) for r in f_rows[:5])
# 공매도 최근 비중
short_weight = 0.0
s_cached = await redis_cl.get(f"short:{code}")
if s_cached:
s_rows = json.loads(s_cached)
if s_rows:
short_weight = s_rows[0].get("trade_weight", 0.0)
sig = calc_signal(info, row["news_score"], row["dart_score"],
foreign_net_5d, short_weight)
if not sig:
continue
reasons = await c.fetch("""
SELECT reason FROM news_analysis
WHERE primary_stock=$1 AND intensity>=2
ORDER BY analyzed_at DESC LIMIT 2
""", code)
reason = " | ".join([r["reason"][:100] for r in reasons])
await c.execute("""
INSERT INTO trade_signals (
stock_code, stock_name, signal_type, current_price,
target_price, stop_loss, expected_return_pct, risk_reward_ratio,
confidence, reason, news_score, dart_score, price_momentum,
foreign_net_5d, short_weight)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (stock_code, (created_at::date)) DO UPDATE SET
signal_type=EXCLUDED.signal_type, current_price=EXCLUDED.current_price,
target_price=EXCLUDED.target_price, stop_loss=EXCLUDED.stop_loss,
expected_return_pct=EXCLUDED.expected_return_pct,
risk_reward_ratio=EXCLUDED.risk_reward_ratio,
confidence=EXCLUDED.confidence, reason=EXCLUDED.reason,
news_score=EXCLUDED.news_score, dart_score=EXCLUDED.dart_score,
price_momentum=EXCLUDED.price_momentum,
foreign_net_5d=EXCLUDED.foreign_net_5d, short_weight=EXCLUDED.short_weight
""", code, row["stock_name"], sig["signal_type"], sig["current_price"],
sig["target_price"], sig["stop_loss"], sig["expected_return_pct"],
sig["risk_reward_ratio"], sig["confidence"], reason,
sig["news_score"], sig["dart_score"], sig["price_momentum"],
sig["foreign_net_5d"], sig["short_weight"])
n += 1
logger.info("signals.done", count=n)
# ── FastAPI ───────────────────────────────────────────────
app = FastAPI(title="키움증권 주가·수급·공매도")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@app.on_event("startup")
async def startup():
global pg_pool, redis_cl
pg_pool = await asyncpg.create_pool(
host=PG_HOST, port=PG_PORT, database=PG_DB,
user=PG_USER, password=PG_PASS, min_size=2, max_size=5)
redis_cl = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True)
await init_db()
# 현재가: 평일 9~16시 5분마다
scheduler.add_job(job_price, "cron", day_of_week="mon-fri",
hour="9-15", minute="*/5", id="price", replace_existing=True)
# 장 마감 후 전체 수집: 평일 17:00
scheduler.add_job(job_full, "cron", day_of_week="mon-fri",
hour="17", minute="0", id="full", replace_existing=True)
scheduler.start()
logger.info("kiwoom-api.started")
@app.on_event("shutdown")
async def shutdown():
scheduler.shutdown()
if pg_pool:
await pg_pool.close()
if redis_cl:
await redis_cl.aclose()
@app.get("/health")
async def health():
lu = await redis_cl.get("prices:last_update") if redis_cl else ""
return JSONResponse(content={
"status": "ok", "collected": stats.collected, "errors": stats.errors,
"last_run": stats.last_run, "last_full_run": stats.last_full_run,
"last_update": lu or ""
})
@app.get("/price/{code}")
async def price(code: str):
if redis_cl:
c = await redis_cl.get(f"price:{code}")
if c:
return JSONResponse(content=json.loads(c))
# 실시간 단건 조회
sem = asyncio.Semaphore(1)
async with httpx.AsyncClient() as client:
info = await fetch_basic_info(client, code, sem)
if info:
await save_price(info)
return JSONResponse(content=info)
return JSONResponse(content={"error": "not found"}, status_code=404)
@app.get("/prices")
async def prices(limit: int = Query(default=50)):
if not redis_cl:
return JSONResponse(content={"data": {}})
keys = await redis_cl.keys("price:*")
result = {}
for k in keys[:limit]:
c = await redis_cl.get(k)
if c:
d = json.loads(c)
result[d["code"]] = d
return JSONResponse(content={"count": len(result), "data": result})
@app.get("/foreign/{code}")
async def foreign(code: str):
if redis_cl:
c = await redis_cl.get(f"foreign:{code}")
if c:
return JSONResponse(content={"code": code, "data": json.loads(c)})
sem = asyncio.Semaphore(1)
async with httpx.AsyncClient() as client:
rows = await fetch_foreign_flow(client, code, sem)
await save_foreign_flow(code, rows)
return JSONResponse(content={"code": code, "data": rows})
@app.get("/short/{code}")
async def short_sale(code: str):
if redis_cl:
c = await redis_cl.get(f"short:{code}")
if c:
return JSONResponse(content={"code": code, "data": json.loads(c)})
sem = asyncio.Semaphore(1)
async with httpx.AsyncClient() as client:
rows = await fetch_short_sale(client, code, sem)
await save_short_sale(code, rows)
return JSONResponse(content={"code": code, "data": rows})
@app.get("/ohlcv/{code}")
async def ohlcv(code: str, days: int = Query(default=60)):
if redis_cl:
c = await redis_cl.get(f"ohlcv:{code}")
if c:
data = json.loads(c)
return JSONResponse(content={"code": code, "data": data[:days]})
sem = asyncio.Semaphore(1)
async with httpx.AsyncClient() as client:
rows = await fetch_ohlcv(client, code, sem, days)
await save_ohlcv(code, rows)
return JSONResponse(content={"code": code, "data": rows})
@app.get("/signals")
async def signals(days: int = Query(default=7)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT * FROM trade_signals
WHERE created_at >= NOW() - INTERVAL '%s days'
ORDER BY confidence DESC LIMIT 50
""" % days)
return [dict(r) for r in rows]
@app.get("/signals/{code}")
async def stock_signals(code: str):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT * FROM trade_signals WHERE stock_code=$1
ORDER BY created_at DESC LIMIT 10
""", code)
return [dict(r) for r in rows]
@app.get("/summary/{code}")
async def summary(code: str):
"""종목 종합 요약 (현재가 + 외국인 + 공매도 + 최근 신호)"""
result = {"code": code}
if redis_cl:
for key in [f"price:{code}", f"foreign:{code}", f"short:{code}", f"ohlcv:{code}"]:
c = await redis_cl.get(key)
if c:
field = key.split(":")[0]
result[field] = json.loads(c) if field != "price" else json.loads(c)
return JSONResponse(content=result)
@app.post("/collect/price")
async def collect_price():
asyncio.create_task(job_price())
return {"status": "started", "job": "price"}
@app.post("/collect/full")
async def collect_full(days: int = Query(default=10, ge=1, le=600)):
asyncio.create_task(job_full(days))
return {"status": "started", "job": "full", "days": days}
# ── 추가: 분봉 / 호가 / 거래량급증 ─────────────────────────
@app.get("/minute/{code}")
async def minute_chart(code: str, scope: str = Query(default="5", description="분봉 단위: 1,3,5,10,15,30,60")):
"""ka10080: 분봉차트 (실시간 호출)"""
async with httpx.AsyncClient() as c:
data = await fetch_minute_chart(c, code, scope)
return JSONResponse(content={"code": code, "scope": scope, "data": data})
@app.get("/orderbook/{code}")
async def orderbook(code: str):
"""ka10004: 10단계 호가 잔량"""
async with httpx.AsyncClient() as c:
data = await fetch_orderbook(c, code)
return JSONResponse(content=data or {"code": code, "ask": [], "bid": []})
@app.get("/volume-surge")
async def volume_surge():
"""ka10023: 거래량 급증 종목 TOP50 (Redis 5분 캐시)"""
cached = None
if redis_cl:
try:
v = await redis_cl.get("vol_surge:list")
if v: cached = json.loads(v)
except: pass
if cached:
return JSONResponse(content={"data": cached, "cached": True})
data = await fetch_volume_surge()
if redis_cl and data:
try: await redis_cl.setex("vol_surge:list", 300, json.dumps(data))
except: pass
return JSONResponse(content={"data": data, "cached": False})