feat: 修复报错
This commit is contained in:
4
python-app/app/market/__init__.py
Normal file
4
python-app/app/market/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from market.config import load_market_config
|
||||
from market.factory import create_provider
|
||||
|
||||
__all__ = ["load_market_config", "create_provider"]
|
||||
40
python-app/app/market/config.py
Normal file
40
python-app/app/market/config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketConfig:
|
||||
channel: str
|
||||
provider: str
|
||||
cmes_token: str
|
||||
futu_host: str
|
||||
futu_port: int
|
||||
futu_is_encrypt: bool | None
|
||||
futu_market: str
|
||||
|
||||
|
||||
def load_market_config() -> MarketConfig:
|
||||
channel = os.getenv("MARKET_CHANNEL", "cn").strip().lower()
|
||||
provider = os.getenv("MARKET_PROVIDER", "akshare").strip().lower()
|
||||
cmes_token = os.getenv("CMES_TOKEN", "").strip()
|
||||
futu_host = os.getenv("FUTU_HOST", "127.0.0.1").strip()
|
||||
futu_port = int(os.getenv("FUTU_PORT", "11111").strip())
|
||||
futu_encrypt_raw = os.getenv("FUTU_IS_ENCRYPT", "").strip().lower()
|
||||
if futu_encrypt_raw in {"1", "true", "yes", "y"}:
|
||||
futu_is_encrypt = True
|
||||
elif futu_encrypt_raw in {"0", "false", "no", "n"}:
|
||||
futu_is_encrypt = False
|
||||
else:
|
||||
futu_is_encrypt = None
|
||||
futu_market = os.getenv("FUTU_MARKET", "").strip().lower()
|
||||
return MarketConfig(
|
||||
channel=channel,
|
||||
provider=provider,
|
||||
cmes_token=cmes_token,
|
||||
futu_host=futu_host,
|
||||
futu_port=futu_port,
|
||||
futu_is_encrypt=futu_is_encrypt,
|
||||
futu_market=futu_market,
|
||||
)
|
||||
29
python-app/app/market/factory.py
Normal file
29
python-app/app/market/factory.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from market.config import MarketConfig
|
||||
from market.provider_base import MarketDataProvider
|
||||
from market.providers.akshare_provider import AkshareCnProvider
|
||||
from market.providers.cmes_provider import CmesCnProvider
|
||||
|
||||
|
||||
def create_provider(config: MarketConfig) -> MarketDataProvider:
|
||||
if config.channel == "cn" and config.provider == "akshare":
|
||||
return AkshareCnProvider()
|
||||
if config.channel == "cn" and config.provider == "cmesdata":
|
||||
return CmesCnProvider(token=config.cmes_token)
|
||||
if config.provider == "futu" and config.channel in {"cn", "hk", "us"}:
|
||||
from market.providers.futu_provider import FutuProvider
|
||||
|
||||
return FutuProvider(
|
||||
channel=config.channel,
|
||||
host=config.futu_host,
|
||||
port=config.futu_port,
|
||||
is_encrypt=config.futu_is_encrypt,
|
||||
market=config.futu_market,
|
||||
)
|
||||
if config.channel in {"us", "hk"}:
|
||||
raise RuntimeError(
|
||||
f"channel={config.channel} provider={config.provider} 尚未实现,"
|
||||
"请新增 Provider 后接入 factory。"
|
||||
)
|
||||
raise RuntimeError(f"不支持的通道配置: channel={config.channel}, provider={config.provider}")
|
||||
30
python-app/app/market/provider_base.py
Normal file
30
python-app/app/market/provider_base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class MarketDataProvider(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider_name(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def channel(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def fetch_spot(self) -> pd.DataFrame:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def search_spot(self, query: str, limit: int) -> pd.DataFrame:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
raise NotImplementedError
|
||||
12
python-app/app/market/providers/__init__.py
Normal file
12
python-app/app/market/providers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from market.providers.akshare_provider import AkshareCnProvider
|
||||
from market.providers.cmes_provider import CmesCnProvider
|
||||
|
||||
__all__ = ["AkshareCnProvider", "CmesCnProvider"]
|
||||
|
||||
try:
|
||||
from market.providers.futu_provider import FutuProvider
|
||||
|
||||
__all__.append("FutuProvider")
|
||||
except Exception:
|
||||
# Allow non-futu environments to keep using other providers.
|
||||
pass
|
||||
63
python-app/app/market/providers/akshare_provider.py
Normal file
63
python-app/app/market/providers/akshare_provider.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
|
||||
from market.provider_base import MarketDataProvider
|
||||
|
||||
|
||||
class AkshareCnProvider(MarketDataProvider):
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "akshare"
|
||||
|
||||
@property
|
||||
def channel(self) -> str:
|
||||
return "cn"
|
||||
|
||||
def fetch_spot(self) -> pd.DataFrame:
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
if df.empty:
|
||||
raise RuntimeError("未获取到 A 股实时行情数据")
|
||||
return df
|
||||
|
||||
def search_spot(self, query: str, limit: int) -> pd.DataFrame:
|
||||
q = query.strip().lower()
|
||||
if not q:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = self.fetch_spot()
|
||||
code = df["代码"].astype(str).str.lower()
|
||||
name = df["名称"].astype(str).str.lower()
|
||||
|
||||
exact = df[(code == q) | (name == q)]
|
||||
if not exact.empty:
|
||||
return exact.head(limit)
|
||||
|
||||
starts = df[code.str.startswith(q) | name.str.startswith(q)]
|
||||
if not starts.empty:
|
||||
return starts.head(limit)
|
||||
|
||||
return df[code.str.contains(q, na=False) | name.str.contains(q, na=False)].head(limit)
|
||||
|
||||
def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
hist = ak.stock_zh_a_hist(
|
||||
symbol=code,
|
||||
period="daily",
|
||||
start_date=start.strftime("%Y%m%d"),
|
||||
end_date=end.strftime("%Y%m%d"),
|
||||
adjust="qfq",
|
||||
)
|
||||
if hist.empty:
|
||||
raise RuntimeError(f"未获取到 K 线数据: {code}")
|
||||
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"date": pd.to_datetime(hist["日期"]),
|
||||
"close": pd.to_numeric(hist["收盘"], errors="coerce"),
|
||||
"volume": pd.to_numeric(hist["成交量"], errors="coerce"),
|
||||
}
|
||||
).dropna()
|
||||
return frame.sort_values("date").reset_index(drop=True)
|
||||
70
python-app/app/market/providers/cmes_provider.py
Normal file
70
python-app/app/market/providers/cmes_provider.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from market.provider_base import MarketDataProvider
|
||||
|
||||
|
||||
class CmesCnProvider(MarketDataProvider):
|
||||
def __init__(self, token: str | None = None) -> None:
|
||||
self._token = token or os.getenv("CMES_TOKEN", "").strip()
|
||||
self._module = importlib.import_module("cmesdata")
|
||||
self._login_once()
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "cmesdata"
|
||||
|
||||
@property
|
||||
def channel(self) -> str:
|
||||
return "cn"
|
||||
|
||||
def _login_once(self) -> None:
|
||||
if not self._token:
|
||||
raise RuntimeError("CMES_TOKEN 未配置,无法使用 cmesdata 通道")
|
||||
self._module.login(self._token)
|
||||
|
||||
@staticmethod
|
||||
def _to_prefixed_code(code: str) -> str:
|
||||
raw = code.strip().upper().replace(".", "")
|
||||
if raw.startswith("SH") or raw.startswith("SZ"):
|
||||
return f"{raw[:2]}.{raw[2:]}"
|
||||
if raw.isdigit() and len(raw) == 6:
|
||||
if raw.startswith(("6", "9")):
|
||||
return f"SH.{raw}"
|
||||
return f"SZ.{raw}"
|
||||
raise RuntimeError("cmesdata 通道仅支持 6 位 A 股代码或 SH./SZ. 前缀代码")
|
||||
|
||||
def fetch_spot(self) -> pd.DataFrame:
|
||||
raise RuntimeError("cmesdata 不支持全市场快照拉取,请通过精确代码查询")
|
||||
|
||||
def search_spot(self, query: str, limit: int) -> pd.DataFrame:
|
||||
_ = limit
|
||||
code = self._to_prefixed_code(query)
|
||||
df = self._module.get_real_hq([code])
|
||||
if df is None or df.empty:
|
||||
raise RuntimeError(f"未获取到实时行情: {code}")
|
||||
return df
|
||||
|
||||
def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
prefixed = self._to_prefixed_code(code)
|
||||
df = self._module.get_history_data(
|
||||
prefixed,
|
||||
start.strftime("%Y-%m-%d"),
|
||||
end.strftime("%Y-%m-%d"),
|
||||
"D",
|
||||
)
|
||||
if df is None or df.empty:
|
||||
raise RuntimeError(f"未获取到历史 K 线: {prefixed}")
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"date": pd.to_datetime(df["时间"]),
|
||||
"close": pd.to_numeric(df["收盘价"], errors="coerce"),
|
||||
"volume": pd.to_numeric(df["成交量"], errors="coerce"),
|
||||
}
|
||||
).dropna()
|
||||
return frame.sort_values("date").reset_index(drop=True)
|
||||
207
python-app/app/market/providers/futu_provider.py
Normal file
207
python-app/app/market/providers/futu_provider.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
from futu import AuType, KLType, Market, OpenQuoteContext, RET_OK, SecurityType
|
||||
|
||||
from market.provider_base import MarketDataProvider
|
||||
|
||||
|
||||
class FutuProvider(MarketDataProvider):
|
||||
def __init__(
|
||||
self,
|
||||
channel: str,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 11111,
|
||||
is_encrypt: bool | None = None,
|
||||
market: str = "",
|
||||
) -> None:
|
||||
self._channel = channel.strip().lower()
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
self._is_encrypt = is_encrypt
|
||||
self._market = (market or self._channel).strip().lower()
|
||||
self._ctx = OpenQuoteContext(host=self._host, port=self._port, is_encrypt=self._is_encrypt)
|
||||
self._basicinfo_cache: pd.DataFrame | None = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "futu"
|
||||
|
||||
@property
|
||||
def channel(self) -> str:
|
||||
return self._channel
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
self._ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _require_market(self) -> Market:
|
||||
if self._market == "cn":
|
||||
return Market.SH
|
||||
if self._market == "hk":
|
||||
return Market.HK
|
||||
if self._market == "us":
|
||||
return Market.US
|
||||
raise RuntimeError(f"不支持的 FUTU_MARKET: {self._market},可选: cn/hk/us")
|
||||
|
||||
def _normalize_code(self, query: str) -> str:
|
||||
q = query.strip().upper()
|
||||
if not q:
|
||||
return ""
|
||||
if "." in q:
|
||||
return q
|
||||
if self._market == "cn":
|
||||
if q.isdigit() and len(q) == 6:
|
||||
prefix = "SH" if q.startswith(("5", "6", "9")) else "SZ"
|
||||
return f"{prefix}.{q}"
|
||||
return q
|
||||
if self._market == "hk":
|
||||
if q.isdigit():
|
||||
return f"HK.{q.zfill(5)}"
|
||||
return f"HK.{q}"
|
||||
if self._market == "us":
|
||||
return f"US.{q}"
|
||||
return q
|
||||
|
||||
def _snapshot_to_unified(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
frame = df.copy()
|
||||
if "code" not in frame.columns:
|
||||
raise RuntimeError("Futu 快照返回缺少 code 字段")
|
||||
if "name" not in frame.columns:
|
||||
frame["name"] = ""
|
||||
frame["涨跌额"] = pd.to_numeric(frame.get("last_price"), errors="coerce") - pd.to_numeric(
|
||||
frame.get("prev_close_price"), errors="coerce"
|
||||
)
|
||||
frame["市盈率-动态"] = frame.get("pe_ttm_ratio", frame.get("pe_ratio"))
|
||||
frame["市净率"] = frame.get("pb_ratio")
|
||||
frame["总市值"] = frame.get("total_market_val")
|
||||
frame["流通市值"] = frame.get("circular_market_val")
|
||||
frame["代码"] = frame["code"].astype(str)
|
||||
frame["名称"] = frame["name"]
|
||||
frame["最新价"] = frame.get("last_price")
|
||||
frame["涨跌幅"] = frame.get("change_rate")
|
||||
frame["成交量"] = frame.get("volume")
|
||||
frame["成交额"] = frame.get("turnover")
|
||||
frame["振幅"] = frame.get("amplitude")
|
||||
frame["最高"] = frame.get("high_price")
|
||||
frame["最低"] = frame.get("low_price")
|
||||
frame["今开"] = frame.get("open_price")
|
||||
frame["昨收"] = frame.get("prev_close_price")
|
||||
columns = [
|
||||
"代码",
|
||||
"名称",
|
||||
"最新价",
|
||||
"涨跌幅",
|
||||
"涨跌额",
|
||||
"成交量",
|
||||
"成交额",
|
||||
"振幅",
|
||||
"最高",
|
||||
"最低",
|
||||
"今开",
|
||||
"昨收",
|
||||
"市盈率-动态",
|
||||
"市净率",
|
||||
"总市值",
|
||||
"流通市值",
|
||||
]
|
||||
return frame[columns]
|
||||
|
||||
def _get_snapshot(self, codes: list[str]) -> pd.DataFrame:
|
||||
ret, data = self._ctx.get_market_snapshot(codes)
|
||||
if ret != RET_OK:
|
||||
raise RuntimeError(f"Futu 获取快照失败: {data}")
|
||||
if data is None or data.empty:
|
||||
return pd.DataFrame()
|
||||
return self._snapshot_to_unified(data)
|
||||
|
||||
def _load_basicinfo(self) -> pd.DataFrame:
|
||||
if self._basicinfo_cache is not None:
|
||||
return self._basicinfo_cache
|
||||
market = self._require_market()
|
||||
ret, data = self._ctx.get_stock_basicinfo(market=market, stock_type=SecurityType.STOCK)
|
||||
if ret != RET_OK:
|
||||
raise RuntimeError(f"Futu 获取股票基础信息失败: {data}")
|
||||
if data is None:
|
||||
self._basicinfo_cache = pd.DataFrame(columns=["code", "name"])
|
||||
return self._basicinfo_cache
|
||||
frame = data.copy()
|
||||
frame["code"] = frame["code"].astype(str)
|
||||
frame["name"] = frame["name"].astype(str)
|
||||
self._basicinfo_cache = frame
|
||||
return frame
|
||||
|
||||
def fetch_spot(self) -> pd.DataFrame:
|
||||
raise RuntimeError("Futu 不支持直接拉取全市场实时快照,请使用 search_spot")
|
||||
|
||||
def search_spot(self, query: str, limit: int) -> pd.DataFrame:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
return pd.DataFrame()
|
||||
|
||||
code = self._normalize_code(q)
|
||||
if code:
|
||||
exact = self._get_snapshot([code])
|
||||
if not exact.empty:
|
||||
return exact.head(limit)
|
||||
|
||||
basic = self._load_basicinfo()
|
||||
q_lower = q.lower()
|
||||
code_col = basic["code"].astype(str)
|
||||
name_col = basic["name"].astype(str)
|
||||
mask = (
|
||||
code_col.str.lower().eq(q_lower)
|
||||
| name_col.str.lower().eq(q_lower)
|
||||
| code_col.str.lower().str.startswith(q_lower)
|
||||
| name_col.str.lower().str.startswith(q_lower)
|
||||
| code_col.str.lower().str.contains(q_lower, na=False)
|
||||
| name_col.str.lower().str.contains(q_lower, na=False)
|
||||
)
|
||||
candidates = basic.loc[mask, "code"].drop_duplicates().head(max(limit * 2, 20)).tolist()
|
||||
if not candidates:
|
||||
return pd.DataFrame()
|
||||
snap = self._get_snapshot(candidates)
|
||||
if snap.empty:
|
||||
return pd.DataFrame()
|
||||
return snap.head(limit)
|
||||
|
||||
def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
normalized = self._normalize_code(code)
|
||||
page_key = None
|
||||
frames: list[pd.DataFrame] = []
|
||||
|
||||
while True:
|
||||
ret, data, page_key = self._ctx.request_history_kline(
|
||||
normalized,
|
||||
start=start.strftime("%Y-%m-%d"),
|
||||
end=end.strftime("%Y-%m-%d"),
|
||||
ktype=KLType.K_DAY,
|
||||
autype=AuType.QFQ,
|
||||
max_count=1000,
|
||||
page_req_key=page_key,
|
||||
)
|
||||
if ret != RET_OK:
|
||||
raise RuntimeError(f"Futu 获取历史 K 线失败: {data}")
|
||||
if data is not None and not data.empty:
|
||||
frames.append(data.copy())
|
||||
if page_key is None:
|
||||
break
|
||||
|
||||
if not frames:
|
||||
raise RuntimeError(f"未获取到 K 线数据: {normalized}")
|
||||
|
||||
full = pd.concat(frames, ignore_index=True)
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"date": pd.to_datetime(full["time_key"]),
|
||||
"close": pd.to_numeric(full["close"], errors="coerce"),
|
||||
"volume": pd.to_numeric(full["volume"], errors="coerce"),
|
||||
}
|
||||
).dropna()
|
||||
if frame.empty:
|
||||
raise RuntimeError(f"K 线数据为空: {normalized}")
|
||||
return frame.sort_values("date").reset_index(drop=True)
|
||||
164
python-app/app/market/service.py
Normal file
164
python-app/app/market/service.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from time import time
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from market.provider_base import MarketDataProvider
|
||||
|
||||
|
||||
DISPLAY_FIELDS = [
|
||||
"代码",
|
||||
"名称",
|
||||
"最新价",
|
||||
"涨跌幅",
|
||||
"涨跌额",
|
||||
"成交量",
|
||||
"成交额",
|
||||
"振幅",
|
||||
"最高",
|
||||
"最低",
|
||||
"今开",
|
||||
"昨收",
|
||||
"市盈率-动态",
|
||||
"市净率",
|
||||
"总市值",
|
||||
"流通市值",
|
||||
]
|
||||
|
||||
|
||||
def _safe(v: Any) -> Any:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, float) and pd.isna(v):
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def build_signals(hist: pd.DataFrame, sma_fast: int, sma_slow: int) -> list[dict[str, Any]]:
|
||||
frame = hist.copy()
|
||||
frame["sma_fast"] = frame["close"].rolling(sma_fast).mean()
|
||||
frame["sma_slow"] = frame["close"].rolling(sma_slow).mean()
|
||||
signals: list[dict[str, Any]] = []
|
||||
|
||||
for i in range(1, len(frame)):
|
||||
prev = frame.iloc[i - 1]
|
||||
curr = frame.iloc[i]
|
||||
if pd.isna(prev["sma_fast"]) or pd.isna(prev["sma_slow"]) or pd.isna(curr["sma_fast"]) or pd.isna(curr["sma_slow"]):
|
||||
continue
|
||||
cross_up = prev["sma_fast"] <= prev["sma_slow"] and curr["sma_fast"] > curr["sma_slow"]
|
||||
cross_down = prev["sma_fast"] >= prev["sma_slow"] and curr["sma_fast"] < curr["sma_slow"]
|
||||
if cross_up:
|
||||
signals.append({"date": curr["date"].strftime("%Y-%m-%d"), "type": "buy", "price": float(curr["close"])})
|
||||
elif cross_down:
|
||||
signals.append({"date": curr["date"].strftime("%Y-%m-%d"), "type": "sell", "price": float(curr["close"])})
|
||||
return signals
|
||||
|
||||
|
||||
def resolve_candidates(provider: MarketDataProvider, query: str, limit: int) -> pd.DataFrame:
|
||||
return provider.search_spot(query=query, limit=limit)
|
||||
|
||||
|
||||
def _pick_first_candidate(provider: MarketDataProvider, query: str) -> pd.Series:
|
||||
candidates = resolve_candidates(provider, query=query, limit=1)
|
||||
if candidates.empty:
|
||||
raise RuntimeError(f"未找到匹配股票: {query}")
|
||||
row = candidates.iloc[0]
|
||||
code = str(row.get("代码", "")).strip()
|
||||
if not code:
|
||||
raise RuntimeError("行情返回缺少代码字段")
|
||||
return row
|
||||
|
||||
|
||||
def _build_info(provider: MarketDataProvider, row: pd.Series) -> dict[str, Any]:
|
||||
code = str(row.get("代码", "")).strip()
|
||||
return {
|
||||
"code": code,
|
||||
"name": str(row.get("名称", "")),
|
||||
"price": _safe(row.get("最新价")),
|
||||
"change_pct": _safe(row.get("涨跌幅")),
|
||||
"change": _safe(row.get("涨跌额")),
|
||||
"volume": _safe(row.get("成交量")),
|
||||
"turnover": _safe(row.get("成交额")),
|
||||
"amplitude": _safe(row.get("振幅")),
|
||||
"high": _safe(row.get("最高")),
|
||||
"low": _safe(row.get("最低")),
|
||||
"open": _safe(row.get("今开")),
|
||||
"prev_close": _safe(row.get("昨收")),
|
||||
"pe": _safe(row.get("市盈率-动态")),
|
||||
"pb": _safe(row.get("市净率")),
|
||||
"market_cap": _safe(row.get("总市值")),
|
||||
"float_market_cap": _safe(row.get("流通市值")),
|
||||
"provider": provider.provider_name,
|
||||
"channel": provider.channel,
|
||||
}
|
||||
|
||||
|
||||
def build_realtime_info(provider: MarketDataProvider, query: str) -> dict[str, Any]:
|
||||
row = _pick_first_candidate(provider, query=query)
|
||||
info = _build_info(provider=provider, row=row)
|
||||
now = datetime.now()
|
||||
price = info.get("price")
|
||||
volume = info.get("volume")
|
||||
realtime_point = {
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"close": float(price) if price is not None else None,
|
||||
"volume": float(volume) if volume is not None else 0.0,
|
||||
}
|
||||
return {
|
||||
"info": info,
|
||||
"realtime_point": realtime_point,
|
||||
"source": "realtime",
|
||||
"updated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
_DASHBOARD_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_DASHBOARD_CACHE_TTL_SECONDS = 15.0
|
||||
|
||||
|
||||
def build_dashboard(
|
||||
provider: MarketDataProvider,
|
||||
query: str,
|
||||
days: int,
|
||||
sma_fast: int,
|
||||
sma_slow: int,
|
||||
) -> dict[str, Any]:
|
||||
if sma_fast >= sma_slow:
|
||||
raise RuntimeError("sma_fast must be less than sma_slow")
|
||||
|
||||
row = _pick_first_candidate(provider, query=query)
|
||||
code = str(row.get("代码", "")).strip()
|
||||
cache_key = "|".join([provider.provider_name, provider.channel, code, str(days), str(sma_fast), str(sma_slow)])
|
||||
now = time()
|
||||
cached = _DASHBOARD_CACHE.get(cache_key)
|
||||
if cached is not None and now - cached[0] <= _DASHBOARD_CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
end = datetime.now()
|
||||
lookback_days = max(days + (sma_slow * 3), days + 30)
|
||||
start = end - timedelta(days=lookback_days)
|
||||
hist = provider.fetch_daily_kline(code=code, start=start, end=end).tail(days).reset_index(drop=True)
|
||||
if hist.empty:
|
||||
raise RuntimeError(f"未获取到 K 线数据: {code}")
|
||||
|
||||
signals = build_signals(hist, sma_fast=sma_fast, sma_slow=sma_slow)
|
||||
hist["sma_fast"] = hist["close"].rolling(sma_fast).mean()
|
||||
hist["sma_slow"] = hist["close"].rolling(sma_slow).mean()
|
||||
|
||||
points = [
|
||||
{
|
||||
"date": d.strftime("%Y-%m-%d"),
|
||||
"close": float(c),
|
||||
"sma_fast": _safe(f),
|
||||
"sma_slow": _safe(s),
|
||||
"volume": float(v),
|
||||
}
|
||||
for d, c, f, s, v in zip(hist["date"], hist["close"], hist["sma_fast"], hist["sma_slow"], hist["volume"])
|
||||
]
|
||||
|
||||
result = {"info": _build_info(provider=provider, row=row), "points": points, "signals": signals}
|
||||
_DASHBOARD_CACHE[cache_key] = (now, result)
|
||||
return result
|
||||
32
python-app/app/market/types.py
Normal file
32
python-app/app/market/types.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpotRow:
|
||||
code: str
|
||||
name: str
|
||||
price: Any
|
||||
change_pct: Any
|
||||
change: Any
|
||||
volume: Any
|
||||
turnover: Any
|
||||
amplitude: Any
|
||||
high: Any
|
||||
low: Any
|
||||
open: Any
|
||||
prev_close: Any
|
||||
pe: Any
|
||||
pb: Any
|
||||
market_cap: Any
|
||||
float_market_cap: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class KlineRow:
|
||||
date: datetime
|
||||
close: float
|
||||
volume: float
|
||||
Reference in New Issue
Block a user