This commit is contained in:
aaron 2026-04-23 17:24:55 +08:00
parent ea9fa6e5ff
commit c03d5a88e8
24 changed files with 304 additions and 25 deletions

View File

@ -2,3 +2,6 @@ ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc
ASTOCK_DEBUG=true
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
ASTOCK_ALERT_ENABLED=true
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
ASTOCK_ALERT_ENVIRONMENT=local

View File

@ -20,7 +20,7 @@ from datetime import datetime
from app.data.tushare_client import tushare_client
from app.data import tencent_client
from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote
from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS
from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS, _parse_eastmoney_json
from app.analysis.sector_scanner import scan_hot_sectors
from app.analysis.technical import add_all_indicators
from app.analysis.signals import generate_signals
@ -68,7 +68,7 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
("m:0+t:6,m:0+t:80,m:0+t:81+s:2048", 9.9), # 主板 10%
("m:1+t:2,m:1+t:23", 19.9), # 创业板/科创板 20%
]:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(follow_redirects=True) as client:
# 涨停:按涨幅降序取 top 200
params_up = {
"pn": "1", "pz": "200", "po": "1", "np": "1",
@ -77,7 +77,8 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
"fields": "f3,f12,f14",
}
resp = await client.get(SECTOR_LIST_URL, params=params_up, headers=SECTOR_HEADERS, timeout=10)
items = resp.json().get("data", {}).get("diff", []) if resp.json().get("data") else []
data_up = _parse_eastmoney_json(resp, "涨停统计")
items = data_up.get("data", {}).get("diff", []) if data_up.get("data") else []
for item in items:
pct = item.get("f3")
if pct == "-" or pct is None:
@ -93,7 +94,8 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
"fields": "f3,f12,f14",
}
resp_down = await client.get(SECTOR_LIST_URL, params=params_down, headers=SECTOR_HEADERS, timeout=10)
items_down = resp_down.json().get("data", {}).get("diff", []) if resp_down.json().get("data") else []
data_down = _parse_eastmoney_json(resp_down, "跌停统计")
items_down = data_down.get("data", {}).get("diff", []) if data_down.get("data") else []
neg_threshold = -threshold
for item in items_down:
pct = item.get("f3")

View File

@ -5,12 +5,14 @@ POST /api/chat/stream - SSE 流式对话
import json
import logging
import traceback
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.core.deps import get_current_user
from app.db.error_logger import log_error
from app.llm.chat_agent import chat_stream
logger = logging.getLogger(__name__)
@ -39,6 +41,12 @@ async def chat_stream_endpoint(req: ChatRequest, current_user: dict = Depends(ge
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"Chat stream error: {e}")
await log_error(
"chat",
f"Chat stream error: {e}",
detail=traceback.format_exc(),
context={"method": "POST", "path": "/api/chat/stream"},
)
error_data = json.dumps(
{"type": "content", "content": f"出错了: {e}"},
ensure_ascii=False,

View File

@ -304,6 +304,7 @@ async def get_diagnose_history(ts_code: str):
return history
except Exception as e:
logger.error(f"获取诊断历史失败: {e}")
await log_error("stocks", f"获取诊断历史失败: {e}", detail=traceback.format_exc())
return []
@ -648,6 +649,12 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
except Exception as e:
error_msg = str(e)
logger.error(f"诊断流式调用失败: {error_msg}")
await log_error(
"stocks",
f"诊断流式调用失败: {error_msg}",
detail=traceback.format_exc(),
context={"method": "POST", "path": f"/api/stocks/{ts_code}/diagnose"},
)
yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"

View File

@ -59,6 +59,14 @@ class Settings(BaseSettings):
llm_max_tokens: int = 2000
llm_temperature: float = 0.3
# 告警Feishu / Lark Incoming Webhook
alert_enabled: bool = False
feishu_webhook_url: str = ""
alert_dedup_ttl_seconds: int = 300
alert_max_detail_chars: int = 1200
alert_app_name: str = "AStock Agent"
alert_environment: str = "local"
# 前端
frontend_url: str = "http://localhost:3002"

View File

@ -14,18 +14,19 @@ from datetime import datetime
from app.data.cache import cache
from app.config import settings
from app.db.error_logger import log_error
logger = logging.getLogger(__name__)
# 东方财富接口
EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get"
SECTOR_LIST_URL = "http://push2.eastmoney.com/api/qt/clist/get"
EASTMONEY_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
SECTOR_LIST_URL = "https://push2.eastmoney.com/api/qt/clist/get"
HEADERS = {
"Referer": "http://finance.eastmoney.com",
"Referer": "https://finance.eastmoney.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
SECTOR_HEADERS = {
"Referer": "http://data.eastmoney.com",
"Referer": "https://data.eastmoney.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
@ -95,8 +96,9 @@ async def get_sector_realtime_ranking(
params=params,
headers=SECTOR_HEADERS,
timeout=10,
follow_redirects=True,
)
data = resp.json()
data = _parse_eastmoney_json(resp, "板块实时排名")
items = data.get("data", {}).get("diff", [])
if not items:
@ -130,6 +132,11 @@ async def get_sector_realtime_ranking(
except Exception as e:
logger.error(f"东方财富板块实时排名获取失败: {e}")
await log_error(
"eastmoney",
f"东方财富板块实时排名获取失败: {e}",
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}",
)
return []
@ -174,8 +181,9 @@ async def get_min_kline(
params=params,
headers=HEADERS,
timeout=10,
follow_redirects=True,
)
data = resp.json()
data = _parse_eastmoney_json(resp, f"分钟K线 {ts_code}")
klines = data.get("data", {}).get("klines", [])
if not klines:
@ -209,9 +217,31 @@ async def get_min_kline(
except Exception as e:
logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}")
await log_error(
"eastmoney",
f"东方财富分钟K线获取失败 {ts_code}: {e}",
detail=f"period={period}, count={count}",
)
return pd.DataFrame()
def _parse_eastmoney_json(resp: httpx.Response, label: str) -> dict:
"""解析东方财富 JSON 响应,遇到 302/HTML 等非 JSON 情况给出更清晰日志。"""
resp.raise_for_status()
content_type = resp.headers.get("content-type", "")
text_preview = (resp.text or "")[:160].replace("\n", " ").replace("\r", " ")
if "json" not in content_type.lower() and not resp.text.strip().startswith("{"):
raise ValueError(
f"{label} 返回非JSON响应(status={resp.status_code}, content_type={content_type}, body={text_preview})"
)
try:
return resp.json()
except Exception as e:
raise ValueError(
f"{label} JSON解析失败(status={resp.status_code}, content_type={content_type}, body={text_preview})"
) from e
def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict:
"""分析盘中量能分布基于5分钟K线

View File

@ -9,6 +9,7 @@ import httpx
from app.data.cache import cache
from app.config import settings
from app.data.models import StockQuote
from app.db.error_logger import log_error
logger = logging.getLogger(__name__)
@ -109,6 +110,7 @@ async def get_realtime_quote(ts_code: str) -> StockQuote | None:
return quote
except Exception as e:
logger.error(f"腾讯行情获取失败 {ts_code}: {e}")
await log_error("tencent", f"腾讯行情获取失败 {ts_code}: {e}")
return None
@ -172,6 +174,11 @@ async def get_realtime_quotes_batch(ts_codes: list[str]) -> dict[str, StockQuote
results[ts_code] = quote
except Exception as e:
logger.error(f"腾讯批量行情获取失败: {e}")
await log_error(
"tencent",
f"腾讯批量行情获取失败: {e}",
detail=f"batch_size={len(batch)}",
)
return results
@ -217,5 +224,10 @@ async def get_index_realtime(index_codes: list[str] = None) -> dict[str, dict]:
}
except Exception as e:
logger.error(f"腾讯指数行情获取失败: {e}")
await log_error(
"tencent",
f"腾讯指数行情获取失败: {e}",
detail=f"indices={','.join(index_codes)}",
)
return results

View File

@ -11,6 +11,7 @@ from datetime import datetime, timedelta
from app.config import settings
from app.data.cache import cache
from app.db.error_logger import log_error_background
logger = logging.getLogger(__name__)
@ -51,6 +52,10 @@ class TushareClient:
time.sleep((2 ** attempt) * 1)
else:
logger.error(f"Tushare 请求最终失败: {e}")
log_error_background(
"tushare",
f"Tushare 请求最终失败: {e}",
)
return pd.DataFrame()
return pd.DataFrame()

View File

@ -1,13 +1,22 @@
"""错误日志持久化"""
import asyncio
import traceback
from datetime import datetime
from app.db.database import get_db
from app.db import tables
from app.notifications.feishu import send_feishu_alert
async def log_error(source: str, message: str, detail: str = "", level: str = "error"):
"""将错误写入数据库,失败时静默(不影响主流程)"""
async def log_error(
source: str,
message: str,
detail: str = "",
level: str = "error",
context: dict | None = None,
notify: bool = True,
):
"""将错误写入数据库,并按策略发送告警。"""
try:
async with get_db() as db:
stmt = tables.error_logs_table.insert().values(
@ -21,3 +30,40 @@ async def log_error(source: str, message: str, detail: str = "", level: str = "e
await db.commit()
except Exception:
pass # 写日志失败不应影响主业务
if notify and level.lower() in {"error", "critical"}:
try:
await send_feishu_alert(
source=source,
message=message,
detail=detail,
level=level,
context=context,
)
except Exception:
pass
def log_error_background(
source: str,
message: str,
detail: str = "",
level: str = "error",
context: dict | None = None,
notify: bool = True,
):
"""在存在事件循环时后台投递错误记录。"""
try:
loop = asyncio.get_running_loop()
loop.create_task(
log_error(
source=source,
message=message,
detail=detail,
level=level,
context=context,
notify=notify,
)
)
except RuntimeError:
pass

View File

@ -10,6 +10,7 @@ import logging
import re
from app.config import settings
from app.db.error_logger import log_error
logger = logging.getLogger(__name__)
@ -62,6 +63,11 @@ async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict:
return _parse_prefilter_response(content)
except Exception as e:
logger.error(f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}")
await log_error(
"llm_prefilter",
f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}",
detail=f"candidate={candidate.get('ts_code')}|{candidate.get('name', '')}",
)
return {
"decision": "watch",
"confidence": 5,
@ -135,6 +141,11 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
except Exception as e:
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
await log_error(
"llm_final",
f"LLM 分析 {candidate.get('ts_code')} 失败: {e}",
detail=f"candidate={candidate.get('ts_code')}|{candidate.get('name', '')}",
)
return {
"verdict": "watch",
"action_plan": "重点关注",

View File

@ -6,6 +6,7 @@
import logging
from openai import AsyncOpenAI
from app.config import settings
from app.db.error_logger import log_error
logger = logging.getLogger(__name__)
@ -53,6 +54,11 @@ async def chat_completion(
return resp.choices[0].message
except Exception as e:
logger.error(f"LLM 调用失败: {e}")
await log_error(
"llm",
f"LLM 调用失败: {e}",
detail=f"model={settings.deepseek_model}, tools={bool(tools)}",
)
return None
@ -86,3 +92,8 @@ async def stream_chat_completion(
yield chunk.choices[0].delta
except Exception as e:
logger.error(f"LLM 流式调用失败: {e}")
await log_error(
"llm",
f"LLM 流式调用失败: {e}",
detail=f"model={settings.deepseek_model}, tools={bool(tools)}",
)

View File

@ -7,6 +7,8 @@ import json
import logging
import math
from app.db.error_logger import log_error
logger = logging.getLogger(__name__)
_chat_user_context: dict | None = None
@ -50,6 +52,11 @@ async def execute_tool(name: str, arguments: dict) -> str:
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
except Exception as e:
logger.error(f"工具执行失败 {name}: {e}")
await log_error(
"llm_tool",
f"工具执行失败 {name}: {e}",
detail=f"arguments={json.dumps(arguments, ensure_ascii=False, default=str)}",
)
return json.dumps({"error": str(e)}, ensure_ascii=False)
@ -272,4 +279,5 @@ async def _get_realtime_indices() -> str:
}, ensure_ascii=False, default=str)
except Exception as e:
logger.error(f"获取实时指数失败: {e}")
await log_error("llm_tool", f"获取实时指数失败: {e}")
return json.dumps({"error": f"获取指数数据失败: {e}"}, ensure_ascii=False)

View File

@ -1,11 +1,14 @@
"""A 股分析推荐 Agent - FastAPI 入口"""
import logging
import traceback
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db.error_logger import log_error
from app.db.database import init_db
from app.engine.scheduler import start_scheduler, stop_scheduler
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug
@ -46,13 +49,23 @@ async def ensure_admin_exists():
async def lifespan(app: FastAPI):
# 启动
logger.info("A 股分析推荐 Agent 启动中...")
try:
await init_db()
logger.info("数据库初始化完成")
await ensure_admin_exists()
start_scheduler()
logger.info("调度器已启动")
yield
# 关闭
except Exception as e:
logger.exception("应用生命周期异常")
await log_error(
"lifespan",
f"应用生命周期异常: {e}",
detail=traceback.format_exc(),
level="critical",
)
raise
finally:
stop_scheduler()
logger.info("服务已关闭")
@ -87,6 +100,27 @@ app.include_router(debug.router)
app.websocket("/ws")(websocket.ws_endpoint)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception("未处理的接口异常: %s %s", request.method, request.url.path)
query = str(request.url.query or "")
await log_error(
"asgi",
f"未处理的接口异常: {exc}",
detail=traceback.format_exc(),
level="error",
context={
"method": request.method,
"path": request.url.path,
"query": query,
},
)
return JSONResponse(
status_code=500,
content={"detail": "服务器内部错误"},
)
@app.get("/api/health")
async def health():
return {

View File

@ -0,0 +1 @@
"""通知模块"""

View File

@ -0,0 +1,93 @@
"""Feishu/Lark 告警发送"""
from __future__ import annotations
import hashlib
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
from app.config import settings
from app.data.cache import cache
logger = logging.getLogger(__name__)
def _build_signature(
source: str,
message: str,
level: str,
context: dict | None = None,
) -> str:
context = context or {}
basis = "|".join([
source,
level,
message.strip(),
str(context.get("method", "")),
str(context.get("path", "")),
])
return hashlib.sha1(basis.encode("utf-8")).hexdigest()
def _truncate(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return f"{text[:limit]}..."
async def send_feishu_alert(
source: str,
message: str,
detail: str = "",
level: str = "error",
context: dict | None = None,
) -> bool:
"""发送 Feishu 告警,内置去重,失败不抛异常。"""
if not settings.alert_enabled or not settings.feishu_webhook_url:
return False
signature = _build_signature(source, message, level, context)
dedup_key = f"feishu_alert:{signature}"
if cache.get(dedup_key):
return False
cache.set(dedup_key, True, settings.alert_dedup_ttl_seconds)
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
context = context or {}
detail = _truncate(detail, settings.alert_max_detail_chars)
lines = [
f"[{settings.alert_app_name}] {level.upper()}",
f"环境: {settings.alert_environment}",
f"时间: {now}",
f"来源: {source}",
f"摘要: {message}",
]
if context.get("method") or context.get("path"):
lines.append(
f"请求: {context.get('method', '')} {context.get('path', '')}".strip()
)
if context.get("query"):
lines.append(f"Query: {context['query']}")
if detail:
lines.append(f"详情: {detail}")
payload = {
"msg_type": "text",
"content": {
"text": "\n".join(lines),
},
}
try:
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
resp = await client.post(settings.feishu_webhook_url, json=payload)
resp.raise_for_status()
return True
except Exception as e:
logger.warning("Feishu 告警发送失败: %s", e)
return False