Initial commit: stock analysis backend and prototype UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
624
backend/main.py
Normal file
624
backend/main.py
Normal file
@@ -0,0 +1,624 @@
|
||||
"""智策股票终端 — FastAPI 后端入口。
|
||||
|
||||
- /api/* : 数据接口(基于 AkShare,带缓存与降级)
|
||||
- / : 托管前端原型(prototype 目录)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import datetime as dt
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import akshare_service as svc
|
||||
import config
|
||||
import scheduler
|
||||
import backtest as bt
|
||||
import ai
|
||||
import signals as sig
|
||||
import report as rpt
|
||||
import portfolio as pf
|
||||
import llm
|
||||
import alerts as al
|
||||
import notifier
|
||||
from db import init_db, get_session
|
||||
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
|
||||
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
|
||||
AlertRule, AlertEvent)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
try:
|
||||
init_db()
|
||||
scheduler.start_scheduler()
|
||||
print("[startup] db + scheduler ready")
|
||||
except Exception as e:
|
||||
print("[startup] WARN:", repr(e)[:160])
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 自选股本地存储
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
WATCH_FILE = os.path.join(BASE_DIR, "watchlist.json")
|
||||
DEFAULT_WATCH = ["600519", "300750", "002594", "688981", "300059", "601012"]
|
||||
|
||||
|
||||
def load_watch():
|
||||
if os.path.exists(WATCH_FILE):
|
||||
try:
|
||||
with open(WATCH_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_WATCH
|
||||
|
||||
|
||||
def save_watch(symbols):
|
||||
with open(WATCH_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(symbols, f, ensure_ascii=False)
|
||||
|
||||
|
||||
# ============ API ============
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True, "akshare": svc.AK_OK}
|
||||
|
||||
|
||||
@app.get("/api/indices")
|
||||
def indices():
|
||||
return svc.get_indices()
|
||||
|
||||
|
||||
@app.get("/api/kline")
|
||||
def kline(symbol: str = Query("600519"), days: int = Query(120, ge=20, le=500)):
|
||||
return svc.get_kline(symbol, days)
|
||||
|
||||
|
||||
@app.get("/api/sentiment")
|
||||
def sentiment():
|
||||
return svc.get_sentiment()
|
||||
|
||||
|
||||
@app.get("/api/treemap")
|
||||
def treemap(mode: str = Query("sector")):
|
||||
return svc.get_treemap(mode)
|
||||
|
||||
|
||||
@app.get("/api/fundflow")
|
||||
def fundflow():
|
||||
return svc.get_fund_flow()
|
||||
|
||||
|
||||
@app.get("/api/hot/stocks")
|
||||
def hot_stocks():
|
||||
return svc.get_hot_stocks()
|
||||
|
||||
|
||||
@app.get("/api/hot/sectors")
|
||||
def hot_sectors():
|
||||
return svc.get_industry_boards()
|
||||
|
||||
|
||||
@app.get("/api/dragon")
|
||||
def dragon():
|
||||
return svc.get_dragon_tiger()
|
||||
|
||||
|
||||
@app.get("/api/watchlist")
|
||||
def watchlist():
|
||||
return svc.get_watchlist(load_watch())
|
||||
|
||||
|
||||
@app.post("/api/watchlist/{code}")
|
||||
def watch_add(code: str):
|
||||
w = load_watch()
|
||||
if code not in w:
|
||||
w.append(code)
|
||||
save_watch(w)
|
||||
return {"ok": True, "list": w}
|
||||
|
||||
|
||||
@app.delete("/api/watchlist/{code}")
|
||||
def watch_del(code: str):
|
||||
w = [c for c in load_watch() if c != code]
|
||||
save_watch(w)
|
||||
return {"ok": True, "list": w}
|
||||
|
||||
|
||||
# ============ 数据中台 ============
|
||||
@app.get("/api/admin/status")
|
||||
def admin_status():
|
||||
counts, last_dates = {}, {}
|
||||
with get_session() as s:
|
||||
for label, model in [("securities", Security), ("quotes_daily", DailyQuote),
|
||||
("index_daily", IndexDaily), ("sector_daily", SectorDaily),
|
||||
("fund_flow_daily", FundFlowDaily), ("sentiment_daily", SentimentDaily),
|
||||
("dragon_tiger", DragonTiger)]:
|
||||
counts[label] = s.execute(select(func.count()).select_from(model)).scalar() or 0
|
||||
if hasattr(model, "date"):
|
||||
d = s.execute(select(func.max(model.date))).scalar()
|
||||
last_dates[label] = d.isoformat() if d else None
|
||||
jobs = s.execute(select(JobRun).order_by(desc(JobRun.id)).limit(8)).scalars().all()
|
||||
job_list = [{"id": j.id, "job": j.job, "status": j.status,
|
||||
"started": j.started_at.strftime("%m-%d %H:%M:%S") if j.started_at else "",
|
||||
"finished": j.finished_at.strftime("%H:%M:%S") if j.finished_at else "",
|
||||
"message": j.message[:200]} for j in jobs]
|
||||
return {"counts": counts, "last_dates": last_dates, "jobs": job_list,
|
||||
"running": scheduler.is_running(), "universe": config.DEFAULT_UNIVERSE,
|
||||
"schedule": f"周一至周五 {config.INGEST_HOUR:02d}:{config.INGEST_MINUTE:02d}"}
|
||||
|
||||
|
||||
@app.post("/api/admin/ingest")
|
||||
def admin_ingest():
|
||||
if scheduler.is_running():
|
||||
return {"started": False, "msg": "已有入库任务在执行"}
|
||||
return scheduler.trigger_async()
|
||||
|
||||
|
||||
@app.post("/api/admin/ingest_all")
|
||||
def admin_ingest_all():
|
||||
return scheduler.trigger_all_async()
|
||||
|
||||
|
||||
@app.get("/api/db/kline")
|
||||
def db_kline(symbol: str = Query("600519"), days: int = Query(250, ge=20, le=1000)):
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(DailyQuote).where(DailyQuote.code == symbol)
|
||||
.order_by(DailyQuote.date.desc()).limit(days)
|
||||
).scalars().all()
|
||||
rows = list(reversed(rows))
|
||||
if not rows:
|
||||
return {"source": "db", "empty": True, "symbol": symbol, "dates": [], "ohlc": [], "vols": []}
|
||||
return {"source": "db", "symbol": symbol,
|
||||
"dates": [r.date.strftime("%m/%d") for r in rows],
|
||||
"ohlc": [[r.open, r.close, r.low, r.high] for r in rows],
|
||||
"vols": [r.volume for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/db/sentiment_history")
|
||||
def db_sentiment_history(days: int = Query(60, ge=5, le=365)):
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(SentimentDaily).order_by(SentimentDaily.date.desc()).limit(days)).scalars().all()
|
||||
rows = list(reversed(rows))
|
||||
return {"dates": [r.date.isoformat() for r in rows],
|
||||
"up": [r.up for r in rows], "down": [r.down for r in rows],
|
||||
"limit_up": [r.limit_up for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/review/daily")
|
||||
def review_daily(date: str = Query(None)):
|
||||
with get_session() as s:
|
||||
if date:
|
||||
d = dt.date.fromisoformat(date)
|
||||
else:
|
||||
d = s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
if not d:
|
||||
return {"ok": False, "msg": "暂无入库数据,请先在数据中台执行入库"}
|
||||
sectors = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc())).scalars().all()
|
||||
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc())).scalars().all()
|
||||
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
|
||||
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d).order_by(DragonTiger.net.desc()).limit(10)).scalars().all()
|
||||
|
||||
top_sec = [{"name": x.name, "pct": x.pct} for x in sectors[:8]]
|
||||
bot_sec = [{"name": x.name, "pct": x.pct} for x in sectors[-5:]]
|
||||
inflow = [{"name": x.name, "net": x.net} for x in flows[:8]]
|
||||
outflow = [{"name": x.name, "net": x.net} for x in flows[-5:][::-1]]
|
||||
senti_d = ({"up": senti.up, "down": senti.down, "limit_up": senti.limit_up,
|
||||
"limit_down": senti.limit_down} if senti else None)
|
||||
|
||||
summary = _gen_review_text(d, senti_d, top_sec, inflow)
|
||||
return {"ok": True, "date": d.isoformat(), "sentiment": senti_d,
|
||||
"top_sectors": top_sec, "weak_sectors": bot_sec,
|
||||
"inflow": inflow, "outflow": outflow,
|
||||
"dragon": [{"name": x.name, "code": x.code, "net": x.net, "pct": x.pct} for x in lhb],
|
||||
"summary": summary}
|
||||
|
||||
|
||||
def _gen_review_text(d, senti, top_sec, inflow):
|
||||
parts = [f"【{d.isoformat()} 复盘】"]
|
||||
if senti:
|
||||
tone = "情绪偏暖" if senti["up"] > senti["down"] else "情绪偏弱"
|
||||
parts.append(f"全市场上涨 {senti['up']} 家、下跌 {senti['down']} 家,涨停 {senti['limit_up']} 家、跌停 {senti['limit_down']} 家,{tone}。")
|
||||
if top_sec:
|
||||
names = "、".join(x["name"] for x in top_sec[:3])
|
||||
parts.append(f"领涨板块:{names}。")
|
||||
if inflow:
|
||||
names = "、".join(x["name"] for x in inflow[:3])
|
||||
parts.append(f"主力净流入居前:{names}。")
|
||||
parts.append("注:以上为基于入库数据的自动统计,AI 智能点评将在 AI 分析模块接入大模型后生成。")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@app.get("/api/backtest")
|
||||
def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60),
|
||||
slow: int = Query(20, ge=5, le=250)):
|
||||
if fast >= slow:
|
||||
return {"ok": False, "msg": "快线周期需小于慢线周期"}
|
||||
return bt.run_backtest(symbol, fast, slow)
|
||||
|
||||
|
||||
# ============ 全市场选股 ============
|
||||
STRATEGIES = {
|
||||
"surge": "最近暴涨(5日涨幅≥20%)",
|
||||
"plunge": "最近暴跌(5日跌幅≥15%)",
|
||||
"dip": "超跌抄底(60日分位≤20%且当日企稳)",
|
||||
"breakout": "突破走强(逼近60日新高)",
|
||||
"ma_bull": "均线多头(MA5>10>20)",
|
||||
"volume": "放量上攻(量比≥2且上涨)",
|
||||
"macd_gold": "MACD金叉",
|
||||
"strong": "强势连涨(≥3日连阳)",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/screen/strategies")
|
||||
def screen_strategies():
|
||||
return {"list": [{"id": k, "name": v} for k, v in STRATEGIES.items()]}
|
||||
|
||||
|
||||
@app.get("/api/screen")
|
||||
def screen(strategy: str = Query("surge"), limit: int = Query(60, ge=10, le=300),
|
||||
min_amount: float = Query(0.0)):
|
||||
M = StockMetric
|
||||
q = select(M)
|
||||
order = M.ret5.desc()
|
||||
if strategy == "surge":
|
||||
q = q.where(M.ret5 >= 20)
|
||||
elif strategy == "plunge":
|
||||
q = q.where(M.ret5 <= -15); order = M.ret5.asc()
|
||||
elif strategy == "dip":
|
||||
q = q.where(M.pos60 <= 0.2, M.pct > 0); order = M.pos60.asc()
|
||||
elif strategy == "breakout":
|
||||
q = q.where(M.pos60 >= 0.95, M.pct > 0); order = M.ret20.desc()
|
||||
elif strategy == "ma_bull":
|
||||
q = q.where(M.ma_bull.is_(True)); order = M.ret20.desc()
|
||||
elif strategy == "volume":
|
||||
q = q.where(M.vol_ratio >= 2, M.pct > 0); order = M.vol_ratio.desc()
|
||||
elif strategy == "macd_gold":
|
||||
q = q.where(M.macd_gold.is_(True)); order = M.ret5.desc()
|
||||
elif strategy == "strong":
|
||||
q = q.where(M.up_streak >= 3); order = M.up_streak.desc()
|
||||
if min_amount > 0:
|
||||
q = q.where(M.amount >= min_amount)
|
||||
q = q.order_by(order).limit(limit)
|
||||
with get_session() as s:
|
||||
rows = s.execute(q).scalars().all()
|
||||
total = s.execute(select(func.count()).select_from(M)).scalar() or 0
|
||||
return {"strategy": strategy, "name": STRATEGIES.get(strategy, strategy), "pool_size": total,
|
||||
"count": len(rows), "list": [{
|
||||
"code": r.code, "name": r.name, "close": r.close, "pct": r.pct,
|
||||
"ret5": r.ret5, "ret20": r.ret20, "vol_ratio": r.vol_ratio,
|
||||
"rsi14": r.rsi14, "pos60": round(r.pos60 * 100, 1), "amount": r.amount,
|
||||
"up_streak": r.up_streak} for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/securities/search")
|
||||
def securities_search(q: str = Query("", min_length=0), limit: int = Query(15, le=50)):
|
||||
with get_session() as s:
|
||||
stmt = select(Security)
|
||||
if q:
|
||||
stmt = stmt.where((Security.code.like(f"{q}%")) | (Security.name.like(f"%{q}%")))
|
||||
rows = s.execute(stmt.limit(limit)).scalars().all()
|
||||
return {"list": [{"code": r.code, "name": r.name} for r in rows]}
|
||||
|
||||
|
||||
# ============ 个股复盘(K线 + 买卖点 + 回放) ============
|
||||
def _ma_list(close, n):
|
||||
out = [None] * len(close)
|
||||
for i in range(len(close)):
|
||||
if i >= n - 1:
|
||||
out[i] = round(sum(close[i - n + 1:i + 1]) / n, 3)
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/review/stock")
|
||||
def review_stock(symbol: str = Query("600519"), days: int = Query(250, ge=40, le=1000),
|
||||
fast: int = Query(5), slow: int = Query(20)):
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(DailyQuote).where(DailyQuote.code == symbol)
|
||||
.order_by(DailyQuote.date.desc()).limit(days)
|
||||
).scalars().all()
|
||||
sec = s.get(Security, symbol)
|
||||
rows = list(reversed(rows))
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "该股票库内无日线,请先在数据中台入库该股或执行全市场回填", "symbol": symbol}
|
||||
dates = [r.date.strftime("%y/%m/%d") for r in rows]
|
||||
ohlc = [[r.open, r.close, r.low, r.high] for r in rows]
|
||||
vols = [r.volume for r in rows]
|
||||
close = [r.close for r in rows]
|
||||
maf, mas = _ma_list(close, fast), _ma_list(close, slow)
|
||||
|
||||
signals = []
|
||||
for i in range(1, len(close)):
|
||||
if maf[i] is None or mas[i] is None or maf[i - 1] is None or mas[i - 1] is None:
|
||||
continue
|
||||
if maf[i - 1] <= mas[i - 1] and maf[i] > mas[i]:
|
||||
signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "buy"})
|
||||
elif maf[i - 1] >= mas[i - 1] and maf[i] < mas[i]:
|
||||
signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "sell"})
|
||||
|
||||
# 区间统计
|
||||
hi = max(r.high for r in rows); lo = min(r.low for r in rows)
|
||||
period_ret = round((close[-1] / close[0] - 1) * 100, 2)
|
||||
return {"ok": True, "symbol": symbol, "name": sec.name if sec else symbol,
|
||||
"dates": dates, "ohlc": ohlc, "vols": vols,
|
||||
"ma_fast": maf, "ma_slow": mas, "fast": fast, "slow": slow,
|
||||
"signals": signals,
|
||||
"stats": {"period_return": period_ret, "high": hi, "low": lo,
|
||||
"start": dates[0], "end": dates[-1], "bars": len(rows)}}
|
||||
|
||||
|
||||
# ============ AI 分析 ============
|
||||
@app.get("/api/ai/status")
|
||||
def ai_status():
|
||||
return {"enabled": llm.enabled(), "model": config.LLM_MODEL if llm.enabled() else None}
|
||||
|
||||
|
||||
@app.get("/api/ai/review_daily")
|
||||
def ai_review_daily(date: str = Query(None)):
|
||||
return ai.review_daily_comment(date)
|
||||
|
||||
|
||||
@app.get("/api/ai/diagnose")
|
||||
def ai_diagnose(symbol: str = Query("600519")):
|
||||
return ai.diagnose(symbol)
|
||||
|
||||
|
||||
@app.get("/api/ai/today")
|
||||
def ai_today():
|
||||
return ai.today_strategy()
|
||||
|
||||
|
||||
# ============ 可回溯:信号历史胜率 + 实测准确率 ============
|
||||
@app.get("/api/ai/signal_stats")
|
||||
def ai_signal_stats(horizon: int = Query(5, ge=1, le=20)):
|
||||
return {"horizon": horizon, "stats": sig.get_stats(horizon)}
|
||||
|
||||
|
||||
@app.post("/api/ai/signal_stats/compute")
|
||||
def ai_signal_stats_compute(sample: int = Query(500, ge=50, le=4000), horizon: int = Query(5, ge=1, le=20)):
|
||||
return scheduler.trigger_signal_stats_async(sample, horizon)
|
||||
|
||||
|
||||
@app.get("/api/ai/accuracy")
|
||||
def ai_accuracy():
|
||||
return sig.accuracy()
|
||||
|
||||
|
||||
@app.post("/api/ai/accuracy/verify")
|
||||
def ai_accuracy_verify():
|
||||
return sig.verify_predictions()
|
||||
|
||||
|
||||
# ============ AI 自动复盘日报 ============
|
||||
@app.get("/api/report/daily")
|
||||
def report_daily(date: str = Query(None)):
|
||||
return rpt.get_by_date(date) if date else rpt.latest()
|
||||
|
||||
|
||||
@app.get("/api/report/history")
|
||||
def report_history(limit: int = Query(30, ge=1, le=120)):
|
||||
return rpt.history(limit)
|
||||
|
||||
|
||||
@app.post("/api/report/generate")
|
||||
def report_generate(date: str = Query(None), push: bool = Query(False)):
|
||||
return rpt.generate(date, push=push)
|
||||
|
||||
|
||||
# ============ 交易日志 & 组合 ============
|
||||
class TradeIn(BaseModel):
|
||||
code: str
|
||||
name: str = ""
|
||||
side: str = "buy"
|
||||
price: float
|
||||
qty: int
|
||||
fee: float = 0.0
|
||||
date: str = ""
|
||||
reason: str = ""
|
||||
emotion: str = ""
|
||||
|
||||
|
||||
@app.get("/api/trades")
|
||||
def list_trades():
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(Trade).order_by(Trade.date.desc(), Trade.id.desc())).scalars().all()
|
||||
names = {}
|
||||
return {"list": [{"id": t.id, "date": t.date.isoformat(), "code": t.code, "name": t.name,
|
||||
"side": t.side, "price": t.price, "qty": t.qty, "fee": t.fee,
|
||||
"reason": t.reason, "emotion": t.emotion} for t in rows]}
|
||||
|
||||
|
||||
@app.post("/api/trades")
|
||||
def add_trade(t: TradeIn):
|
||||
d = dt.date.fromisoformat(t.date) if t.date else dt.date.today()
|
||||
name = t.name
|
||||
if not name:
|
||||
with get_session() as s:
|
||||
sec = s.get(Security, t.code)
|
||||
name = sec.name if sec else t.code
|
||||
with get_session() as s:
|
||||
row = Trade(date=d, code=t.code, name=name, side=t.side, price=t.price,
|
||||
qty=t.qty, fee=t.fee, reason=t.reason, emotion=t.emotion)
|
||||
s.add(row); s.commit()
|
||||
return {"ok": True, "id": row.id}
|
||||
|
||||
|
||||
@app.delete("/api/trades/{tid}")
|
||||
def del_trade(tid: int):
|
||||
with get_session() as s:
|
||||
row = s.get(Trade, tid)
|
||||
if row:
|
||||
s.delete(row); s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/portfolio")
|
||||
def get_portfolio():
|
||||
return pf.compute()
|
||||
|
||||
|
||||
@app.get("/api/portfolio/equity")
|
||||
def portfolio_equity():
|
||||
return pf.equity_curve()
|
||||
|
||||
|
||||
# ============ 推送通知 ============
|
||||
@app.get("/api/notify/status")
|
||||
def notify_status():
|
||||
return {"channels": notifier.channels_status(), "enabled": notifier.any_enabled()}
|
||||
|
||||
|
||||
@app.post("/api/notify/test")
|
||||
def notify_test():
|
||||
if not notifier.any_enabled():
|
||||
return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"}
|
||||
res = notifier.notify("【智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。")
|
||||
return {"ok": True, "result": res}
|
||||
|
||||
|
||||
# ============ 智能预警 ============
|
||||
class AlertIn(BaseModel):
|
||||
code: str
|
||||
kind: str = "price_above"
|
||||
threshold: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
@app.get("/api/alerts")
|
||||
def list_alerts():
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(AlertRule).order_by(AlertRule.id.desc())).scalars().all()
|
||||
return {"list": [{"id": r.id, "code": r.code, "name": r.name, "kind": r.kind,
|
||||
"threshold": r.threshold, "status": r.status, "note": r.note,
|
||||
"last_value": r.last_value,
|
||||
"triggered_at": r.triggered_at.strftime("%m-%d %H:%M") if r.triggered_at else ""}
|
||||
for r in rows]}
|
||||
|
||||
|
||||
@app.post("/api/alerts")
|
||||
def add_alert(a: AlertIn):
|
||||
with get_session() as s:
|
||||
sec = s.get(Security, a.code)
|
||||
name = sec.name if sec else a.code
|
||||
row = AlertRule(code=a.code, name=name, kind=a.kind, threshold=a.threshold, note=a.note)
|
||||
s.add(row); s.commit()
|
||||
return {"ok": True, "id": row.id}
|
||||
|
||||
|
||||
@app.delete("/api/alerts/{aid}")
|
||||
def del_alert(aid: int):
|
||||
with get_session() as s:
|
||||
row = s.get(AlertRule, aid)
|
||||
if row:
|
||||
s.delete(row); s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/alerts/{aid}/reactivate")
|
||||
def reactivate_alert(aid: int):
|
||||
with get_session() as s:
|
||||
row = s.get(AlertRule, aid)
|
||||
if row:
|
||||
row.status = "active"; row.triggered_at = None; s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/alerts/check")
|
||||
def manual_check():
|
||||
return al.check_alerts()
|
||||
|
||||
|
||||
@app.get("/api/alerts/events")
|
||||
def alert_events(unread_only: bool = Query(False), limit: int = Query(30, le=100)):
|
||||
with get_session() as s:
|
||||
stmt = select(AlertEvent).order_by(AlertEvent.id.desc())
|
||||
if unread_only:
|
||||
stmt = stmt.where(AlertEvent.read.is_(False))
|
||||
rows = s.execute(stmt.limit(limit)).scalars().all()
|
||||
unread = s.execute(select(func.count()).select_from(AlertEvent).where(AlertEvent.read.is_(False))).scalar() or 0
|
||||
return {"unread": unread, "list": [{"id": e.id, "code": e.code, "name": e.name,
|
||||
"message": e.message, "time": e.created_at.strftime("%m-%d %H:%M:%S") if e.created_at else ""}
|
||||
for e in rows]}
|
||||
|
||||
|
||||
@app.post("/api/alerts/events/read")
|
||||
def mark_events_read():
|
||||
with get_session() as s:
|
||||
for e in s.execute(select(AlertEvent).where(AlertEvent.read.is_(False))).scalars():
|
||||
e.read = True
|
||||
s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ 资讯中心 ============
|
||||
@app.get("/api/news")
|
||||
def news(limit: int = Query(40, le=100)):
|
||||
return svc.get_news(limit)
|
||||
|
||||
|
||||
@app.get("/api/news/stock")
|
||||
def news_stock(code: str = Query(...)):
|
||||
return svc.get_stock_news(code)
|
||||
|
||||
|
||||
@app.get("/api/news/watch")
|
||||
def news_watch():
|
||||
codes = load_watch()[:6]
|
||||
out = []
|
||||
for c in codes:
|
||||
r = svc.get_stock_news(c, limit=4)
|
||||
for x in r["list"]:
|
||||
x["code"] = c
|
||||
out.append(x)
|
||||
out.sort(key=lambda x: x["time"], reverse=True)
|
||||
return {"list": out[:40]}
|
||||
|
||||
|
||||
class NewsAI(BaseModel):
|
||||
title: str
|
||||
content: str = ""
|
||||
|
||||
|
||||
@app.post("/api/news/ai")
|
||||
def news_ai(n: NewsAI):
|
||||
text_in = (n.title + "。" + n.content).strip()
|
||||
senti, kw = svc.judge_sentiment(text_in)
|
||||
if llm.enabled():
|
||||
try:
|
||||
prompt = ("请分析下面这条财经资讯:\n"
|
||||
"1) 一句话摘要;2) 利好/利空/中性判断及理由;3) 可能受影响的板块或个股方向。120字内。\n\n"
|
||||
+ text_in[:1200])
|
||||
text = llm.ask(prompt, temperature=0.3, max_tokens=400)
|
||||
return {"ok": True, "source": "llm", "sentiment": senti, "text": text}
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "source": "rule", "sentiment": senti,
|
||||
"text": f"判断:{senti}(关键词:{'、'.join(kw) or '无'})。摘要:{text_in[:80]}…\n(配置大模型后可获得更深入的关联分析)"}
|
||||
|
||||
|
||||
# ============ 静态前端 ============
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype")
|
||||
if os.path.isdir(FRONTEND_DIR):
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
||||
Reference in New Issue
Block a user