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