chore: establish stable application baseline
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from heaven_engine import _market_line_scores, build_manual_market_hexagram
|
||||
from realtime_aggregator import WebRealtimeAggregator
|
||||
from server import DashboardService
|
||||
from tushare_client import TushareClient
|
||||
|
||||
|
||||
class HeavenMarketLineTests(unittest.TestCase):
|
||||
def test_index_external_uses_only_current_index_change(self):
|
||||
dashboard = {
|
||||
"overview": {
|
||||
"up_count": 1740,
|
||||
"down_count": 3710,
|
||||
"amount_billion": 27180.8,
|
||||
"sentiment_score": 27,
|
||||
"seal_rate": 57.3,
|
||||
"limit_up_count": 55,
|
||||
"limit_down_count": 267,
|
||||
},
|
||||
"sectors": [],
|
||||
"sector_rotation": [],
|
||||
}
|
||||
index_context = {
|
||||
"aggregate": {
|
||||
"average_pct_chg": 0.187,
|
||||
"average_return_5d": -5.606,
|
||||
}
|
||||
}
|
||||
|
||||
scores = _market_line_scores(
|
||||
dashboard,
|
||||
[{"amount_billion": 25000}],
|
||||
index_context,
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(scores[5]["score"], 0.187 / 3)
|
||||
self.assertGreater(scores[5]["score"], 0)
|
||||
self.assertIn("不参与外显阴阳", scores[5]["evidence"][1])
|
||||
|
||||
def test_manual_calibration_preserves_six_lines_and_marks_user_evidence(self):
|
||||
chart = build_manual_market_hexagram(
|
||||
[8, 7, 6, 9, 8, 7],
|
||||
"20260722",
|
||||
{"name": "元件", "taxonomy": "sw_l2"},
|
||||
{"code": "002141", "name": "贤丰控股"},
|
||||
{},
|
||||
"人工核对",
|
||||
)
|
||||
|
||||
self.assertTrue(chart["manual_calibration"])
|
||||
self.assertEqual([line["value"] for line in chart["hexagram"]["lines"]], [8, 7, 6, 9, 8, 7])
|
||||
self.assertEqual(chart["hexagram"]["moving_lines"], [3, 4])
|
||||
self.assertIn("用户手动校准", chart["hexagram"]["lines"][0]["evidence"][0])
|
||||
|
||||
def test_quantitative_supplement_repairs_only_failed_lines_and_recalculates(self):
|
||||
trade_date = "20260722"
|
||||
dashboard = {
|
||||
"overview": {
|
||||
"sentiment_score": 32,
|
||||
"seal_rate": 48,
|
||||
"amount_billion": 16500,
|
||||
"up_count": 1800,
|
||||
"down_count": 3500,
|
||||
"limit_up_count": 35,
|
||||
"limit_down_count": 192,
|
||||
},
|
||||
"limits": [{"amount_billion": 12}, {"amount_billion": 25}],
|
||||
"sectors": [],
|
||||
"sector_rotation": [],
|
||||
}
|
||||
history = [{"amount_billion": 16000}, {"amount_billion": 15800}]
|
||||
index_context = {
|
||||
"trade_date": trade_date,
|
||||
"source": "tushare",
|
||||
"realtime": False,
|
||||
"precise": False,
|
||||
"indices": [
|
||||
{"ts_code": code, "trade_date": "20260721", "pct_chg": 0.1}
|
||||
for code in ("000001.SH", "399001.SZ", "399006.SZ")
|
||||
],
|
||||
}
|
||||
sector = {"taxonomy": "sw_l2", "precise": False, "error": "行业日线尚未返回"}
|
||||
stock = {
|
||||
"code": "002141", "name": "贤丰控股", "trade_date": trade_date,
|
||||
"data_source": "tushare", "realtime": False, "precise": True,
|
||||
"amount_billion": 20, "turnover_rate": 8.5, "seal_amount_million": 0,
|
||||
"open_times": 0, "change": 2.4, "streak": 0, "status": "普通",
|
||||
}
|
||||
|
||||
automatic = DashboardService._heaven_line_checks(
|
||||
trade_date, dashboard, history, index_context, sector, stock, "closed", {}
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["line"] for item in automatic if not item["passed"]], [3, 4, 6]
|
||||
)
|
||||
|
||||
manual = {
|
||||
"sector_name": "元件", "sector_up_count": 18, "sector_down_count": 42,
|
||||
"sector_coverage": 96, "sector_member_equal_change": -2.2,
|
||||
"sector_change": -2.6, "sector_leading_pct": 3.1,
|
||||
"index_sh_change": -0.9, "index_sz_change": -1.4, "index_cy_change": -1.8,
|
||||
}
|
||||
merged = DashboardService._apply_heaven_manual_data(
|
||||
dashboard, index_context, sector, stock, manual, "closed", trade_date, "002141"
|
||||
)
|
||||
repaired = DashboardService._heaven_line_checks(
|
||||
trade_date, merged[0], history, merged[1], merged[2], merged[3], "closed", manual
|
||||
)
|
||||
|
||||
self.assertTrue(all(item["passed"] for item in repaired))
|
||||
self.assertEqual(
|
||||
[item["line"] for item in repaired if item["status"] == "manual"], [3, 4, 6]
|
||||
)
|
||||
self.assertEqual(repaired[5]["line_value"], 8)
|
||||
self.assertAlmostEqual(repaired[5]["score"], (-0.9 - 1.4 - 1.8) / 3 / 3, places=3)
|
||||
|
||||
def test_sector_inner_and_outer_have_independent_quality_gates(self):
|
||||
trade_date = "20260722"
|
||||
dashboard = {
|
||||
"overview": {
|
||||
"sentiment_score": 30, "seal_rate": 50, "amount_billion": 15000,
|
||||
"up_count": 2000, "down_count": 3000,
|
||||
"limit_up_count": 40, "limit_down_count": 80,
|
||||
},
|
||||
"limits": [], "sectors": [], "sector_rotation": [],
|
||||
}
|
||||
history = [{"amount_billion": 14800}, {"amount_billion": 14900}]
|
||||
indices = {
|
||||
"trade_date": trade_date, "source": "tushare", "realtime": False,
|
||||
"precise": True,
|
||||
"indices": [
|
||||
{"ts_code": code, "trade_date": trade_date, "pct_chg": -1}
|
||||
for code in ("000001.SH", "399001.SZ", "399006.SZ")
|
||||
],
|
||||
"aggregate": {"average_pct_chg": -1},
|
||||
}
|
||||
sector = {
|
||||
"name": "元件", "code": "801083.SI", "taxonomy": "sw_l2",
|
||||
"trade_date": trade_date, "realtime": False, "finalized": True,
|
||||
"inner_precise": True, "outer_precise": False, "precise": False,
|
||||
"coverage": 98, "up_count": 19, "down_count": 46,
|
||||
"member_equal_change": -2.15, "leading_pct": 5.2,
|
||||
"outer_error": "申万日线尚未发布", "source": "tushare_member_daily",
|
||||
}
|
||||
stock = {
|
||||
"code": "002141", "trade_date": trade_date, "precise": True,
|
||||
"realtime": False, "data_source": "tushare", "amount_billion": 10,
|
||||
"turnover_rate": 5, "seal_amount_million": 0, "open_times": 0,
|
||||
"change": 2, "streak": 0, "status": "普通",
|
||||
}
|
||||
|
||||
checks = DashboardService._heaven_line_checks(
|
||||
trade_date, dashboard, history, indices, sector, stock, "closed", {}
|
||||
)
|
||||
|
||||
self.assertTrue(checks[2]["passed"])
|
||||
self.assertFalse(checks[3]["passed"])
|
||||
self.assertIn("申万日线尚未发布", checks[3]["reasons"])
|
||||
|
||||
manual = {"sector_change": -3.85}
|
||||
merged = DashboardService._apply_heaven_manual_data(
|
||||
dashboard, indices, sector, stock, manual, "closed", trade_date, "002141"
|
||||
)
|
||||
repaired = DashboardService._heaven_line_checks(
|
||||
trade_date, merged[0], history, merged[1], merged[2], merged[3], "closed", manual
|
||||
)
|
||||
self.assertTrue(repaired[3]["passed"])
|
||||
self.assertEqual(repaired[3]["status"], "manual")
|
||||
self.assertEqual(
|
||||
[field["key"] for field in repaired[3]["fields"] if field["manual"]],
|
||||
["sector_change"],
|
||||
)
|
||||
|
||||
missing_leader_sector = {**merged[2], "leading_pct": None}
|
||||
still_blocked = DashboardService._heaven_line_checks(
|
||||
trade_date,
|
||||
merged[0],
|
||||
history,
|
||||
merged[1],
|
||||
missing_leader_sector,
|
||||
merged[3],
|
||||
"closed",
|
||||
manual,
|
||||
)
|
||||
self.assertFalse(still_blocked[3]["passed"])
|
||||
self.assertIn("需补充:行业领涨股涨跌幅", still_blocked[3]["reasons"])
|
||||
|
||||
|
||||
class ShenwanMembershipTests(unittest.TestCase):
|
||||
@patch.object(TushareClient, "query")
|
||||
def test_latest_effective_membership_wins_over_stale_is_new_row(self, query: MagicMock):
|
||||
stale_y = {
|
||||
"l1_code": "801010.SI", "l1_name": "农林牧渔",
|
||||
"l2_code": "801018.SI", "l2_name": "动物保健Ⅱ",
|
||||
"l3_code": "850181.SI", "l3_name": "动物保健Ⅲ",
|
||||
"ts_code": "002141.SZ", "in_date": "20240730", "out_date": None, "is_new": "Y",
|
||||
}
|
||||
current_y = {
|
||||
"l1_code": "801080.SI", "l1_name": "电子",
|
||||
"l2_code": "801083.SI", "l2_name": "元件",
|
||||
"l3_code": "850822.SI", "l3_name": "印制电路板",
|
||||
"ts_code": "002141.SZ", "in_date": "20260701", "out_date": None, "is_new": "Y",
|
||||
}
|
||||
closed_n = {**stale_y, "out_date": "20260630", "is_new": "N"}
|
||||
query.side_effect = lambda _api, params, _fields: (
|
||||
[stale_y, current_y] if params["is_new"] == "Y" else [closed_n]
|
||||
)
|
||||
|
||||
industry = TushareClient("token").sw_stock_industry("002141.SZ", "20260722")
|
||||
|
||||
self.assertEqual(industry["l2_code"], "801083.SI")
|
||||
self.assertEqual(industry["l2_name"], "元件")
|
||||
|
||||
|
||||
class RealtimeAggregatorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
WebRealtimeAggregator._response_cache.clear()
|
||||
|
||||
@staticmethod
|
||||
def _response(payload: dict) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.headers.get.return_value = "application/json"
|
||||
response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
context = MagicMock()
|
||||
context.__enter__.return_value = response
|
||||
return context
|
||||
|
||||
@patch("realtime_aggregator.urllib.request.urlopen")
|
||||
def test_transport_failure_is_retried(self, urlopen: MagicMock):
|
||||
urlopen.side_effect = [
|
||||
http.client.RemoteDisconnected("temporary disconnect"),
|
||||
self._response({"rc": 0, "data": {"diff": []}}),
|
||||
]
|
||||
aggregator = WebRealtimeAggregator(retry_delay_seconds=0)
|
||||
|
||||
payload = aggregator._get_json("https://example.test", {}, "https://example.test")
|
||||
|
||||
self.assertEqual(payload["rc"], 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
|
||||
@patch("realtime_aggregator.urllib.request.urlopen")
|
||||
def test_recent_success_is_used_after_retries_fail(self, urlopen: MagicMock):
|
||||
aggregator = WebRealtimeAggregator(retry_delay_seconds=0)
|
||||
urlopen.return_value = self._response({"rc": 0, "data": {"diff": []}})
|
||||
aggregator._get_json("https://example.test", {}, "https://example.test")
|
||||
urlopen.side_effect = http.client.RemoteDisconnected("temporary disconnect")
|
||||
|
||||
payload = aggregator._get_json("https://example.test", {}, "https://example.test")
|
||||
|
||||
self.assertIn("_aggregate_cache", payload)
|
||||
self.assertEqual(urlopen.call_count, 4)
|
||||
|
||||
@patch.object(WebRealtimeAggregator, "_get_text")
|
||||
def test_tencent_indices_include_verifiable_quote_times(self, get_text: MagicMock):
|
||||
def quote_line(
|
||||
symbol: str,
|
||||
name: str,
|
||||
code: str,
|
||||
price: str,
|
||||
previous_close: str,
|
||||
quote_time: str,
|
||||
change_amount: str,
|
||||
change: str,
|
||||
amount: str,
|
||||
) -> str:
|
||||
fields = [""] * 38
|
||||
fields[1] = name
|
||||
fields[2] = code
|
||||
fields[3] = price
|
||||
fields[4] = previous_close
|
||||
fields[5] = price
|
||||
fields[30] = quote_time
|
||||
fields[31] = change_amount
|
||||
fields[32] = change
|
||||
fields[33] = price
|
||||
fields[34] = price
|
||||
fields[37] = amount
|
||||
return f'v_{symbol}="{"~".join(fields)}";'
|
||||
|
||||
get_text.return_value = (
|
||||
'\n'.join(
|
||||
[
|
||||
quote_line("sh000001", "上证指数", "000001", "3796.28", "3764.15", "20260720155402", "32.13", "0.85", "129465190"),
|
||||
quote_line("sz399001", "深证成指", "399001", "13610.23", "13706.88", "20260720155330", "-96.65", "-0.71", "140747525"),
|
||||
quote_line("sz399006", "创业板指", "399006", "3443.10", "3428.63", "20260720155345", "14.47", "0.42", "67120487"),
|
||||
]
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
rows = WebRealtimeAggregator().tencent_indices()
|
||||
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0]["source"], "tencent_qt")
|
||||
self.assertEqual(rows[0]["quote_time"][:10], "2026-07-20")
|
||||
self.assertAlmostEqual(rows[0]["amount_billion"], 12946.52)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user