feat: add portfolio graph enrichment

This commit is contained in:
Windows
2026-09-11 14:14:50 +08:00
parent 47a5ca498c
commit 8ffd08b4ce
5 changed files with 117 additions and 13 deletions
+33 -5
View File
@@ -6,20 +6,31 @@ from datetime import date
from decimal import Decimal
from typing import Any
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery
from app.infrastructure.db import SessionFactory
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
from app.model.advisor_product import AdvisorProductIndustryExposure, AdvisorProductMetricSnapshot
from app.model.fund import FundHolding, FundProduct
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
from app.service.authorization_service import AuthorizationService
from app.service.relationship_service import RelationshipService
HUNDRED = Decimal("100")
class PortfolioAnalysisService:
def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None:
def __init__(
self,
*,
session_factory: Callable[[], Any] = SessionFactory,
graph_service: RelationshipService | None = None,
) -> None:
self.session_factory = session_factory
self.graph_service = graph_service or RelationshipService(
Neo4jGraphDriver(get_settings())
)
async def analyze_for_agent(
self, _arguments: PortfolioAnalysisQuery, context: RequestContext
@@ -32,12 +43,29 @@ class PortfolioAnalysisService:
exposures = await repository.latest_industry_exposures(ids, date.today())
metrics = await repository.latest_metrics(ids, date.today())
result = self._analyze(positions, exposures, metrics)
result["graph_context"] = {
"degraded": True,
"reason": "neo4j_not_configured",
}
result["graph_context"] = await self._graph_context(int(context.user_id))
return result
async def _graph_context(self, customer_id: int) -> dict[str, object]:
try:
context = await self.graph_service.portfolio_industry_context(customer_id)
except Exception as exc:
return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"}
if bool(context.get("degraded")):
return {"degraded": True, "reason": str(context.get("reason", "neo4j_unavailable"))}
raw = context.get("overlaps")
if not isinstance(raw, list):
return {"degraded": True, "reason": "neo4j_invalid_result"}
overlaps = [
{"industry_name": item["industry_name"], "product_count": item["product_count"]}
for item in raw
if isinstance(item, dict)
and isinstance(item.get("industry_name"), str)
and isinstance(item.get("product_count"), int)
and item["product_count"] >= 2
]
return {"degraded": False, "overlaps": overlaps[:5]}
@classmethod
def _analyze(
cls,
+17
View File
@@ -49,3 +49,20 @@ class RelationshipService:
return await self.driver.execute_query(query, customer_id=customer_id, limit=safe_limit)
except Exception as exc:
return [GraphDegradedResult(f"neo4j_unavailable:{type(exc).__name__}")]
async def portfolio_industry_context(self, customer_id: int) -> dict[str, object]:
"""Return only relationship enrichment; no position value comes from Neo4j."""
query = (
"MATCH (c:Customer {customer_id: $customer_id})-[:HOLDS]->"
"(h:Holding)-[:EXPOSED_TO_INDUSTRY]->(i:Industry) "
"RETURN i.industry_name AS industry_name, count(DISTINCT h) AS product_count "
"ORDER BY product_count DESC LIMIT $limit"
)
try:
rows = await self.driver.execute_query(query, customer_id=customer_id, limit=20)
except Exception as exc:
return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"}
if any(isinstance(row, GraphDegradedResult) for row in rows):
degraded = next(row for row in rows if isinstance(row, GraphDegradedResult))
return {"degraded": True, "reason": degraded.reason}
return {"degraded": False, "overlaps": list(rows)}