236 lines
8.8 KiB
Python
236 lines
8.8 KiB
Python
"""个股分析 API"""
|
||
|
||
from fastapi import APIRouter
|
||
|
||
from app.data.tushare_client import tushare_client
|
||
from app.data import tencent_client
|
||
from app.analysis.technical import add_all_indicators
|
||
from app.analysis.signals import generate_signals
|
||
|
||
router = APIRouter(prefix="/api/stocks", tags=["stocks"])
|
||
|
||
|
||
@router.get("/{ts_code}/quote")
|
||
async def get_quote(ts_code: str):
|
||
"""获取个股实时行情"""
|
||
quote = await tencent_client.get_realtime_quote(ts_code)
|
||
if not quote:
|
||
return {"error": "获取行情失败"}
|
||
return quote.model_dump()
|
||
|
||
|
||
@router.get("/{ts_code}/kline")
|
||
async def get_kline(ts_code: str, days: int = 120):
|
||
"""获取个股K线数据(含技术指标)"""
|
||
df = tushare_client.get_stock_daily(ts_code, days=days)
|
||
if df.empty:
|
||
return []
|
||
df = df.sort_values("trade_date").reset_index(drop=True)
|
||
df = add_all_indicators(df)
|
||
# 替换 NaN 为 None(JSON 兼容)
|
||
import math
|
||
records = df.to_dict(orient="records")
|
||
for rec in records:
|
||
for k, v in rec.items():
|
||
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
|
||
rec[k] = None
|
||
return records
|
||
|
||
|
||
@router.get("/{ts_code}/signals")
|
||
async def get_signals(ts_code: str):
|
||
"""获取个股技术面买卖信号"""
|
||
signal = generate_signals(ts_code)
|
||
return signal.model_dump()
|
||
|
||
|
||
@router.get("/{ts_code}/capital_flow")
|
||
async def get_capital_flow(ts_code: str, days: int = 10):
|
||
"""获取个股资金流向(含大/中/小单分拆)"""
|
||
df = tushare_client.get_stock_moneyflow(ts_code, days=days)
|
||
if df.empty:
|
||
return []
|
||
df = df.sort_values("trade_date")
|
||
records = []
|
||
for _, row in df.iterrows():
|
||
main_net = (
|
||
(row.get("buy_elg_amount", 0) or 0) - (row.get("sell_elg_amount", 0) or 0) +
|
||
(row.get("buy_lg_amount", 0) or 0) - (row.get("sell_lg_amount", 0) or 0)
|
||
)
|
||
records.append({
|
||
"trade_date": row["trade_date"],
|
||
"main_net_inflow": round(main_net, 2),
|
||
"net_mf_amount": round(float(row.get("net_mf_amount", 0) or 0), 2),
|
||
"elg_net": round(
|
||
(row.get("buy_elg_amount", 0) or 0) - (row.get("sell_elg_amount", 0) or 0), 2
|
||
),
|
||
"lg_net": round(
|
||
(row.get("buy_lg_amount", 0) or 0) - (row.get("sell_lg_amount", 0) or 0), 2
|
||
),
|
||
"md_net": round(
|
||
(row.get("buy_md_amount", 0) or 0) - (row.get("sell_md_amount", 0) or 0), 2
|
||
),
|
||
"sm_net": round(
|
||
(row.get("buy_sm_amount", 0) or 0) - (row.get("sell_sm_amount", 0) or 0), 2
|
||
),
|
||
})
|
||
return records
|
||
|
||
|
||
@router.get("/search")
|
||
async def search_stock(keyword: str):
|
||
"""搜索股票"""
|
||
basic = tushare_client.get_stock_basic()
|
||
if basic.empty:
|
||
return []
|
||
matches = basic[
|
||
basic["name"].str.contains(keyword, na=False) |
|
||
basic["ts_code"].str.contains(keyword, na=False) |
|
||
basic["symbol"].str.contains(keyword, na=False)
|
||
].head(20)
|
||
return matches[["ts_code", "name", "industry"]].to_dict(orient="records")
|
||
|
||
|
||
@router.post("/{ts_code}/diagnose")
|
||
async def diagnose_stock(ts_code: str):
|
||
"""AI 诊断个股"""
|
||
from app.config import settings
|
||
if not settings.deepseek_api_key:
|
||
return {"status": "error", "message": "未配置 LLM API Key"}
|
||
|
||
from app.llm.client import get_client
|
||
|
||
# 收集数据
|
||
quote = await tencent_client.get_realtime_quote(ts_code)
|
||
signals = generate_signals(ts_code)
|
||
|
||
df_daily = tushare_client.get_stock_daily(ts_code, days=30)
|
||
df_flow = tushare_client.get_stock_moneyflow(ts_code, days=10)
|
||
|
||
# 构建数据摘要
|
||
quote_str = ""
|
||
if quote:
|
||
quote_str = (
|
||
f"当前价: {quote.price}, 涨跌幅: {quote.pct_chg}%, "
|
||
f"换手率: {quote.turnover_rate}%, 量比: {quote.volume_ratio}, "
|
||
f"PE: {quote.pe}, PB: {quote.pb}, "
|
||
f"总市值: {quote.total_mv}亿, 流通市值: {quote.circ_mv}亿"
|
||
)
|
||
|
||
signal_str = (
|
||
f"技术评分: {signals.score}/100(基于7项技术信号触发计分,触发少不代表一定差,可能处于蓄势阶段), "
|
||
f"信号数: {signals.signal_count}/7, "
|
||
f"均线多头: {signals.ma_bullish}, "
|
||
f"放量突破: {signals.volume_breakout}, "
|
||
f"MACD金叉: {signals.macd_golden}, "
|
||
f"RSI健康: {signals.rsi_healthy}, "
|
||
f"缩量回踩: {signals.pullback_support}, "
|
||
f"放量长阳: {signals.big_yang}, "
|
||
f"布林支撑: {signals.boll_support}, "
|
||
f"支撑位: {signals.support_price}, "
|
||
f"压力位: {signals.resist_price}, "
|
||
f"止损位: {signals.stop_loss_price}"
|
||
)
|
||
|
||
position_str = (
|
||
f"位置安全评分: {signals.position_score}/100(越高表示位置越低越安全,96分以上表示处于相对低位), "
|
||
f"近5日涨幅: {signals.rally_pct_5d}%, "
|
||
f"近10日涨幅: {signals.rally_pct_10d}%, "
|
||
f"距60日高点: {signals.distance_from_high}%"
|
||
)
|
||
|
||
trend_str = ""
|
||
ma_info = ""
|
||
if not df_daily.empty:
|
||
df_daily = df_daily.sort_values("trade_date")
|
||
latest = df_daily.iloc[-1]
|
||
if len(df_daily) >= 5:
|
||
pct_5d = (latest["close"] - df_daily.iloc[-5]["close"]) / df_daily.iloc[-5]["close"] * 100
|
||
trend_str += f"5日涨幅: {pct_5d:.2f}%, "
|
||
if len(df_daily) >= 20:
|
||
pct_20d = (latest["close"] - df_daily.iloc[-20]["close"]) / df_daily.iloc[-20]["close"] * 100
|
||
trend_str += f"20日涨幅: {pct_20d:.2f}%, "
|
||
vol_avg_5 = df_daily.tail(5)["vol"].mean()
|
||
vol_latest = latest["vol"]
|
||
trend_str += f"量比(5日均): {vol_latest / vol_avg_5:.2f}" if vol_avg_5 > 0 else ""
|
||
# MA 信息
|
||
if "ma5" in latest and "ma20" in latest:
|
||
ma5 = latest.get("ma5", 0)
|
||
ma10 = latest.get("ma10", 0)
|
||
ma20 = latest.get("ma20", 0)
|
||
ma60 = latest.get("ma60", 0)
|
||
price = latest["close"]
|
||
ma_info = (
|
||
f"价格与均线关系: 现价{price:.2f}, "
|
||
f"MA5={ma5:.2f}, MA10={ma10:.2f}, MA20={ma20:.2f}, MA60={ma60:.2f}, "
|
||
f"{'价格在MA5上方' if price > ma5 else '价格在MA5下方'}, "
|
||
f"{'价格在MA20上方' if price > ma20 else '价格在MA20下方'}, "
|
||
f"{'均线多头排列' if ma5 > ma10 > ma20 else '均线未多头排列'}"
|
||
)
|
||
|
||
flow_str = ""
|
||
if not df_flow.empty:
|
||
df_flow = df_flow.sort_values("trade_date")
|
||
recent_3 = df_flow.tail(3)
|
||
total_main = 0
|
||
for _, r in recent_3.iterrows():
|
||
main_net = (
|
||
(r.get("buy_elg_amount", 0) or 0) - (r.get("sell_elg_amount", 0) or 0) +
|
||
(r.get("buy_lg_amount", 0) or 0) - (r.get("sell_lg_amount", 0) or 0)
|
||
)
|
||
total_main += main_net
|
||
flow_str = f"近3日主力净流入: {total_main:.0f}万"
|
||
|
||
# 基本信息
|
||
basic_info = ""
|
||
basic_df = tushare_client.get_stock_basic()
|
||
if not basic_df.empty:
|
||
row = basic_df[basic_df["ts_code"] == ts_code]
|
||
if not row.empty:
|
||
r = row.iloc[0]
|
||
basic_info = f"名称: {r['name']}, 行业: {r.get('industry', '未知')}"
|
||
|
||
user_msg = f"""请对以下A股进行全面诊断分析:
|
||
|
||
股票: {ts_code} ({basic_info})
|
||
{quote_str}
|
||
|
||
技术面: {signal_str}
|
||
|
||
位置安全: {position_str}
|
||
|
||
趋势: {trend_str}
|
||
{ma_info}
|
||
资金面: {flow_str}
|
||
|
||
重要提示:技术评分基于7项信号触发计分,分数低不代表股票差,可能处于蓄势阶段。位置安全评分高(>80)表示股价处于相对低位。请综合技术评分和位置安全评分一起判断。
|
||
|
||
请从以下维度分析(Markdown格式,简洁专业):
|
||
## 综合评级
|
||
(给出1-5星评级和一句话总结,综合技术面和位置安全评分)
|
||
|
||
## 技术面分析
|
||
(趋势方向、均线关系、支撑压力、量价配合,注意区分"技术信号未触发"和"技术面恶化")
|
||
|
||
## 资金面分析
|
||
(主力资金态度、筹码集中度推测)
|
||
|
||
## 操作建议
|
||
(适合什么类型的投资者、入场时机、风险提示)"""
|
||
|
||
try:
|
||
client = get_client()
|
||
response = await client.chat.completions.create(
|
||
model=settings.deepseek_model,
|
||
messages=[
|
||
{"role": "system", "content": "你是一位专业的A股分析师,擅长技术面和资金面分析。回复使用Markdown格式,简洁专业,客观理性。"},
|
||
{"role": "user", "content": user_msg},
|
||
],
|
||
max_tokens=1500,
|
||
temperature=0.5,
|
||
)
|
||
content = response.choices[0].message.content.strip()
|
||
return {"status": "ok", "ts_code": ts_code, "diagnosis": content}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|