"""产品服务:列表(分页/筛选/排序)+ 历史业绩(净值/收益走势)。""" from __future__ import annotations from datetime import date, timedelta from sqlalchemy.ext.asyncio import AsyncSession from model.fin_product import FinProduct from repositories.fund_nav import FundNavRepo from repositories.product import ProductRepo from utils.exceptions import NotFoundError, ParamError from utils.pagination import normalize_pagination, pagination_result def _to_item(p: FinProduct) -> dict: """ORM → 响应 dict(Decimal 显式转 float,避免序列化歧义)。""" return { "id": p.id, "product_code": p.product_code, "product_name": p.product_name, "product_type": p.product_type, "risk_level": p.risk_level, "expected_return": float(p.expected_return) if p.expected_return is not None else None, "nav": float(p.nav) if p.nav is not None else None, "nav_date": p.nav_date.isoformat() if p.nav_date else None, "fee_rate": float(p.fee_rate), "term_days": p.term_days, "fund_manager": p.fund_manager, "status": p.status, # 不改表:交易方式标识由现有字段推导(status=在售 可购买;term_days=0 可定投) "support_purchase": p.status == "在售", "support_dca": p.term_days == 0, } async def list_products( db: AsyncSession, *, page: int = 1, page_size: int = 10, keyword: str | None = None, product_type: str | None = None, risk_level: str | None = None, status: str = "在售", sort_by: str = "create_time", sort_order: str = "desc", ) -> dict: page, page_size, offset = normalize_pagination(page, page_size, max_page_size=100) if sort_order not in ("asc", "desc"): raise ParamError("sort_order 仅支持 asc/desc") items, total = await ProductRepo(db).search( keyword=keyword, product_type=product_type, risk_level=risk_level, status=status, sort_by=sort_by, sort_order=sort_order, limit=page_size, offset=offset, ) return pagination_result( [_to_item(p) for p in items], total, page=page, page_size=page_size ) def _calc_metrics(rows: list) -> dict: """基于净值序列计算区间汇总指标(百分比)。 - total_growth_rate:总增长率 = 每天日增长率累加 - max_return_rate:区间最大收益率 = 从区间内最低点买入到其后最高点的最大涨幅 - max_drawdown_rate:区间最大回撤率 = 从区间内最高点回落到其后最低点的最大跌幅 """ total_growth = sum( float(r.daily_growth) for r in rows if r.daily_growth is not None ) peak = trough = float(rows[0].unit_nav) max_return = max_drawdown = 0.0 for r in rows: nav = float(r.unit_nav) peak = max(peak, nav) trough = min(trough, nav) max_drawdown = max(max_drawdown, (peak - nav) / peak if peak else 0.0) max_return = max(max_return, (nav - trough) / trough if trough else 0.0) return { "total_growth_rate": round(total_growth, 4), "max_return_rate": round(max_return * 100, 4), "max_drawdown_rate": round(max_drawdown * 100, 4), } async def get_history( db: AsyncSession, product_code: str, start_date: date | None = None, end_date: date | None = None, series: str = "nav", ) -> dict: if series not in ("nav", "return"): raise ParamError("series 仅支持 nav/return") if await ProductRepo(db).get_by_code(product_code) is None: raise NotFoundError("基金产品不存在") end = end_date or date.today() start = start_date or (end - timedelta(days=90)) if start > end: raise ParamError("start_date 不能晚于 end_date") rows = await FundNavRepo(db).list_series(product_code, start, end) if not rows: return { "product_code": product_code, "series": series, "total_growth_rate": 0.0, "max_return_rate": 0.0, "max_drawdown_rate": 0.0, "items": [], } if series == "nav": items = [ { "nav_date": r.nav_date.isoformat(), "unit_nav": float(r.unit_nav), "accumulated_nav": float(r.accumulated_nav) if r.accumulated_nav is not None else None, "daily_growth": float(r.daily_growth) if r.daily_growth is not None else None, } for r in rows ] else: base = float(rows[0].unit_nav) # 区间首日为基准,累计收益实时从净值计算 items = [ { "nav_date": r.nav_date.isoformat(), "unit_nav": float(r.unit_nav), "cum_return_pct": round((float(r.unit_nav) / base - 1) * 100, 4), } for r in rows ] metrics = _calc_metrics(rows) return { "product_code": product_code, "series": series, "total_growth_rate": metrics["total_growth_rate"], "max_return_rate": metrics["max_return_rate"], "max_drawdown_rate": metrics["max_drawdown_rate"], "items": items, }