feat: add Nailong Fund advisor capabilities

This commit is contained in:
Windows
2026-09-11 09:47:40 +08:00
parent 5907fcd6d2
commit a4499defee
143 changed files with 12255 additions and 89 deletions
@@ -0,0 +1,78 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from tools.sync_nanfang_official_product_governance import (
collect_official_rows,
contract_document,
)
def overview() -> dict[str, object]:
return {
"code": "ETS-5BP00000",
"data": {
"fundRiskRating": {"RISKRATING": "\u4e2d\u4f4e\u98ce\u9669(R2)"},
"fund_info": {
"fundName": "\u5357\u65b9\u6d4b\u8bd5ETF",
"fundDate": "20240101",
"contractValidDate": "20240101",
"basedetailType": "\u503a\u5238\u578b\u57fa\u91d1",
"tzfw": "\u6d4b\u8bd5\u6295\u8d44\u8303\u56f4",
"yjbjjz": "\u6d4b\u8bd5\u4e1a\u7ee9\u6bd4\u8f83\u57fa\u51c6",
"jjtgr": "\u6d4b\u8bd5\u6258\u7ba1\u4eba",
"glYearRatio": 0.5,
"tgYearRatio": 0.1,
},
},
}
def documents() -> list[dict[str, object]]:
return [
{
"title": "\u5357\u65b9\u6d4b\u8bd5ETF\u6258\u7ba1\u534f\u8bae",
"linkUrl": "/bad.doc",
"publishTime": "2026-01-01",
},
{
"title": "\u5357\u65b9\u6d4b\u8bd5ETF\u57fa\u91d1\u5408\u540c",
"linkUrl": "/main/files/contract.docx",
"createTimeString": "2024-01-01",
"publishTime": "2024-01-02",
},
]
def test_collects_official_rating_and_contract_hash() -> None:
suitability, contract = collect_official_rows(
"123456",
overview(),
documents(),
b"official-contract",
synced_at=datetime(2026, 9, 10, 8, 0, tzinfo=UTC),
)
assert suitability["risk_level"] == "R2"
assert suitability["review_status"] == "verified"
assert contract["source_url"] == "https://www.nffund.com/main/files/contract.docx"
assert contract["document_sha256"] == (
"0d53cbfbbcfea4857c2575dd2441ced3d6f53a56b34fdaa6b526ce6b755faaf1"
)
assert contract["management_fee_rate_pct"] == "0.5"
def test_contract_document_excludes_custody_agreement() -> None:
assert contract_document(documents(), "123456")["linkUrl"] == "/main/files/contract.docx"
def test_requires_official_risk_rating() -> None:
invalid = overview()
data = invalid["data"]
assert isinstance(data, dict)
data["fundRiskRating"] = {"RISKRATING": "\u4e2d\u7b49\u98ce\u9669"}
with pytest.raises(ValueError, match="did not disclose"):
collect_official_rows(
"123456", invalid, documents(), b"contract", synced_at=datetime.now(UTC)
)
@@ -0,0 +1,13 @@
from tools.import_product_asset_classifications import classify_contract
def test_classify_contract_uses_official_fund_type_before_scope_keywords() -> None:
assert classify_contract("货币市场基金", "货币市场工具和银行存款") == "cash_management_etf"
assert classify_contract("债券型", "债券资产比例不低于基金资产的80%") == "bond_etf"
assert classify_contract("指数型", "标的指数成份股比例不低于90%") == "equity_etf"
def test_classify_contract_requires_stock_scope_for_hybrid_and_skips_fund_of_funds() -> None:
assert classify_contract("混合型", "股票投资比例范围为40%-95%") == "equity_etf"
assert classify_contract("混合型", "债券、现金及其他工具") is None
assert classify_contract("基金中基金", "主要投资于公募基金") is None
@@ -0,0 +1,82 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tools.import_product_governance_reference import (
CONTRACT_FIELDS,
SUITABILITY_FIELDS,
normalize_contracts,
normalize_suitability,
read_rows,
)
def suitability_row() -> dict[str, str]:
return {
"product_code": "511810",
"sales_institution": "\u6d4b\u8bd5\u9500\u552e\u673a\u6784",
"risk_level": "R1",
"effective_from": "2026-01-01",
"effective_until": "",
"source_url": "https://institution.example/risk-disclosure.pdf",
"document_title": "\u4ea7\u54c1\u98ce\u9669\u7b49\u7ea7\u516c\u793a",
"document_published_at": "2026-01-01",
"document_sha256": "a" * 64,
"source": "sales_institution_disclosure",
"review_status": "verified",
"verified_by": "reviewer",
"verified_at": "2026-01-02T08:00:00+08:00",
"_line_number": "2",
}
def contract_row() -> dict[str, str]:
return {
"product_code": "511810",
"effective_from": "2026-01-01",
"effective_until": "",
"fund_type": "ETF",
"investment_scope": "\u8d27\u5e01\u5e02\u573a\u5de5\u5177",
"performance_benchmark": "\u4e03\u5929\u901a\u77e5\u5b58\u6b3e\u5229\u7387",
"risk_return_characteristics": "\u4f4e\u98ce\u9669\u3001\u4f4e\u6536\u76ca",
"custodian_name": "\u6d4b\u8bd5\u6258\u7ba1\u4eba",
"management_fee_rate_pct": "0.33",
"custodian_fee_rate_pct": "0.10",
"inception_date": "2013-01-01",
"source_url": "https://manager.example/contract.pdf",
"document_title": "\u57fa\u91d1\u5408\u540c",
"document_published_at": "2026-01-01",
"document_sha256": "b" * 64,
"source": "fund_manager_official",
"review_status": "verified",
"verified_by": "reviewer",
"verified_at": "2026-01-02T08:00:00+08:00",
"_line_number": "2",
}
def test_normalize_references_preserves_verification_and_contract_fields() -> None:
suitability = normalize_suitability([suitability_row()])
contract = normalize_contracts([contract_row()])
assert suitability[0]["risk_level"] == "R1"
assert suitability[0]["verified_at"] is not None
assert contract[0]["management_fee_rate_pct"] is not None
assert contract[0]["investment_scope"] == "\u8d27\u5e01\u5e02\u573a\u5de5\u5177"
def test_verified_reference_requires_reviewer_and_timestamp() -> None:
row = suitability_row()
row["verified_at"] = ""
with pytest.raises(ValueError, match="verified rows require"):
normalize_suitability([row])
def test_reader_rejects_missing_required_columns(tmp_path: Path) -> None:
path = tmp_path / "bad.csv"
path.write_text("product_code,risk_level\n511810,R1\n", encoding="utf-8")
with pytest.raises(ValueError, match="missing CSV columns"):
read_rows(path, SUITABILITY_FIELDS)
with pytest.raises(ValueError, match="missing CSV columns"):
read_rows(path, CONTRACT_FIELDS)