diff --git a/backend/requirements.txt b/backend/requirements.txt index e8d0334..ee4045a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,4 +2,5 @@ fastapi==0.115.0 uvicorn==0.30.0 httpx==0.27.0 python-dotenv==1.0.1 -akshare==1.18.64 \ No newline at end of file +akshare==1.18.64 +mootdx \ No newline at end of file diff --git a/backend/routes/sectors.py b/backend/routes/sectors.py index 62931bb..d5f112b 100644 --- a/backend/routes/sectors.py +++ b/backend/routes/sectors.py @@ -1,7 +1,7 @@ """板块数据路由:行业板块、概念板块""" from fastapi import APIRouter, Query, HTTPException -from services import eastmoney +from services import eastmoney, mootdx router = APIRouter() @@ -14,4 +14,12 @@ async def sector_list( raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept") data = await eastmoney.fetch_sector_list(type) - return {"data": data, "count": len(data), "type": type} + if data: + return {"data": data, "count": len(data), "type": type} + + # 降级:通达信 mootdx(不含实时资金流数据) + md_data = await mootdx.fetch_sector_list(type) + if md_data: + return {"data": md_data, "count": len(md_data), "type": type, "source": "mootdx"} + + return {"data": [], "count": 0, "type": type} diff --git a/backend/routes/stock.py b/backend/routes/stock.py index bcd35c0..93c4924 100644 --- a/backend/routes/stock.py +++ b/backend/routes/stock.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Query, HTTPException import re -from services import tencent, sina, eastmoney +from services import tencent, sina, eastmoney, mootdx from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse router = APIRouter() @@ -54,21 +54,28 @@ async def stock_history( if not re.match(r"^\d{6}$", code): raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字") - # 主数据源:东方财富 push2his(含成交额/涨跌幅/振幅/换手率,可能被限流) + # 主数据源:通达信 mootdx(TCP直连,不被限流,稳定可靠) + md_klines = await mootdx.fetch_kline_history(code, days) + if md_klines and len(md_klines) >= 2: + return {"data": md_klines, "count": len(md_klines), "source": "mootdx"} + + # 降级1:东方财富 push2his(含成交额/涨跌幅/振幅/换手率) em_klines = await eastmoney.fetch_kline_history(code, days) if em_klines and len(em_klines) >= 2: return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"} - # 降级1:腾讯(含涨跌幅) + # 降级2:腾讯(含涨跌幅) tencent_klines = await tencent.fetch_history(code, days) if tencent_klines and len(tencent_klines) >= 2: return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"} - # 降级2:新浪 + # 降级3:新浪 sina_klines = await sina.fetch_history(code, days) if sina_klines: return {"data": sina_klines, "count": len(sina_klines), "source": "sina"} + if md_klines: + return {"data": md_klines, "count": len(md_klines), "source": "mootdx"} if em_klines: return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"} if tencent_klines: diff --git a/backend/services/mootdx.py b/backend/services/mootdx.py new file mode 100644 index 0000000..0fc0020 --- /dev/null +++ b/backend/services/mootdx.py @@ -0,0 +1,128 @@ +"""通达信数据源(mootdx TCP直连通达信服务器) + +通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。 +主要用途: +- K线数据:主数据源(稳定可靠) +- 板块数据:东方财富 push2 的降级方案 +""" + +import asyncio +from typing import Optional, List + + +def _get_market(code: str) -> str: + return "sh" if code.startswith("6") else "sz" + + +def _create_client(): + from mootdx.quotes import Quotes + + return Quotes.factory(market="std", multithread=True, heartbeat=True) + + +def _sync_fetch_kline(code: str, days: int) -> Optional[list]: + client = _create_client() + + klines = client.bars(symbol=code, frequency=9, offset=min(days, 800)) + if klines is None or len(klines) == 0: + return None + + result = [] + prev_close = 0.0 + for bar in reversed(klines): + close = float(bar.close) + change_pct = 0 + if prev_close > 0: + change_pct = (close - prev_close) / prev_close * 100 + + result.append( + { + "date": ( + bar.datetime.strftime("%Y-%m-%d") + if hasattr(bar.datetime, "strftime") + else str(bar.datetime)[:10] + ), + "open": float(bar.open), + "close": close, + "high": float(bar.high), + "low": float(bar.low), + "volume": int(bar.vol) if hasattr(bar, "vol") else 0, + "turnover": ( + float(bar.amount) if hasattr(bar, "amount") and bar.amount else 0 + ), + "changePercent": round(change_pct, 2), + } + ) + prev_close = close + + result.sort(key=lambda x: x["date"]) + return result + + +async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]: + """获取日K线(TCP直连通达信,主数据源)""" + try: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _sync_fetch_kline, code, days) + except ImportError: + return None + except Exception as e: + print(f"[mootdx] fetch_kline error: {e}") + return None + + +# ---- 板块数据(东方财富降级方案)---- + + +def _sync_fetch_sectors(sector_type: str) -> Optional[list]: + from mootdx.consts import MARKET_SH, MARKET_SZ + + client = _create_client() + + # block() 返回 DataFrame,列:code, name 等 + # 按板块类型过滤 + block_df = client.block() + if block_df is None or block_df.empty: + return None + + items = [] + for _, row in block_df.iterrows(): + name = str(row.get("name", "") or row.get("blockname", "")) + code = str(row.get("code", "") or row.get("blockcode", "")) + if not code or not name: + continue + + items.append( + { + "code": code, + "name": name, + "level": None, + "changePercent": None, + "changeAmount": None, + "mainNetInflow": 0, + "mainNetInflowPercent": None, + "superLargeInflow": None, + "superLargeInflowPercent": None, + "largeInflow": None, + "largeInflowPercent": None, + "mediumInflow": None, + "mediumInflowPercent": None, + "smallInflow": None, + "smallInflowPercent": None, + "turnover": 0, + } + ) + + return items if items else None + + +async def fetch_sector_list(sector_type: str) -> Optional[List[dict]]: + """获取板块列表(东方财富的降级方案,仅含代码和名称)""" + try: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _sync_fetch_sectors, sector_type) + except ImportError: + return None + except Exception as e: + print(f"[mootdx] fetch_sectors error: {e}") + return None