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,60 @@
"""Asynchronously projects non-sensitive portfolio relationships to Neo4j."""
from collections.abc import Callable
from datetime import date
from typing import Any
from app.infrastructure.db import SessionFactory
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
from app.service.relationship_service import RelationshipService
class PortfolioGraphProjectionWorker:
"""MySQL remains authoritative; Neo4j only receives minimal relationship snapshots."""
def __init__(
self,
relationships: RelationshipService,
*,
session_factory: Callable[[], Any] = SessionFactory,
) -> None:
self.relationships = relationships
self.session_factory = session_factory
async def project_customer(self, customer_id: int) -> None:
async with self.session_factory() as session:
repository = PortfolioAnalysisRepository(session)
positions = await repository.positions(customer_id)
exposures = await repository.latest_industry_exposures(
tuple(holding.product_id for holding, _product in positions), date.today()
)
holdings = [
{
"source_holding_id": str(holding.id),
"product_id": product.id,
"product_code": product.product_code,
"risk_level": product.risk_level,
"product_category": product.product_category,
"updated_at": holding.updated_at.isoformat(),
}
for holding, product in positions
]
unique_products = {product.id: product for _holding, product in positions}
products = [
{
"product_id": product.id,
"industries": [
{
"industry_code": exposure.industry_code,
"industry_name": exposure.industry_name,
"exposure_weight_pct": str(exposure.exposure_weight_pct),
"as_of_date": exposure.as_of_date.isoformat(),
"source": exposure.source,
}
for exposure in exposures.get(product.id, [])
],
}
for product in unique_products.values()
]
await self.relationships.replace_portfolio_projection(customer_id, holdings)
await self.relationships.replace_product_industry_projection(products)
@@ -0,0 +1,27 @@
"""Rate-limited official-source monitoring for advisory product governance."""
import time
from app.core.config import get_settings
from app.service.product_governance_monitor_service import ProductGovernanceMonitorService
class ProductGovernanceMonitorWorker:
def __init__(
self,
service: ProductGovernanceMonitorService | None = None,
*,
interval_seconds: int | None = None,
) -> None:
self.service = service or ProductGovernanceMonitorService()
configured_interval = get_settings().product_governance_sync_interval_seconds
self.interval_seconds = interval_seconds or configured_interval
self._last_run_at: float | None = None
async def refresh_once(self) -> int:
now = time.monotonic()
if self._last_run_at is not None and now - self._last_run_at < self.interval_seconds:
return 0
self._last_run_at = now
result = await self.service.monitor()
return result.change_count
+12
View File
@@ -0,0 +1,12 @@
"""Periodic public fund NAV synchronization for advisory historical analysis."""
from app.service.product_history_sync_service import ProductHistorySyncService
class ProductHistorySyncWorker:
def __init__(self, service: ProductHistorySyncService | None = None) -> None:
self.service = service or ProductHistorySyncService()
async def refresh_once(self) -> int:
result = await self.service.sync(days=7)
return result.observation_count
@@ -0,0 +1,27 @@
"""Rate-limited exchange quote refresh for advisory market and liquidity evidence."""
import time
from app.core.config import get_settings
from app.service.product_market_quote_sync_service import ProductMarketQuoteSyncService
class ProductMarketQuoteSyncWorker:
def __init__(
self,
service: ProductMarketQuoteSyncService | None = None,
*,
interval_seconds: int | None = None,
) -> None:
self.service = service or ProductMarketQuoteSyncService()
configured_interval = get_settings().product_market_quote_sync_interval_seconds
self.interval_seconds = interval_seconds or configured_interval
self._last_run_at: float | None = None
async def refresh_once(self) -> int:
now = time.monotonic()
if self._last_run_at is not None and now - self._last_run_at < self.interval_seconds:
return 0
self._last_run_at = now
result = await self.service.sync()
return result.quote_count
@@ -0,0 +1,35 @@
"""Refreshes additive advisory metrics through the additive-first dual-read history path."""
from collections.abc import Callable
from typing import Any
from app.infrastructure.db import SessionFactory
from app.repository.product_metric_repository import ProductMetricRepository
from app.service.market_data_quality_service import MarketDataQualityService
from app.service.product_metric_service import ProductMetricService
class ProductMetricRefreshWorker:
def __init__(
self, *, session_factory: Callable[[], Any] = SessionFactory, batch_size: int = 100
) -> None:
self.session_factory = session_factory
self.batch_size = batch_size
async def refresh_once(self) -> int:
async with self.session_factory() as session, session.begin():
repository = ProductMetricRepository(session)
refreshed = 0
for product_id in await repository.product_ids_with_prices(limit=self.batch_size):
prices = await repository.prices(product_id, limit=252)
snapshot = ProductMetricService.snapshot(product_id, prices)
if snapshot is None:
continue
await repository.save_quality_snapshot(
MarketDataQualityService.snapshot(
product_id, prices, evaluated_on=snapshot.as_of_date
)
)
await repository.save_snapshot(snapshot)
refreshed += 1
return refreshed
+46 -4
View File
@@ -12,6 +12,7 @@ from app.core.config import Settings, get_settings
from app.core.contracts import AgentRequest, AgentRequestMetadata, AgentResult, RequestContext
from app.core.errors import AgentError, RecoverableAgentError, RunLeaseLostError
from app.infrastructure.db import SessionFactory
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, DomainEventOutbox, RequestIdempotency
@@ -22,7 +23,14 @@ from app.service.agent.factory import AgentFactory
from app.service.agent_persistence_service import AgentPersistenceService
from app.service.identity_service import IdentityService
from app.service.memory_service import MemoryService
from app.service.portfolio_projection_scheduler import PortfolioProjectionScheduler
from app.service.relationship_service import RelationshipService
from app.worker.outbox_worker import OutboxWorker
from app.worker.portfolio_graph_projection_worker import PortfolioGraphProjectionWorker
from app.worker.product_governance_monitor_worker import ProductGovernanceMonitorWorker
from app.worker.product_history_sync_worker import ProductHistorySyncWorker
from app.worker.product_market_quote_sync_worker import ProductMarketQuoteSyncWorker
from app.worker.product_metric_refresh_worker import ProductMetricRefreshWorker
logger = logging.getLogger(__name__)
@@ -35,6 +43,14 @@ class WorkerRuntime:
self.factory = factory if factory is not None else get_agent_factory()
self.settings = settings or get_settings()
self.resolve_identity = resolve_identity or IdentityService().resolve
self.portfolio_projection_scheduler = PortfolioProjectionScheduler()
self.portfolio_projection_worker = PortfolioGraphProjectionWorker(
RelationshipService(Neo4jGraphDriver(self.settings))
)
self.product_history_sync_worker = ProductHistorySyncWorker()
self.product_governance_monitor_worker = ProductGovernanceMonitorWorker()
self.product_market_quote_sync_worker = ProductMarketQuoteSyncWorker()
self.product_metric_refresh_worker = ProductMetricRefreshWorker()
async def dispatch_one(self, *, run_id: str | None = None) -> bool:
# Outbox acknowledges a durable SQL queue entry, not an in-memory task.
@@ -44,10 +60,23 @@ class WorkerRuntime:
if run is None:
raise ValueError("run not found")
return await OutboxWorker(session, {"agent.run_requested": dispatch}).publish_one(
aggregate_id=run_id)
async def project_portfolio(payload: dict[str, Any]) -> None:
customer_id = payload.get("customer_id")
if type(customer_id) is not int or customer_id <= 0:
raise ValueError("invalid portfolio projection customer")
await self.portfolio_projection_worker.project_customer(customer_id)
return await OutboxWorker(session, {
"agent.run_requested": dispatch,
"portfolio.projection_requested": project_portfolio,
}).publish_one(aggregate_id=run_id)
async def run_once(self) -> bool:
governance_changes = await self.product_governance_monitor_worker.refresh_once()
quotes_refreshed = await self.product_market_quote_sync_worker.refresh_once()
history_refreshed = await self.product_history_sync_worker.refresh_once()
metrics_refreshed = await self.product_metric_refresh_worker.refresh_once()
projection_enqueued = await self.portfolio_projection_scheduler.enqueue_changes()
dispatched = await self.dispatch_one()
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session:
@@ -56,8 +85,21 @@ class WorkerRuntime:
(AgentRun.locked_until.is_(None) | (AgentRun.locked_until < now)),
).order_by(AgentRun.created_at).limit(1))
if run_id is None:
return dispatched
return await self.execute(run_id) or dispatched
return any((
dispatched,
bool(projection_enqueued),
bool(metrics_refreshed),
bool(history_refreshed),
bool(quotes_refreshed),
bool(governance_changes),
))
return (
await self.execute(run_id)
or dispatched
or bool(projection_enqueued)
or bool(metrics_refreshed)
or bool(history_refreshed)
)
async def execute(self, run_id: str) -> bool:
# A new fencing token for each claim also fences restarts of the same process.