feat: add Nailong Fund advisor capabilities
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user