mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 23:54:17 +08:00
537 lines
18 KiB
Python
537 lines
18 KiB
Python
"""Web API tests (offline, no network).
|
||
|
||
Covers: schemas, error handling, app factory, DI, all routers,
|
||
CLI serve command, OpenAPI schema generation.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 2: Schemas & Error Handling
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_market_enum_values():
|
||
"""MarketEnum should map string names to int values matching Market enum."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.schemas import MarketEnum
|
||
|
||
assert MarketEnum.SZ == 0
|
||
assert MarketEnum.SH == 1
|
||
assert MarketEnum.BJ == 2
|
||
|
||
|
||
def test_kline_category_enum():
|
||
"""KlineCategoryEnum should map string names to int values."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.schemas import KlineCategoryEnum
|
||
|
||
assert KlineCategoryEnum.MIN_5 == 0
|
||
assert KlineCategoryEnum.DAY == 4
|
||
assert KlineCategoryEnum.WEEK == 5
|
||
|
||
|
||
def test_quote_request_validation():
|
||
"""QuoteRequest should validate stocks list."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.schemas import QuoteRequest
|
||
|
||
req = QuoteRequest(stocks=[{"market": "SZ", "code": "000001"}])
|
||
assert len(req.stocks) == 1
|
||
assert req.stocks[0].market == "SZ"
|
||
assert req.stocks[0].code == "000001"
|
||
|
||
|
||
def test_chanlun_request_defaults():
|
||
"""ChanlunRequest should have sensible defaults."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.schemas import ChanlunRequest
|
||
|
||
req = ChanlunRequest(market="SZ", code="000001")
|
||
assert req.category == "DAY"
|
||
assert req.count == 800
|
||
|
||
|
||
def test_api_error_response():
|
||
"""ApiErrorResponse should serialize correctly."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.errors import ApiErrorResponse
|
||
|
||
err = ApiErrorResponse(error="test error", detail="some detail")
|
||
d = err.model_dump()
|
||
assert d["error"] == "test error"
|
||
assert d["detail"] == "some detail"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 3: App Factory & Dependency Injection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_app_returns_fastapi_instance():
|
||
"""create_app should return a FastAPI app with routers mounted."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web import create_app
|
||
|
||
app = create_app()
|
||
assert app.title == "easy-tdx API"
|
||
|
||
# Check routers are mounted
|
||
routes = [r.path for r in app.routes]
|
||
assert any("/api/v1/security" in r for r in routes)
|
||
assert any("/api/v1/bars" in r for r in routes)
|
||
assert any("/api/v1/chanlun" in r for r in routes)
|
||
assert any("/ws/realtime" in r for r in routes)
|
||
|
||
|
||
def test_deps_get_client_type():
|
||
"""get_client should be callable (actual client creation needs network)."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.deps import get_client
|
||
|
||
assert callable(get_client)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 4: Market Router
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_market_router_endpoints():
|
||
"""Market router should define all expected endpoints."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.routers.market import router
|
||
|
||
paths = [r.path for r in router.routes]
|
||
assert "/security/count" in paths
|
||
assert "/security/list" in paths
|
||
assert "/security/list-all" in paths
|
||
assert "/quotes" in paths
|
||
assert "/market/stat" in paths
|
||
assert "/fund-flow" in paths
|
||
assert "/fund-flow/history" in paths
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 5: Bars Router
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_bars_router_endpoints():
|
||
"""Bars router should define all expected endpoints."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.routers.bars import router
|
||
|
||
paths = [r.path for r in router.routes]
|
||
assert "/bars" in paths
|
||
assert "/bars/index" in paths
|
||
assert "/minute" in paths
|
||
assert "/minute/history" in paths
|
||
assert "/transaction" in paths
|
||
assert "/transaction/history" in paths
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 6: Finance Router
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_finance_router_endpoints():
|
||
"""Finance router should define all expected endpoints."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.routers.finance import router
|
||
|
||
paths = [r.path for r in router.routes]
|
||
assert "/xdxr" in paths
|
||
assert "/finance" in paths
|
||
assert "/company/category" in paths
|
||
assert "/company/content" in paths
|
||
assert "/financial/file-list" in paths
|
||
assert "/financial/records" in paths
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 7: Block Router
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_block_router_endpoints():
|
||
"""Block router should define expected endpoints."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.routers.block import router
|
||
|
||
paths = [r.path for r in router.routes]
|
||
assert "/block" in paths
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 8: Chanlun Router
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_chanlun_router_endpoints():
|
||
"""Chanlun router should define the analyze endpoint."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.routers.chanlun import router
|
||
|
||
paths = [r.path for r in router.routes]
|
||
assert "/chanlun/analyze" in paths
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 9: Realtime Router
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_realtime_router_endpoints():
|
||
"""Realtime router should define the WebSocket endpoint."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.routers.realtime import router
|
||
|
||
paths = [r.path for r in router.routes]
|
||
assert any("realtime" in p for p in paths)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 10: CLI serve command
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_serve_command_exists():
|
||
"""CLI should have a serve command registered."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.cli import cli
|
||
|
||
assert "serve" in cli.commands
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task 11: Integration — route registration & OpenAPI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Regression: input validation (case-insensitive + invalid → ValueError → 400)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_convert_market_lowercase():
|
||
"""market_from_str should accept lowercase input."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.models.enums import Market
|
||
from easy_tdx.web.convert import market_from_str
|
||
|
||
assert market_from_str("sz") == Market.SZ
|
||
assert market_from_str("sh") == Market.SH
|
||
assert market_from_str("Bj") == Market.BJ
|
||
|
||
|
||
def test_convert_market_invalid_raises_valueerror():
|
||
"""market_from_str should raise ValueError for invalid market codes."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.convert import market_from_str
|
||
|
||
with pytest.raises(ValueError, match="无效市场代码"):
|
||
market_from_str("ZZZ")
|
||
|
||
|
||
def test_convert_category_from_int_string():
|
||
"""category_from_str should accept numeric string like '4'."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.models.enums import KlineCategory
|
||
from easy_tdx.web.convert import category_from_str
|
||
|
||
assert category_from_str("4") == KlineCategory.DAY
|
||
|
||
|
||
def test_convert_category_invalid_raises_valueerror():
|
||
"""category_from_str should raise ValueError for invalid period."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.convert import category_from_str
|
||
|
||
with pytest.raises(ValueError, match="无效K线周期"):
|
||
category_from_str("INVALID_PERIOD")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /bars 迁移到 MacClient:KlineCategory→(Period,times) 映射 + adjust 转换
|
||
# (Issue #43)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_period_times_from_category_mapping():
|
||
"""KlineCategory → (Period, times) 完整映射,重点 YEAR/SEASON 值不同。"""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.mac.enums import Period
|
||
from easy_tdx.models.enums import KlineCategory
|
||
from easy_tdx.web.convert import period_times_from_category
|
||
|
||
expected = {
|
||
KlineCategory.MIN_5: (Period.MIN_5, 1),
|
||
KlineCategory.MIN_15: (Period.MIN_15, 1),
|
||
KlineCategory.MIN_30: (Period.MIN_30, 1),
|
||
KlineCategory.MIN_60: (Period.MIN_60, 1),
|
||
KlineCategory.DAY: (Period.DAILY, 1),
|
||
KlineCategory.WEEK: (Period.WEEKLY, 1),
|
||
KlineCategory.MONTH: (Period.MONTHLY, 1),
|
||
KlineCategory.MIN_1: (Period.MIN_1, 1),
|
||
KlineCategory.YEAR: (Period.YEARLY, 1), # 值 9 → Period.YEARLY 值 11
|
||
KlineCategory.SEASON: (Period.QUARTERLY, 1), # SEASON → QUARTERLY
|
||
}
|
||
for cat, want in expected.items():
|
||
assert period_times_from_category(cat) == want, f"{cat} 应映射到 {want}"
|
||
|
||
|
||
def test_period_times_from_category_rejects_unmappable():
|
||
"""无法映射的 KlineCategory 值(如 MIN_3=8)应抛 ValueError。"""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.convert import period_times_from_category
|
||
|
||
with pytest.raises(ValueError, match="无法映射"):
|
||
period_times_from_category(8) # MIN_3 不在 /bars 支持范围
|
||
|
||
|
||
def test_adjust_from_str_accepts_name_case_and_int():
|
||
"""adjust_from_str 支持 NONE/QFQ/HFQ 名称(大小写)和数字字符串。"""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.mac.enums import Adjust
|
||
from easy_tdx.web.convert import adjust_from_str
|
||
|
||
assert adjust_from_str("QFQ") == Adjust.QFQ
|
||
assert adjust_from_str("qfq") == Adjust.QFQ
|
||
assert adjust_from_str("1") == Adjust.QFQ # 数字字符串
|
||
assert adjust_from_str("NONE") == Adjust.NONE
|
||
assert adjust_from_str("none") == Adjust.NONE
|
||
assert adjust_from_str("0") == Adjust.NONE
|
||
assert adjust_from_str("HFQ") == Adjust.HFQ
|
||
assert adjust_from_str("2") == Adjust.HFQ
|
||
|
||
|
||
def test_adjust_from_str_invalid_raises():
|
||
"""非法复权类型应抛 ValueError。"""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web.convert import adjust_from_str
|
||
|
||
with pytest.raises(ValueError, match="无效复权类型"):
|
||
adjust_from_str("XXX")
|
||
|
||
|
||
def test_normalize_mac_df_daily_plus():
|
||
"""日线规整:datetime→date(截断时分)、drop float_shares、OHLC 顺序 open/close/high/low。"""
|
||
pytest.importorskip("fastapi")
|
||
import pandas as pd
|
||
|
||
from easy_tdx.web.routers.bars import _normalize_mac_df
|
||
|
||
df = pd.DataFrame(
|
||
{
|
||
"datetime": [pd.Timestamp("2026-07-10 15:00:00"), pd.Timestamp("2026-07-11 15:00:00")],
|
||
"open": [10.0, 10.5],
|
||
"high": [10.8, 10.9],
|
||
"low": [9.9, 10.3],
|
||
"close": [10.5, 10.6],
|
||
"vol": [1000.0, 1100.0],
|
||
"amount": [10500.0, 11600.0],
|
||
"float_shares": [0.0, 0.0],
|
||
}
|
||
)
|
||
out = _normalize_mac_df(df, daily_plus=True)
|
||
# 时间列:datetime → date,且截断为 00:00:00
|
||
assert "date" in out.columns
|
||
assert "datetime" not in out.columns
|
||
assert out["date"].iloc[0] == pd.Timestamp("2026-07-11 00:00:00") - pd.Timedelta(days=1)
|
||
# drop float_shares
|
||
assert "float_shares" not in out.columns
|
||
# 列顺序:date 在前,OHLC 顺序 open/close/high/low
|
||
assert list(out.columns) == ["date", "open", "close", "high", "low", "vol", "amount"]
|
||
|
||
|
||
def test_normalize_mac_df_intraday_keeps_datetime():
|
||
"""分钟线规整:保留 datetime 列(含时分)。"""
|
||
pytest.importorskip("fastapi")
|
||
import pandas as pd
|
||
|
||
from easy_tdx.web.routers.bars import _normalize_mac_df
|
||
|
||
df = pd.DataFrame(
|
||
{
|
||
"datetime": [pd.Timestamp("2026-07-10 09:35:00")],
|
||
"open": [10.0],
|
||
"high": [10.8],
|
||
"low": [9.9],
|
||
"close": [10.5],
|
||
"vol": [1000.0],
|
||
"amount": [10500.0],
|
||
}
|
||
)
|
||
out = _normalize_mac_df(df, daily_plus=False)
|
||
assert "datetime" in out.columns
|
||
assert "date" not in out.columns
|
||
# 时分保留
|
||
assert out["datetime"].iloc[0] == pd.Timestamp("2026-07-10 09:35:00")
|
||
assert list(out.columns) == ["datetime", "open", "close", "high", "low", "vol", "amount"]
|
||
|
||
|
||
def test_normalize_mac_df_empty_noop():
|
||
"""空 DataFrame 规整不报错。"""
|
||
pytest.importorskip("fastapi")
|
||
import pandas as pd
|
||
|
||
from easy_tdx.web.routers.bars import _normalize_mac_df
|
||
|
||
out = _normalize_mac_df(pd.DataFrame(), daily_plus=True)
|
||
assert out.empty
|
||
|
||
|
||
def test_is_daily_plus_covers_all_categories():
|
||
"""daily_plus 判定必须按显式周期表,不能按枚举整数大小(issue #49)。
|
||
|
||
KlineCategory 值无序(MIN_1=7、MIN_3=8 > DAY=4),整数比较会把 1/3 分钟线
|
||
误判成日线,导致 datetime 被截断为 00:00:00 且列名变 date。
|
||
"""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.models.enums import KlineCategory
|
||
from easy_tdx.web.routers.bars import _is_daily_plus
|
||
|
||
intraday = {
|
||
KlineCategory.MIN_1,
|
||
KlineCategory.MIN_3,
|
||
KlineCategory.MIN_5,
|
||
KlineCategory.MIN_15,
|
||
KlineCategory.MIN_30,
|
||
KlineCategory.MIN_60,
|
||
}
|
||
for cat in KlineCategory:
|
||
assert _is_daily_plus(cat) == (cat not in intraday), f"{cat.name} 判定错误"
|
||
|
||
|
||
class _FakeMacClient:
|
||
"""替身 AsyncMacClient:固定返回 MacClient 风格的 K 线 DataFrame。"""
|
||
|
||
def __init__(self, df):
|
||
import pandas as pd
|
||
|
||
self._df = df if isinstance(df, pd.DataFrame) else pd.DataFrame(df)
|
||
self.calls: list[dict] = []
|
||
|
||
async def get_stock_kline(self, market, code, period, start, count, times, **kwargs):
|
||
self.calls.append({"period": period, "adjust": kwargs.get("adjust")})
|
||
return self._df
|
||
|
||
|
||
def _bars_app(mac_client):
|
||
"""构造只挂 bars 路由的最小 app(无 lifespan,不触发真实行情连接)。"""
|
||
from fastapi import FastAPI
|
||
|
||
from easy_tdx.web.routers import bars
|
||
|
||
app = FastAPI()
|
||
app.include_router(bars.router, prefix="/api/v1")
|
||
app.state.tdx_client = object() # mac_client 非 None 时不会被用到
|
||
app.state.mac_client = mac_client
|
||
return app
|
||
|
||
|
||
def test_bars_min1_endpoint_keeps_datetime():
|
||
"""端到端回归(issue #49):/bars MIN_1 必须返回 datetime 列且保留时分。"""
|
||
pytest.importorskip("fastapi")
|
||
import pandas as pd
|
||
from fastapi.testclient import TestClient
|
||
|
||
mac_df = pd.DataFrame(
|
||
{
|
||
"datetime": [pd.Timestamp("2026-08-14 09:31:00"), pd.Timestamp("2026-08-14 09:32:00")],
|
||
"open": [10.0, 10.1],
|
||
"high": [10.2, 10.3],
|
||
"low": [9.9, 10.0],
|
||
"close": [10.1, 10.2],
|
||
"vol": [1000.0, 1100.0],
|
||
"amount": [10100.0, 11220.0],
|
||
"float_shares": [0.0, 0.0],
|
||
}
|
||
)
|
||
fake = _FakeMacClient(mac_df)
|
||
with TestClient(_bars_app(fake)) as client:
|
||
resp = client.get(
|
||
"/api/v1/bars",
|
||
params={"market": "SH", "code": "603179", "category": "MIN_1", "count": 2},
|
||
)
|
||
assert resp.status_code == 200
|
||
rows = resp.json()["data"]
|
||
assert len(rows) == 2
|
||
for row in rows:
|
||
assert "datetime" in row and "date" not in row
|
||
assert rows[0]["datetime"] == "2026-08-14T09:31:00"
|
||
assert rows[1]["datetime"] == "2026-08-14T09:32:00"
|
||
|
||
|
||
def test_bars_day_endpoint_returns_date():
|
||
"""端到端对照:/bars DAY 仍返回 date 列(00:00:00),确认修复无回归。"""
|
||
pytest.importorskip("fastapi")
|
||
import pandas as pd
|
||
from fastapi.testclient import TestClient
|
||
|
||
mac_df = pd.DataFrame(
|
||
{
|
||
"datetime": [pd.Timestamp("2026-08-14 15:00:00")],
|
||
"open": [10.0],
|
||
"high": [10.2],
|
||
"low": [9.9],
|
||
"close": [10.1],
|
||
"vol": [1000.0],
|
||
"amount": [10100.0],
|
||
"float_shares": [0.0],
|
||
}
|
||
)
|
||
fake = _FakeMacClient(mac_df)
|
||
with TestClient(_bars_app(fake)) as client:
|
||
resp = client.get(
|
||
"/api/v1/bars", params={"market": "SH", "code": "603179", "category": "DAY", "count": 1}
|
||
)
|
||
assert resp.status_code == 200
|
||
rows = resp.json()["data"]
|
||
assert len(rows) == 1
|
||
assert "date" in rows[0] and "datetime" not in rows[0]
|
||
assert rows[0]["date"] == "2026-08-14T00:00:00"
|
||
|
||
|
||
def test_full_app_routes_registered():
|
||
"""All routers should be mounted and accessible."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web import create_app
|
||
|
||
app = create_app()
|
||
all_paths = [r.path for r in app.routes]
|
||
expected_prefixes = [
|
||
"/api/v1/security",
|
||
"/api/v1/bars",
|
||
"/api/v1/xdxr",
|
||
"/api/v1/block",
|
||
"/api/v1/chanlun",
|
||
"/api/v1/announcements",
|
||
"/api/v1/sina/financial-report",
|
||
"/ws/realtime",
|
||
]
|
||
for prefix in expected_prefixes:
|
||
matched = any(prefix in p for p in all_paths)
|
||
assert matched, f"Expected route with prefix '{prefix}' not found in {all_paths}"
|
||
|
||
|
||
def test_openapi_schema_generated():
|
||
"""OpenAPI schema should be auto-generated and contain key paths."""
|
||
pytest.importorskip("fastapi")
|
||
from easy_tdx.web import create_app
|
||
|
||
app = create_app()
|
||
schema = app.openapi()
|
||
assert schema["info"]["title"] == "easy-tdx API"
|
||
assert "/api/v1/security/count" in schema["paths"]
|
||
assert "/api/v1/bars" in schema["paths"]
|
||
assert "/api/v1/chanlun/analyze" in schema["paths"]
|
||
# WebSocket routes are NOT included in OpenAPI schema by default;
|
||
# they are verified in test_full_app_routes_registered instead.
|
||
# Just ensure REST paths are present.
|
||
assert "/api/v1/fund-flow" in schema["paths"]
|