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) -> dict[str, Any]: code = normalize_symbol(symbol) record = self.db.execute( select(MarketWatchlist).where( MarketWatchlist.actor == actor, MarketWatchlist.symbol == code ) ).scalar_one_or_none() if record is None: record = MarketWatchlist(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, "symbol": code, "enabled": True} def watchlist(self, actor: str) -> list[dict[str, Any]]: records = self.db.execute( select(MarketWatchlist).where( MarketWatchlist.actor == actor, MarketWatchlist.enabled.is_(True) ) ).scalars() return [{"symbol": r.symbol} for r in 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 ]