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