423 lines
16 KiB
Python
423 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from backend.data.providers.tushare_client import TushareClient, TushareError
|
|
from backend.data.providers.tushare_helpers import _moneyflow_payload
|
|
from backend.data.realtime import (
|
|
WebRealtimeAggregator,
|
|
_normalize_eastmoney_limit_row,
|
|
_normalize_eastmoney_stock_quote,
|
|
)
|
|
from backend.data.providers.tushare_daily import DailyMarketMixin
|
|
|
|
|
|
class MoneyflowPayloadTests(unittest.TestCase):
|
|
def test_missing_row_is_not_zero(self) -> None:
|
|
payload = _moneyflow_payload(None)
|
|
self.assertFalse(payload["available"])
|
|
self.assertIsNone(payload["net_million"])
|
|
self.assertIsNone(payload["large_million"])
|
|
|
|
def test_empty_row_is_not_zero(self) -> None:
|
|
payload = _moneyflow_payload({})
|
|
self.assertFalse(payload["available"])
|
|
self.assertIsNone(payload["net_million"])
|
|
|
|
def test_real_zero_net_is_kept_when_source_exists(self) -> None:
|
|
payload = _moneyflow_payload(
|
|
{
|
|
"net_mf_amount": 0,
|
|
"buy_lg_amount": 1,
|
|
"sell_lg_amount": 1,
|
|
"buy_elg_amount": 0,
|
|
"sell_elg_amount": 0,
|
|
"buy_md_amount": 0,
|
|
"sell_md_amount": 0,
|
|
"buy_sm_amount": 0,
|
|
"sell_sm_amount": 0,
|
|
}
|
|
)
|
|
self.assertTrue(payload["available"])
|
|
self.assertEqual(payload["net_million"], 0)
|
|
|
|
|
|
class LimitOverlayTests(unittest.TestCase):
|
|
def test_normalize_limit_keeps_missing_seal_as_none(self) -> None:
|
|
row = DailyMarketMixin._normalize_limit(
|
|
{
|
|
"ts_code": "000737.SZ",
|
|
"name": "北方铜业",
|
|
"close": 12.3,
|
|
"pct_chg": 10,
|
|
"amount": 1e8,
|
|
"amount_unit": "yuan",
|
|
},
|
|
"涨停",
|
|
)
|
|
self.assertIsNone(row["seal_amount_million"])
|
|
self.assertEqual(row["first_time"], "--")
|
|
|
|
def test_overlay_fills_board_times_from_official_list(self) -> None:
|
|
mixin = DailyMarketMixin()
|
|
mixin._load_limit_lists = lambda trade_date: [
|
|
{
|
|
"ts_code": "000737.SZ",
|
|
"first_time": "09:31:02",
|
|
"last_time": "10:18:11",
|
|
"fd_amount": 82000000,
|
|
"open_times": 1,
|
|
"turnover_ratio": 18.4,
|
|
}
|
|
]
|
|
mixin.realtime_aggregator = None
|
|
rows = mixin._overlay_board_fields(
|
|
[{"ts_code": "000737.SZ", "close": 12.3, "limit_type": "U"}],
|
|
"20260908",
|
|
)
|
|
self.assertEqual(rows[0]["first_time"], "09:31:02")
|
|
self.assertEqual(rows[0]["fd_amount"], 82000000)
|
|
self.assertEqual(rows[0]["turnover_ratio"], 18.4)
|
|
|
|
def test_overlay_replaces_provisional_first_board_with_observed_streak(self) -> None:
|
|
mixin = DailyMarketMixin()
|
|
mixin._load_limit_lists = lambda trade_date: []
|
|
mixin.try_limit_pool = lambda trade_date: [
|
|
{"ts_code": "000737.SZ", "limit_times": 3}
|
|
]
|
|
|
|
rows = mixin._overlay_board_fields(
|
|
[{"ts_code": "000737.SZ", "limit_times": 1, "limit_type": "U"}],
|
|
"20260909",
|
|
)
|
|
|
|
self.assertEqual(rows[0]["limit_times"], 3)
|
|
|
|
def test_daily_fallback_extends_yesterday_streak(self) -> None:
|
|
mixin = DailyMarketMixin()
|
|
mixin.query = lambda *args, **kwargs: []
|
|
mixin._load_limit_lists = lambda trade_date: []
|
|
|
|
rows = mixin._derive_limits(
|
|
"20260909",
|
|
[
|
|
{
|
|
"ts_code": "000737.SZ",
|
|
"trade_date": "20260909",
|
|
"close": 11.0,
|
|
"high": 11.0,
|
|
"pct_chg": 10.0,
|
|
"amount": 100000,
|
|
}
|
|
],
|
|
price_limits=[
|
|
{"ts_code": "000737.SZ", "up_limit": 11.0, "down_limit": 9.0}
|
|
],
|
|
basic_rows=[
|
|
{"ts_code": "000737.SZ", "name": "北方铜业", "industry": "有色"}
|
|
],
|
|
previous_limit_rows=[
|
|
{"ts_code": "000737.SZ", "limit_times": 2}
|
|
],
|
|
)
|
|
|
|
self.assertEqual(rows[0]["limit_times"], 3)
|
|
|
|
|
|
class ShenwanRealtimeSourceTests(unittest.TestCase):
|
|
def test_transport_refuses_rt_sw_k(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
with self.assertRaisesRegex(TushareError, "rt_sw_k is disabled"):
|
|
client.query("rt_sw_k", {"ts_code": "801074.SI"})
|
|
|
|
def test_outer_realtime_uses_hub_sector_quote_not_rt_sw_k(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
client.query = MagicMock(side_effect=AssertionError("should not call tushare"))
|
|
client.try_sector_quote = MagicMock(return_value={
|
|
"code": "801074.SI",
|
|
"name": "工业金属",
|
|
"close": 1234.5,
|
|
"pre_close": 1200,
|
|
"change": 2.88,
|
|
"pct_change": 2.88,
|
|
"quote_date": "20260908",
|
|
"quote_time": "2026-09-08T14:50:00+08:00",
|
|
"source": "eastmoney_sw",
|
|
})
|
|
row, source, error = client._sw_outer_realtime("801074.SI", "工业金属", "20260908")
|
|
self.assertEqual(source, "eastmoney_sw")
|
|
self.assertEqual(error, "")
|
|
self.assertEqual(row["change"], 2.88)
|
|
client.query.assert_not_called()
|
|
|
|
def test_outer_waiting_state_has_no_permission_error(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
client.realtime_aggregator = None
|
|
row, source, error = client._sw_outer_realtime(
|
|
"801074.SI", "工业金属", "20260908", finalized=True
|
|
)
|
|
self.assertEqual(row, {})
|
|
self.assertIn("尚未入库", error)
|
|
self.assertNotIn("权限", error)
|
|
self.assertNotIn("rt_sw_k", error)
|
|
|
|
|
|
class EastmoneyHelperTests(unittest.TestCase):
|
|
def test_limit_pool_row_keeps_board_clock(self) -> None:
|
|
parsed = _normalize_eastmoney_limit_row(
|
|
{
|
|
"c": "000737",
|
|
"m": 0,
|
|
"n": "北方铜业",
|
|
"fbt": 93102,
|
|
"lbt": 101811,
|
|
"zbc": 1,
|
|
"lbc": 2,
|
|
"hs": 18.4,
|
|
"fund": 82000000,
|
|
},
|
|
"U",
|
|
)
|
|
self.assertEqual(parsed["ts_code"], "000737.SZ")
|
|
self.assertEqual(parsed["first_time"], "09:31:02")
|
|
self.assertEqual(parsed["last_time"], "10:18:11")
|
|
self.assertEqual(parsed["fd_amount"], 82000000)
|
|
|
|
def test_stock_quote_keeps_moneyflow_when_present(self) -> None:
|
|
quote = _normalize_eastmoney_stock_quote(
|
|
{
|
|
"f43": 12.3,
|
|
"f60": 11.18,
|
|
"f46": 11.2,
|
|
"f44": 12.3,
|
|
"f45": 11.1,
|
|
"f47": 1000,
|
|
"f48": 150000000,
|
|
"f58": "北方铜业",
|
|
"f86": 0,
|
|
"f168": 8.5,
|
|
"f62": 25000000,
|
|
"f78": 3000000,
|
|
"f84": -1000000,
|
|
},
|
|
"000737.SZ",
|
|
)
|
|
self.assertEqual(quote["net_mf_amount"], 2500)
|
|
payload = _moneyflow_payload(quote)
|
|
self.assertTrue(payload["available"])
|
|
self.assertEqual(payload["net_million"], 25)
|
|
|
|
@patch.object(WebRealtimeAggregator, "_get_json")
|
|
def test_shenwan_quote_uses_eastmoney_90_prefix(self, get_json: MagicMock) -> None:
|
|
get_json.return_value = {
|
|
"rc": 0,
|
|
"data": {
|
|
"diff": [
|
|
{
|
|
"f12": "801074",
|
|
"f14": "工业金属",
|
|
"f2": 1234.5,
|
|
"f3": 2.88,
|
|
"f18": 1200,
|
|
"f17": 1205,
|
|
"f15": 1240,
|
|
"f16": 1198,
|
|
"f6": 1,
|
|
"f124": 1757319000,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
quote = WebRealtimeAggregator().eastmoney_shenwan_quote("801074.SI")
|
|
self.assertEqual(quote["source"], "eastmoney_sw")
|
|
self.assertAlmostEqual(quote["change"], 2.88)
|
|
params = get_json.call_args.args[1]
|
|
self.assertEqual(params["secids"], "90.801074")
|
|
|
|
|
|
class ChartWindowTests(unittest.TestCase):
|
|
def test_display_window_is_45_not_250(self) -> None:
|
|
from backend.features.market.charts import DAILY_CHART_LIMIT
|
|
|
|
self.assertEqual(DAILY_CHART_LIMIT, 45)
|
|
|
|
|
|
class MemberQuoteCoverageTests(unittest.TestCase):
|
|
def test_prefers_full_hub_market_over_truncated_named_quotes(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
wanted = [f"{index:06d}.SZ" for index in range(205)]
|
|
market = [
|
|
{"ts_code": code, "close": 10.0, "pre_close": 9.0}
|
|
for code in wanted
|
|
]
|
|
client.try_market_quotes = MagicMock(return_value=market)
|
|
client.try_quotes = MagicMock(return_value=market[:60])
|
|
client.realtime_aggregator = MagicMock()
|
|
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
|
|
self.assertEqual(len(rows), 205)
|
|
self.assertEqual(source, "datahub")
|
|
client.try_quotes.assert_not_called()
|
|
|
|
def test_hub_named_quotes_cover_members_when_market_missing(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
wanted = ["000737.SZ", "000630.SZ"]
|
|
client.try_market_quotes = MagicMock(return_value=None)
|
|
client.try_quotes = MagicMock(return_value=[
|
|
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
|
|
{"ts_code": "000630.SZ", "close": 4.5, "pre_close": 4.4},
|
|
])
|
|
client.realtime_aggregator = MagicMock()
|
|
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
|
|
self.assertEqual(len(rows), 2)
|
|
self.assertEqual(source, "datahub")
|
|
client.try_quotes.assert_called()
|
|
client.realtime_aggregator.eastmoney_stock_quotes.assert_not_called()
|
|
client.realtime_aggregator.tencent_stock_quotes.assert_not_called()
|
|
|
|
def test_delayed_hub_quotes_are_kept_not_zeroed(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
delayed = [
|
|
{
|
|
"ts_code": "000737.SZ",
|
|
"close": 12.3,
|
|
"pre_close": 11.2,
|
|
"delayed": True,
|
|
"delay_seconds": 90,
|
|
"delay_notice": "主备免费行情均暂不可用,显示 90 秒前的真实快照",
|
|
}
|
|
]
|
|
client.try_market_quotes = MagicMock(return_value=delayed)
|
|
client.try_quotes = MagicMock()
|
|
client.realtime_aggregator = MagicMock()
|
|
rows, source = client._load_member_realtime_quotes(["000737.SZ"], "20260908")
|
|
self.assertEqual(source, "datahub_delayed")
|
|
self.assertEqual(rows[0]["close"], 12.3)
|
|
client.try_quotes.assert_not_called()
|
|
|
|
def test_ignores_non_member_quotes_from_market_snapshot(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
client.try_market_quotes = MagicMock(
|
|
return_value=[
|
|
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
|
|
{"ts_code": "600000.SH", "close": 10.0, "pre_close": 9.9},
|
|
]
|
|
)
|
|
client.try_quotes = MagicMock(return_value=[])
|
|
client._free_realtime_quotes = MagicMock(return_value=([], "empty"))
|
|
rows, _source = client._load_member_realtime_quotes(
|
|
["000737.SZ", "000630.SZ"], "20260908"
|
|
)
|
|
self.assertEqual([row["ts_code"] for row in rows], ["000737.SZ"])
|
|
|
|
def test_local_sw_members_survive_tushare_outage(self) -> None:
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from backend.data.providers import tushare_industries as module
|
|
|
|
client = TushareClient(token="demo")
|
|
stored = [
|
|
{
|
|
"ts_code": "000737.SZ",
|
|
"name": "北方铜业",
|
|
"l2_code": "801074.SI",
|
|
"in_date": "20200101",
|
|
"out_date": "",
|
|
}
|
|
]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
original = module._SW_MEMBER_DIR
|
|
module._SW_MEMBER_DIR = Path(tmp)
|
|
try:
|
|
client._sw_member_cache.clear()
|
|
client._write_local_sw_members("801074.SI", stored)
|
|
client.query = MagicMock(side_effect=TushareError("index_member_all down"))
|
|
members = client._sw_sector_members("801074.SI", "20260908")
|
|
finally:
|
|
module._SW_MEMBER_DIR = original
|
|
client._sw_member_cache.clear()
|
|
self.assertEqual([item["ts_code"] for item in members], ["000737.SZ"])
|
|
client.query.assert_not_called()
|
|
|
|
def test_closed_keeps_daily_inner_when_sw_daily_missing(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
client.resolve_trade_context = lambda _date: ("20260908", "20260907")
|
|
client.sw_stock_industry = MagicMock(
|
|
return_value={"l2_code": "801074.SI", "l2_name": "工业金属"}
|
|
)
|
|
client._sw_sector_members = MagicMock(
|
|
return_value=[
|
|
{"ts_code": "000737.SZ", "name": "北方铜业"},
|
|
{"ts_code": "000630.SZ", "name": "铜陵有色"},
|
|
]
|
|
)
|
|
client._stock_listing_reference = MagicMock(return_value={})
|
|
client._load_daily = MagicMock(
|
|
return_value=[
|
|
{"ts_code": "000737.SZ", "name": "北方铜业", "pct_chg": 2, "amount": 1e8},
|
|
{"ts_code": "000630.SZ", "name": "铜陵有色", "pct_chg": 1, "amount": 1e8},
|
|
]
|
|
)
|
|
client._confirmed_suspended_members = MagicMock(return_value=[])
|
|
client.query = MagicMock(return_value=[])
|
|
client._sw_realtime_sector_snapshot = MagicMock(
|
|
side_effect=AssertionError("daily inner should be kept")
|
|
)
|
|
client.try_sector_quote = MagicMock(return_value={
|
|
"code": "801074.SI",
|
|
"name": "工业金属",
|
|
"change": 1.5,
|
|
"pct_change": 1.5,
|
|
"quote_date": "20260908",
|
|
"quote_time": "2026-09-08T15:00:00+08:00",
|
|
"source": "eastmoney_sw",
|
|
})
|
|
snapshot = client.sw_sector_snapshot(
|
|
"000737.SZ", "20260908", allow_realtime_close=True
|
|
)
|
|
self.assertEqual(snapshot["quote_count"], 2)
|
|
self.assertEqual(snapshot["member_count"], 2)
|
|
self.assertTrue(snapshot["inner_precise"])
|
|
self.assertTrue(snapshot["outer_precise"])
|
|
self.assertEqual(snapshot["inner_source"], "tushare_member_daily")
|
|
self.assertEqual(snapshot["change"], 1.5)
|
|
self.assertNotIn("权限", snapshot.get("outer_error") or "")
|
|
self.assertNotIn("rt_sw_k", snapshot.get("outer_error") or "")
|
|
|
|
def test_closed_uses_complete_member_daily_when_sector_quote_is_wrong(self) -> None:
|
|
client = TushareClient(token="demo")
|
|
client.resolve_trade_context = lambda _date: ("20260909", "20260908")
|
|
client.sw_stock_industry = MagicMock(
|
|
return_value={"l2_code": "801074.SI", "l2_name": "专用设备"}
|
|
)
|
|
client._sw_sector_members = MagicMock(return_value=[
|
|
{"ts_code": "603011.SH", "name": "合锻智能"},
|
|
{"ts_code": "000821.SZ", "name": "京山轻机"},
|
|
])
|
|
client._stock_listing_reference = MagicMock(return_value={})
|
|
client._load_daily = MagicMock(return_value=[
|
|
{"ts_code": "603011.SH", "name": "合锻智能", "pct_chg": 2, "amount": 1e8},
|
|
{"ts_code": "000821.SZ", "name": "京山轻机", "pct_chg": -1, "amount": 1e8},
|
|
])
|
|
client._confirmed_suspended_members = MagicMock(return_value=[])
|
|
client.query = MagicMock(return_value=[])
|
|
client.try_sector_quote = MagicMock(return_value={
|
|
"code": "801074.SI",
|
|
"name": "托育服务",
|
|
"change": -2.19,
|
|
"quote_date": "20260909",
|
|
"quote_time": "2026-09-09T15:00:00+08:00",
|
|
"source": "eastmoney_sw",
|
|
})
|
|
|
|
snapshot = client.sw_sector_snapshot(
|
|
"603011.SH", "20260909", allow_realtime_close=True
|
|
)
|
|
|
|
self.assertTrue(snapshot["precise"])
|
|
self.assertEqual(snapshot["name"], "专用设备")
|
|
self.assertEqual(snapshot["change"], 0.5)
|
|
self.assertEqual(snapshot["outer_source"], "sw_member_equal_daily")
|
|
self.assertEqual(snapshot["outer_error"], "")
|