Initial commit: stock market platform

This commit is contained in:
admin
2026-06-11 01:41:47 +08:00
commit 63718906e9
62 changed files with 8962 additions and 0 deletions

View File

@@ -0,0 +1 @@

View 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]