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:
+902
@@ -0,0 +1,902 @@
|
||||
"""
|
||||
키움증권 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})
|
||||
Reference in New Issue
Block a user