156 lines
5.2 KiB
Python
156 lines
5.2 KiB
Python
"""产品服务:列表(分页/筛选/排序)+ 历史业绩(净值/收益走势)。"""
|
||||
|
|
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
|
|||
|
|
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
if page < 1:
|
|||
|
|
raise ParamError("page 必须 >= 1")
|
|||
|
|
if page_size < 1 or page_size > 100:
|
|||
|
|
raise ParamError("page_size 必须在 1-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=(page - 1) * page_size,
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"total": total,
|
|||
|
|
"page": page,
|
|||
|
|
"page_size": page_size,
|
|||
|
|
"items": [_to_item(p) for p in items],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
}
|