Files
Mutual_Fund/service/product.py

156 lines
5.2 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.
"""产品服务:列表(分页/筛选/排序)+ 历史业绩(净值/收益走势)。"""
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,
}