Files
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

1121 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
]