etoai f23ffa7610 init: LOF 基金实时溢价监控系统
前后端分离项目,展示 LOF 基金的实时交易价格、溢价率和申购限额。
- 后端: FastAPI + akshare + pandas
- 前端: Vue 3 + Vite + Element Plus + Axios
2026-05-07 23:16:43 +08:00

242 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import akshare as ak
import pandas as pd
import logging
import concurrent.futures
import requests
# 配置日志
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI()
# 缓存最近一次成功的数据
cache_data = {"data": [], "time": None}
# 解决跨域,让 Vue 能调用
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def format_limit(value):
"""格式化限额显示"""
if pd.isna(value):
return "-"
if value == 0:
return "-"
if value >= 1e8:
return "不限"
if value < 10000:
return f"{value:.0f}元/日"
return f"{value / 10000:.0f}万/日"
def format_amount(value):
"""格式化金额:成交额/总市值"""
if pd.isna(value):
return "-"
if value >= 1e8:
return f"{value / 1e8:.2f}亿"
if value >= 1e4:
return f"{value / 1e4:.2f}"
return f"{value:.0f}"
def fetch_spot_data():
"""获取 LOF 实时交易数据(直接请求东方财富接口,绕过 akshare 失效域名)"""
url = "https://push2delay.eastmoney.com/api/qt/clist/get"
base_params = {
"pn": "1",
"pz": "100",
"po": "1",
"np": "1",
"ut": "bd1d9ddb04089700cf9c27f6f7426281",
"fltt": "2",
"invt": "2",
"wbp2u": "|0|0|0|web",
"fid": "f3",
"fs": "b:MK0404,b:MK0405,b:MK0406,b:MK0407",
"fields": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23,f24,f25,f22,f11,f62,f128,f136,f115,f152",
}
# 获取第一页
r = requests.get(url, params=base_params, timeout=30)
r.raise_for_status()
data_json = r.json()
per_page_num = len(data_json["data"]["diff"])
total_page = (data_json["data"]["total"] + per_page_num - 1) // per_page_num
temp_list = [pd.DataFrame(data_json["data"]["diff"])]
# 获取剩余页面
for page in range(2, total_page + 1):
params = base_params.copy()
params["pn"] = str(page)
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
data_json = r.json()
temp_list.append(pd.DataFrame(data_json["data"]["diff"]))
temp_df = pd.concat(temp_list, ignore_index=True)
temp_df.rename(
columns={
"f12": "代码",
"f14": "名称",
"f2": "最新价",
"f4": "涨跌额",
"f3": "涨跌幅",
"f5": "成交量",
"f6": "成交额",
"f17": "开盘价",
"f15": "最高价",
"f16": "最低价",
"f18": "昨收",
"f20": "总市值",
},
inplace=True,
)
# 数值类型转换
numeric_cols = ["最新价", "涨跌额", "涨跌幅", "成交量", "成交额", "开盘价", "最高价", "最低价", "昨收", "总市值"]
for col in numeric_cols:
if col in temp_df.columns:
temp_df[col] = pd.to_numeric(temp_df[col], errors="coerce")
return temp_df
def fetch_purchase_data():
"""获取基金净值和限额信息"""
df = ak.fund_purchase_em()
return df[["基金代码", "最新净值/万份收益", "日累计限定金额", "申购状态"]]
def fetch_estimate_data():
"""获取基金实时估算净值(东方财富估值数据)"""
try:
df = ak.fund_value_estimation_em()
# 列名格式如2026-05-07-估算数据-估算值,每天日期会变,需要模糊匹配
estimate_col = [c for c in df.columns if "估算数据-估算值" in c]
if not estimate_col:
logger.warning("未找到估算净值列,返回空数据")
return pd.DataFrame(columns=["基金代码", "估算净值"])
df = df.rename(columns={estimate_col[0]: "估算净值"})
df["估算净值"] = pd.to_numeric(df["估算净值"], errors="coerce")
return df[["基金代码", "估算净值"]].copy()
except Exception as e:
logger.warning("获取估算净值失败:%s", e)
return pd.DataFrame(columns=["基金代码", "估算净值"])
@app.get("/api/lof")
def get_lof_data():
"""获取 LOF 实时数据 + 溢价率 + 限额"""
global cache_data
try:
logger.info("开始获取 LOF 数据...")
# 1. 获取 LOF 实时交易数据(带 30 秒超时)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_spot_data)
spot = future.result(timeout=30)
logger.info("LOF 实时数据获取成功,共 %d", len(spot))
# 2. 获取基金净值和限额信息(带 30 秒超时)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_purchase_data)
purchase = future.result(timeout=30)
logger.info("基金净值/限额数据获取成功,共 %d", len(purchase))
# 2.5 获取实时估算净值(带 30 秒超时)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_estimate_data)
estimate = future.result(timeout=30)
logger.info("基金估算净值获取成功,共 %d", len(estimate))
# 3. 合并数据
df = spot.merge(
purchase,
left_on="代码",
right_on="基金代码",
how="left"
).merge(
estimate,
left_on="代码",
right_on="基金代码",
how="left"
)
# 4. 计算溢价率
# 静态溢价率:基于最新公布的收盘净值(通常是昨日)
df["溢价率"] = (
(df["最新价"] - df["最新净值/万份收益"])
/ df["最新净值/万份收益"]
* 100
).round(2)
# 动态溢价率(估算溢价率):基于实时估算净值,交易时间内更真实
df["估算溢价率"] = (
(df["最新价"] - df["估算净值"])
/ df["估算净值"]
* 100
).round(2)
# 5. 格式化限额
df["限额"] = df["日累计限定金额"].apply(format_limit)
# 6. 格式化总市值和成交额
df["总市值_格式化"] = df["总市值"].apply(format_amount)
df["成交额_格式化"] = df["成交额"].apply(format_amount)
# 7. 只保留需要的字段
df = df[[
"代码", "名称", "最新价", "涨跌幅",
"最新净值/万份收益", "估算净值", "溢价率", "估算溢价率",
"限额", "申购状态",
"总市值_格式化", "成交量", "成交额_格式化"
]]
# 8. 格式化字段名(给前端用)
df.columns = [
"fundCode",
"fundName",
"tradePrice",
"increaseRate",
"netValue",
"estimateValue",
"premiumRate",
"estimatePremiumRate",
"purchaseLimit",
"purchaseStatus",
"fundSize",
"volume",
"turnover"
]
# 8. 处理 NaN 值,避免 JSON 序列化失败
df = df.replace({pd.NA: "-"})
df = df.where(pd.notnull(df), "-")
# 9. 转成 JSON 格式
data = df.to_dict(orient="records")
cache_data = {"data": data, "time": pd.Timestamp.now()}
logger.info("数据返回成功,共 %d", len(data))
return {"code": 200, "data": data}
except concurrent.futures.TimeoutError:
logger.error("请求 akshare 数据源超时(超过 30 秒)")
if cache_data["data"]:
logger.info("返回缓存数据,缓存时间:%s", cache_data["time"])
return {"code": 200, "data": cache_data["data"], "cached": True}
return {"code": 500, "msg": "数据获取超时,请稍后重试"}
except Exception as e:
logger.exception("数据获取失败")
if cache_data["data"]:
logger.info("返回缓存数据,缓存时间:%s", cache_data["time"])
return {"code": 200, "data": cache_data["data"], "cached": True}
return {"code": 500, "msg": f"数据获取失败:{str(e)}"}