up
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps for akshare (pandas/numpy) and network requests
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# SQLite db will be created at runtime in /app
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,65 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "stock_data.db")
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS stock_collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES stock_collections(id) ON DELETE CASCADE,
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL,
|
||||
added_price REAL,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(collection_id, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES stock_collections(id) ON DELETE CASCADE,
|
||||
short_code TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_connection()
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
|
||||
# 清理过期缓存
|
||||
from services.cache import clean_expired
|
||||
clean_expired()
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def dict_from_row(row: sqlite3.Row) -> dict:
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database import init_db
|
||||
from routes import stock, collections, shares, sectors
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="A股走势追踪 API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(stock.router, prefix="/api/stock")
|
||||
app.include_router(collections.router, prefix="/api/collections")
|
||||
app.include_router(shares.router, prefix="/api/share")
|
||||
app.include_router(sectors.router, prefix="/api/sectors")
|
||||
@@ -0,0 +1,121 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class StockQuote(BaseModel):
|
||||
code: str
|
||||
market: str
|
||||
name: str
|
||||
todayOpen: float
|
||||
yesterdayClose: float
|
||||
currentPrice: float
|
||||
high: float
|
||||
low: float
|
||||
volume: float
|
||||
amount: float
|
||||
outerDisk: float
|
||||
innerDisk: float
|
||||
date: str
|
||||
time: str
|
||||
change: float
|
||||
changePercent: float
|
||||
|
||||
|
||||
class KLineData(BaseModel):
|
||||
date: str
|
||||
open: float
|
||||
close: float
|
||||
high: float
|
||||
low: float
|
||||
volume: float
|
||||
|
||||
|
||||
class StockSearchResult(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
market: str
|
||||
type: str
|
||||
|
||||
|
||||
class FundFlowData(BaseModel):
|
||||
date: str
|
||||
mainNetInflow: float
|
||||
superLargeInflow: float
|
||||
superLargeOutflow: float
|
||||
largeInflow: float
|
||||
largeOutflow: float
|
||||
mediumInflow: float
|
||||
mediumOutflow: float
|
||||
smallInflow: float
|
||||
smallOutflow: float
|
||||
mainNetInflowPercent: float
|
||||
superLargeInflowPercent: float
|
||||
superLargeOutflowPercent: float
|
||||
largeInflowPercent: float
|
||||
largeOutflowPercent: float
|
||||
mediumInflowPercent: float
|
||||
mediumOutflowPercent: float
|
||||
smallInflowPercent: float
|
||||
smallOutflowPercent: float
|
||||
turnover: float
|
||||
mainForceNet: float
|
||||
retailNet: float
|
||||
cumulativeMainNet: float
|
||||
cumulativeRetailNet: float
|
||||
changePercent: float
|
||||
closePrice: float
|
||||
|
||||
|
||||
class FundFlowSummary(BaseModel):
|
||||
totalMainNet: float
|
||||
totalTurnover: float
|
||||
totalLargeInflow: Optional[float] = 0
|
||||
avgDailyMainNet: float
|
||||
positiveDays: int
|
||||
negativeDays: int
|
||||
|
||||
|
||||
class StockCollection(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
user_id: str
|
||||
last_accessed_at: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class StockCollectionCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
user_id: str
|
||||
|
||||
|
||||
class StockCollectionUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
last_accessed_at: Optional[str] = None
|
||||
|
||||
|
||||
class CollectionStock(BaseModel):
|
||||
id: int
|
||||
collection_id: int
|
||||
stock_code: str
|
||||
stock_name: str
|
||||
added_price: Optional[float] = None
|
||||
added_at: str
|
||||
|
||||
|
||||
class CollectionStockCreate(BaseModel):
|
||||
stock_code: str
|
||||
stock_name: str
|
||||
added_price: Optional[float] = None
|
||||
|
||||
|
||||
class ShareLink(BaseModel):
|
||||
id: int
|
||||
collection_id: int
|
||||
short_code: str
|
||||
created_at: str
|
||||
expires_at: Optional[str] = None
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.0
|
||||
httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
akshare==1.18.64
|
||||
@@ -0,0 +1,190 @@
|
||||
"""股票集合 CRUD 路由"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
user_id: str
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
last_accessed_at: Optional[str] = None
|
||||
|
||||
|
||||
class StockAdd(BaseModel):
|
||||
stock_code: str
|
||||
stock_name: str
|
||||
added_price: Optional[float] = None
|
||||
|
||||
|
||||
@router.get("", summary="列表集合")
|
||||
async def list_collections(user_id: str = ""):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM stock_collections WHERE user_id = ? OR user_id = '' ORDER BY created_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
collections = [dict_from_row(r) for r in rows]
|
||||
|
||||
# 更新空 user_id 的记录
|
||||
for c in collections:
|
||||
if not c["user_id"]:
|
||||
conn.execute("UPDATE stock_collections SET user_id = ? WHERE id = ?", (user_id, c["id"]))
|
||||
conn.commit()
|
||||
return collections
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("", summary="创建集合", status_code=201)
|
||||
async def create_collection(body: CollectionCreate):
|
||||
from datetime import datetime
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO stock_collections (name, description, user_id, last_accessed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(body.name, body.description, body.user_id, now, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM stock_collections WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return dict_from_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{collection_id}", summary="获取集合详情")
|
||||
async def get_collection(collection_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM stock_collections WHERE id = ?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="集合不存在")
|
||||
return dict_from_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/{collection_id}", summary="更新集合")
|
||||
async def update_collection(collection_id: int, body: CollectionUpdate):
|
||||
conn = get_connection()
|
||||
try:
|
||||
existing = conn.execute("SELECT * FROM stock_collections WHERE id = ?", (collection_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="集合不存在")
|
||||
|
||||
updates = {}
|
||||
if body.name is not None:
|
||||
updates["name"] = body.name
|
||||
if body.description is not None:
|
||||
updates["description"] = body.description
|
||||
if body.user_id is not None:
|
||||
updates["user_id"] = body.user_id
|
||||
if body.last_accessed_at is not None:
|
||||
updates["last_accessed_at"] = body.last_accessed_at
|
||||
|
||||
if updates:
|
||||
from datetime import datetime
|
||||
updates["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values())
|
||||
values.append(collection_id)
|
||||
conn.execute(f"UPDATE stock_collections SET {set_clause} WHERE id = ?", values)
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute("SELECT * FROM stock_collections WHERE id = ?", (collection_id,)).fetchone()
|
||||
return dict_from_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/{collection_id}", summary="删除集合")
|
||||
async def delete_collection(collection_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
existing = conn.execute("SELECT * FROM stock_collections WHERE id = ?", (collection_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="集合不存在")
|
||||
conn.execute("DELETE FROM stock_collections WHERE id = ?", (collection_id,))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{collection_id}/stocks", summary="列表集合中的股票")
|
||||
async def list_stocks(collection_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_stocks WHERE collection_id = ? ORDER BY added_at DESC",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [dict_from_row(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{collection_id}/stocks", summary="添加股票到集合", status_code=201)
|
||||
async def add_stock(collection_id: int, body: StockAdd):
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 检查集合存在
|
||||
coll = conn.execute("SELECT id FROM stock_collections WHERE id = ?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="集合不存在")
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_stocks (collection_id, stock_code, stock_name, added_price) VALUES (?, ?, ?, ?)",
|
||||
(collection_id, body.stock_code, body.stock_name, body.added_price),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collection_stocks WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return dict_from_row(row)
|
||||
except Exception as e:
|
||||
if "UNIQUE constraint failed" in str(e):
|
||||
raise HTTPException(status_code=409, detail="该股票已在集合中")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/stock/{stock_code}", summary="在所有集合中查找股票记录")
|
||||
async def find_stock(stock_code: str):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM collection_stocks WHERE stock_code = ? ORDER BY added_at DESC LIMIT 1",
|
||||
(stock_code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return dict_from_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/stocks/{stock_code}", summary="从集合移除股票")
|
||||
async def remove_stock(collection_id: int, stock_code: str):
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM collection_stocks WHERE collection_id = ? AND stock_code = ?",
|
||||
(collection_id, stock_code),
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""板块数据路由:行业板块、概念板块"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from services import eastmoney
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", summary="板块列表")
|
||||
async def sector_list(
|
||||
type: str = Query("industry", description="板块类型:industry=行业板块, concept=概念板块"),
|
||||
):
|
||||
if type not in ("industry", "concept"):
|
||||
raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept")
|
||||
|
||||
data = await eastmoney.fetch_sector_list(type)
|
||||
return {"data": data, "count": len(data), "type": type}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""分享链接管理路由"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import get_connection, dict_from_row
|
||||
import random
|
||||
import string
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{short_code}", summary="获取分享链接信息")
|
||||
async def get_share(short_code: str):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM share_links WHERE short_code = ?",
|
||||
(short_code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="分享链接无效")
|
||||
share = dict_from_row(row)
|
||||
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM stock_collections WHERE id = ?",
|
||||
(share["collection_id"],),
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="集合不存在")
|
||||
|
||||
stocks = conn.execute(
|
||||
"SELECT * FROM collection_stocks WHERE collection_id = ? ORDER BY added_at DESC",
|
||||
(share["collection_id"],),
|
||||
).fetchall()
|
||||
|
||||
coll_data = dict_from_row(collection)
|
||||
coll_data["stocks"] = [dict_from_row(s) for s in stocks]
|
||||
return coll_data
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class ShareCreate(BaseModel):
|
||||
collection_id: int
|
||||
|
||||
|
||||
def generate_short_code(length: int = 8) -> str:
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
|
||||
|
||||
|
||||
@router.post("", summary="创建分享链接", status_code=201)
|
||||
async def create_share(body: ShareCreate):
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 检查集合存在
|
||||
coll = conn.execute("SELECT id FROM stock_collections WHERE id = ?", (body.collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="集合不存在")
|
||||
|
||||
# 查询是否已有分享链接
|
||||
existing = conn.execute(
|
||||
"SELECT short_code FROM share_links WHERE collection_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
(body.collection_id,),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
return {"short_code": existing["short_code"]}
|
||||
|
||||
# 生成新链接
|
||||
for _ in range(10):
|
||||
short_code = generate_short_code()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO share_links (collection_id, short_code) VALUES (?, ?)",
|
||||
(body.collection_id, short_code),
|
||||
)
|
||||
conn.commit()
|
||||
return {"short_code": short_code}
|
||||
except Exception:
|
||||
continue
|
||||
raise HTTPException(status_code=500, detail="生成分享链接失败")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,215 @@
|
||||
"""股票数据路由:搜索、行情、K线、资金流向"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
import re
|
||||
import math
|
||||
|
||||
from services import tencent, sina, eastmoney
|
||||
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/search", summary="股票搜索")
|
||||
async def search_stock(keyword: str = Query(..., description="关键词:名称/代码/拼音")):
|
||||
keyword = keyword.strip()
|
||||
if not keyword:
|
||||
return {"data": [], "count": 0}
|
||||
|
||||
results = await tencent.search_stock(keyword)
|
||||
|
||||
# 降级:搜索无结果但 keyword 是 6 位数字代码,直接查行情接口
|
||||
if not results and re.match(r"^\d{6}$", keyword):
|
||||
direct = await tencent.lookup_by_quote(keyword)
|
||||
if direct:
|
||||
results = [direct]
|
||||
|
||||
# 去重
|
||||
seen = set()
|
||||
unique = []
|
||||
for r in results:
|
||||
if r["code"] not in seen:
|
||||
seen.add(r["code"])
|
||||
unique.append(r)
|
||||
|
||||
limited = unique[:20]
|
||||
return {"data": limited, "count": len(limited)}
|
||||
|
||||
|
||||
@router.get("/quote", summary="实时行情")
|
||||
async def stock_quote(code: str = Query(..., description="6位股票代码")):
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
|
||||
data = await tencent.fetch_quote(code)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="股票不存在或已停牌")
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.get("/history", summary="历史K线")
|
||||
async def stock_history(
|
||||
code: str = Query(..., description="6位股票代码"),
|
||||
days: int = Query(90, description="天数"),
|
||||
):
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
|
||||
# 主数据源:腾讯
|
||||
klines = await tencent.fetch_history(code, days)
|
||||
if len(klines) >= 2:
|
||||
return {"data": klines, "count": len(klines), "source": "tencent"}
|
||||
|
||||
# 降级:新浪
|
||||
sina_klines = await sina.fetch_history(code, days)
|
||||
if sina_klines:
|
||||
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
|
||||
|
||||
if klines:
|
||||
return {"data": klines, "count": len(klines), "source": "tencent"}
|
||||
|
||||
raise HTTPException(status_code=404, detail="未获取到K线数据")
|
||||
|
||||
|
||||
@router.get("/fund-flow", summary="资金流向")
|
||||
async def stock_fund_flow(
|
||||
code: str = Query(..., description="6位股票代码"),
|
||||
name: str = Query("", description="股票名称"),
|
||||
days: int = Query(21, description="目标天数"),
|
||||
):
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
|
||||
# 获取股票名称(如果未提供)
|
||||
stock_name = name
|
||||
if not stock_name:
|
||||
quote = await tencent.fetch_quote(code)
|
||||
if quote:
|
||||
stock_name = quote.get("name", "")
|
||||
|
||||
# MX 请求 90 天以获取尽可能多的数据,最终只取最近 days 条
|
||||
fetch_days = max(90, days)
|
||||
|
||||
# 数据源1:MX API
|
||||
mx_data = None
|
||||
if stock_name:
|
||||
mx_data = await eastmoney.fetch_mx_api(stock_name, fetch_days)
|
||||
|
||||
# 数据源2:push2his 补充(MX 数据不足时尝试)
|
||||
push2his_data = None
|
||||
need_more = not mx_data or len(mx_data) < days
|
||||
if need_more:
|
||||
push2his_data = await eastmoney.fetch_push2his(code, fetch_days)
|
||||
|
||||
# 数据源3:腾讯K线(合并收盘价、涨跌幅)
|
||||
kline_map = await tencent.fetch_kline_map(code, fetch_days)
|
||||
|
||||
# ---- 解析 ----
|
||||
|
||||
def _build_entry(date: str, main_net: float, turnover: float, force_net: float,
|
||||
super_large: float, large: float, retail: float, pcts: dict) -> dict:
|
||||
ki = kline_map.get(date, {})
|
||||
return {
|
||||
"date": date,
|
||||
"mainNetInflow": main_net,
|
||||
"superLargeInflow": max(0, super_large),
|
||||
"superLargeOutflow": abs(min(0, super_large)),
|
||||
"largeInflow": max(0, large),
|
||||
"largeOutflow": abs(min(0, large)),
|
||||
"mediumInflow": 0,
|
||||
"mediumOutflow": 0,
|
||||
"smallInflow": 0,
|
||||
"smallOutflow": 0,
|
||||
"mainNetInflowPercent": pcts.get("main", 0),
|
||||
"superLargeInflowPercent": pcts.get("superLarge", 0),
|
||||
"superLargeOutflowPercent": pcts.get("superLargeOut", 0),
|
||||
"largeInflowPercent": pcts.get("large", 0),
|
||||
"largeOutflowPercent": pcts.get("largeOut", 0),
|
||||
"mediumInflowPercent": pcts.get("medium", 0),
|
||||
"mediumOutflowPercent": pcts.get("mediumOut", 0),
|
||||
"smallInflowPercent": pcts.get("small", 0),
|
||||
"smallOutflowPercent": pcts.get("smallOut", 0),
|
||||
"turnover": turnover,
|
||||
"mainForceNet": force_net,
|
||||
"retailNet": retail,
|
||||
"changePercent": ki.get("changePercent", 0),
|
||||
"closePrice": ki.get("close", 0),
|
||||
}
|
||||
|
||||
parsed_data = []
|
||||
|
||||
# 先解析 MX 数据(主力净额准确)
|
||||
if mx_data:
|
||||
mx_data.sort(key=lambda x: x["date"])
|
||||
for item in mx_data:
|
||||
main_net = item["mainNetInflow"]
|
||||
main_force = main_net
|
||||
super_large = main_net * 0.4
|
||||
large = main_net * 0.6
|
||||
parsed_data.append(_build_entry(
|
||||
item["date"], main_net, item["amount"], main_force,
|
||||
super_large, large, 0, {},
|
||||
))
|
||||
|
||||
# 补全 push2his 数据(f52=主力净流入为权威值)
|
||||
if push2his_data:
|
||||
existing_dates = {e["date"] for e in parsed_data}
|
||||
for line in push2his_data:
|
||||
fields = line.split(",")
|
||||
if len(fields) < 11:
|
||||
continue
|
||||
date = fields[0]
|
||||
if date in existing_dates:
|
||||
continue
|
||||
ki = kline_map.get(date, {})
|
||||
def f(i): return float(fields[i]) if fields[i] else 0
|
||||
main_force = f(1) # f52 主力净流入(权威值,与 MX 口径一致)
|
||||
super_large_net = main_force * 0.4
|
||||
large_net = main_force * 0.6
|
||||
retail = f(4) + f(5) # 中单+小单净流入
|
||||
pcts = {
|
||||
"main": f(6), "superLarge": f(7), "superLargeOut": 0,
|
||||
"large": f(8), "largeOut": 0, "medium": f(9),
|
||||
"mediumOut": 0, "small": f(10), "smallOut": 0,
|
||||
}
|
||||
parsed_data.append(_build_entry(
|
||||
date, main_force, ki.get("turnover", 0), main_force,
|
||||
super_large_net, large_net, retail, pcts,
|
||||
))
|
||||
|
||||
if not parsed_data:
|
||||
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
|
||||
|
||||
# 统一排序,只保留最新 days 条
|
||||
parsed_data.sort(key=lambda x: x["date"])
|
||||
if len(parsed_data) > days:
|
||||
parsed_data = parsed_data[-days:]
|
||||
|
||||
# 重新计算累计值
|
||||
cumulative_main = 0
|
||||
cumulative_retail = 0
|
||||
for d in parsed_data:
|
||||
cumulative_main += d["mainForceNet"]
|
||||
cumulative_retail += d["retailNet"]
|
||||
d["cumulativeMainNet"] = cumulative_main
|
||||
d["cumulativeRetailNet"] = cumulative_retail
|
||||
|
||||
total_main = sum(d["mainForceNet"] for d in parsed_data)
|
||||
total_turnover = sum(d["turnover"] for d in parsed_data)
|
||||
total_large_inflow = sum(d["largeInflow"] for d in parsed_data)
|
||||
positive = sum(1 for d in parsed_data if d["mainForceNet"] > 0)
|
||||
negative = sum(1 for d in parsed_data if d["mainForceNet"] < 0)
|
||||
|
||||
return {
|
||||
"data": parsed_data,
|
||||
"count": len(parsed_data),
|
||||
"stockCode": code,
|
||||
"summary": {
|
||||
"totalMainNet": total_main,
|
||||
"totalTurnover": total_turnover,
|
||||
"totalLargeInflow": total_large_inflow,
|
||||
"avgDailyMainNet": total_main / len(parsed_data) if parsed_data else 0,
|
||||
"positiveDays": positive,
|
||||
"negativeDays": negative,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""磁盘缓存:SQLite 持久化,重启不丢"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
|
||||
|
||||
def get_cache(key: str) -> Optional[str]:
|
||||
"""获取缓存,过期或不存在返回 None"""
|
||||
now = datetime.now().isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM cache WHERE key = ? AND expires_at > ?",
|
||||
(key, now),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["value"]
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_cache(key: str, value: str, ttl_hours: int = 6):
|
||||
"""写入缓存,过期时间 = now + ttl_hours"""
|
||||
expires_at = (datetime.now() + timedelta(hours=ttl_hours)).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
|
||||
(key, value, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def del_cache(key: str):
|
||||
"""删除指定缓存"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clean_expired():
|
||||
"""清理已过期的缓存(可在启动时调用一次)"""
|
||||
now = datetime.now().isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM cache WHERE expires_at <= ?", (now,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,403 @@
|
||||
"""东方财富 API 客户端(资金流向数据)"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
# ---- API Key 轮询 ----
|
||||
|
||||
_api_keys: List[str] = []
|
||||
_key_index: int = -1
|
||||
|
||||
|
||||
def _load_api_keys() -> List[str]:
|
||||
"""收集所有可用 MX API key(支持逗号分隔和 MX_APIKEY_1 编号后缀)"""
|
||||
keys: List[str] = []
|
||||
seen: set = set()
|
||||
def add(k: str):
|
||||
k = k.strip()
|
||||
if k and k not in seen:
|
||||
seen.add(k)
|
||||
keys.append(k)
|
||||
raw = os.environ.get("MX_APIKEY", "")
|
||||
if raw:
|
||||
for k in raw.split(","):
|
||||
add(k)
|
||||
for i in range(1, 10):
|
||||
raw = os.environ.get(f"MX_APIKEY_{i}", "")
|
||||
if raw:
|
||||
for k in raw.split(","):
|
||||
add(k)
|
||||
return keys
|
||||
|
||||
|
||||
def _ensure_keys() -> bool:
|
||||
global _api_keys, _key_index
|
||||
if not _api_keys:
|
||||
_api_keys = _load_api_keys()
|
||||
if _key_index == -1:
|
||||
val = get_cache("_mx_key_index")
|
||||
_key_index = int(val) if val else 0
|
||||
return bool(_api_keys)
|
||||
|
||||
|
||||
def _get_key() -> str:
|
||||
"""获取当前使用的 API key"""
|
||||
if not _ensure_keys():
|
||||
return ""
|
||||
return _api_keys[_key_index % len(_api_keys)]
|
||||
|
||||
|
||||
def _rotate_key():
|
||||
"""切换到下一个 key 并持久化当前索引"""
|
||||
global _key_index
|
||||
_key_index += 1
|
||||
set_cache("_mx_key_index", str(_key_index), ttl_hours=24)
|
||||
|
||||
|
||||
# ---- 工具函数 ----
|
||||
|
||||
|
||||
def get_eastmoney_market(code: str) -> str:
|
||||
"""获取东方财富格式的市场标识"""
|
||||
if code.startswith("688"):
|
||||
return "6"
|
||||
if code.startswith("60"):
|
||||
return "1"
|
||||
return "0"
|
||||
|
||||
|
||||
def parse_amount(text) -> float:
|
||||
"""解析金额文本(支持 1.432亿元, -9915万元, 0元)"""
|
||||
if isinstance(text, (int, float)):
|
||||
return float(text)
|
||||
if not text or not isinstance(text, str):
|
||||
return 0
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0
|
||||
sign = -1 if text.startswith("-") else 1
|
||||
clean = text.lstrip("+-").strip()
|
||||
m = re.match(r"^([\d.]+)\s*(亿|万|元)?$", clean)
|
||||
if not m:
|
||||
return 0
|
||||
value = float(m.group(1)) if m.group(1) else 0
|
||||
unit = m.group(2) or "元"
|
||||
if unit == "亿":
|
||||
return sign * value * 100000000
|
||||
elif unit == "万":
|
||||
return sign * value * 10000
|
||||
return sign * value
|
||||
|
||||
|
||||
# ---- MX API ----
|
||||
|
||||
|
||||
async def _call_mx_api(api_key: str, name: str, days: int) -> Optional[dict]:
|
||||
"""执行一次 MX API 调用,返回原始 JSON"""
|
||||
url = "https://mkapi2.dfcfs.com/finskillshub/api/claw/query"
|
||||
payload = {"toolQuery": f"{name}最近{days}天主力资金流向和成交额"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"apikey": api_key,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] MX API error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mx_response(result: dict) -> Optional[List[dict]]:
|
||||
"""解析 MX API 返回,提取资金流向数据列表"""
|
||||
if result.get("status") != 0:
|
||||
return None
|
||||
dto_list = result.get("data", {}).get("data", {}).get("searchDataResultDTO", {}).get("dataTableDTOList", [])
|
||||
if not dto_list:
|
||||
return None
|
||||
dto = dto_list[0]
|
||||
name_map = dto.get("nameMap", {})
|
||||
raw_table = dto.get("rawTable", {})
|
||||
table = dto.get("table", {})
|
||||
dates = table.get("headName", [])
|
||||
if not dates:
|
||||
return None
|
||||
|
||||
name_to_id = {}
|
||||
for kid, v in name_map.items():
|
||||
if isinstance(v, str):
|
||||
name_to_id[v] = kid
|
||||
|
||||
main_net_col = name_to_id.get("(区间)主力净流入资金")
|
||||
amount_col = name_to_id.get("区间成交额")
|
||||
data_list = []
|
||||
|
||||
for i, date_str in enumerate(dates):
|
||||
date_str = str(date_str).strip()
|
||||
m1 = re.match(r"^(\d{4})[/-](\d{1,2})[/-](\d{1,2})", date_str)
|
||||
if m1:
|
||||
date_str = f"{m1.group(1)}-{int(m1.group(2)):02d}-{int(m1.group(3)):02d}"
|
||||
else:
|
||||
m2 = re.match(r"^(\d{1,2})[/-](\d{1,2})", date_str)
|
||||
if m2:
|
||||
year = datetime.now().year
|
||||
date_str = f"{year}-{int(m2.group(1)):02d}-{int(m2.group(2)):02d}"
|
||||
else:
|
||||
continue
|
||||
|
||||
main_net = 0
|
||||
if main_net_col and raw_table.get(main_net_col) and i < len(raw_table[main_net_col]):
|
||||
main_net = parse_amount(raw_table[main_net_col][i])
|
||||
|
||||
amount = 0
|
||||
if amount_col and raw_table.get(amount_col) and i < len(raw_table[amount_col]):
|
||||
amount = parse_amount(raw_table[amount_col][i])
|
||||
|
||||
data_list.append({"date": date_str, "mainNetInflow": main_net, "amount": amount})
|
||||
|
||||
return data_list
|
||||
|
||||
|
||||
async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
|
||||
"""调用东方财富妙想MX API获取资金流向(缓存6小时,多key轮询)"""
|
||||
cache_key = f"mx_fund_flow:{name}:{days}"
|
||||
|
||||
# 缓存命中
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
# 多 key 轮询:按序尝试,遇到 113(超限) 自动切下一个 key
|
||||
if not _ensure_keys():
|
||||
return None
|
||||
|
||||
for attempt in range(len(_api_keys)):
|
||||
key = _get_key()
|
||||
result = await _call_mx_api(key, name, days)
|
||||
if result is None:
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
status = result.get("status", -1)
|
||||
if status == 113:
|
||||
print(f"[eastmoney] key {_key_index % len(_api_keys)} 已达每日上限,切换到下一个")
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
if status == 114:
|
||||
print(f"[eastmoney] key {_key_index % len(_api_keys)} 无效(114),跳过")
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
if status != 0:
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
# 成功
|
||||
data_list = _parse_mx_response(result)
|
||||
if data_list:
|
||||
set_cache(cache_key, json.dumps(data_list, ensure_ascii=False))
|
||||
return data_list
|
||||
|
||||
print(f"[eastmoney] 所有 {len(_api_keys)} 个 MX API key 均已耗尽")
|
||||
return None
|
||||
|
||||
|
||||
# ---- 板块数据 ----
|
||||
|
||||
# 东方财富板块类型映射
|
||||
SECTOR_TYPE_MAP = {
|
||||
"industry": "m:90+t:2", # 行业板块
|
||||
"concept": "m:90+t:3", # 概念板块
|
||||
}
|
||||
|
||||
# 板块列表字段:f12=代码, f14=名称, f3=涨跌幅%, f62=主力净流入, f184=主力净流入占比
|
||||
# f66=超大单净流入, f69=超大单净流入占比, f70=成交额, f78=小单净流入
|
||||
SECTOR_FIELDS = "f12,f14,f2,f3,f4,f62,f184,f66,f69,f70,f78"
|
||||
|
||||
|
||||
# ---- 板块数据(通过 akshare 调用同花顺数据源)----
|
||||
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
|
||||
|
||||
async def fetch_sector_list(sector_type: str) -> list[dict]:
|
||||
"""
|
||||
获取板块资金流向数据
|
||||
sector_type: "industry" 或 "concept"
|
||||
返回按主力净流入降序排列的板块列表
|
||||
编码优先东方财富(BKxxxx),降级同花顺(6位数字)
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _get_data():
|
||||
# 1) 板块编码映射:东方财富 BK 编码 → 降级同花顺编码
|
||||
code_map = {}
|
||||
try:
|
||||
if sector_type == "industry":
|
||||
code_df = ak.stock_board_industry_name_em()
|
||||
else:
|
||||
code_df = ak.stock_board_concept_name_em()
|
||||
if code_df is not None and not code_df.empty:
|
||||
for _, r in code_df.iterrows():
|
||||
code_map[str(r.get("f14", ""))] = str(r.get("f12", ""))
|
||||
except Exception:
|
||||
try:
|
||||
if sector_type == "industry":
|
||||
code_df = ak.stock_board_industry_name_ths()
|
||||
else:
|
||||
code_df = ak.stock_board_concept_name_ths()
|
||||
if code_df is not None and not code_df.empty:
|
||||
for _, r in code_df.iterrows():
|
||||
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) 资金流向(10jqka 同花顺数据源)
|
||||
if sector_type == "industry":
|
||||
df = ak.stock_fund_flow_industry()
|
||||
else:
|
||||
df = ak.stock_fund_flow_concept()
|
||||
|
||||
return code_map, df
|
||||
|
||||
try:
|
||||
code_map, df = await loop.run_in_executor(None, _get_data)
|
||||
|
||||
if df is None or df.empty:
|
||||
return []
|
||||
|
||||
df = df.sort_values("净额", ascending=False)
|
||||
|
||||
items = []
|
||||
for _, row in df.iterrows():
|
||||
name = str(row.get("行业", "")).strip()
|
||||
items.append({
|
||||
"code": code_map.get(name, ""),
|
||||
"name": name,
|
||||
"level": _safe_float(row.get("行业指数")),
|
||||
"changePercent": _safe_float(row.get("行业-涨跌幅")),
|
||||
"changeAmount": None,
|
||||
"mainNetInflow": _safe_float(row.get("净额", 0)) * 100000000, # 亿→元
|
||||
"mainNetInflowPercent": None,
|
||||
"superLargeInflow": None,
|
||||
"superLargeInflowPercent": None,
|
||||
"turnover": _safe_float(row.get("流入资金", 0)) * 100000000 + _safe_float(row.get("流出资金", 0)) * 100000000,
|
||||
"smallNetInflow": None,
|
||||
})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] 获取{sector_type}板块失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _safe_float(val) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
async def fetch_sector_list_direct(sector_type: str) -> list[dict]:
|
||||
"""
|
||||
直接从东方财富 push2 API 获取板块列表(备用,当 akshare 不可用时)
|
||||
"""
|
||||
fs = SECTOR_TYPE_MAP.get(sector_type)
|
||||
if not fs:
|
||||
return []
|
||||
|
||||
url = (
|
||||
f"https://push2.eastmoney.com/api/qt/clist/get"
|
||||
f"?fs={fs}&fields={SECTOR_FIELDS}"
|
||||
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://quote.eastmoney.com/",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
result = resp.json()
|
||||
if result.get("rc") != 0:
|
||||
return []
|
||||
diff = result.get("data", {}).get("diff", [])
|
||||
items = []
|
||||
for item in diff:
|
||||
items.append({
|
||||
"code": item.get("f12", ""),
|
||||
"name": item.get("f14", ""),
|
||||
"level": item.get("f2"),
|
||||
"changePercent": item.get("f3"),
|
||||
"changeAmount": item.get("f4"),
|
||||
"mainNetInflow": item.get("f62", 0),
|
||||
"mainNetInflowPercent": item.get("f184", 0),
|
||||
"superLargeInflow": item.get("f66", 0),
|
||||
"superLargeInflowPercent": item.get("f69", 0),
|
||||
"turnover": item.get("f70", 0),
|
||||
"smallNetInflow": item.get("f78", 0),
|
||||
})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] 获取{sector_type}板块失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_push2his(code: str, days: int) -> Optional[list]:
|
||||
"""回退到东方财富 push2his 接口(自动重试一次)"""
|
||||
market = get_eastmoney_market(code)
|
||||
secid = f"{market}.{code}"
|
||||
url = (
|
||||
f"https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get"
|
||||
f"?lmt={days}&fields1=f1,f2,f3,f7"
|
||||
f"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65,f66,f67,f68,f69"
|
||||
f"&ut=b2884a393a59ad64002292a3e90d46a5&secid={secid}"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://quote.eastmoney.com/",
|
||||
}
|
||||
|
||||
for attempt in range(2):
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
if attempt == 0:
|
||||
continue
|
||||
print(f"[eastmoney] push2his HTTP {resp.status_code}")
|
||||
return None
|
||||
result = resp.json()
|
||||
klines = result.get("data", {}).get("klines", [])
|
||||
if not klines:
|
||||
if attempt == 0:
|
||||
continue
|
||||
print(f"[eastmoney] push2his no klines in response")
|
||||
return None
|
||||
return klines
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
continue
|
||||
print(f"[eastmoney] push2his error: {e}")
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""新浪财经 API 客户端(K线降级数据源)"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
|
||||
async def fetch_history(code: str, days: int = 90) -> List[dict]:
|
||||
"""获取新浪财经日K线数据"""
|
||||
market = "sh" if code.startswith(("688", "60")) else "bj" if code.startswith(("920", "8", "4")) else "sz"
|
||||
symbol = f"{market}{code}"
|
||||
url = f"https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol={symbol}&scale=240&ma=no&datalen={days}"
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
text = resp.text
|
||||
if not text or text.strip() in ("", "null"):
|
||||
return []
|
||||
arr = json.loads(text)
|
||||
if not isinstance(arr, list):
|
||||
return []
|
||||
result = []
|
||||
for item in arr:
|
||||
result.append({
|
||||
"date": item.get("day", ""),
|
||||
"open": float(item.get("open", 0) or 0),
|
||||
"close": float(item.get("close", 0) or 0),
|
||||
"high": float(item.get("high", 0) or 0),
|
||||
"low": float(item.get("low", 0) or 0),
|
||||
"volume": int(item.get("volume", 0) or 0),
|
||||
})
|
||||
return result
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,271 @@
|
||||
"""腾讯财经 API 客户端"""
|
||||
|
||||
import httpx
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# 根据代码推断市场前缀(腾讯格式)
|
||||
def get_market_prefix(code: str) -> str:
|
||||
if code.startswith("688") or code.startswith("60"):
|
||||
return "sh"
|
||||
if code.startswith("920") or code.startswith("8") or code.startswith("4"):
|
||||
return "bj"
|
||||
return "sz"
|
||||
|
||||
|
||||
# 解码 \u 转义的 unicode 字符串
|
||||
def decode_unicode(s: str) -> str:
|
||||
try:
|
||||
return s.encode("utf-8").decode("unicode_escape")
|
||||
except Exception:
|
||||
return s
|
||||
|
||||
|
||||
# 解析腾讯报价文本格式(按 ~ 分隔)
|
||||
def parse_tencent_data(text: str):
|
||||
eq_idx = text.index('="')
|
||||
if eq_idx == -1:
|
||||
return None
|
||||
start = eq_idx + 2
|
||||
end = text.rindex('"')
|
||||
if end <= start:
|
||||
return None
|
||||
content = text[start:end]
|
||||
if not content:
|
||||
return None
|
||||
return content.split("~")
|
||||
|
||||
|
||||
async def search_stock(keyword: str) -> List[dict]:
|
||||
"""腾讯智能搜索:支持名称/代码/拼音模糊匹配"""
|
||||
url = f"https://smartbox.gtimg.cn/s3/?t=all&q={quote(keyword, safe='')}&v=2"
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://stockapp.finance.qq.com/",
|
||||
}
|
||||
results = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return results
|
||||
text = resp.text
|
||||
eq_idx = text.index('="')
|
||||
if eq_idx == -1:
|
||||
return results
|
||||
start = eq_idx + 2
|
||||
end = text.rindex('"')
|
||||
if end <= start:
|
||||
return results
|
||||
content = text[start:end]
|
||||
if not content or content == "N":
|
||||
return results
|
||||
records = content.split("^")
|
||||
for record in records:
|
||||
if not record:
|
||||
continue
|
||||
parts = record.split("~")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
market = parts[0]
|
||||
code = parts[1]
|
||||
name_raw = parts[2]
|
||||
typ = parts[4]
|
||||
is_sh_a = market == "sh" and (typ in ("GP-A", "GP-A-KCB"))
|
||||
is_sz_a = market == "sz" and (typ in ("GP-A", "GP-A-CYB"))
|
||||
is_bj_a = market == "bj" and (typ in ("GP-A", "GP-A-BJB"))
|
||||
if (is_sh_a or is_sz_a or is_bj_a) and re.match(r"^\d{6}$", code):
|
||||
name = decode_unicode(name_raw)
|
||||
if name:
|
||||
results.append({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"market": market.upper(),
|
||||
"type": typ,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
async def lookup_by_quote(code: str) -> Optional[dict]:
|
||||
"""通过行情接口直接查询股票(用于搜索接口不支持的股票)"""
|
||||
market = get_market_prefix(code)
|
||||
stock_code = f"{market}{code}"
|
||||
url = f"https://qt.gtimg.cn/q={stock_code}"
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
text = resp.content.decode("gbk", errors="replace")
|
||||
parts = parse_tencent_data(text)
|
||||
if not parts or len(parts) < 3:
|
||||
return None
|
||||
name = parts[1]
|
||||
current_price = float(parts[3]) if parts[3] else 0
|
||||
if not name or current_price == 0:
|
||||
return None
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"market": market.upper(),
|
||||
"type": get_board_type(code),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_board_type(code: str) -> str:
|
||||
if code.startswith("688"):
|
||||
return "GP-A-KCB"
|
||||
if code.startswith("300") or code.startswith("301"):
|
||||
return "GP-A-CYB"
|
||||
if code.startswith("8") or code.startswith("4") or code.startswith("920"):
|
||||
return "GP-A-BJB"
|
||||
return "GP-A"
|
||||
|
||||
|
||||
async def fetch_quote(code: str) -> Optional[dict]:
|
||||
"""获取实时行情"""
|
||||
market = get_market_prefix(code)
|
||||
stock_code = f"{market}{code}"
|
||||
url = f"https://qt.gtimg.cn/q={stock_code}"
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
text = resp.content.decode("gbk", errors="replace")
|
||||
parts = parse_tencent_data(text)
|
||||
if not parts or len(parts) < 38:
|
||||
return None
|
||||
# 字段索引(1-based):1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
|
||||
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
|
||||
name = parts[1] or ""
|
||||
current_price = float(parts[3]) if parts[3] else 0
|
||||
yesterday_close = float(parts[4]) if parts[4] else 0
|
||||
today_open = float(parts[5]) if parts[5] else 0
|
||||
volume = float(parts[6]) if parts[6] else 0
|
||||
high = float(parts[33]) if parts[33] else 0
|
||||
low = float(parts[34]) if parts[34] else 0
|
||||
amount = float(parts[37]) if parts[37] else 0
|
||||
change_val = float(parts[31]) if parts[31] else 0
|
||||
change_pct = float(parts[32]) if parts[32] else 0
|
||||
outer_disk = float(parts[7]) if parts[7] else 0
|
||||
inner_disk = float(parts[8]) if parts[8] else 0
|
||||
|
||||
if not name or current_price == 0:
|
||||
return None
|
||||
|
||||
change = change_val if change_val != 0 else current_price - yesterday_close
|
||||
change_percent = change_pct if change_pct != 0 else (
|
||||
(current_price - yesterday_close) / yesterday_close * 100 if yesterday_close > 0 else 0
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return {
|
||||
"code": code,
|
||||
"market": market.upper(),
|
||||
"name": name,
|
||||
"todayOpen": today_open,
|
||||
"yesterdayClose": yesterday_close,
|
||||
"currentPrice": current_price,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"volume": volume * 100,
|
||||
"amount": amount,
|
||||
"outerDisk": outer_disk,
|
||||
"innerDisk": inner_disk,
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"time": now.strftime("%H:%M:%S"),
|
||||
"change": round(change, 2),
|
||||
"changePercent": round(change_percent, 2),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_history(code: str, days: int = 90) -> List[dict]:
|
||||
"""获取历史K线(前复权日K),主数据源"""
|
||||
market = get_market_prefix(code)
|
||||
stock_code = f"{market}{code}"
|
||||
url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={stock_code},day,,,{days},qfq"
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://stockapp.finance.qq.com/",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
data = resp.json()
|
||||
stock_data = data.get("data", {}).get(stock_code, {})
|
||||
raw = stock_data.get("qfqday") or stock_data.get("day") or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
result = []
|
||||
for record in raw:
|
||||
if not isinstance(record, list) or len(record) < 6:
|
||||
continue
|
||||
result.append({
|
||||
"date": record[0],
|
||||
"open": float(record[1]) if record[1] else 0,
|
||||
"close": float(record[2]) if record[2] else 0,
|
||||
"high": float(record[3]) if record[3] else 0,
|
||||
"low": float(record[4]) if record[4] else 0,
|
||||
"volume": int(record[5]) if record[5] else 0,
|
||||
})
|
||||
return result
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_kline_map(code: str, days: int = 30) -> dict:
|
||||
"""获取K线数据并返回 { date: { close, changePercent, turnover } } 映射"""
|
||||
market = get_market_prefix(code)
|
||||
stock_code = f"{market}{code}"
|
||||
url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={stock_code},day,,,{days + 30},qfq"
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://stockapp.finance.qq.com/",
|
||||
}
|
||||
kline_map = {}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return kline_map
|
||||
data = resp.json()
|
||||
stock_data = data.get("data", {}).get(stock_code, {})
|
||||
raw = stock_data.get("qfqday") or stock_data.get("day") or []
|
||||
prev_close = 0
|
||||
for record in raw:
|
||||
if not isinstance(record, list) or len(record) < 6:
|
||||
continue
|
||||
try:
|
||||
date = record[0]
|
||||
close = float(record[2]) if record[2] else 0
|
||||
turnover = 0
|
||||
if len(record) > 6 and isinstance(record[6], (int, float, str)):
|
||||
try:
|
||||
turnover = float(record[6])
|
||||
except (ValueError, TypeError):
|
||||
turnover = 0
|
||||
change_pct = 0
|
||||
if prev_close > 0:
|
||||
change_pct = (close - prev_close) / prev_close * 100
|
||||
kline_map[date] = {"close": close, "changePercent": change_pct, "turnover": turnover}
|
||||
prev_close = close
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return kline_map
|
||||
Binary file not shown.
Reference in New Issue
Block a user