Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions application/signal_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,32 @@ def build_signal_snapshot(
source.get("signal_source"),
),
"quote_overlay_used": source.get("quote_overlay_used"),
"price_as_of": _json_safe(
_first_value(source.get("price_as_of"), source.get("snapshot_manifest_price_as_of"))
),
"universe_as_of": _json_safe(
_first_value(source.get("universe_as_of"), source.get("snapshot_manifest_universe_as_of"))
Comment on lines +171 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass manifest metadata into signal snapshots

Reading snapshot_manifest_* keys here does not surface the new diagnostics in the normal rebalance/heartbeat path: LongBridgeRuntimeStrategyAdapters.resolve_rebalance_plan() receives them as evaluation metadata, decision_mapper.map_strategy_decision_to_plan() only emits the whitelisted execution fields, and run_strategy() calls build_signal_snapshot() with only execution={...} and allocation=... (no metadata/diagnostics). For strategies where the manifest diagnostics are supplied via runtime metadata, these fields remain None, so the newly added notification line never shows the intended price/universe/fallback data outside the direct unit test.

Useful? React with 👍 / 👎.

),
"source_input_status": _first_value(
source.get("source_input_status"),
source.get("snapshot_manifest_source_input_status"),
),
"source_input_fallback_used": _first_value(
source.get("source_input_fallback_used"),
source.get("snapshot_manifest_source_input_fallback_used"),
),
"source_input_fallback_reason": _first_value(
source.get("source_input_fallback_reason"),
source.get("snapshot_manifest_source_input_fallback_reason"),
),
"source_input_fallback_streak": _first_value(
source.get("source_input_fallback_streak"),
source.get("snapshot_manifest_source_input_fallback_streak"),
),
"source_refresh_run_id": _first_value(
source.get("source_refresh_run_id"),
source.get("snapshot_manifest_source_refresh_run_id"),
),
"data_freshness_warning": _first_value(
source.get("data_freshness_warning"),
source.get("snapshot_price_fallback_used"),
Expand Down
55 changes: 55 additions & 0 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,57 @@ def _append_signal_snapshot_line(lines, *, execution, translator) -> None:
lines.append(line)


def _is_truthy(value) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "y"}


def _format_source_input_line(snapshot, *, translator) -> str:
if not isinstance(snapshot, Mapping):
return ""
price_as_of = str(snapshot.get("price_as_of") or "").strip()
universe_as_of = str(snapshot.get("universe_as_of") or "").strip()
status = str(snapshot.get("source_input_status") or "").strip()
fallback_used = _is_truthy(snapshot.get("source_input_fallback_used"))
fallback_streak = snapshot.get("source_input_fallback_streak")
if not price_as_of and not universe_as_of and not status and not fallback_used:
return ""
if _translator_uses_zh(translator):
parts = []
if price_as_of:
parts.append(f"价格 {price_as_of}")
if universe_as_of:
parts.append(f"股票池 {universe_as_of}")
if fallback_used:
fallback_text = "股票池复用"
if fallback_streak not in (None, "", 0, "0"):
fallback_text += f" 连续{fallback_streak}次"
parts.append(fallback_text)
elif status:
parts.append(f"状态 {status}")
return "🧩 输入状态: " + " | ".join(parts)
parts = []
if price_as_of:
parts.append(f"price {price_as_of}")
if universe_as_of:
parts.append(f"universe {universe_as_of}")
if fallback_used:
fallback_text = "universe fallback"
if fallback_streak not in (None, "", 0, "0"):
fallback_text += f" streak={fallback_streak}"
parts.append(fallback_text)
elif status:
parts.append(f"status {status}")
return "🧩 Inputs: " + " | ".join(parts)


def _append_source_input_line(lines, *, execution, translator) -> None:
line = _format_source_input_line(execution.get("signal_snapshot"), translator=translator)
if line:
lines.append(line)


def _append_status_lines(lines, *, execution, translator, signal_key):
status_display = _localize_notification_text(execution.get("status_display"), translator=translator)
if status_display:
Expand Down Expand Up @@ -270,6 +321,7 @@ def render_rebalance_notification(
_append_dashboard_block(detailed_lines, execution=execution, separator=separator)
_append_timing_lines(detailed_lines, execution=execution, translator=translator)
_append_signal_snapshot_line(detailed_lines, execution=execution, translator=translator)
_append_source_input_line(detailed_lines, execution=execution, translator=translator)
_append_status_lines(
detailed_lines,
execution=execution,
Expand All @@ -286,6 +338,7 @@ def render_rebalance_notification(
_append_dashboard_block(compact_lines, execution=execution, separator=separator)
_append_timing_lines(compact_lines, execution=execution, translator=translator)
_append_signal_snapshot_line(compact_lines, execution=execution, translator=translator)
_append_source_input_line(compact_lines, execution=execution, translator=translator)
_append_compact_status_lines(
compact_lines,
execution=execution,
Expand Down Expand Up @@ -318,6 +371,7 @@ def render_heartbeat_notification(
_append_dashboard_block(detailed_lines, execution=execution, separator=separator)
_append_timing_lines(detailed_lines, execution=execution, translator=translator)
_append_signal_snapshot_line(detailed_lines, execution=execution, translator=translator)
_append_source_input_line(detailed_lines, execution=execution, translator=translator)
detailed_lines.append(separator)
_append_status_lines(
detailed_lines,
Expand Down Expand Up @@ -353,6 +407,7 @@ def render_heartbeat_notification(
_append_dashboard_block(compact_lines, execution=execution, separator=separator)
_append_timing_lines(compact_lines, execution=execution, translator=translator)
_append_signal_snapshot_line(compact_lines, execution=execution, translator=translator)
_append_source_input_line(compact_lines, execution=execution, translator=translator)
_append_compact_status_lines(
compact_lines,
execution=execution,
Expand Down
2 changes: 2 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"strategy_name_qqq_tech_enhancement": "科技通信回调增强",
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动",
"strategy_name_hk_listed_global_etf_rotation": "港股上市全球 ETF 轮动",
"strategy_name_hk_high_dividend_low_vol_trend": "港股高股息低波趋势",
"strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}",
"strategy_plugin_alert_subject": "🚨 策略插件告警:{plugin} | {route}",
"strategy_plugin_alert_title": "🚨 【策略插件告警】",
Expand Down Expand Up @@ -220,6 +221,7 @@
"strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement",
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced",
"strategy_name_hk_listed_global_etf_rotation": "HK-listed Global ETF Rotation",
"strategy_name_hk_high_dividend_low_vol_trend": "HK High Dividend Low-Volatility Trend",
"strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}",
"strategy_plugin_alert_subject": "🚨 Strategy plugin alert: {plugin} | {route}",
"strategy_plugin_alert_title": "🚨 【Strategy Plugin Alert】",
Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.35
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.49
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@71141ce9e8a343cec8e2140994071eea66422bc6
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.36
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.50
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@v0.4.2
pandas
requests
pytz
Expand Down
8 changes: 8 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def test_supported_strategy_profiles_have_translated_names(self):
self.assertEqual(en_name("global_etf_confidence_vol_gate"), "Global ETF Confidence Vol Gate")
self.assertEqual(zh_name("hk_listed_global_etf_rotation"), "港股上市全球 ETF 轮动")
self.assertEqual(en_name("hk_listed_global_etf_rotation"), "HK-listed Global ETF Rotation")
self.assertEqual(zh_name("hk_high_dividend_low_vol_trend"), "港股高股息低波趋势")
self.assertEqual(en_name("hk_high_dividend_low_vol_trend"), "HK High Dividend Low-Volatility Trend")

for profile in SUPPORTED_STRATEGY_PROFILES:
self.assertNotEqual(zh_name(profile), profile)
Expand All @@ -122,6 +124,11 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self):
"market_date": "2026-05-28",
"latest_price_source": "longbridge_candlesticks",
"quote_overlay_used": None,
"price_as_of": "2026-05-28",
"universe_as_of": "2026-04-30",
"source_input_status": "universe_fallback",
"source_input_fallback_used": True,
"source_input_fallback_streak": 1,
},
"status_display": "🚀 风险开启(SOXX+SOXL)",
"signal_display": "SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%",
Expand All @@ -135,6 +142,7 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self):
)

self.assertIn("数据源 LongBridge 日线K线", rendered.compact_text)
self.assertIn("🧩 输入状态: 价格 2026-05-28 | 股票池 2026-04-30 | 股票池复用 连续1次", rendered.compact_text)
self.assertNotIn("报价覆盖", rendered.compact_text)
self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text)
self.assertNotIn("longbridge_candlesticks", rendered.compact_text)
Expand Down
64 changes: 44 additions & 20 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@
}
)
OPTIONAL_LONGBRIDGE_PROFILES = frozenset({"global_etf_confidence_vol_gate"})
HK_RUNTIME_ENABLED_PROFILES = frozenset({"hk_listed_global_etf_rotation"})
HK_RUNTIME_ENABLED_PROFILES = frozenset(
{
"hk_listed_global_etf_rotation",
"hk_high_dividend_low_vol_trend",
}
)
HK_DISABLED_PROFILES = frozenset(
{
"hk_blue_chip_leader_rotation",
Expand Down Expand Up @@ -614,6 +619,17 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self)
"platform": "longbridge",
},
)
self.assertEqual(
by_profile["hk_high_dividend_low_vol_trend"],
{
"canonical_profile": "hk_high_dividend_low_vol_trend",
"display_name": "HK High Dividend Low-Volatility Trend",
"domain": "hk_equity",
"eligible": True,
"enabled": True,
"platform": "longbridge",
},
)
for profile in HK_DISABLED_PROFILES:
self.assertNotIn(profile, by_profile)

Expand Down Expand Up @@ -654,25 +670,31 @@ def test_rejects_disabled_hk_profiles(self):
with self.assertRaisesRegex(ValueError, "Unsupported STRATEGY_PROFILE"):
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

def test_accepts_runtime_enabled_hk_global_etf_rotation(self):
with patch.dict(
os.environ,
{
"RUNTIME_TARGET_JSON": runtime_target_json("hk_listed_global_etf_rotation"),
"ACCOUNT_REGION": "HK",
},
clear=True,
):
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
def test_accepts_runtime_enabled_hk_profiles(self):
expected_names = {
"hk_listed_global_etf_rotation": "HK-listed Global ETF Rotation",
"hk_high_dividend_low_vol_trend": "HK High Dividend Low-Volatility Trend",
}
for profile, display_name in expected_names.items():
with self.subTest(profile=profile):
with patch.dict(
os.environ,
{
"RUNTIME_TARGET_JSON": runtime_target_json(profile),
"ACCOUNT_REGION": "HK",
},
clear=True,
):
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

self.assertEqual(settings.strategy_profile, "hk_listed_global_etf_rotation")
self.assertEqual(settings.strategy_display_name, "HK-listed Global ETF Rotation")
self.assertEqual(settings.strategy_domain, "hk_equity")
self.assertEqual(settings.market, HK_MARKET)
self.assertEqual(settings.market_calendar, HK_MARKET_CALENDAR)
self.assertEqual(settings.market_timezone, HK_MARKET_TIMEZONE)
self.assertEqual(settings.symbol_suffix, HK_SYMBOL_SUFFIX)
self.assertEqual(settings.trading_currency, HK_TRADING_CURRENCY)
self.assertEqual(settings.strategy_profile, profile)
self.assertEqual(settings.strategy_display_name, display_name)
self.assertEqual(settings.strategy_domain, "hk_equity")
self.assertEqual(settings.market, HK_MARKET)
self.assertEqual(settings.market_calendar, HK_MARKET_CALENDAR)
self.assertEqual(settings.market_timezone, HK_MARKET_TIMEZONE)
self.assertEqual(settings.symbol_suffix, HK_SYMBOL_SUFFIX)
self.assertEqual(settings.trading_currency, HK_TRADING_CURRENCY)

def test_derives_feature_snapshot_paths_from_artifact_root(self):
with TemporaryDirectory() as tmp_dir:
Expand Down Expand Up @@ -739,7 +761,7 @@ def test_print_strategy_profile_status_json_matches_registry(self):
self.assertFalse(by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_strategy_config_path"])
for profile in ("hk_index_mean_reversion", "hk_etf_regime_rotation", "hk_blue_chip_leader_rotation"):
self.assertNotIn(profile, by_profile)
for profile in ("hk_listed_global_etf_rotation",):
for profile in HK_RUNTIME_ENABLED_PROFILES:
self.assertEqual(by_profile[profile]["profile_group"], "direct_runtime_inputs")
self.assertEqual(by_profile[profile]["input_mode"], "market_history")
self.assertFalse(by_profile[profile]["requires_snapshot_artifacts"])
Expand All @@ -765,9 +787,11 @@ def test_print_strategy_profile_status_table_contains_expected_headers(self):
self.assertIn("soxl_soxx_trend_income", result.stdout)
self.assertIn("global_etf_rotation", result.stdout)
self.assertIn("hk_listed_global_etf_rotation", result.stdout)
self.assertIn("hk_high_dividend_low_vol_trend", result.stdout)
self.assertIn("russell_1000_multi_factor_defensive", result.stdout)
self.assertIn("Global ETF Rotation", result.stdout)
self.assertIn("HK-listed Global ETF Rotation", result.stdout)
self.assertIn("HK High Dividend Low-Volatility Trend", result.stdout)
self.assertIn("Russell 1000 Multi-Factor", result.stdout)
self.assertIn("Tech/Communication Pullback Enhancement", result.stdout)
self.assertNotIn("hk_blue_chip_leader_rotation", result.stdout)
Expand Down
21 changes: 21 additions & 0 deletions tests/test_signal_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ def test_prefers_structured_market_date_over_status_text(self):
self.assertEqual(snapshot["market_date"], "2026-06-02")
self.assertEqual(snapshot["signal_as_of"], "2026-06-01")

def test_includes_snapshot_manifest_input_diagnostics(self):
snapshot = build_signal_snapshot(
platform="longbridge",
metadata={
"snapshot_manifest_price_as_of": "2026-06-01",
"snapshot_manifest_universe_as_of": "2026-05-31",
"snapshot_manifest_source_input_status": "universe_fallback",
"snapshot_manifest_source_input_fallback_used": True,
"snapshot_manifest_source_input_fallback_reason": "RuntimeError: upstream returned HTML",
"snapshot_manifest_source_input_fallback_streak": 1,
"snapshot_manifest_source_refresh_run_id": "12345",
},
)

self.assertEqual(snapshot["price_as_of"], "2026-06-01")
self.assertEqual(snapshot["universe_as_of"], "2026-05-31")
self.assertEqual(snapshot["source_input_status"], "universe_fallback")
self.assertIs(snapshot["source_input_fallback_used"], True)
self.assertEqual(snapshot["source_input_fallback_streak"], 1)
self.assertEqual(snapshot["source_refresh_run_id"], "12345")


if __name__ == "__main__":
unittest.main()