feat:新增接口

This commit is contained in:
zhangyongcai
2026-09-11 09:51:46 +08:00
parent afbbf461b6
commit 201dcc6a10
13 changed files with 592 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
"""产品服务:列表(分页/筛选/排序)+ 历史业绩(净值/收益走势)。"""
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,
}
+85
View File
@@ -0,0 +1,85 @@
"""问卷服务:提交风险测评 → 完整性/选项校验 → 算分定级 → 落风评记录 → 回写画像。"""
from __future__ import annotations
from datetime import date, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from model.fin_risk_assessment import FinRiskAssessment
from repositories.questionnaire import QuestionRepo, QuestionnaireRepo
from repositories.risk_assessment import CustomerProfileRepo, RiskAssessmentRepo
from utils.exceptions import NotFoundError, ParamError
# 分数 → 风险等级映射(总分 0-100,20 分一档;后续可迁移到 sys_config 运营化)
_SCORE_LEVELS = (
(80, "激进"),
(60, "进取"),
(40, "平衡"),
(20, "稳健"),
(0, "保守"),
)
def _score_to_level(score: int) -> str:
for threshold, level in _SCORE_LEVELS:
if score >= threshold:
return level
return "保守"
async def submit_assessment(
db: AsyncSession,
customer_id: int,
questionnaire_id: int,
answers: list[dict],
) -> dict:
questionnaire = await QuestionnaireRepo(db).get(questionnaire_id)
if questionnaire is None:
raise NotFoundError("问卷不存在")
if questionnaire.status != "启用":
raise ParamError("问卷未启用")
questions = await QuestionRepo(db).list_by_questionnaire(questionnaire_id)
if not questions:
raise ParamError("问卷尚未配置题目")
# 提交答案按 question_no(题号)建索引,校验必填完整性与选项合法性
by_no = {a["question_no"]: a["option"] for a in answers}
total_score = 0
detail = []
for q in questions:
option = by_no.get(q.question_no)
if option is None:
raise ParamError(f"第{q.question_no}题未作答")
scores = q.score_json or {}
if option not in scores:
raise ParamError(f"第{q.question_no}题选项非法: {option}")
score = int(scores[option])
total_score += score
detail.append({"q": q.question_no, "a": option, "score": score})
risk_level = _score_to_level(total_score)
today = date.today()
valid_until = today + timedelta(days=365) # 监管要求一年一评
record = FinRiskAssessment(
customer_id=customer_id,
assessment_date=today,
question_version=questionnaire.version,
total_score=total_score,
risk_level=risk_level,
answers=detail,
assessor_type="AI评估",
valid_until=valid_until,
)
record = await RiskAssessmentRepo(db).add(record)
# 回写画像:无画像初始化,有画像更新风险等级/评分并递增版本号
await CustomerProfileRepo(db).upsert_risk(customer_id, risk_level, total_score)
return {
"assessment_id": record.id,
"total_score": total_score,
"risk_level": risk_level,
"valid_until": valid_until.isoformat(),
}