feat: add shared fund quote capability

This commit is contained in:
2026-09-09 23:40:35 +08:00
parent ca680ca46f
commit 46fc976b24
22 changed files with 1561 additions and 8 deletions
@@ -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"
+15
View File
@@ -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"