Initial commit: stock market platform
This commit is contained in:
332
backend/app/services/stock_service.py
Normal file
332
backend/app/services/stock_service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Stock data service — wraps AKShare with Redis caching.
|
||||
All AKShare calls are blocking I/O; run them in a thread pool via asyncio.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
AK_AVAILABLE = True
|
||||
except Exception:
|
||||
AK_AVAILABLE = False
|
||||
logger.warning("AKShare not available, using mock data")
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_sync(fn, *args, **kwargs):
|
||||
"""Run a sync blocking function in the default thread pool."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||
|
||||
|
||||
# ── market overview ───────────────────────────────────────────────────────────
|
||||
|
||||
MAJOR_INDICES = [
|
||||
("sh000001", "上证指数"),
|
||||
("sz399001", "深证成指"),
|
||||
("sz399006", "创业板指"),
|
||||
("sh000688", "科创50"),
|
||||
("sh000300", "沪深300"),
|
||||
]
|
||||
|
||||
|
||||
async def get_market_overview() -> list[dict]:
|
||||
if not AK_AVAILABLE:
|
||||
return _mock_market_overview()
|
||||
|
||||
try:
|
||||
df = await _run_sync(ak.stock_zh_index_spot_em)
|
||||
result = []
|
||||
code_map = {code: name for code, name in MAJOR_INDICES}
|
||||
for _, row in df.iterrows():
|
||||
code = str(row.get("代码", ""))
|
||||
if code in code_map:
|
||||
result.append({
|
||||
"index_code": code,
|
||||
"index_name": code_map[code],
|
||||
"current": float(row.get("最新价", 0)),
|
||||
"change": float(row.get("涨跌额", 0)),
|
||||
"change_pct": float(row.get("涨跌幅", 0)),
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_market_overview error: {e}")
|
||||
return _mock_market_overview()
|
||||
|
||||
|
||||
# ── real-time quotes (A-share spot) ──────────────────────────────────────────
|
||||
|
||||
async def get_all_stocks_spot() -> list[dict]:
|
||||
"""All A-share real-time quotes — used for heatmap."""
|
||||
if not AK_AVAILABLE:
|
||||
return _mock_heatmap_data()
|
||||
|
||||
try:
|
||||
df = await _run_sync(ak.stock_zh_a_spot_em)
|
||||
result = []
|
||||
for _, row in df.iterrows():
|
||||
pct = float(row.get("涨跌幅", 0) or 0)
|
||||
result.append({
|
||||
"symbol": str(row.get("代码", "")),
|
||||
"name": str(row.get("名称", "")),
|
||||
"price": float(row.get("最新价", 0) or 0),
|
||||
"change": float(row.get("涨跌额", 0) or 0),
|
||||
"change_pct": pct,
|
||||
"open": float(row.get("今开", 0) or 0),
|
||||
"high": float(row.get("最高", 0) or 0),
|
||||
"low": float(row.get("最低", 0) or 0),
|
||||
"prev_close": float(row.get("昨收", 0) or 0),
|
||||
"volume": float(row.get("成交量", 0) or 0),
|
||||
"amount": float(row.get("成交额", 0) or 0),
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_all_stocks_spot error: {e}")
|
||||
return _mock_heatmap_data()
|
||||
|
||||
|
||||
async def get_stock_quote(symbol: str) -> Optional[dict]:
|
||||
"""Single stock real-time quote."""
|
||||
all_stocks = await get_all_stocks_spot()
|
||||
for s in all_stocks:
|
||||
if s["symbol"] == symbol:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
# ── K-line data ───────────────────────────────────────────────────────────────
|
||||
|
||||
PERIOD_MAP = {
|
||||
"daily": "daily",
|
||||
"weekly": "weekly",
|
||||
"monthly": "monthly",
|
||||
}
|
||||
|
||||
|
||||
async def get_kline(symbol: str, period: str = "daily", adjust: str = "qfq", limit: int = 250) -> list[dict]:
|
||||
"""
|
||||
period: daily | weekly | monthly
|
||||
adjust: qfq (前复权) | hfq (后复权) | "" (不复权)
|
||||
"""
|
||||
if not AK_AVAILABLE:
|
||||
return _mock_kline(limit)
|
||||
|
||||
ak_period = PERIOD_MAP.get(period, "daily")
|
||||
|
||||
try:
|
||||
df = await _run_sync(
|
||||
ak.stock_zh_a_hist,
|
||||
symbol=symbol,
|
||||
period=ak_period,
|
||||
adjust=adjust,
|
||||
)
|
||||
df = df.tail(limit)
|
||||
result = []
|
||||
for _, row in df.iterrows():
|
||||
result.append({
|
||||
"date": str(row.get("日期", "")),
|
||||
"open": float(row.get("开盘", 0)),
|
||||
"high": float(row.get("最高", 0)),
|
||||
"low": float(row.get("最低", 0)),
|
||||
"close": float(row.get("收盘", 0)),
|
||||
"volume": float(row.get("成交量", 0)),
|
||||
"amount": float(row.get("成交额", 0)),
|
||||
"change_pct": float(row.get("涨跌幅", 0)),
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_kline {symbol} {period} error: {e}")
|
||||
return _mock_kline(limit)
|
||||
|
||||
|
||||
async def get_intraday(symbol: str) -> list[dict]:
|
||||
"""Today's minute-level data."""
|
||||
if not AK_AVAILABLE:
|
||||
return _mock_intraday()
|
||||
|
||||
try:
|
||||
df = await _run_sync(ak.stock_zh_a_hist_min_em, symbol=symbol, period="1", adjust="")
|
||||
result = []
|
||||
for _, row in df.iterrows():
|
||||
result.append({
|
||||
"time": str(row.get("时间", "")),
|
||||
"price": float(row.get("收盘", 0)),
|
||||
"volume": float(row.get("成交量", 0)),
|
||||
"amount": float(row.get("成交额", 0)),
|
||||
"avg_price": float(row.get("均价", 0) or 0),
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_intraday {symbol} error: {e}")
|
||||
return _mock_intraday()
|
||||
|
||||
|
||||
async def get_five_day(symbol: str) -> list[dict]:
|
||||
"""5-day minute-level data."""
|
||||
if not AK_AVAILABLE:
|
||||
return _mock_intraday(days=5)
|
||||
|
||||
try:
|
||||
df = await _run_sync(ak.stock_zh_a_hist_min_em, symbol=symbol, period="1", adjust="")
|
||||
result = []
|
||||
for _, row in df.iterrows():
|
||||
result.append({
|
||||
"time": str(row.get("时间", "")),
|
||||
"price": float(row.get("收盘", 0)),
|
||||
"volume": float(row.get("成交量", 0)),
|
||||
"amount": float(row.get("成交额", 0) or 0),
|
||||
"avg_price": float(row.get("均价", 0) or 0),
|
||||
})
|
||||
return result[-5 * 240:]
|
||||
except Exception as e:
|
||||
logger.error(f"get_five_day {symbol} error: {e}")
|
||||
return _mock_intraday(days=5)
|
||||
|
||||
|
||||
# ── search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_stock_list_cached() -> list[dict]:
|
||||
"""Cache the full stock list in memory (refreshed on process restart)."""
|
||||
if not AK_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
import akshare as ak
|
||||
df = ak.stock_info_a_code_name()
|
||||
return [{"symbol": str(r["code"]), "name": str(r["name"]), "market": "A股"} for _, r in df.iterrows()]
|
||||
except Exception as e:
|
||||
logger.error(f"_get_stock_list_cached error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def search_stocks(query: str, limit: int = 20) -> list[dict]:
|
||||
stock_list = await _run_sync(_get_stock_list_cached)
|
||||
query = query.strip().lower()
|
||||
results = [
|
||||
s for s in stock_list
|
||||
if query in s["symbol"].lower() or query in s["name"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
|
||||
# ── sector data for heatmap ───────────────────────────────────────────────────
|
||||
|
||||
async def get_sector_spot() -> list[dict]:
|
||||
"""Board/sector change pct for heatmap grouping."""
|
||||
if not AK_AVAILABLE:
|
||||
return _mock_sectors()
|
||||
|
||||
try:
|
||||
df = await _run_sync(ak.stock_board_industry_name_em)
|
||||
result = []
|
||||
for _, row in df.iterrows():
|
||||
result.append({
|
||||
"sector": str(row.get("板块名称", "")),
|
||||
"change_pct": float(row.get("涨跌幅", 0) or 0),
|
||||
"volume": float(row.get("成交量", 0) or 0),
|
||||
"amount": float(row.get("成交额", 0) or 0),
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"get_sector_spot error: {e}")
|
||||
return _mock_sectors()
|
||||
|
||||
|
||||
# ── mock data (fallback when market is closed or AKShare unavailable) ─────────
|
||||
|
||||
import random
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def _mock_market_overview() -> list[dict]:
|
||||
return [
|
||||
{"index_code": "sh000001", "index_name": "上证指数", "current": 3312.46, "change": -28.84, "change_pct": -0.86},
|
||||
{"index_code": "sz399001", "index_name": "深证成指", "current": 10573.99, "change": -93.16, "change_pct": -0.87},
|
||||
{"index_code": "sz399006", "index_name": "创业板指", "current": 2105.37, "change": -18.42, "change_pct": -0.87},
|
||||
{"index_code": "sh000688", "index_name": "科创50", "current": 968.12, "change": -9.56, "change_pct": -0.98},
|
||||
{"index_code": "sh000300", "index_name": "沪深300", "current": 3843.20, "change": -30.11, "change_pct": -0.78},
|
||||
]
|
||||
|
||||
|
||||
def _mock_heatmap_data() -> list[dict]:
|
||||
sectors = ["银行", "电力设备", "食品饮料", "医药生物", "电子", "汽车", "非银金融", "计算机", "有色金属", "化工"]
|
||||
stocks = []
|
||||
for i in range(80):
|
||||
pct = round(random.uniform(-5, 5), 2)
|
||||
sector = sectors[i % len(sectors)]
|
||||
stocks.append({
|
||||
"symbol": f"{600000 + i:06d}",
|
||||
"name": f"测试股票{i+1:02d}",
|
||||
"price": round(random.uniform(5, 100), 2),
|
||||
"change": round(pct * 0.1, 2),
|
||||
"change_pct": pct,
|
||||
"open": round(random.uniform(5, 100), 2),
|
||||
"high": round(random.uniform(5, 100), 2),
|
||||
"low": round(random.uniform(5, 100), 2),
|
||||
"prev_close": round(random.uniform(5, 100), 2),
|
||||
"volume": random.randint(100000, 10000000),
|
||||
"amount": random.randint(1000000, 100000000),
|
||||
"sector": sector,
|
||||
})
|
||||
return stocks
|
||||
|
||||
|
||||
def _mock_kline(limit: int = 250) -> list[dict]:
|
||||
bars = []
|
||||
price = 20.0
|
||||
today = date.today()
|
||||
for i in range(limit):
|
||||
d = today - timedelta(days=limit - i)
|
||||
pct = random.uniform(-0.05, 0.05)
|
||||
close = round(price * (1 + pct), 2)
|
||||
high = round(max(price, close) * random.uniform(1.0, 1.03), 2)
|
||||
low = round(min(price, close) * random.uniform(0.97, 1.0), 2)
|
||||
bars.append({
|
||||
"date": d.isoformat(),
|
||||
"open": round(price, 2),
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"volume": random.randint(500000, 5000000),
|
||||
"amount": random.randint(5000000, 50000000),
|
||||
"change_pct": round(pct * 100, 2),
|
||||
})
|
||||
price = close
|
||||
return bars
|
||||
|
||||
|
||||
def _mock_intraday(days: int = 1) -> list[dict]:
|
||||
bars = []
|
||||
price = 20.0
|
||||
for d in range(days):
|
||||
for minute in range(240):
|
||||
h = 9 + minute // 60
|
||||
m = (minute % 60) + (30 if d == 0 and minute < 60 else 0)
|
||||
if h == 11 and m >= 30:
|
||||
continue
|
||||
pct = random.uniform(-0.01, 0.01)
|
||||
price = round(price * (1 + pct), 2)
|
||||
bars.append({
|
||||
"time": f"2026-06-0{d+1} {h:02d}:{m % 60:02d}",
|
||||
"price": price,
|
||||
"volume": random.randint(10000, 500000),
|
||||
"amount": random.randint(100000, 5000000),
|
||||
"avg_price": price,
|
||||
})
|
||||
return bars
|
||||
|
||||
|
||||
def _mock_sectors() -> list[dict]:
|
||||
sectors = [
|
||||
"银行", "电力设备", "食品饮料", "医药生物", "电子",
|
||||
"汽车", "非银金融", "计算机", "有色金属", "化工",
|
||||
"机械设备", "建筑材料", "传媒", "房地产", "交通运输",
|
||||
]
|
||||
return [{"sector": s, "change_pct": round(random.uniform(-3, 3), 2), "volume": 0, "amount": 0}
|
||||
for s in sectors]
|
||||
Reference in New Issue
Block a user