This commit is contained in:
Sakurasan
2026-07-05 21:29:17 +08:00
commit 013ebdaffe
150 changed files with 15614 additions and 0 deletions
View File
+58
View File
@@ -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()
+403
View File
@@ -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
+40
View File
@@ -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 []
+271
View File
@@ -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