up
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user