feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
1121 lines
42 KiB
Python
1121 lines
42 KiB
Python
import math
|
||
from calendar import monthrange
|
||
from datetime import date, datetime, timedelta
|
||
from decimal import Decimal, InvalidOperation
|
||
from hashlib import sha256
|
||
from statistics import pstdev
|
||
from typing import Any
|
||
|
||
import httpx
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import get_settings
|
||
from app.core.utils.time import utc_now
|
||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||
from app.modules.ai_agent.service import AIService
|
||
from app.modules.ai_agent.skills import AISkillId
|
||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditTargetType
|
||
from app.modules.audit.schemas import AuditLogCreate
|
||
from app.modules.audit.service import AuditService
|
||
from app.modules.business.models import (
|
||
MarketAnnouncement,
|
||
MarketDailyQuote,
|
||
MarketFinancialMetric,
|
||
MarketInstrument,
|
||
MarketMacroIndicator,
|
||
MarketWatchlist,
|
||
)
|
||
|
||
ALLOWED_APIS = frozenset(
|
||
{
|
||
"stock_basic",
|
||
"trade_cal",
|
||
"daily",
|
||
"daily_basic",
|
||
"index_daily",
|
||
"fina_indicator",
|
||
"anns_d",
|
||
"shibor",
|
||
"cn_cpi",
|
||
"cn_gdp",
|
||
"cn_m",
|
||
}
|
||
)
|
||
INDEX_SYMBOLS = {
|
||
"000001.SH": "上证指数",
|
||
"399001.SZ": "深证成指",
|
||
"399006.SZ": "创业板指",
|
||
"000688.SH": "科创50",
|
||
}
|
||
|
||
|
||
class TushareClient:
|
||
def __init__(self) -> None:
|
||
settings = get_settings()
|
||
if settings.market_data_provider != "tushare":
|
||
raise HTTPException(status_code=503, detail="Unsupported market data provider")
|
||
if not settings.market_data_token:
|
||
raise HTTPException(status_code=503, detail="MARKET_DATA_TOKEN is not configured")
|
||
self.base_url = settings.market_data_base_url
|
||
self.token = settings.market_data_token
|
||
|
||
def query(self, api_name: str, params: dict[str, Any], fields: list[str]) -> list[dict]:
|
||
if api_name not in ALLOWED_APIS:
|
||
raise HTTPException(status_code=403, detail="Market API is not allowed")
|
||
try:
|
||
response = httpx.post(
|
||
self.base_url,
|
||
json={
|
||
"api_name": api_name,
|
||
"token": self.token,
|
||
"params": params,
|
||
"fields": ",".join(fields),
|
||
},
|
||
timeout=30,
|
||
)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
except (httpx.HTTPError, ValueError) as exc:
|
||
raise HTTPException(status_code=503, detail="Market data provider unavailable") from exc
|
||
if payload.get("code") != 0:
|
||
raise HTTPException(
|
||
status_code=502, detail=str(payload.get("msg") or "Market provider error")
|
||
)
|
||
data = payload.get("data") or {}
|
||
names = data.get("fields") or []
|
||
return [dict(zip(names, item, strict=False)) for item in data.get("items") or []]
|
||
|
||
|
||
class MarketService:
|
||
def __init__(self, db: Session, provider: TushareClient | None = None):
|
||
self.db = db
|
||
self.provider = provider
|
||
|
||
def sync_daily(self, trade_date: date) -> dict[str, int]:
|
||
provider = self.provider or TushareClient()
|
||
raw_date = trade_date.strftime("%Y%m%d")
|
||
if not self.is_trading_day(trade_date, provider):
|
||
result = {"instruments": 0, "quotes": 0, "valuations": 0, "market_closed": 1}
|
||
self._audit_sync("daily", result)
|
||
return result
|
||
instruments = provider.query(
|
||
"stock_basic",
|
||
{"list_status": "L"},
|
||
["ts_code", "symbol", "name", "area", "industry", "market", "exchange", "list_date"],
|
||
)
|
||
self._upsert_instruments(instruments)
|
||
quotes = provider.query(
|
||
"daily",
|
||
{"trade_date": raw_date},
|
||
[
|
||
"ts_code",
|
||
"trade_date",
|
||
"open",
|
||
"high",
|
||
"low",
|
||
"close",
|
||
"pre_close",
|
||
"pct_chg",
|
||
"vol",
|
||
"amount",
|
||
],
|
||
)
|
||
valuations = provider.query(
|
||
"daily_basic",
|
||
{"trade_date": raw_date},
|
||
["ts_code", "trade_date", "pe_ttm", "pb", "total_mv"],
|
||
)
|
||
valuation_map = {row["ts_code"]: row for row in valuations}
|
||
for symbol in INDEX_SYMBOLS:
|
||
quotes.extend(
|
||
provider.query(
|
||
"index_daily",
|
||
{"ts_code": symbol, "trade_date": raw_date},
|
||
[
|
||
"ts_code",
|
||
"trade_date",
|
||
"open",
|
||
"high",
|
||
"low",
|
||
"close",
|
||
"pre_close",
|
||
"pct_chg",
|
||
"vol",
|
||
"amount",
|
||
],
|
||
)
|
||
)
|
||
self._ensure_indices()
|
||
self._upsert_quotes(quotes, valuation_map)
|
||
result = {
|
||
"instruments": len(instruments),
|
||
"quotes": len(quotes),
|
||
"valuations": len(valuations),
|
||
}
|
||
self._audit_sync("daily", result)
|
||
return result
|
||
|
||
def is_trading_day(
|
||
self, trade_date: date, provider: TushareClient | None = None
|
||
) -> bool:
|
||
source = provider or self.provider or TushareClient()
|
||
raw_date = trade_date.strftime("%Y%m%d")
|
||
calendar = source.query(
|
||
"trade_cal",
|
||
{"exchange": "SSE", "start_date": raw_date, "end_date": raw_date},
|
||
["exchange", "cal_date", "is_open", "pretrade_date"],
|
||
)
|
||
return bool(calendar and str(calendar[0].get("is_open")) == "1")
|
||
|
||
def sync_financials(self, symbol: str) -> int:
|
||
provider = self.provider or TushareClient()
|
||
code = normalize_symbol(symbol)
|
||
target = date.today()
|
||
raw_rows = provider.query(
|
||
"fina_indicator",
|
||
{
|
||
"ts_code": code,
|
||
"start_date": date(target.year - 3, 1, 1).strftime("%Y%m%d"),
|
||
"end_date": target.strftime("%Y%m%d"),
|
||
},
|
||
[
|
||
"ts_code",
|
||
"ann_date",
|
||
"end_date",
|
||
"update_flag",
|
||
"or_yoy",
|
||
"netprofit_yoy",
|
||
"roe",
|
||
"debt_to_assets",
|
||
"ocf_yoy",
|
||
],
|
||
)
|
||
rows: dict[date, dict[str, Any]] = {}
|
||
for row in raw_rows:
|
||
period = _date(row.get("end_date"))
|
||
if not period:
|
||
continue
|
||
current = rows.get(period)
|
||
if current is None or _financial_row_order(row) > _financial_row_order(current):
|
||
rows[period] = row
|
||
for period, row in rows.items():
|
||
record = self.db.execute(
|
||
select(MarketFinancialMetric).where(
|
||
MarketFinancialMetric.symbol == code, MarketFinancialMetric.period_end == period
|
||
)
|
||
).scalar_one_or_none()
|
||
values = {
|
||
"revenue_yoy": _decimal(row.get("or_yoy")),
|
||
"net_profit_yoy": _decimal(row.get("netprofit_yoy")),
|
||
"roe": _decimal(row.get("roe")),
|
||
"debt_to_assets": _decimal(row.get("debt_to_assets")),
|
||
"operating_cashflow_yoy": _decimal(row.get("ocf_yoy")),
|
||
}
|
||
if record is None:
|
||
self.db.add(MarketFinancialMetric(symbol=code, period_end=period, **values))
|
||
else:
|
||
for key, value in values.items():
|
||
setattr(record, key, value)
|
||
self.db.commit()
|
||
self._audit_sync(
|
||
"financials", {"symbol": code, "raw": len(raw_rows), "processed": len(rows)}
|
||
)
|
||
return len(rows)
|
||
|
||
def sync_macro(self, reference_date: date | None = None) -> dict[str, int]:
|
||
provider = self.provider or TushareClient()
|
||
target = reference_date or date.today()
|
||
start = target - timedelta(days=400)
|
||
month_params = {"start_m": start.strftime("%Y%m"), "end_m": target.strftime("%Y%m")}
|
||
quarter_params = {
|
||
"start_q": f"{start.year}Q1",
|
||
"end_q": f"{target.year}Q{(target.month - 1) // 3 + 1}",
|
||
}
|
||
sources = (
|
||
(
|
||
"shibor",
|
||
{"start_date": start.strftime("%Y%m%d"), "end_date": target.strftime("%Y%m%d")},
|
||
["date", "on"],
|
||
"date",
|
||
(("SHIBOR_ON", "隔夜Shibor", "on", "%"),),
|
||
),
|
||
(
|
||
"cn_cpi",
|
||
month_params,
|
||
["month", "nt_yoy"],
|
||
"month",
|
||
(("CPI_YOY", "全国CPI同比", "nt_yoy", "%"),),
|
||
),
|
||
(
|
||
"cn_gdp",
|
||
quarter_params,
|
||
["quarter", "gdp_yoy"],
|
||
"quarter",
|
||
(("GDP_YOY", "国内生产总值同比", "gdp_yoy", "%"),),
|
||
),
|
||
(
|
||
"cn_m",
|
||
month_params,
|
||
["month", "m2_yoy"],
|
||
"month",
|
||
(("M2_YOY", "M2同比", "m2_yoy", "%"),),
|
||
),
|
||
)
|
||
result: dict[str, int] = {}
|
||
for api_name, params, fields, period_field, indicators in sources:
|
||
rows = provider.query(api_name, params, fields)
|
||
written = 0
|
||
for row in rows:
|
||
period = _macro_period(row.get(period_field), period_field)
|
||
if period is None:
|
||
continue
|
||
for code, name, value_field, unit in indicators:
|
||
value = _decimal(row.get(value_field))
|
||
if value is None:
|
||
continue
|
||
record = self.db.execute(
|
||
select(MarketMacroIndicator).where(
|
||
MarketMacroIndicator.code == code,
|
||
MarketMacroIndicator.period_date == period,
|
||
)
|
||
).scalar_one_or_none()
|
||
values = {"name": name, "value": value, "unit": unit, "source": "tushare"}
|
||
if record is None:
|
||
self.db.add(
|
||
MarketMacroIndicator(code=code, period_date=period, **values)
|
||
)
|
||
else:
|
||
for key, item in values.items():
|
||
setattr(record, key, item)
|
||
written += 1
|
||
result[api_name] = written
|
||
self.db.commit()
|
||
self._audit_sync("macro", result)
|
||
return result
|
||
|
||
def sync_announcements(self, start_date: date, end_date: date) -> int:
|
||
if end_date < start_date or (end_date - start_date).days > 30:
|
||
raise HTTPException(status_code=422, detail="Announcement range must be 1 to 31 days")
|
||
provider = self.provider or TushareClient()
|
||
rows: list[dict] = []
|
||
current = start_date
|
||
while current <= end_date:
|
||
rows.extend(
|
||
provider.query(
|
||
"anns_d",
|
||
{"ann_date": current.strftime("%Y%m%d")},
|
||
["ann_date", "ts_code", "name", "title", "url", "rec_time"],
|
||
)
|
||
)
|
||
current += timedelta(days=1)
|
||
written = 0
|
||
for row in rows:
|
||
announced = _date(row.get("ann_date"))
|
||
title = str(row.get("title") or "").strip()
|
||
if announced is None or not title:
|
||
continue
|
||
symbol = str(row.get("ts_code") or "").strip() or None
|
||
source_url = str(row.get("url") or "").strip() or None
|
||
source_key = sha256(
|
||
f"{symbol or ''}|{announced.isoformat()}|{title}|{source_url or ''}".encode()
|
||
).hexdigest()
|
||
record = self.db.execute(
|
||
select(MarketAnnouncement).where(MarketAnnouncement.source_key == source_key)
|
||
).scalar_one_or_none()
|
||
values = {
|
||
"symbol": symbol,
|
||
"announcement_date": announced,
|
||
"published_at": _datetime(row.get("rec_time")),
|
||
"title": title[:512],
|
||
"category": None,
|
||
"source_url": source_url,
|
||
"source": "tushare",
|
||
}
|
||
if record is None:
|
||
self.db.add(MarketAnnouncement(source_key=source_key, **values))
|
||
else:
|
||
for key, value in values.items():
|
||
setattr(record, key, value)
|
||
written += 1
|
||
self.db.commit()
|
||
self._audit_sync("announcements", {"processed": written})
|
||
return written
|
||
|
||
def sync_watchlist_financials(self) -> dict[str, int]:
|
||
symbols = set(
|
||
self.db.execute(
|
||
select(MarketWatchlist.symbol).where(MarketWatchlist.enabled.is_(True))
|
||
).scalars()
|
||
)
|
||
return {symbol: self.sync_financials(symbol) for symbol in sorted(symbols)}
|
||
|
||
def announcements(
|
||
self, symbol: str | None = None, start_date: date | None = None, limit: int = 50
|
||
) -> dict[str, Any]:
|
||
stmt = select(MarketAnnouncement).order_by(
|
||
MarketAnnouncement.announcement_date.desc(), MarketAnnouncement.id.desc()
|
||
)
|
||
if symbol:
|
||
stmt = stmt.where(MarketAnnouncement.symbol == normalize_symbol(symbol))
|
||
if start_date:
|
||
stmt = stmt.where(MarketAnnouncement.announcement_date >= start_date)
|
||
rows = list(self.db.execute(stmt.limit(min(max(limit, 1), 100))).scalars())
|
||
return {
|
||
"items": [
|
||
{
|
||
"symbol": row.symbol,
|
||
"announcement_date": row.announcement_date.isoformat(),
|
||
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||
"title": row.title,
|
||
"category": row.category,
|
||
"source_url": row.source_url,
|
||
"source": row.source,
|
||
}
|
||
for row in rows
|
||
]
|
||
}
|
||
|
||
def market_overview(
|
||
self, trade_date: date | None = None, include_ai: bool = False, actor: str = "api"
|
||
) -> dict[str, Any]:
|
||
target = (
|
||
trade_date
|
||
or self.db.execute(
|
||
select(MarketDailyQuote.trade_date)
|
||
.order_by(MarketDailyQuote.trade_date.desc())
|
||
.limit(1)
|
||
).scalar()
|
||
)
|
||
if target is None:
|
||
return {
|
||
"title": "A股市场分析",
|
||
"data_available": False,
|
||
"content": "市场数据未接入。",
|
||
"lines": ["- 市场数据未接入。"],
|
||
"ai_analysis": None,
|
||
}
|
||
rows = list(
|
||
self.db.execute(
|
||
select(MarketDailyQuote, MarketInstrument)
|
||
.join(MarketInstrument, MarketInstrument.symbol == MarketDailyQuote.symbol)
|
||
.where(MarketDailyQuote.trade_date == target)
|
||
).all()
|
||
)
|
||
if not rows:
|
||
return {
|
||
"title": "A股市场分析",
|
||
"data_available": False,
|
||
"content": f"{target.isoformat()} 没有可用市场数据。",
|
||
"lines": [f"- {target.isoformat()} 没有可用市场数据。"],
|
||
"ai_analysis": None,
|
||
}
|
||
stocks = [(q, i) for q, i in rows if i.instrument_type == "stock"]
|
||
indices = [
|
||
{
|
||
"symbol": i.symbol,
|
||
"name": i.name,
|
||
"close": float(q.close_price),
|
||
"pct_change": _float(q.pct_change),
|
||
}
|
||
for q, i in rows
|
||
if i.instrument_type == "index"
|
||
]
|
||
advances = sum(1 for q, _ in stocks if _float(q.pct_change) > 0)
|
||
declines = sum(1 for q, _ in stocks if _float(q.pct_change) < 0)
|
||
sectors: dict[str, list[float]] = {}
|
||
for quote, instrument in stocks:
|
||
sectors.setdefault(instrument.industry or "未分类", []).append(_float(quote.pct_change))
|
||
sector_items = sorted(
|
||
(
|
||
{
|
||
"industry": name,
|
||
"average_pct_change": round(sum(values) / len(values), 2),
|
||
"stocks": len(values),
|
||
}
|
||
for name, values in sectors.items()
|
||
),
|
||
key=lambda item: item["average_pct_change"],
|
||
reverse=True,
|
||
)
|
||
metrics = {
|
||
"trade_date": target.isoformat(),
|
||
"stocks": len(stocks),
|
||
"advances": advances,
|
||
"declines": declines,
|
||
"flat": len(stocks) - advances - declines,
|
||
"limit_up": sum(1 for q, _ in stocks if _float(q.pct_change) >= 9.5),
|
||
"limit_down": sum(1 for q, _ in stocks if _float(q.pct_change) <= -9.5),
|
||
"turnover_cny": round(sum(_float(q.amount_cny) for q, _ in stocks), 2),
|
||
"indices": indices,
|
||
"top_sectors": sector_items[:5],
|
||
"bottom_sectors": sector_items[-5:],
|
||
"macro": self.macro_overview()["items"][:10],
|
||
"announcements": _ai_announcements(
|
||
self.announcements(start_date=target - timedelta(days=3), limit=10)["items"]
|
||
),
|
||
}
|
||
lines = [
|
||
f"- 交易日:{target.isoformat()},上涨 {advances},下跌 {declines},平盘 {metrics['flat']}",
|
||
f"- 涨停/跌停:{metrics['limit_up']}/{metrics['limit_down']},成交额 ¥{metrics['turnover_cny']:,.0f}",
|
||
"- 领涨行业:"
|
||
+ "、".join(f"{x['industry']} {x['average_pct_change']}%" for x in sector_items[:5]),
|
||
]
|
||
report = {
|
||
"title": "A股市场分析",
|
||
"data_available": True,
|
||
"metrics": metrics,
|
||
"chart_data": metrics,
|
||
"lines": lines,
|
||
"content": "\n".join(lines),
|
||
}
|
||
report["ai_analysis"] = (
|
||
self._ai(AISkillId.MARKET_OVERVIEW_ANALYSIS, report, actor) if include_ai else None
|
||
)
|
||
if report["ai_analysis"] and report["ai_analysis"].get("ok"):
|
||
lines.append("- AI 市场分析:")
|
||
lines.extend(f" {line}" for line in report["ai_analysis"]["answer"].splitlines())
|
||
report["content"] = "\n".join(lines)
|
||
return report
|
||
|
||
def weekly_overview(
|
||
self, reference_date: date | None = None, include_ai: bool = False, actor: str = "api"
|
||
) -> dict[str, Any]:
|
||
end_date = reference_date or date.today()
|
||
trade_dates = list(
|
||
self.db.execute(
|
||
select(MarketDailyQuote.trade_date)
|
||
.where(MarketDailyQuote.trade_date <= end_date)
|
||
.distinct()
|
||
.order_by(MarketDailyQuote.trade_date.desc())
|
||
.limit(5)
|
||
).scalars()
|
||
)
|
||
if not trade_dates:
|
||
return {
|
||
"title": "A股市场周报",
|
||
"data_available": False,
|
||
"content": "市场数据未接入。",
|
||
"lines": ["- 市场数据未接入。"],
|
||
"ai_analysis": None,
|
||
}
|
||
first_date, last_date = min(trade_dates), max(trade_dates)
|
||
rows = list(
|
||
self.db.execute(
|
||
select(MarketDailyQuote, MarketInstrument)
|
||
.join(MarketInstrument, MarketInstrument.symbol == MarketDailyQuote.symbol)
|
||
.where(MarketDailyQuote.trade_date.in_({first_date, last_date}))
|
||
).all()
|
||
)
|
||
by_symbol: dict[str, dict[date, tuple[MarketDailyQuote, MarketInstrument]]] = {}
|
||
for quote, instrument in rows:
|
||
by_symbol.setdefault(quote.symbol, {})[quote.trade_date] = (quote, instrument)
|
||
stocks: list[tuple[float, MarketInstrument]] = []
|
||
indices: list[dict[str, Any]] = []
|
||
for values in by_symbol.values():
|
||
if first_date not in values or last_date not in values:
|
||
continue
|
||
first_quote, instrument = values[first_date]
|
||
last_quote, _ = values[last_date]
|
||
if not first_quote.close_price:
|
||
continue
|
||
change = round((float(last_quote.close_price) / float(first_quote.close_price) - 1) * 100, 2)
|
||
if instrument.instrument_type == "stock":
|
||
stocks.append((change, instrument))
|
||
elif instrument.instrument_type == "index":
|
||
indices.append(
|
||
{"symbol": instrument.symbol, "name": instrument.name, "pct_change": change}
|
||
)
|
||
if not stocks and not indices:
|
||
return {
|
||
"title": "A股市场周报",
|
||
"data_available": False,
|
||
"content": "最近交易日缺少可比较的市场数据。",
|
||
"lines": ["- 最近交易日缺少可比较的市场数据。"],
|
||
"ai_analysis": None,
|
||
}
|
||
sectors: dict[str, list[float]] = {}
|
||
for change, instrument in stocks:
|
||
sectors.setdefault(instrument.industry or "未分类", []).append(change)
|
||
sector_items = sorted(
|
||
(
|
||
{
|
||
"industry": name,
|
||
"average_pct_change": round(sum(values) / len(values), 2),
|
||
"stocks": len(values),
|
||
}
|
||
for name, values in sectors.items()
|
||
),
|
||
key=lambda item: item["average_pct_change"],
|
||
reverse=True,
|
||
)
|
||
advances = sum(1 for value, _ in stocks if value > 0)
|
||
declines = sum(1 for value, _ in stocks if value < 0)
|
||
metrics = {
|
||
"period_start": first_date.isoformat(),
|
||
"period_end": last_date.isoformat(),
|
||
"trading_days": len(trade_dates),
|
||
"stocks": len(stocks),
|
||
"advances": advances,
|
||
"declines": declines,
|
||
"flat": len(stocks) - advances - declines,
|
||
"indices": sorted(indices, key=lambda item: item["symbol"]),
|
||
"top_sectors": sector_items[:5],
|
||
"bottom_sectors": sector_items[-5:],
|
||
"macro": self.macro_overview()["items"][:10],
|
||
"announcements": _ai_announcements(
|
||
self.announcements(start_date=first_date, limit=10)["items"]
|
||
),
|
||
}
|
||
lines = [
|
||
f"- 周期:{first_date.isoformat()} 至 {last_date.isoformat()},共 {len(trade_dates)} 个交易日",
|
||
f"- 区间上涨 {advances},下跌 {declines},平盘 {metrics['flat']}",
|
||
"- 领涨行业:"
|
||
+ ("、".join(f"{x['industry']} {x['average_pct_change']}%" for x in sector_items[:5]) or "无"),
|
||
]
|
||
report = {
|
||
"title": "A股市场周报",
|
||
"data_available": True,
|
||
"metrics": metrics,
|
||
"chart_data": metrics,
|
||
"lines": lines,
|
||
"content": "\n".join(lines),
|
||
}
|
||
report["ai_analysis"] = (
|
||
self._ai(AISkillId.MARKET_OVERVIEW_ANALYSIS, report, actor) if include_ai else None
|
||
)
|
||
if report["ai_analysis"] and report["ai_analysis"].get("ok"):
|
||
lines.append("- AI 市场周度分析:")
|
||
lines.extend(f" {line}" for line in report["ai_analysis"]["answer"].splitlines())
|
||
report["content"] = "\n".join(lines)
|
||
return report
|
||
|
||
def stock_analysis(
|
||
self, symbol: str, include_ai: bool = False, actor: str = "api"
|
||
) -> dict[str, Any]:
|
||
code = normalize_symbol(symbol)
|
||
instrument = self.db.execute(
|
||
select(MarketInstrument).where(MarketInstrument.symbol == code)
|
||
).scalar_one_or_none()
|
||
quotes = list(
|
||
self.db.execute(
|
||
select(MarketDailyQuote)
|
||
.where(MarketDailyQuote.symbol == code)
|
||
.order_by(MarketDailyQuote.trade_date.desc())
|
||
.limit(120)
|
||
).scalars()
|
||
)
|
||
if instrument is None or not quotes:
|
||
raise HTTPException(status_code=404, detail="Stock market data not found")
|
||
quotes.reverse()
|
||
closes = [float(q.close_price) for q in quotes]
|
||
latest = quotes[-1]
|
||
returns = [(closes[i] / closes[i - 1] - 1) for i in range(1, len(closes)) if closes[i - 1]]
|
||
financial = self.db.execute(
|
||
select(MarketFinancialMetric)
|
||
.where(MarketFinancialMetric.symbol == code)
|
||
.order_by(MarketFinancialMetric.period_end.desc())
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
metrics = {
|
||
"symbol": code,
|
||
"name": instrument.name,
|
||
"trade_date": latest.trade_date.isoformat(),
|
||
"close": float(latest.close_price),
|
||
"pct_change": _float(latest.pct_change),
|
||
"return_5d": _period_return(closes, 5),
|
||
"return_20d": _period_return(closes, 20),
|
||
"return_60d": _period_return(closes, 60),
|
||
"ma5": _mean(closes[-5:]),
|
||
"ma20": _mean(closes[-20:]),
|
||
"ma60": _mean(closes[-60:]),
|
||
"annualized_volatility": round(pstdev(returns) * math.sqrt(250) * 100, 2)
|
||
if len(returns) > 1
|
||
else None,
|
||
"max_drawdown": _max_drawdown(closes),
|
||
"rsi14": _rsi(closes, 14),
|
||
"pe": _float(latest.pe) if latest.pe is not None else None,
|
||
"pb": _float(latest.pb) if latest.pb is not None else None,
|
||
"financial": _financial(financial),
|
||
}
|
||
lines = [
|
||
f"- {instrument.name}({code})收盘 {metrics['close']},当日 {metrics['pct_change']}%",
|
||
f"- 5/20/60日收益:{metrics['return_5d']}% / {metrics['return_20d']}% / {metrics['return_60d']}%",
|
||
f"- 波动率 {metrics['annualized_volatility']}%,最大回撤 {metrics['max_drawdown']}%,RSI14 {metrics['rsi14']}",
|
||
f"- PE/PB:{metrics['pe']} / {metrics['pb']}",
|
||
]
|
||
report = {
|
||
"title": f"股票分析:{instrument.name}",
|
||
"data_available": True,
|
||
"metrics": metrics,
|
||
"chart_data": {
|
||
"symbol": code,
|
||
"name": instrument.name,
|
||
"quotes": [
|
||
{"date": q.trade_date.isoformat(), "close": float(q.close_price)}
|
||
for q in quotes[-60:]
|
||
],
|
||
},
|
||
"lines": lines,
|
||
"content": "\n".join(lines),
|
||
}
|
||
report["ai_analysis"] = (
|
||
self._ai(AISkillId.STOCK_ANALYSIS, report, actor) if include_ai else None
|
||
)
|
||
if report["ai_analysis"] and report["ai_analysis"].get("ok"):
|
||
lines.append("- AI 股票研究:")
|
||
lines.extend(f" {line}" for line in report["ai_analysis"]["answer"].splitlines())
|
||
report["content"] = "\n".join(lines)
|
||
return report
|
||
|
||
def industry_analysis(self, industry: str) -> dict[str, Any]:
|
||
latest = self.db.execute(
|
||
select(MarketDailyQuote.trade_date)
|
||
.order_by(MarketDailyQuote.trade_date.desc())
|
||
.limit(1)
|
||
).scalar()
|
||
if latest is None:
|
||
raise HTTPException(status_code=404, detail="Market data not found")
|
||
rows = list(
|
||
self.db.execute(
|
||
select(MarketDailyQuote, MarketInstrument)
|
||
.join(MarketInstrument, MarketInstrument.symbol == MarketDailyQuote.symbol)
|
||
.where(
|
||
MarketDailyQuote.trade_date == latest,
|
||
MarketInstrument.industry == industry,
|
||
)
|
||
).all()
|
||
)
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail="Industry data not found")
|
||
items = sorted(
|
||
[
|
||
{
|
||
"symbol": instrument.symbol,
|
||
"name": instrument.name,
|
||
"pct_change": _float(quote.pct_change),
|
||
"pe": _float(quote.pe) if quote.pe is not None else None,
|
||
}
|
||
for quote, instrument in rows
|
||
],
|
||
key=lambda item: item["pct_change"],
|
||
reverse=True,
|
||
)
|
||
return {
|
||
"title": f"行业分析:{industry}",
|
||
"trade_date": latest.isoformat(),
|
||
"industry": industry,
|
||
"average_pct_change": round(sum(item["pct_change"] for item in items) / len(items), 2),
|
||
"items": items,
|
||
}
|
||
|
||
def compare_stocks(self, symbols: list[str]) -> dict[str, Any]:
|
||
if len(symbols) != 2:
|
||
raise HTTPException(status_code=422, detail="Exactly two stock symbols are required")
|
||
reports = [self.stock_analysis(symbol) for symbol in symbols]
|
||
return {
|
||
"title": "股票对比",
|
||
"items": [report["metrics"] for report in reports],
|
||
"content": "\n".join(
|
||
f"- {item['name']}({item['symbol']}):20日收益 {item['return_20d']}%,"
|
||
f"波动率 {item['annualized_volatility']}%,PE/PB {item['pe']}/{item['pb']}"
|
||
for item in (report["metrics"] for report in reports)
|
||
),
|
||
}
|
||
|
||
def macro_overview(self) -> dict[str, Any]:
|
||
rows = list(
|
||
self.db.execute(
|
||
select(MarketMacroIndicator).order_by(
|
||
MarketMacroIndicator.period_date.desc(), MarketMacroIndicator.code
|
||
)
|
||
).scalars()
|
||
)
|
||
latest: dict[str, MarketMacroIndicator] = {}
|
||
for row in rows:
|
||
latest.setdefault(row.code, row)
|
||
items = [
|
||
{
|
||
"code": item.code,
|
||
"name": item.name,
|
||
"period_date": item.period_date.isoformat(),
|
||
"value": float(item.value),
|
||
"unit": item.unit,
|
||
"source": item.source,
|
||
}
|
||
for item in latest.values()
|
||
]
|
||
return {
|
||
"title": "金融市场宏观分析",
|
||
"data_available": bool(items),
|
||
"items": items,
|
||
"content": (
|
||
"宏观金融数据未接入。"
|
||
if not items
|
||
else "\n".join(
|
||
f"- {item['name']}:{item['value']} {item['unit'] or ''}"
|
||
f"({item['period_date']})"
|
||
for item in items
|
||
)
|
||
),
|
||
}
|
||
|
||
def macro_analysis(self, include_ai: bool = False, actor: str = "api") -> dict[str, Any]:
|
||
overview = self.macro_overview()
|
||
lines = overview["content"].splitlines()
|
||
report = {
|
||
**overview,
|
||
"metrics": {"macro": overview["items"]},
|
||
"lines": lines,
|
||
}
|
||
report["ai_analysis"] = (
|
||
self._ai(AISkillId.MARKET_OVERVIEW_ANALYSIS, report, actor)
|
||
if include_ai and overview["data_available"]
|
||
else None
|
||
)
|
||
if report["ai_analysis"] and report["ai_analysis"].get("ok"):
|
||
lines.append("- AI 宏观金融分析:")
|
||
lines.extend(f" {line}" for line in report["ai_analysis"]["answer"].splitlines())
|
||
report["content"] = "\n".join(lines)
|
||
return report
|
||
|
||
def add_watchlist(
|
||
self,
|
||
actor: str,
|
||
symbol: str,
|
||
owner_id: int | None = None,
|
||
) -> dict[str, Any]:
|
||
code = normalize_symbol(symbol)
|
||
owner_clause = (
|
||
MarketWatchlist.owner_id.is_(None)
|
||
if owner_id is None
|
||
else MarketWatchlist.owner_id == owner_id
|
||
)
|
||
record = self.db.execute(
|
||
select(MarketWatchlist).where(
|
||
owner_clause,
|
||
MarketWatchlist.symbol == code,
|
||
*(
|
||
(MarketWatchlist.actor == actor,)
|
||
if owner_id is None
|
||
else ()
|
||
),
|
||
)
|
||
).scalar_one_or_none()
|
||
if record is None:
|
||
record = MarketWatchlist(owner_id=owner_id, actor=actor, symbol=code)
|
||
self.db.add(record)
|
||
else:
|
||
record.enabled = True
|
||
self.db.commit()
|
||
AuditService(self.db).log(
|
||
AuditLogCreate(
|
||
actor=actor,
|
||
source=AuditSource.MARKET,
|
||
action=AuditAction.MARKET_WATCHLIST_UPDATE,
|
||
target_type=AuditTargetType.MARKET,
|
||
target_id=code,
|
||
risk_level=AuditRiskLevel.LOW,
|
||
response_payload={"enabled": True},
|
||
)
|
||
)
|
||
return {
|
||
"actor": actor,
|
||
"owner_id": owner_id,
|
||
"symbol": code,
|
||
"enabled": True,
|
||
}
|
||
|
||
def watchlist(
|
||
self,
|
||
actor: str,
|
||
owner_id: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
owner_clause = (
|
||
MarketWatchlist.owner_id.is_(None)
|
||
if owner_id is None
|
||
else MarketWatchlist.owner_id == owner_id
|
||
)
|
||
records = self.db.execute(
|
||
select(MarketWatchlist).where(
|
||
owner_clause,
|
||
MarketWatchlist.enabled.is_(True),
|
||
*(
|
||
(MarketWatchlist.actor == actor,)
|
||
if owner_id is None
|
||
else ()
|
||
),
|
||
)
|
||
).scalars()
|
||
return [{"symbol": r.symbol} for r in records]
|
||
|
||
def claim_legacy_watchlist(self, owner_id: int, open_id: str) -> int:
|
||
"""Claim still-unowned rows created by the verified legacy Feishu actor."""
|
||
|
||
legacy_records = list(
|
||
self.db.execute(
|
||
select(MarketWatchlist).where(
|
||
MarketWatchlist.owner_id.is_(None),
|
||
MarketWatchlist.actor == open_id,
|
||
)
|
||
).scalars()
|
||
)
|
||
claimed = 0
|
||
for legacy in legacy_records:
|
||
existing = self.db.execute(
|
||
select(MarketWatchlist).where(
|
||
MarketWatchlist.owner_id == owner_id,
|
||
MarketWatchlist.symbol == legacy.symbol,
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is not None:
|
||
existing.enabled = existing.enabled or legacy.enabled
|
||
self.db.delete(legacy)
|
||
continue
|
||
legacy.owner_id = owner_id
|
||
claimed += 1
|
||
self.db.commit()
|
||
return claimed
|
||
|
||
def delete_owner_watchlist(self, owner_id: int) -> int:
|
||
"""Stage deletion of all personal watchlist rows for an owner."""
|
||
|
||
records = list(
|
||
self.db.execute(
|
||
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
|
||
).scalars()
|
||
)
|
||
for record in records:
|
||
self.db.delete(record)
|
||
self.db.flush()
|
||
return len(records)
|
||
|
||
def _ai(self, skill: AISkillId, report: dict[str, Any], actor: str) -> dict[str, Any]:
|
||
try:
|
||
result = AIService(self.db).run_skill(
|
||
skill,
|
||
context={
|
||
"metrics": report["metrics"],
|
||
"memory_scope": "market",
|
||
"memory_subject": "market",
|
||
"scope": "market",
|
||
"subject": "market",
|
||
},
|
||
actor=actor,
|
||
)
|
||
except Exception as exc:
|
||
self.db.rollback()
|
||
return {"ok": False, "error": str(exc), "type": type(exc).__name__}
|
||
if result.get(AIResponseKey.PROVIDER) == AIProviderName.NOOP:
|
||
return {"ok": False, "error": "AI unavailable"}
|
||
return {"ok": True, **result}
|
||
|
||
def _upsert_instruments(self, rows: list[dict]) -> None:
|
||
existing = {r.symbol: r for r in self.db.execute(select(MarketInstrument)).scalars()}
|
||
for row in rows:
|
||
code = row["ts_code"]
|
||
record = existing.get(code)
|
||
values = {
|
||
"name": row.get("name") or code,
|
||
"exchange": row.get("exchange"),
|
||
"instrument_type": "stock",
|
||
"industry": row.get("industry"),
|
||
"list_date": _date(row.get("list_date")),
|
||
"is_active": True,
|
||
"source_updated_at": utc_now(),
|
||
}
|
||
if record is None:
|
||
self.db.add(MarketInstrument(symbol=code, **values))
|
||
else:
|
||
for key, value in values.items():
|
||
setattr(record, key, value)
|
||
self.db.commit()
|
||
|
||
def _audit_sync(self, dataset: str, result: dict[str, Any]) -> None:
|
||
AuditService(self.db).log(
|
||
AuditLogCreate(
|
||
actor="market-sync",
|
||
source=AuditSource.MARKET,
|
||
action=AuditAction.MARKET_SYNC,
|
||
target_type=AuditTargetType.MARKET,
|
||
target_id=dataset,
|
||
risk_level=AuditRiskLevel.LOW,
|
||
response_payload=result,
|
||
)
|
||
)
|
||
|
||
def _ensure_indices(self) -> None:
|
||
existing = {
|
||
r.symbol
|
||
for r in self.db.execute(
|
||
select(MarketInstrument).where(MarketInstrument.symbol.in_(INDEX_SYMBOLS))
|
||
).scalars()
|
||
}
|
||
for symbol, name in INDEX_SYMBOLS.items():
|
||
if symbol not in existing:
|
||
self.db.add(
|
||
MarketInstrument(
|
||
symbol=symbol,
|
||
name=name,
|
||
exchange=symbol.split(".")[-1],
|
||
instrument_type="index",
|
||
)
|
||
)
|
||
self.db.commit()
|
||
|
||
def _upsert_quotes(self, rows: list[dict], valuations: dict[str, dict]) -> None:
|
||
for row in rows:
|
||
day = _date(row.get("trade_date"))
|
||
code = row.get("ts_code")
|
||
if not day or not code or row.get("close") is None:
|
||
continue
|
||
record = self.db.execute(
|
||
select(MarketDailyQuote).where(
|
||
MarketDailyQuote.symbol == code, MarketDailyQuote.trade_date == day
|
||
)
|
||
).scalar_one_or_none()
|
||
basic = valuations.get(code, {})
|
||
values = {
|
||
"open_price": _decimal(row.get("open")),
|
||
"high_price": _decimal(row.get("high")),
|
||
"low_price": _decimal(row.get("low")),
|
||
"close_price": _decimal(row.get("close")),
|
||
"pre_close": _decimal(row.get("pre_close")),
|
||
"pct_change": _decimal(row.get("pct_chg")),
|
||
"volume": _decimal(row.get("vol")),
|
||
"amount_cny": (_decimal(row.get("amount")) or Decimal(0)) * 1000,
|
||
"pe": _decimal(basic.get("pe_ttm")),
|
||
"pb": _decimal(basic.get("pb")),
|
||
"total_market_value": (_decimal(basic.get("total_mv")) or Decimal(0)) * 10000,
|
||
}
|
||
if record is None:
|
||
self.db.add(MarketDailyQuote(symbol=code, trade_date=day, **values))
|
||
else:
|
||
for key, value in values.items():
|
||
setattr(record, key, value)
|
||
self.db.commit()
|
||
|
||
|
||
def normalize_symbol(value: str) -> str:
|
||
text = value.strip().upper()
|
||
if "." in text:
|
||
return text
|
||
if not text.isdigit() or len(text) != 6:
|
||
raise HTTPException(status_code=422, detail="Invalid A-share symbol")
|
||
return f"{text}.{'SH' if text[0] in {'6','9'} else 'BJ' if text[0] in {'4','8'} else 'SZ'}"
|
||
|
||
|
||
def _decimal(value: Any) -> Decimal | None:
|
||
try:
|
||
return None if value in (None, "") else Decimal(str(value))
|
||
except (InvalidOperation, ValueError):
|
||
return None
|
||
|
||
|
||
def _float(value: Any) -> float:
|
||
return round(float(value or 0), 4)
|
||
|
||
|
||
def _date(value: Any) -> date | None:
|
||
if isinstance(value, date):
|
||
return value
|
||
try:
|
||
return datetime.strptime(str(value), "%Y%m%d").date()
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _datetime(value: Any) -> datetime | None:
|
||
if isinstance(value, datetime):
|
||
return value
|
||
text = str(value or "").strip()
|
||
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y%m%d%H%M%S", "%Y%m%d %H:%M:%S"):
|
||
try:
|
||
return datetime.strptime(text, pattern)
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _macro_period(value: Any, kind: str) -> date | None:
|
||
text = str(value or "").strip().upper()
|
||
try:
|
||
if kind == "date":
|
||
return datetime.strptime(text, "%Y%m%d").date()
|
||
if kind == "month" and len(text) == 6:
|
||
year, month = int(text[:4]), int(text[4:])
|
||
return date(year, month, monthrange(year, month)[1])
|
||
if kind == "quarter" and len(text) == 6 and text[4] == "Q":
|
||
year, quarter = int(text[:4]), int(text[5])
|
||
month = quarter * 3
|
||
return date(year, month, monthrange(year, month)[1])
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _mean(values: list[float]) -> float | None:
|
||
return round(sum(values) / len(values), 4) if values else None
|
||
|
||
|
||
def _period_return(values: list[float], days: int) -> float | None:
|
||
return (
|
||
round((values[-1] / values[-days - 1] - 1) * 100, 2)
|
||
if len(values) > days and values[-days - 1]
|
||
else None
|
||
)
|
||
|
||
|
||
def _max_drawdown(values: list[float]) -> float | None:
|
||
if not values:
|
||
return None
|
||
peak = values[0]
|
||
worst = 0.0
|
||
for value in values:
|
||
peak = max(peak, value)
|
||
worst = min(worst, value / peak - 1)
|
||
return round(worst * 100, 2)
|
||
|
||
|
||
def _rsi(values: list[float], days: int) -> float | None:
|
||
if len(values) <= days:
|
||
return None
|
||
changes = [values[i] - values[i - 1] for i in range(len(values) - days, len(values))]
|
||
gains = sum(max(x, 0) for x in changes) / days
|
||
losses = sum(max(-x, 0) for x in changes) / days
|
||
return 100.0 if not losses else round(100 - 100 / (1 + gains / losses), 2)
|
||
|
||
|
||
def _financial(record: MarketFinancialMetric | None) -> dict[str, Any] | None:
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"period_end": record.period_end.isoformat(),
|
||
"revenue_yoy": _optional_float(record.revenue_yoy),
|
||
"net_profit_yoy": _optional_float(record.net_profit_yoy),
|
||
"roe": _optional_float(record.roe),
|
||
"debt_to_assets": _optional_float(record.debt_to_assets),
|
||
"operating_cashflow_yoy": _optional_float(record.operating_cashflow_yoy),
|
||
}
|
||
|
||
|
||
def _optional_float(value: Any) -> float | None:
|
||
return None if value is None else round(float(value), 4)
|
||
|
||
|
||
def _financial_row_order(row: dict[str, Any]) -> tuple[bool, str]:
|
||
return str(row.get("update_flag") or "") == "1", str(row.get("ann_date") or "")
|
||
|
||
|
||
def _ai_announcements(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"symbol": item["symbol"],
|
||
"announcement_date": item["announcement_date"],
|
||
"title": item["title"],
|
||
"category": item["category"],
|
||
}
|
||
for item in items
|
||
]
|