fix(HEL-356): complete Shenwan industry signal
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -169,6 +169,16 @@ class ShenwanIndustryMixin:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
outer_error = rt_error or outer_error
|
outer_error = rt_error or outer_error
|
||||||
|
if not outer_precise:
|
||||||
|
# The exact SW L2 member set and every member's final daily bar
|
||||||
|
# are already available. Use their equal-weight move as an
|
||||||
|
# explicitly labelled provisional outer value until Tushare's
|
||||||
|
# official sw_daily row is published.
|
||||||
|
daily = {"change": equal_change}
|
||||||
|
actual_trade_date = trade_date
|
||||||
|
outer_precise = True
|
||||||
|
outer_source = "sw_member_equal_daily"
|
||||||
|
outer_error = ""
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
snapshot = self._sw_realtime_sector_snapshot(
|
snapshot = self._sw_realtime_sector_snapshot(
|
||||||
@@ -520,6 +530,12 @@ class ShenwanIndustryMixin:
|
|||||||
for item in members
|
for item in members
|
||||||
}
|
}
|
||||||
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
||||||
|
if not outer_precise and inner_precise:
|
||||||
|
official_change = equal_change
|
||||||
|
outer_precise = True
|
||||||
|
outer_source = "sw_member_equal_realtime"
|
||||||
|
outer_error = ""
|
||||||
|
quote_date = trade_date
|
||||||
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
||||||
market_rows: list[dict[str, Any]] = []
|
market_rows: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
@@ -642,6 +658,11 @@ class ShenwanIndustryMixin:
|
|||||||
return {}, "", f"申万行业 {sector_code} 盘后正式数据待入库"
|
return {}, "", f"申万行业 {sector_code} 盘后正式数据待入库"
|
||||||
return {}, "", f"数据中枢申万实时暂不可用:{message[:180]}"
|
return {}, "", f"数据中枢申万实时暂不可用:{message[:180]}"
|
||||||
if row:
|
if row:
|
||||||
|
actual_name = str(row.get("name") or "").strip()
|
||||||
|
if sector_name and actual_name != str(sector_name).strip():
|
||||||
|
return {}, "", (
|
||||||
|
f"数据中枢返回的行业名称不匹配:期望 {sector_name},实际 {actual_name or '--'}"
|
||||||
|
)
|
||||||
return dict(row), str(row.get("source") or "datahub"), ""
|
return dict(row), str(row.get("source") or "datahub"), ""
|
||||||
if finalized:
|
if finalized:
|
||||||
return {}, "", f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
return {}, "", f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||||||
|
|||||||
@@ -802,7 +802,11 @@ def _market_line_scores(
|
|||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
||||||
actual_sector_source = str(sector.get("source") or "").startswith("tushare_")
|
sector_source = str(sector.get("source") or "")
|
||||||
|
actual_sector_source = (
|
||||||
|
sector_source.startswith("tushare_")
|
||||||
|
or "sw_member_equal_" in sector_source
|
||||||
|
)
|
||||||
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
||||||
sector_change = float(sector.get("change") or 0)
|
sector_change = float(sector.get("change") or 0)
|
||||||
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
||||||
@@ -831,8 +835,13 @@ def _market_line_scores(
|
|||||||
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
||||||
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
||||||
]
|
]
|
||||||
|
sector_change_label = (
|
||||||
|
"申万二级行业成分等权涨跌"
|
||||||
|
if "sw_member_equal_" in sector_source
|
||||||
|
else "申万二级行业官方涨跌"
|
||||||
|
)
|
||||||
sector_outer_evidence = [
|
sector_outer_evidence = [
|
||||||
f"申万二级行业官方涨跌 {sector_change:+.2f}%",
|
f"{sector_change_label} {sector_change:+.2f}%",
|
||||||
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
||||||
]
|
]
|
||||||
elif actual_sector_source or sector_quantitative_mode == "historical":
|
elif actual_sector_source or sector_quantitative_mode == "historical":
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class HeavenManualMixin:
|
|||||||
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
|
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
|
||||||
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
|
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
|
||||||
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_change": {"line": 4, "label": "申万行业涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100},
|
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100},
|
||||||
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
|
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
|
||||||
|
|||||||
@@ -473,8 +473,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/engine.py",
|
"path": "backend/features/heaven/engine.py",
|
||||||
"bytes": 51764,
|
"bytes": 52046,
|
||||||
"lines": 1183
|
"lines": 1192
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/index.html",
|
"path": "frontend/index.html",
|
||||||
@@ -483,8 +483,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_industries.py",
|
"path": "backend/data/providers/tushare_industries.py",
|
||||||
"bytes": 37786,
|
"bytes": 38936,
|
||||||
"lines": 873
|
"lines": 894
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/catalog.py",
|
"path": "backend/features/screener/catalog.py",
|
||||||
|
|||||||
@@ -384,3 +384,39 @@ class MemberQuoteCoverageTests(unittest.TestCase):
|
|||||||
self.assertEqual(snapshot["change"], 1.5)
|
self.assertEqual(snapshot["change"], 1.5)
|
||||||
self.assertNotIn("权限", snapshot.get("outer_error") or "")
|
self.assertNotIn("权限", snapshot.get("outer_error") or "")
|
||||||
self.assertNotIn("rt_sw_k", 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"], "")
|
||||||
|
|||||||
@@ -197,17 +197,22 @@ def fetch_sector_quote(db: HubDB, code: str, expected_date: str = "") -> dict[st
|
|||||||
if ts_code.isdigit():
|
if ts_code.isdigit():
|
||||||
ts_code = f"{ts_code}.SI"
|
ts_code = f"{ts_code}.SI"
|
||||||
cache_key = f"sector:{ts_code}"
|
cache_key = f"sector:{ts_code}"
|
||||||
|
canonical_name = _canonical_sw_name(db, ts_code)
|
||||||
cached = _read_cache(db, cache_key)
|
cached = _read_cache(db, cache_key)
|
||||||
if cached is not None:
|
if cached is not None and _sector_payload_matches(cached, canonical_name):
|
||||||
return cached
|
return cached
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
try:
|
try:
|
||||||
row = EastmoneyAdapter().fetch_shenwan_quote(ts_code)
|
row = EastmoneyAdapter().fetch_shenwan_quote(ts_code)
|
||||||
|
if not _sector_row_matches(row, canonical_name):
|
||||||
|
raise AdapterError(
|
||||||
|
f"industry name mismatch: expected {canonical_name}, got {row.get('name') or '--'}"
|
||||||
|
)
|
||||||
source = str(row.get("source") or "eastmoney_sw")
|
source = str(row.get("source") or "eastmoney_sw")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"eastmoney:{exc}")
|
errors.append(f"eastmoney:{exc}")
|
||||||
recovered = _load_quotes_lkg(db, cache_key, expected_date)
|
recovered = _load_quotes_lkg(db, cache_key, expected_date)
|
||||||
if recovered is not None:
|
if recovered is not None and _sector_payload_matches(recovered, canonical_name):
|
||||||
return recovered
|
return recovered
|
||||||
raise RealtimeApiError(
|
raise RealtimeApiError(
|
||||||
"SOURCE_UNAVAILABLE",
|
"SOURCE_UNAVAILABLE",
|
||||||
@@ -217,7 +222,7 @@ def fetch_sector_quote(db: HubDB, code: str, expected_date: str = "") -> dict[st
|
|||||||
quote_date = str(row.get("quote_date") or "")
|
quote_date = str(row.get("quote_date") or "")
|
||||||
if want and quote_date and quote_date != want:
|
if want and quote_date and quote_date != want:
|
||||||
recovered = _load_quotes_lkg(db, cache_key, want)
|
recovered = _load_quotes_lkg(db, cache_key, want)
|
||||||
if recovered is not None:
|
if recovered is not None and _sector_payload_matches(recovered, canonical_name):
|
||||||
return recovered
|
return recovered
|
||||||
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"sector quote date {quote_date} != {want}")
|
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"sector quote date {quote_date} != {want}")
|
||||||
payload = _envelope(
|
payload = _envelope(
|
||||||
@@ -235,6 +240,29 @@ def fetch_sector_quote(db: HubDB, code: str, expected_date: str = "") -> dict[st
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_sw_name(db: HubDB, ts_code: str) -> str:
|
||||||
|
row = db.fetchone(
|
||||||
|
"SELECT name FROM sector_master WHERE ts_code = ? AND family = 'sw'",
|
||||||
|
(ts_code,),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = db.fetchone(
|
||||||
|
"SELECT name FROM eod_sector_daily WHERE ts_code = ? AND family = 'sw' "
|
||||||
|
"ORDER BY trade_date DESC LIMIT 1",
|
||||||
|
(ts_code,),
|
||||||
|
)
|
||||||
|
return str((row or {}).get("name") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_row_matches(row: dict[str, Any], canonical_name: str) -> bool:
|
||||||
|
return not canonical_name or str(row.get("name") or "").strip() == canonical_name
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_payload_matches(payload: dict[str, Any], canonical_name: str) -> bool:
|
||||||
|
data = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
return isinstance(data, dict) and _sector_row_matches(data, canonical_name)
|
||||||
|
|
||||||
|
|
||||||
def fetch_limit_pool(db: HubDB, trade_date: str = "") -> dict[str, Any]:
|
def fetch_limit_pool(db: HubDB, trade_date: str = "") -> dict[str, Any]:
|
||||||
day = yyyymmdd(trade_date or now_shanghai())
|
day = yyyymmdd(trade_date or now_shanghai())
|
||||||
cache_key = f"limit-pool:{day}"
|
cache_key = f"limit-pool:{day}"
|
||||||
@@ -489,18 +517,29 @@ def warm_realtime(db: HubDB) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
masters = db.fetchall(
|
masters = db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT ts_code FROM sector_master WHERE family = 'sw'
|
SELECT ts_code, name, 0 AS priority FROM sector_master WHERE family = 'sw'
|
||||||
UNION
|
UNION ALL
|
||||||
SELECT ts_code FROM eod_sector_daily WHERE family = 'sw'
|
SELECT ts_code, name, 1 AS priority FROM eod_sector_daily WHERE family = 'sw'
|
||||||
ORDER BY ts_code
|
ORDER BY priority, ts_code
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
codes = [str(row.get("ts_code") or "") for row in masters if row.get("ts_code")]
|
canonical_names: dict[str, str] = {}
|
||||||
sector_rows = _eastmoney_sector_quotes(codes)
|
for master in masters:
|
||||||
for row in sector_rows:
|
master_code = str(master.get("ts_code") or "")
|
||||||
|
master_name = str(master.get("name") or "").strip()
|
||||||
|
if master_code and master_name:
|
||||||
|
canonical_names.setdefault(master_code, master_name)
|
||||||
|
codes = list(canonical_names)
|
||||||
|
fetched_sector_rows = _eastmoney_sector_quotes(codes)
|
||||||
|
for row in fetched_sector_rows:
|
||||||
if _row_quote_date(row, today) != today:
|
if _row_quote_date(row, today) != today:
|
||||||
continue
|
continue
|
||||||
code = str(row.get("ts_code") or "").upper()
|
code = str(row.get("ts_code") or "").upper()
|
||||||
|
if not _sector_row_matches(row, canonical_names.get(code, "")):
|
||||||
|
result["errors"].append(
|
||||||
|
f"sector:{code}:industry name mismatch"
|
||||||
|
)
|
||||||
|
continue
|
||||||
payload = _envelope(
|
payload = _envelope(
|
||||||
row,
|
row,
|
||||||
{
|
{
|
||||||
@@ -513,6 +552,7 @@ def warm_realtime(db: HubDB) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
_write_cache(db, f"sector:{code}", payload, INDEX_TTL, "eastmoney_sw")
|
_write_cache(db, f"sector:{code}", payload, INDEX_TTL, "eastmoney_sw")
|
||||||
|
sector_rows.append(row)
|
||||||
result["sectors"] = len(sector_rows)
|
result["sectors"] = len(sector_rows)
|
||||||
result["rows"] += len(sector_rows)
|
result["rows"] += len(sector_rows)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datahub.adapters.base import AdapterError
|
|||||||
from datahub.adapters.eastmoney import HIS_TRENDS_URL, TRENDS_URL, EastmoneyAdapter, _normalize_market_quote
|
from datahub.adapters.eastmoney import HIS_TRENDS_URL, TRENDS_URL, EastmoneyAdapter, _normalize_market_quote
|
||||||
from datahub.adapters.tencent import _tencent_symbol
|
from datahub.adapters.tencent import _tencent_symbol
|
||||||
from datahub.db import HubDB
|
from datahub.db import HubDB
|
||||||
from datahub.realtime_serve import fetch_intraday
|
from datahub.realtime_serve import fetch_intraday, fetch_sector_quote
|
||||||
from datahub.serving import ApiError, V1API
|
from datahub.serving import ApiError, V1API
|
||||||
from datahub.timeutil import now_shanghai, yyyymmdd
|
from datahub.timeutil import now_shanghai, yyyymmdd
|
||||||
|
|
||||||
@@ -110,6 +110,33 @@ class IntradayLkgTests(unittest.TestCase):
|
|||||||
self.assertIn("intraday unavailable", str(ctx.exception))
|
self.assertIn("intraday unavailable", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class SectorQuoteIdentityTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = HubDB(Path(self.tmp.name) / "hub.db")
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO sector_master(ts_code,name,family,updated_at) VALUES (?,?,?,?)",
|
||||||
|
("801074.SI", "专用设备", "sw", "2026-09-09T10:00:00+08:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_rejects_same_numeric_code_from_different_taxonomy(self) -> None:
|
||||||
|
wrong = {
|
||||||
|
"ts_code": "801074.SI",
|
||||||
|
"name": "托育服务",
|
||||||
|
"change": -2.19,
|
||||||
|
"quote_date": "20260909",
|
||||||
|
"source": "eastmoney_sw",
|
||||||
|
}
|
||||||
|
with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked:
|
||||||
|
mocked.return_value.fetch_shenwan_quote.return_value = wrong
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
fetch_sector_quote(self.db, "801074.SI", "20260909")
|
||||||
|
self.assertIn("industry name mismatch", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
class ServingIntradayDateTests(unittest.TestCase):
|
class ServingIntradayDateTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
|||||||
Reference in New Issue
Block a user