feat: add shared fund quote capability
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from app.service.agent.bootstrap import get_agent_factory
|
||||
|
||||
|
||||
def test_public_fund_quote_tool_is_registered_as_read_only() -> None:
|
||||
factory = get_agent_factory()
|
||||
tool = factory._tool_executor.registry.get("query_fund_quote")
|
||||
assert tool.read_only is True
|
||||
assert tool.required_permission == "fund:quote:read"
|
||||
assert "advisor" in tool.allowed_roles
|
||||
assert "risk_operator" in tool.allowed_roles
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_parses_names_quotes_and_history() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "pingzhongdata" in str(request.url):
|
||||
return httpx.Response(200, text='var fS_name = "南方测试基金";')
|
||||
if "ulist.np" in str(request.url):
|
||||
return httpx.Response(200, json={"data": {"diff": [{
|
||||
"f12": "159511", "f2": "1.23", "f3": "0.82"
|
||||
}]}})
|
||||
return httpx.Response(200, json={"Data": {"LSJZList": [{
|
||||
"FSRQ": "2026-09-09", "DWJZ": "1.20", "JZZZL": "0.10"
|
||||
}]}})
|
||||
|
||||
adapter = EastmoneyFundAdapter(client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
assert await adapter.fetch_names(["159511"]) == {"159511": "南方测试基金"}
|
||||
assert (await adapter.fetch_quotes(["159511"]))["159511"]["nav"] == Decimal("1.23")
|
||||
history = await adapter.fetch_history("159511", date(2026, 9, 9))
|
||||
assert history["nav"] == Decimal("1.20")
|
||||
await adapter._client.aclose() # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_retries_then_returns_degraded_history() -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return httpx.Response(503)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
adapter = EastmoneyFundAdapter(client=client, retries=1)
|
||||
result = await adapter.fetch_history("159511", date(2026, 9, 9))
|
||||
assert result == {"degraded": True}
|
||||
assert calls == 2
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.fund_contracts import FundQuoteQuery
|
||||
from app.infrastructure.fund_quote_cache import FundQuoteCache
|
||||
from app.infrastructure.memory_cache import CacheReadResult
|
||||
from app.service.fund_quote_service import FundQuoteService
|
||||
|
||||
|
||||
class MemoryClient:
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[str, str] = {}
|
||||
self.ttls: dict[str, int] = {}
|
||||
|
||||
async def get(self, key: str) -> CacheReadResult:
|
||||
return CacheReadResult(self.values.get(key))
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> bool:
|
||||
self.values[key] = value
|
||||
self.ttls[key] = ttl_seconds
|
||||
return True
|
||||
|
||||
|
||||
class Adapter:
|
||||
calls = 0
|
||||
|
||||
async def fetch_names(self, codes: list[str]) -> dict[str, str]:
|
||||
self.calls += 1
|
||||
return {code: "测试基金" for code in codes}
|
||||
|
||||
async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, object]]:
|
||||
self.calls += 1
|
||||
return {code: {"nav": Decimal("1.2")} for code in codes}
|
||||
|
||||
async def fetch_history(self, code: str, target_date: object) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return {"nav": Decimal("1.1"), "nav_date": target_date, "degraded": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_reads_and_writes_cache() -> None:
|
||||
client = MemoryClient()
|
||||
adapter = Adapter()
|
||||
service = FundQuoteService(adapter, FundQuoteCache(client))
|
||||
query = FundQuoteQuery(fund_codes=("159511",))
|
||||
first = await service.query(query, now=datetime(2026, 9, 9, 8, tzinfo=UTC))
|
||||
second = await service.query(query, now=datetime(2026, 9, 9, 8, tzinfo=UTC))
|
||||
assert first[0].quote_source == "eastmoney"
|
||||
assert second[0].quote_source == "cache"
|
||||
assert adapter.calls == 2
|
||||
assert next(iter(client.ttls.values())) == 900
|
||||
@@ -0,0 +1,23 @@
|
||||
from app.service.fund_quote_service import (
|
||||
FUND_TYPE_GROUPS,
|
||||
FundQuoteRuntimeConfig,
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_runtime_config_uses_safe_defaults() -> None:
|
||||
config = FundQuoteRuntimeConfig.from_mapping({
|
||||
"allowed_codes": ["bad", "159511"],
|
||||
"intraday_cache_ttl": -1,
|
||||
"closing_cache_ttl": "900",
|
||||
})
|
||||
assert config.allowed_codes == ("159511",)
|
||||
assert config.intraday_cache_ttl == 60
|
||||
assert config.closing_cache_ttl == 900
|
||||
|
||||
|
||||
def test_empty_code_list_does_not_disable_market_data() -> None:
|
||||
config = FundQuoteRuntimeConfig.from_mapping({"allowed_codes": []})
|
||||
assert config.allowed_codes
|
||||
assert set(config.allowed_codes) == {
|
||||
code for codes in FUND_TYPE_GROUPS.values() for code in codes
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.fund_contracts import FundQuoteQuery
|
||||
from app.service.fund_quote_service import FundQuoteService
|
||||
|
||||
|
||||
class StubAdapter:
|
||||
async def fetch_names(self, codes: list[str]) -> dict[str, str]:
|
||||
return {code: f"基金-{code}" for code in codes}
|
||||
|
||||
async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, object]]:
|
||||
return {code: {"nav": Decimal("1.23"), "daily_change": Decimal("0.82")} for code in codes}
|
||||
|
||||
async def fetch_history(self, code: str, target_date: object) -> dict[str, object]:
|
||||
return {
|
||||
"nav": Decimal("1.20"), "nav_date": target_date,
|
||||
"daily_change": Decimal("0.10"), "degraded": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_uses_intraday_quote_during_market_hours() -> None:
|
||||
service = FundQuoteService(StubAdapter())
|
||||
result = await service.query(
|
||||
FundQuoteQuery(fund_codes=("159511",)),
|
||||
now=datetime(2026, 9, 9, 2, 0, tzinfo=UTC),
|
||||
)
|
||||
assert result[0].nav == Decimal("1.23")
|
||||
assert result[0].is_intraday is True
|
||||
assert result[0].degraded is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_uses_history_outside_market_hours() -> None:
|
||||
service = FundQuoteService(StubAdapter())
|
||||
result = await service.query(
|
||||
FundQuoteQuery(fund_codes=("159511",)),
|
||||
now=datetime(2026, 9, 9, 8, 0, tzinfo=UTC),
|
||||
)
|
||||
assert result[0].nav == Decimal("1.20")
|
||||
assert result[0].is_intraday is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_filters_type_and_rejects_unknown_type() -> None:
|
||||
service = FundQuoteService(StubAdapter())
|
||||
result = await service.query(FundQuoteQuery(fund_type="债券型", limit=1))
|
||||
assert len(result) == 1
|
||||
assert result[0].fund_type == "债券型"
|
||||
with pytest.raises(ValueError, match="基金类型"):
|
||||
await service.query(FundQuoteQuery(fund_type="未知"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intraday_missing_live_quote_is_degraded() -> None:
|
||||
class NoLive(StubAdapter):
|
||||
async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, object]]:
|
||||
return {}
|
||||
|
||||
result = await FundQuoteService(NoLive()).query(
|
||||
FundQuoteQuery(fund_codes=("159511",)),
|
||||
now=datetime(2026, 9, 9, 2, 0, tzinfo=UTC),
|
||||
)
|
||||
assert result[0].degraded is True
|
||||
assert result[0].quote_source == "degraded"
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.fund_contracts import FundQuote
|
||||
from app.service import fund_quote_service
|
||||
from app.service.fund_quote_service import query_fund_quote_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_fund_quote_tool_returns_stable_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class StubService:
|
||||
async def query(self, query: object) -> list[FundQuote]:
|
||||
return [FundQuote(
|
||||
fund_code="159511", fund_name="测试基金", fund_type="股票型",
|
||||
nav=Decimal("1.20"), nav_date=date(2026, 9, 9),
|
||||
quote_time="2026-09-09T10:00:00+08:00",
|
||||
quote_source="eastmoney", is_intraday=True,
|
||||
)]
|
||||
|
||||
class Factory:
|
||||
@staticmethod
|
||||
def create() -> object:
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(fund_quote_service.EastmoneyAdapterFactory, "create", Factory.create)
|
||||
monkeypatch.setattr(
|
||||
fund_quote_service,
|
||||
"FundQuoteService",
|
||||
lambda _adapter, **_kwargs: StubService(),
|
||||
)
|
||||
result = await query_fund_quote_tool(
|
||||
fund_quote_service.FundQuoteQuery(fund_codes=("159511",)),
|
||||
RequestContext(user_id="1", trace_id="quote"),
|
||||
)
|
||||
assert result[0]["fund_code"] == "159511"
|
||||
assert result[0]["nav"] == "1.20"
|
||||
@@ -0,0 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from app.service.health_service import HealthService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_failure_marks_readiness_degraded(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def unavailable(self: HealthService) -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(HealthService, "_redis_ready", unavailable)
|
||||
service = HealthService()
|
||||
result = await service.ready()
|
||||
assert result["checks"]["redis"] is False
|
||||
assert result["status"] == "degraded"
|
||||
Reference in New Issue
Block a user