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,134 @@
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
import pytest
from app.model.product import FinancialHolding, FinancialProduct, ProductIndustryExposure
from app.service.relationship_service import RelationshipService
from app.worker.portfolio_graph_projection_worker import PortfolioGraphProjectionWorker
class Session:
async def __aenter__(self) -> "Session":
return self
async def __aexit__(self, _type: object, _value: object, _traceback: object) -> None:
return None
class RecordingGraph:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
async def execute_query(self, query: str, **parameters: Any) -> list[object]:
self.calls.append((query, parameters))
return []
def product(product_id: int, code: str) -> FinancialProduct:
return FinancialProduct(
id=product_id,
product_code=code,
product_name="不应写入图谱的产品名称",
exchange_code="SSE",
product_category="ETF",
risk_level="R3",
fund_manager=None,
current_nav=None,
open_start_at=None,
open_end_at=None,
open_period_start=None,
open_period_end=None,
risk_disclosure_required=True,
second_confirmation_required=False,
recording_required=False,
status="上市",
)
def holding() -> FinancialHolding:
return FinancialHolding(
id=9,
customer_id=7,
product_id=3,
total_quantity=Decimal("100"),
market_value=Decimal("9999.99"),
profit_loss=Decimal("123.45"),
profit_loss_ratio=Decimal("1.23"),
status="持有中",
updated_at=datetime.now(UTC).replace(tzinfo=None),
)
def exposure() -> ProductIndustryExposure:
now = datetime.now(UTC).replace(tzinfo=None)
return ProductIndustryExposure(
id=1,
product_id=3,
industry_code="TECH",
industry_name="科技",
exposure_weight_pct=Decimal("80"),
as_of_date=date.today(),
source="test",
status="active",
created_at=now,
updated_at=now,
)
@pytest.mark.asyncio
async def test_worker_projects_only_minimal_fixed_portfolio_relationships(
monkeypatch: pytest.MonkeyPatch,
) -> None:
positions = [(holding(), product(3, "510300"))]
async def repository_positions(_self: object, customer_id: int) -> list[object]:
assert customer_id == 7
return positions
async def repository_exposures(
_self: object, product_ids: tuple[int, ...], _as_of_date: date
) -> dict[int, list[ProductIndustryExposure]]:
assert product_ids == (3,)
return {3: [exposure()]}
monkeypatch.setattr(
"app.worker.portfolio_graph_projection_worker.PortfolioAnalysisRepository.positions",
repository_positions,
)
monkeypatch.setattr(
"app.worker.portfolio_graph_projection_worker.PortfolioAnalysisRepository.latest_industry_exposures",
repository_exposures,
)
graph = RecordingGraph()
worker = PortfolioGraphProjectionWorker(
RelationshipService(graph), session_factory=Session
)
await worker.project_customer(7)
holding_query, holding_parameters = graph.calls[0]
exposure_query, exposure_parameters = graph.calls[1]
assert "[old:HOLDS]" in holding_query
assert "MERGE (c)-[r:HOLDS]->(p)" in holding_query
assert "[old:EXPOSED_TO_INDUSTRY]" in exposure_query
assert "MERGE (p)-[r:EXPOSED_TO_INDUSTRY]->(i)" in exposure_query
assert set(holding_parameters["holdings"][0]) == {
"source_holding_id",
"product_id",
"product_code",
"risk_level",
"product_category",
"updated_at",
}
assert set(exposure_parameters["products"][0]) == {"product_id", "industries"}
serialized_payload = repr({
"holdings": holding_parameters["holdings"],
"products": exposure_parameters["products"],
})
forbidden_fields = (
"market_value", "cost_amount", "product_name", "customer_name", "phone", "email"
)
for forbidden in forbidden_fields:
assert forbidden not in serialized_payload
@@ -0,0 +1,50 @@
from dataclasses import dataclass
import pytest
from app.worker.product_governance_monitor_worker import ProductGovernanceMonitorWorker
from app.worker.product_market_quote_sync_worker import ProductMarketQuoteSyncWorker
@dataclass
class GovernanceResult:
change_count: int
@dataclass
class QuoteResult:
quote_count: int
class GovernanceService:
calls = 0
async def monitor(self) -> GovernanceResult:
self.calls += 1
return GovernanceResult(change_count=2)
class QuoteService:
calls = 0
async def sync(self) -> QuoteResult:
self.calls += 1
return QuoteResult(quote_count=3)
@pytest.mark.asyncio
async def test_governance_worker_rate_limits_source_monitoring() -> None:
service = GovernanceService()
worker = ProductGovernanceMonitorWorker(service=service, interval_seconds=3600)
assert await worker.refresh_once() == 2
assert await worker.refresh_once() == 0
assert service.calls == 1
@pytest.mark.asyncio
async def test_quote_worker_rate_limits_exchange_refreshes() -> None:
service = QuoteService()
worker = ProductMarketQuoteSyncWorker(service=service, interval_seconds=60)
assert await worker.refresh_once() == 3
assert await worker.refresh_once() == 0
assert service.calls == 1
@@ -0,0 +1,89 @@
from datetime import date
from decimal import Decimal
import pytest
from app.model.product import (
FinancialMarketPrice,
ProductDataQualitySnapshot,
ProductMetricSnapshot,
)
from app.worker.product_metric_refresh_worker import ProductMetricRefreshWorker
class Transaction:
async def __aenter__(self) -> "Transaction":
return self
async def __aexit__(self, _type: object, _value: object, _traceback: object) -> None:
return None
class Session:
def __init__(self) -> None:
self.snapshots: list[ProductMetricSnapshot] = []
self.quality_snapshots: list[ProductDataQualitySnapshot] = []
async def __aenter__(self) -> "Session":
return self
async def __aexit__(self, _type: object, _value: object, _traceback: object) -> None:
return None
def begin(self) -> Transaction:
return Transaction()
def add(self, snapshot: ProductMetricSnapshot) -> None:
self.snapshots.append(snapshot)
@pytest.mark.asyncio
async def test_refresh_worker_creates_one_snapshot_for_new_market_date(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = Session()
prices = [
FinancialMarketPrice(
id=index,
product_id=7,
trade_date=date(2026, 1, index),
close_price=Decimal(str(100 + index)),
turnover_amount=Decimal("1000"),
)
for index in range(1, 22)
]
async def product_ids(_self: object, *, limit: int) -> list[int]:
assert limit == 100
return [7]
async def history(_self: object, product_id: int, *, limit: int) -> list[FinancialMarketPrice]:
assert product_id == 7
assert limit == 252
return prices
async def save(_self: object, snapshot: ProductMetricSnapshot) -> None:
session.add(snapshot)
async def save_quality(_self: object, snapshot: ProductDataQualitySnapshot) -> None:
session.quality_snapshots.append(snapshot)
monkeypatch.setattr(
"app.worker.product_metric_refresh_worker.ProductMetricRepository.product_ids_with_prices",
product_ids,
)
monkeypatch.setattr(
"app.worker.product_metric_refresh_worker.ProductMetricRepository.prices", history
)
monkeypatch.setattr(
"app.worker.product_metric_refresh_worker.ProductMetricRepository.save_snapshot", save
)
monkeypatch.setattr(
"app.worker.product_metric_refresh_worker.ProductMetricRepository.save_quality_snapshot",
save_quality,
)
assert await ProductMetricRefreshWorker(session_factory=lambda: session).refresh_once() == 1
assert len(session.snapshots) == 1
assert len(session.quality_snapshots) == 1
assert session.snapshots[0].product_id == 7