aria-code 4.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
|
@@ -0,0 +1,2509 @@
|
|
|
1
|
+
"""Market data handlers extracted from aria_cli.py.
|
|
2
|
+
|
|
3
|
+
Handles market data prefetching, snapshot rows, and full snapshot analysis.
|
|
4
|
+
Imports market detection helpers from apps.cli.utils.market_detect.
|
|
5
|
+
_HAS_MDC and _get_mdc are resolved via lazy import to avoid circular deps.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from apps.cli.utils.market_detect import (
|
|
15
|
+
_re_sym, _STOCK_PATTERN,
|
|
16
|
+
_CRYPTO_WORDS, _COMPANY_TO_TICKER,
|
|
17
|
+
_FINANCIAL_TERMS_BLOCKLIST,
|
|
18
|
+
_extract_market_symbol, _extract_market_symbols, _extract_symbol_from_history,
|
|
19
|
+
_is_realty_query, _is_market_snapshot_request,
|
|
20
|
+
_format_compact_market_cap, _market_snapshot_trend,
|
|
21
|
+
_has_unresolved_company_mention,
|
|
22
|
+
_detect_market_overview,
|
|
23
|
+
_PRIVATE_COMPANY_PROFILES,
|
|
24
|
+
)
|
|
25
|
+
from apps.cli.market_metadata import enrich_market_quote, market_display_label
|
|
26
|
+
|
|
27
|
+
_PROVIDERS_FILE = Path.home() / ".arthera" / "providers.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_lang(text: str) -> str:
|
|
31
|
+
"""Return 'zh' for predominantly Chinese input, 'en' otherwise."""
|
|
32
|
+
if not text:
|
|
33
|
+
return "zh"
|
|
34
|
+
zh_chars = sum(1 for c in text if '一' <= c <= '鿿')
|
|
35
|
+
return "zh" if zh_chars / max(len(text), 1) > 0.15 else "en"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── TA session cache (populated during prefetch, read during snapshot) ────────
|
|
39
|
+
_TA_SESSION_CACHE: dict = {}
|
|
40
|
+
_TA_SESSION_CACHE_TTL = 600 # 10 minutes
|
|
41
|
+
_LEVEL_HISTORY_CACHE: dict = {}
|
|
42
|
+
_LEVEL_HISTORY_CACHE_TTL = 300 # 5 minutes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fmt_int(value) -> str:
|
|
46
|
+
try:
|
|
47
|
+
return f"{int(float(value)):,}"
|
|
48
|
+
except Exception:
|
|
49
|
+
return "N/A"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _num_or_none(value):
|
|
53
|
+
try:
|
|
54
|
+
if value in (None, "", "N/A", "-", "nan"):
|
|
55
|
+
return None
|
|
56
|
+
out = float(value)
|
|
57
|
+
return out if out == out else None
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fmt_money(currency: str, value) -> str:
|
|
63
|
+
num = _num_or_none(value)
|
|
64
|
+
if num is None:
|
|
65
|
+
return "—"
|
|
66
|
+
return f"{currency} {num:,.2f}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clean_provider_chain(providers) -> list[str]:
|
|
70
|
+
generic = {
|
|
71
|
+
"quote",
|
|
72
|
+
"fundamentals",
|
|
73
|
+
"technical",
|
|
74
|
+
"history",
|
|
75
|
+
"market_data_client",
|
|
76
|
+
"market_data_client.quote",
|
|
77
|
+
"market_data_client.fundamentals",
|
|
78
|
+
"market_data_client.technical_indicators",
|
|
79
|
+
}
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
for provider in providers or []:
|
|
82
|
+
p = str(provider or "").strip()
|
|
83
|
+
if not p or p in generic:
|
|
84
|
+
continue
|
|
85
|
+
if p not in out:
|
|
86
|
+
out.append(p)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _snapshot_signal(price, change_pct, rsi, macd_hist, ma20, ma60) -> tuple[str, int, float, str]:
|
|
91
|
+
"""Return (signal, score, confidence, label) for a compact market snapshot."""
|
|
92
|
+
enough = any(v is not None for v in (rsi, macd_hist, ma20, ma60))
|
|
93
|
+
if not enough:
|
|
94
|
+
return "—", 0, 0.0, "指标不足"
|
|
95
|
+
score = 0
|
|
96
|
+
if ma20 is not None and price is not None:
|
|
97
|
+
score += 1 if price > ma20 else -1
|
|
98
|
+
if ma60 is not None and price is not None:
|
|
99
|
+
score += 1 if price > ma60 else -1
|
|
100
|
+
if macd_hist is not None:
|
|
101
|
+
score += 1 if macd_hist > 0 else -1 if macd_hist < 0 else 0
|
|
102
|
+
if rsi is not None:
|
|
103
|
+
if rsi <= 30:
|
|
104
|
+
score += 2
|
|
105
|
+
elif rsi <= 40:
|
|
106
|
+
score += 1
|
|
107
|
+
elif rsi >= 75:
|
|
108
|
+
score -= 2
|
|
109
|
+
elif rsi >= 65:
|
|
110
|
+
score -= 1
|
|
111
|
+
if change_pct is not None:
|
|
112
|
+
if change_pct >= 2:
|
|
113
|
+
score += 1
|
|
114
|
+
elif change_pct <= -2:
|
|
115
|
+
score -= 1
|
|
116
|
+
|
|
117
|
+
if score >= 4:
|
|
118
|
+
signal, label = "STRONG_BUY", "强势多头"
|
|
119
|
+
elif score >= 2:
|
|
120
|
+
signal, label = "BUY", "偏多"
|
|
121
|
+
elif score <= -4:
|
|
122
|
+
signal, label = "STRONG_SELL", "强势空头"
|
|
123
|
+
elif score <= -2:
|
|
124
|
+
signal, label = "SELL", "偏空"
|
|
125
|
+
elif score == 1:
|
|
126
|
+
signal, label = "HOLD+", "短线偏强"
|
|
127
|
+
elif score == -1:
|
|
128
|
+
signal, label = "HOLD-", "短线偏弱"
|
|
129
|
+
else:
|
|
130
|
+
signal, label = "NEUTRAL", "震荡观察"
|
|
131
|
+
confidence = min(0.82, 0.46 + abs(score) * 0.07)
|
|
132
|
+
return signal, score, confidence, label
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _support_resistance_for_row(currency: str, price, ma20, ma60, bb_lower, bb_upper) -> tuple[str, str]:
|
|
136
|
+
p = _num_or_none(price)
|
|
137
|
+
if p is None:
|
|
138
|
+
return "", ""
|
|
139
|
+
supports = sorted(
|
|
140
|
+
{round(v, 2) for v in (_num_or_none(bb_lower), _num_or_none(ma60), _num_or_none(ma20)) if v is not None and v < p},
|
|
141
|
+
reverse=True,
|
|
142
|
+
)[:3]
|
|
143
|
+
resistances = sorted(
|
|
144
|
+
{round(v, 2) for v in (_num_or_none(ma20), _num_or_none(ma60), _num_or_none(bb_upper)) if v is not None and v > p},
|
|
145
|
+
)[:3]
|
|
146
|
+
support_str = ", ".join(f"{currency} {v:,.2f}" for v in supports)
|
|
147
|
+
resistance_str = ", ".join(f"{currency} {v:,.2f}" for v in resistances)
|
|
148
|
+
return support_str, resistance_str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _history_records(history_result) -> list[dict]:
|
|
152
|
+
"""Return normalized OHLC records from a market_data_client history result."""
|
|
153
|
+
if not isinstance(history_result, dict) or not history_result.get("success"):
|
|
154
|
+
return []
|
|
155
|
+
records = history_result.get("data") or []
|
|
156
|
+
out: list[dict] = []
|
|
157
|
+
for row in records:
|
|
158
|
+
if not isinstance(row, dict):
|
|
159
|
+
continue
|
|
160
|
+
close = _num_or_none(row.get("close") or row.get("Close"))
|
|
161
|
+
high = _num_or_none(row.get("high") or row.get("High") or close)
|
|
162
|
+
low = _num_or_none(row.get("low") or row.get("Low") or close)
|
|
163
|
+
open_ = _num_or_none(row.get("open") or row.get("Open") or close)
|
|
164
|
+
if close is None or high is None or low is None:
|
|
165
|
+
continue
|
|
166
|
+
out.append({
|
|
167
|
+
"date": row.get("date") or row.get("datetime") or row.get("time") or "",
|
|
168
|
+
"open": open_ if open_ is not None else close,
|
|
169
|
+
"high": high,
|
|
170
|
+
"low": low,
|
|
171
|
+
"close": close,
|
|
172
|
+
"volume": _num_or_none(row.get("volume") or row.get("Volume")),
|
|
173
|
+
})
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _cached_history_records(mdc, symbol: str, days: int, interval: str) -> list[dict]:
|
|
178
|
+
if mdc is None or not hasattr(mdc, "history"):
|
|
179
|
+
return []
|
|
180
|
+
import time as _time
|
|
181
|
+
key = (str(symbol).upper(), int(days), str(interval))
|
|
182
|
+
now = _time.time()
|
|
183
|
+
cached = _LEVEL_HISTORY_CACHE.get(key)
|
|
184
|
+
if cached and now - cached.get("ts", 0) < _LEVEL_HISTORY_CACHE_TTL:
|
|
185
|
+
return cached.get("records", [])[:]
|
|
186
|
+
try:
|
|
187
|
+
records = _history_records(mdc.history(symbol, days=days, interval=interval))
|
|
188
|
+
except Exception:
|
|
189
|
+
records = []
|
|
190
|
+
if records:
|
|
191
|
+
_LEVEL_HISTORY_CACHE[key] = {"ts": now, "records": records}
|
|
192
|
+
return records
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _aggregate_ohlc(records: list[dict], bars: int) -> list[dict]:
|
|
196
|
+
"""Aggregate chronological OHLC records into fixed-size bars."""
|
|
197
|
+
if bars <= 1 or len(records) < bars:
|
|
198
|
+
return records[:]
|
|
199
|
+
out: list[dict] = []
|
|
200
|
+
for i in range(0, len(records), bars):
|
|
201
|
+
chunk = records[i:i + bars]
|
|
202
|
+
if len(chunk) < bars:
|
|
203
|
+
continue
|
|
204
|
+
out.append({
|
|
205
|
+
"date": chunk[-1].get("date") or "",
|
|
206
|
+
"open": chunk[0]["open"],
|
|
207
|
+
"high": max(float(r["high"]) for r in chunk),
|
|
208
|
+
"low": min(float(r["low"]) for r in chunk),
|
|
209
|
+
"close": chunk[-1]["close"],
|
|
210
|
+
"volume": sum(float(r.get("volume") or 0) for r in chunk),
|
|
211
|
+
})
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _rolling_mean_from_records(records: list[dict], n: int) -> float | None:
|
|
216
|
+
if len(records) < n:
|
|
217
|
+
return None
|
|
218
|
+
vals = [_num_or_none(r.get("close")) for r in records[-n:]]
|
|
219
|
+
vals = [v for v in vals if v is not None]
|
|
220
|
+
if len(vals) < n:
|
|
221
|
+
return None
|
|
222
|
+
return sum(vals) / len(vals)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _bollinger_from_records(records: list[dict], n: int = 20) -> tuple[float | None, float | None]:
|
|
226
|
+
if len(records) < n:
|
|
227
|
+
return None, None
|
|
228
|
+
vals = [_num_or_none(r.get("close")) for r in records[-n:]]
|
|
229
|
+
vals = [v for v in vals if v is not None]
|
|
230
|
+
if len(vals) < n:
|
|
231
|
+
return None, None
|
|
232
|
+
mean = sum(vals) / len(vals)
|
|
233
|
+
if len(vals) < 2:
|
|
234
|
+
return None, None
|
|
235
|
+
variance = sum((v - mean) ** 2 for v in vals) / (len(vals) - 1)
|
|
236
|
+
std = variance ** 0.5
|
|
237
|
+
return mean + 2 * std, mean - 2 * std
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _nearest_levels(
|
|
241
|
+
records: list[dict],
|
|
242
|
+
price,
|
|
243
|
+
*,
|
|
244
|
+
window: int,
|
|
245
|
+
extra_levels=(),
|
|
246
|
+
max_levels: int = 3,
|
|
247
|
+
) -> tuple[list[float], list[float]]:
|
|
248
|
+
"""Find nearest support/resistance using swing points plus dynamic levels."""
|
|
249
|
+
p = _num_or_none(price)
|
|
250
|
+
if p is None:
|
|
251
|
+
return [], []
|
|
252
|
+
highs = [_num_or_none(r.get("high")) for r in records]
|
|
253
|
+
lows = [_num_or_none(r.get("low")) for r in records]
|
|
254
|
+
closes = [_num_or_none(r.get("close")) for r in records]
|
|
255
|
+
usable = [
|
|
256
|
+
(h, l, c)
|
|
257
|
+
for h, l, c in zip(highs, lows, closes)
|
|
258
|
+
if h is not None and l is not None and c is not None
|
|
259
|
+
]
|
|
260
|
+
if len(usable) < max(5, window * 2 + 1):
|
|
261
|
+
candidates = [_num_or_none(v) for v in extra_levels]
|
|
262
|
+
else:
|
|
263
|
+
highs = [float(h) for h, _l, _c in usable]
|
|
264
|
+
lows = [float(l) for _h, l, _c in usable]
|
|
265
|
+
candidates: list[float | None] = []
|
|
266
|
+
for i in range(window, len(usable) - window):
|
|
267
|
+
hi_slice = highs[i - window:i + window + 1]
|
|
268
|
+
lo_slice = lows[i - window:i + window + 1]
|
|
269
|
+
if highs[i] == max(hi_slice):
|
|
270
|
+
candidates.append(highs[i])
|
|
271
|
+
if lows[i] == min(lo_slice):
|
|
272
|
+
candidates.append(lows[i])
|
|
273
|
+
candidates.extend(_num_or_none(v) for v in extra_levels)
|
|
274
|
+
|
|
275
|
+
support = sorted(
|
|
276
|
+
{round(float(v), 2) for v in candidates if v is not None and float(v) < p},
|
|
277
|
+
reverse=True,
|
|
278
|
+
)[:max_levels]
|
|
279
|
+
resistance = sorted(
|
|
280
|
+
{round(float(v), 2) for v in candidates if v is not None and float(v) > p},
|
|
281
|
+
)[:max_levels]
|
|
282
|
+
return support, resistance
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _format_levels(currency: str, levels: list[float]) -> str:
|
|
286
|
+
return ", ".join(f"{currency} {v:,.2f}" for v in levels) if levels else "—"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _level_action_line(
|
|
290
|
+
name: str,
|
|
291
|
+
support: list[float],
|
|
292
|
+
resistance: list[float],
|
|
293
|
+
currency: str,
|
|
294
|
+
*,
|
|
295
|
+
english: bool = False,
|
|
296
|
+
) -> str:
|
|
297
|
+
sup = f"{currency} {support[0]:,.2f}" if support else ""
|
|
298
|
+
res = f"{currency} {resistance[0]:,.2f}" if resistance else ""
|
|
299
|
+
if english:
|
|
300
|
+
if name.startswith("4H"):
|
|
301
|
+
if sup and res:
|
|
302
|
+
return f"Short-term: break {res} to chase strength; lose {sup} to reduce risk."
|
|
303
|
+
return "Short-term: wait for a cleaner nearby level."
|
|
304
|
+
if name.startswith("Daily"):
|
|
305
|
+
if sup and res:
|
|
306
|
+
return f"Swing: hold {sup}; reclaim {res} for daily repair."
|
|
307
|
+
return "Swing: level data is incomplete."
|
|
308
|
+
if sup and res:
|
|
309
|
+
return f"Position: {sup} is the structural line; {res} is the next supply zone."
|
|
310
|
+
return "Position: structural levels are incomplete."
|
|
311
|
+
if name.startswith("4H"):
|
|
312
|
+
if sup and res:
|
|
313
|
+
return f"短线:上破 {res} 转强;跌破 {sup} 降风险。"
|
|
314
|
+
return "短线:近端关键位不足,先等价格走出区间。"
|
|
315
|
+
if name.startswith("日线"):
|
|
316
|
+
if sup and res:
|
|
317
|
+
return f"波段:守住 {sup} 结构未破;站回 {res} 修复。"
|
|
318
|
+
return "波段:日线关键位不足,需结合成交量确认。"
|
|
319
|
+
if sup and res:
|
|
320
|
+
return f"长线:{sup} 是结构防线;{res} 是上方供给/趋势压力。"
|
|
321
|
+
return "长线:结构位不足,暂不做长期突破判断。"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _timeframe_display_name(name: str, *, english: bool = False) -> str:
|
|
325
|
+
if not english:
|
|
326
|
+
return name
|
|
327
|
+
if name.startswith("4H"):
|
|
328
|
+
return "4H/Short-term"
|
|
329
|
+
if name.startswith("日线"):
|
|
330
|
+
return "Daily/Swing"
|
|
331
|
+
if name.startswith("周线"):
|
|
332
|
+
return "Weekly/Position"
|
|
333
|
+
return name
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _timeframe_source_label(source: str, *, english: bool = False) -> str:
|
|
337
|
+
if english:
|
|
338
|
+
return {
|
|
339
|
+
"1h→4h": "1h aggregated to 4H",
|
|
340
|
+
"near-term daily": "near-term daily fallback",
|
|
341
|
+
"daily swings + MA/BOLL": "daily swings + MA/BOLL",
|
|
342
|
+
"daily→weekly swings": "daily aggregated to weekly",
|
|
343
|
+
"MA/BOLL fallback": "MA/BOLL fallback",
|
|
344
|
+
}.get(source, source)
|
|
345
|
+
return {
|
|
346
|
+
"1h→4h": "1小时线聚合",
|
|
347
|
+
"near-term daily": "近端日线替代",
|
|
348
|
+
"daily swings + MA/BOLL": "日线摆动 + 均线/布林",
|
|
349
|
+
"daily→weekly swings": "日线聚合周线",
|
|
350
|
+
"MA/BOLL fallback": "均线/布林兜底",
|
|
351
|
+
}.get(source, source)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _append_timeframe_levels(
|
|
355
|
+
lines: list[str],
|
|
356
|
+
timeframe_levels: list[dict],
|
|
357
|
+
currency: str,
|
|
358
|
+
*,
|
|
359
|
+
english: bool = False,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Append narrow-terminal friendly multi-timeframe levels.
|
|
362
|
+
|
|
363
|
+
A Markdown table with five columns truncates the long "Use" text in 80-col
|
|
364
|
+
terminals. Blocks keep every level visible and let Rich wrap text naturally.
|
|
365
|
+
"""
|
|
366
|
+
if not timeframe_levels:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
lines.append("")
|
|
370
|
+
lines.append(f"**{'Multi-timeframe key levels' if english else '多周期关键位'}**")
|
|
371
|
+
for row in timeframe_levels[:3]:
|
|
372
|
+
raw_name = str(row.get("name") or "—")
|
|
373
|
+
name = _timeframe_display_name(raw_name, english=english)
|
|
374
|
+
source = _timeframe_source_label(str(row.get("source") or ""), english=english)
|
|
375
|
+
horizon = str(row.get("horizon") or "—")
|
|
376
|
+
support = row.get("support") or []
|
|
377
|
+
resistance = row.get("resistance") or []
|
|
378
|
+
action = _level_action_line(name, support, resistance, currency, english=english)
|
|
379
|
+
|
|
380
|
+
if english:
|
|
381
|
+
meta = f"For: {horizon}"
|
|
382
|
+
if source:
|
|
383
|
+
meta += f" · Source: {source}"
|
|
384
|
+
lines.append(f"- **{name}** — {meta}")
|
|
385
|
+
lines.append(f" - Support: {_format_levels(currency, support)}")
|
|
386
|
+
lines.append(f" - Resistance: {_format_levels(currency, resistance)}")
|
|
387
|
+
lines.append(f" - Use: {action}")
|
|
388
|
+
else:
|
|
389
|
+
meta = f"适合:{horizon}"
|
|
390
|
+
if source:
|
|
391
|
+
meta += f" · 来源:{source}"
|
|
392
|
+
lines.append(f"- **{name}** — {meta}")
|
|
393
|
+
lines.append(f" - 支撑:{_format_levels(currency, support)}")
|
|
394
|
+
lines.append(f" - 压力:{_format_levels(currency, resistance)}")
|
|
395
|
+
lines.append(f" - 用法:{action}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _build_timeframe_levels(
|
|
399
|
+
mdc,
|
|
400
|
+
symbol: str,
|
|
401
|
+
price,
|
|
402
|
+
currency: str,
|
|
403
|
+
*,
|
|
404
|
+
ma20=None,
|
|
405
|
+
ma60=None,
|
|
406
|
+
bb_lower=None,
|
|
407
|
+
bb_upper=None,
|
|
408
|
+
fallback_supports: list[float] | None = None,
|
|
409
|
+
fallback_resistances: list[float] | None = None,
|
|
410
|
+
) -> list[dict]:
|
|
411
|
+
"""Build 4H/short, daily, and weekly/position support-resistance levels."""
|
|
412
|
+
p = _num_or_none(price)
|
|
413
|
+
if p is None:
|
|
414
|
+
return []
|
|
415
|
+
fallback_supports = fallback_supports or []
|
|
416
|
+
fallback_resistances = fallback_resistances or []
|
|
417
|
+
rows: list[dict] = []
|
|
418
|
+
|
|
419
|
+
daily_records = _cached_history_records(mdc, symbol, 370, "1d")
|
|
420
|
+
intraday_records = _cached_history_records(mdc, symbol, 30, "1h")
|
|
421
|
+
|
|
422
|
+
short_source = "1h→4h" if len(intraday_records) >= 24 else "near-term daily"
|
|
423
|
+
short_records = _aggregate_ohlc(intraday_records, 4) if len(intraday_records) >= 24 else daily_records[-30:]
|
|
424
|
+
if short_records:
|
|
425
|
+
short_ma20 = _rolling_mean_from_records(short_records, 20)
|
|
426
|
+
short_sup, short_res = _nearest_levels(
|
|
427
|
+
short_records,
|
|
428
|
+
p,
|
|
429
|
+
window=2 if len(short_records) < 60 else 3,
|
|
430
|
+
extra_levels=(short_ma20, *fallback_supports, *fallback_resistances),
|
|
431
|
+
)
|
|
432
|
+
rows.append({
|
|
433
|
+
"name": "4H/短线",
|
|
434
|
+
"horizon": "1-5 日",
|
|
435
|
+
"source": short_source,
|
|
436
|
+
"support": short_sup,
|
|
437
|
+
"resistance": short_res,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
if daily_records:
|
|
441
|
+
day_bbu, day_bbl = _bollinger_from_records(daily_records, 20)
|
|
442
|
+
day_sup, day_res = _nearest_levels(
|
|
443
|
+
daily_records[-180:],
|
|
444
|
+
p,
|
|
445
|
+
window=5,
|
|
446
|
+
extra_levels=(ma20, ma60, bb_lower, bb_upper, day_bbl, day_bbu),
|
|
447
|
+
)
|
|
448
|
+
rows.append({
|
|
449
|
+
"name": "日线/波段",
|
|
450
|
+
"horizon": "1-8 周",
|
|
451
|
+
"source": "daily swings + MA/BOLL",
|
|
452
|
+
"support": day_sup,
|
|
453
|
+
"resistance": day_res,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
weekly_records = _aggregate_ohlc(daily_records[-260:], 5)
|
|
457
|
+
weekly_ma20 = _rolling_mean_from_records(weekly_records, 20)
|
|
458
|
+
weekly_ma40 = _rolling_mean_from_records(weekly_records, 40)
|
|
459
|
+
long_high = max((float(r["high"]) for r in daily_records[-260:] if r.get("high") is not None), default=None)
|
|
460
|
+
long_low = min((float(r["low"]) for r in daily_records[-260:] if r.get("low") is not None), default=None)
|
|
461
|
+
long_sup, long_res = _nearest_levels(
|
|
462
|
+
weekly_records,
|
|
463
|
+
p,
|
|
464
|
+
window=3,
|
|
465
|
+
extra_levels=(weekly_ma20, weekly_ma40, long_high, long_low),
|
|
466
|
+
)
|
|
467
|
+
rows.append({
|
|
468
|
+
"name": "周线/长线",
|
|
469
|
+
"horizon": "2-12 月",
|
|
470
|
+
"source": "daily→weekly swings",
|
|
471
|
+
"support": long_sup,
|
|
472
|
+
"resistance": long_res,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
if not rows and (fallback_supports or fallback_resistances):
|
|
476
|
+
rows.append({
|
|
477
|
+
"name": "近端关键位",
|
|
478
|
+
"horizon": "快照",
|
|
479
|
+
"source": "MA/BOLL fallback",
|
|
480
|
+
"support": fallback_supports[:3],
|
|
481
|
+
"resistance": fallback_resistances[:3],
|
|
482
|
+
})
|
|
483
|
+
return rows
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _is_market_share_request(message: str) -> bool:
|
|
487
|
+
text = (message or "").lower()
|
|
488
|
+
return any(k in text for k in (
|
|
489
|
+
"市场份额", "市占率", "份额", "竞争格局", "market share", "share of market",
|
|
490
|
+
))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _market_share_note(symbols: list[str], *, english: bool = False) -> list[str]:
|
|
494
|
+
if english:
|
|
495
|
+
return [
|
|
496
|
+
"",
|
|
497
|
+
"Market Share Follow-up",
|
|
498
|
+
"- The table above covers stock price and technical trend only.",
|
|
499
|
+
"- Market share requires business-line research, for example: iPhone vs Android, search, digital ads, cloud, browser, payments, or devices.",
|
|
500
|
+
"- Run `" + " ".join(["/research", *symbols[:2]]) + "` or `/web <company> market share latest` to fetch source-backed share data.",
|
|
501
|
+
]
|
|
502
|
+
return [
|
|
503
|
+
"",
|
|
504
|
+
"市场份额后续",
|
|
505
|
+
"- 上表只覆盖股价走势、技术指标和市值,不等同于业务市场份额。",
|
|
506
|
+
"- 市场份额需要按业务线拆开研究,例如:手机、搜索、数字广告、云服务、浏览器、支付、硬件生态。",
|
|
507
|
+
"- 可继续运行 `" + " ".join(["/research", *symbols[:2]]) + "`,或 `/web <公司> 市场份额 最新` 获取带来源的份额数据。",
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
_DATA_KEY_MAP = {
|
|
512
|
+
"finnhub": "FINNHUB_API_KEY",
|
|
513
|
+
"alphavantage": "ALPHAVANTAGE_API_KEY",
|
|
514
|
+
"polygon": "POLYGON_API_KEY",
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _get_provider_key(provider: str) -> str:
|
|
519
|
+
"""Return configured API key for a provider (env var takes priority over providers.json)."""
|
|
520
|
+
env_var = _DATA_KEY_MAP.get(provider.lower(), "")
|
|
521
|
+
if env_var:
|
|
522
|
+
val = os.getenv(env_var, "")
|
|
523
|
+
if val:
|
|
524
|
+
return val
|
|
525
|
+
try:
|
|
526
|
+
if _PROVIDERS_FILE.exists():
|
|
527
|
+
raw = json.loads(_PROVIDERS_FILE.read_text(encoding="utf-8"))
|
|
528
|
+
for section in ("llm", "data"):
|
|
529
|
+
entry = raw.get(section, {}).get(provider.lower(), {})
|
|
530
|
+
if entry.get("api_key"):
|
|
531
|
+
return entry["api_key"]
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
return ""
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# Lazy MDC accessor (mirrors the pattern in market_tools.py)
|
|
538
|
+
def _get_mdc_lazy():
|
|
539
|
+
aria_cli = sys.modules.get("aria_cli")
|
|
540
|
+
injected = getattr(aria_cli, "_get_mdc", None) if aria_cli else None
|
|
541
|
+
if callable(injected):
|
|
542
|
+
try:
|
|
543
|
+
return injected()
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
try:
|
|
547
|
+
from market_data_client import get_mdc as _gm
|
|
548
|
+
return _gm()
|
|
549
|
+
except Exception:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
def _has_mdc_lazy() -> bool:
|
|
553
|
+
aria_cli = sys.modules.get("aria_cli")
|
|
554
|
+
if aria_cli is not None and hasattr(aria_cli, "_HAS_MDC"):
|
|
555
|
+
return bool(getattr(aria_cli, "_HAS_MDC"))
|
|
556
|
+
try:
|
|
557
|
+
import market_data_client # noqa
|
|
558
|
+
return True
|
|
559
|
+
except ImportError:
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _try_prefetch_market_data(message: str, history: list = None) -> str:
|
|
564
|
+
"""
|
|
565
|
+
Pre-fetch real market data and inject it into the system prompt so local
|
|
566
|
+
models always answer with real numbers instead of hallucinating.
|
|
567
|
+
|
|
568
|
+
For technical-analysis queries (support/resistance/RSI/MACD) also fetches
|
|
569
|
+
technical indicators and computes key price levels from the data.
|
|
570
|
+
|
|
571
|
+
跟进问题支持:当前消息无标的但含市场关键词时,从会话历史继承最近标的
|
|
572
|
+
(如上一轮问"寒武纪趋势",这一轮问"现在的股票和趋势呢")。
|
|
573
|
+
|
|
574
|
+
Returns "" if no market query detected or fetch fails.
|
|
575
|
+
"""
|
|
576
|
+
# Real-estate queries must not prefetch stock market data
|
|
577
|
+
if _is_realty_query(message):
|
|
578
|
+
return ""
|
|
579
|
+
|
|
580
|
+
# Trigger for any market / analysis query
|
|
581
|
+
_market_kw = (
|
|
582
|
+
"股票","股价","价格","涨跌","市值","行情","市场","现在多少","现价","今天价格",
|
|
583
|
+
"分析","走势","技术面","基本面","估值","涨跌幅",
|
|
584
|
+
"支撑","阻力","支撑位","阻力位","技术指标","技术分析",
|
|
585
|
+
"stock","price","quote","analyze","analysis","crypto",
|
|
586
|
+
"btc","eth","比特币","以太坊","rsi","macd","bollinger",
|
|
587
|
+
)
|
|
588
|
+
msg_low = message.lower()
|
|
589
|
+
if not any(k in msg_low for k in _market_kw):
|
|
590
|
+
return ""
|
|
591
|
+
|
|
592
|
+
# Detect if this is a technical analysis request
|
|
593
|
+
_tech_kw = ("技术面","技术分析","技术指标","支撑","阻力","支撑位","阻力位",
|
|
594
|
+
"rsi","macd","bollinger","均线","走势","趋势","technical")
|
|
595
|
+
_is_tech_query = any(k in msg_low for k in _tech_kw)
|
|
596
|
+
|
|
597
|
+
msg_for_lookup = message.lower() # case-insensitive company name matching
|
|
598
|
+
_all_syms: list = []
|
|
599
|
+
_seen_syms: set = set()
|
|
600
|
+
|
|
601
|
+
# 1. Known Chinese company / index name → ticker (longest match first, find ALL)
|
|
602
|
+
for cn, tick in sorted(_COMPANY_TO_TICKER.items(), key=lambda x: -len(x[0])):
|
|
603
|
+
if cn.lower() in msg_for_lookup and tick not in _seen_syms:
|
|
604
|
+
_all_syms.append(tick)
|
|
605
|
+
_seen_syms.add(tick)
|
|
606
|
+
|
|
607
|
+
# 2. Crypto names
|
|
608
|
+
for cn, tick in _CRYPTO_WORDS.items():
|
|
609
|
+
if cn.lower() in msg_for_lookup and tick not in _seen_syms:
|
|
610
|
+
_all_syms.append(tick)
|
|
611
|
+
_seen_syms.add(tick)
|
|
612
|
+
|
|
613
|
+
# 3. Uppercase ticker patterns — collect all matches
|
|
614
|
+
for _tm in _re_sym.finditer(r'\b([A-Z]{2,5}(?:\.(?:HK|SH|SZ))?)\b', message):
|
|
615
|
+
tick = _tm.group(1)
|
|
616
|
+
if tick not in _FINANCIAL_TERMS_BLOCKLIST and tick not in _seen_syms:
|
|
617
|
+
_all_syms.append(tick)
|
|
618
|
+
_seen_syms.add(tick)
|
|
619
|
+
|
|
620
|
+
# 4. History fallback when no symbols found
|
|
621
|
+
if not _all_syms and history:
|
|
622
|
+
_hs = _extract_symbol_from_history(history)
|
|
623
|
+
if _hs:
|
|
624
|
+
_all_syms = [_hs]
|
|
625
|
+
|
|
626
|
+
if not _all_syms:
|
|
627
|
+
return ""
|
|
628
|
+
|
|
629
|
+
symbol = _all_syms[0] # primary symbol drives tech-analysis fetch
|
|
630
|
+
_extra_syms = _all_syms[1:3] # up to 2 additional symbols
|
|
631
|
+
|
|
632
|
+
if not _has_mdc_lazy():
|
|
633
|
+
return (
|
|
634
|
+
f"\n## 实时行情状态\n"
|
|
635
|
+
f"- 标的:{symbol}\n"
|
|
636
|
+
f"- 状态:本地 market_data_client 未加载,无法获取实时行情。\n"
|
|
637
|
+
f"- 输出要求:明确说明数据不可用,并建议用户执行 `/quote {symbol}`;"
|
|
638
|
+
"不要输出示例价格、占位符或技术指标。\n"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
mdc = _get_mdc_lazy()
|
|
643
|
+
r = mdc.quote(symbol)
|
|
644
|
+
if not r.get("success"):
|
|
645
|
+
return (
|
|
646
|
+
f"\n## 实时行情状态\n"
|
|
647
|
+
f"- 标的:{symbol}\n"
|
|
648
|
+
f"- 状态:当前数据服务无法获取该标的的实时行情。\n"
|
|
649
|
+
f"- 可用操作:运行 `/quote {symbol}` 重试。\n"
|
|
650
|
+
f"- 输出要求:不要输出示例价格、占位符、RSI、MACD 或支撑阻力位。\n"
|
|
651
|
+
)
|
|
652
|
+
r = enrich_market_quote(symbol, r)
|
|
653
|
+
price = r.get("price", "N/A")
|
|
654
|
+
chg = r.get("change_pct", 0)
|
|
655
|
+
name = r.get("name", symbol)
|
|
656
|
+
currency = r.get("currency", "USD")
|
|
657
|
+
high = r.get("high", "N/A")
|
|
658
|
+
low = r.get("low", "N/A")
|
|
659
|
+
vol = r.get("volume", "N/A")
|
|
660
|
+
mktcap = r.get("market_cap")
|
|
661
|
+
cap_str = ""
|
|
662
|
+
if mktcap and mktcap == mktcap: # excludes NaN
|
|
663
|
+
if mktcap >= 1e12:
|
|
664
|
+
cap_str = f"{currency} {mktcap/1e12:.2f}T"
|
|
665
|
+
elif mktcap >= 1e9:
|
|
666
|
+
cap_str = f"{currency} {mktcap/1e9:.1f}B"
|
|
667
|
+
sign = "+" if chg >= 0 else ""
|
|
668
|
+
provider = r.get("provider", "API")
|
|
669
|
+
display_label = market_display_label(symbol, r)
|
|
670
|
+
exchange = r.get("exchange")
|
|
671
|
+
|
|
672
|
+
block = (
|
|
673
|
+
f"\n## 📊 {display_label} 实时行情(来源:{provider})\n"
|
|
674
|
+
f"- **交易代码**:{symbol}" + (f"({exchange})\n" if exchange else "\n") +
|
|
675
|
+
f"- **名称**:{name}\n"
|
|
676
|
+
f"- **最新价**:{currency} {price}\n"
|
|
677
|
+
f"- **涨跌幅**:{sign}{chg:.2f}%\n"
|
|
678
|
+
f"- **今日高/低**:{high} / {low}\n"
|
|
679
|
+
f"- **成交量**:{vol}\n"
|
|
680
|
+
+ (f"- **市值**:{cap_str}\n" if cap_str else "")
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# For technical analysis queries: fetch indicators and compute support/resistance
|
|
684
|
+
if _is_tech_query:
|
|
685
|
+
try:
|
|
686
|
+
import time as _time_ta
|
|
687
|
+
_raw_ti = mdc.technical_indicators(symbol, days=120)
|
|
688
|
+
if isinstance(_raw_ti, dict) and _raw_ti.get("success"):
|
|
689
|
+
_TA_SESSION_CACHE[symbol] = {"data": _raw_ti, "ts": _time_ta.time()}
|
|
690
|
+
ti = _raw_ti
|
|
691
|
+
else:
|
|
692
|
+
# Fall back to session cache
|
|
693
|
+
_cached_ta = _TA_SESSION_CACHE.get(symbol)
|
|
694
|
+
ti = (_cached_ta["data"] if _cached_ta and
|
|
695
|
+
(_time_ta.time() - _cached_ta["ts"]) < _TA_SESSION_CACHE_TTL
|
|
696
|
+
else {})
|
|
697
|
+
if ti.get("success"):
|
|
698
|
+
rsi = ti.get("rsi")
|
|
699
|
+
macd = ti.get("macd")
|
|
700
|
+
msig = ti.get("macd_signal")
|
|
701
|
+
mhist = ti.get("macd_hist")
|
|
702
|
+
bbu = ti.get("bb_upper")
|
|
703
|
+
bbm = ti.get("bb_mid")
|
|
704
|
+
bbl = ti.get("bb_lower")
|
|
705
|
+
ma20 = ti.get("ma20")
|
|
706
|
+
ma60 = ti.get("ma60")
|
|
707
|
+
ma5 = ti.get("ma5")
|
|
708
|
+
|
|
709
|
+
# Derive support / resistance from MAs and Bollinger Bands
|
|
710
|
+
supports = sorted([v for v in [ma20, ma60, bbl] if v], reverse=False)
|
|
711
|
+
resistances = sorted([v for v in [bbu, bbm] if v], reverse=False)
|
|
712
|
+
if isinstance(price, (int, float)):
|
|
713
|
+
# Primary support = nearest MA below current price
|
|
714
|
+
supports = [f"{currency} {v:.2f}" for v in supports if v < price]
|
|
715
|
+
resistances = [f"{currency} {v:.2f}" for v in resistances if v > price]
|
|
716
|
+
else:
|
|
717
|
+
supports = [f"{currency} {v:.2f}" for v in supports]
|
|
718
|
+
resistances = [f"{currency} {v:.2f}" for v in resistances]
|
|
719
|
+
|
|
720
|
+
# Pre-compute signal labels so the model doesn't need to interpret
|
|
721
|
+
rsi_str = f"{rsi:.1f}" if rsi is not None else "N/A"
|
|
722
|
+
if rsi is not None:
|
|
723
|
+
if rsi >= 70:
|
|
724
|
+
rsi_signal = f"⚠️ 超买 (RSI={rsi:.1f} ≥ 70,回调风险)"
|
|
725
|
+
elif rsi <= 30:
|
|
726
|
+
rsi_signal = f"⚠️ 超卖 (RSI={rsi:.1f} ≤ 30,反弹机会)"
|
|
727
|
+
else:
|
|
728
|
+
rsi_signal = f"中性 (RSI={rsi:.1f},30-70区间,无超买超卖)"
|
|
729
|
+
else:
|
|
730
|
+
rsi_signal = "N/A"
|
|
731
|
+
|
|
732
|
+
# Show MACD histogram prominently (not the MACD line)
|
|
733
|
+
if mhist is not None:
|
|
734
|
+
macd_hist_str = f"{mhist:.4f}"
|
|
735
|
+
macd_signal = "金叉/多头" if mhist > 0 else "死叉/空头"
|
|
736
|
+
macd_label = f"MACD hist={macd_hist_str},信号:{macd_signal}"
|
|
737
|
+
else:
|
|
738
|
+
macd_hist_str = "N/A"
|
|
739
|
+
macd_signal = "N/A"
|
|
740
|
+
macd_label = "N/A"
|
|
741
|
+
|
|
742
|
+
block += (
|
|
743
|
+
f"\n## 📈 技术分析数据(基于120日历史,已预计算信号)\n\n"
|
|
744
|
+
f"### 技术指标与信号\n"
|
|
745
|
+
f"| 指标 | 数值 | 信号判断 |\n"
|
|
746
|
+
f"| --- | --- | --- |\n"
|
|
747
|
+
f"| RSI(14) | {rsi_str} | {rsi_signal} |\n"
|
|
748
|
+
f"| MACD hist(12,26,9) | {macd_hist_str} | {macd_signal}(hist{'>'if mhist and mhist>0 else '<'}0) |\n"
|
|
749
|
+
+ (f"| MA5 | {currency} {ma5:.2f} | 短期均线 |\n" if ma5 else "")
|
|
750
|
+
+ (f"| MA20 | {currency} {ma20:.2f} | 中期支撑/压力 |\n" if ma20 else "")
|
|
751
|
+
+ (f"| MA60 | {currency} {ma60:.2f} | 长期支撑/压力 |\n" if ma60 else "")
|
|
752
|
+
+ (f"| BB Upper | {currency} {bbu:.2f} | 上轨阻力 |\n" if bbu else "")
|
|
753
|
+
+ (f"| BB Lower | {currency} {bbl:.2f} | 下轨支撑 |\n" if bbl else "")
|
|
754
|
+
+ f"\n### 关键价位(直接引用这些数字)\n"
|
|
755
|
+
+ f"- **支撑位**:{', '.join(supports) if supports else '无(当前价已在主要支撑下方)'}\n"
|
|
756
|
+
+ f"- **阻力位**:{', '.join(resistances) if resistances else '无(当前价已突破布林上轨)'}\n"
|
|
757
|
+
+ f"\n### 技术信号汇总\n"
|
|
758
|
+
+ f"- RSI:{rsi_signal}\n"
|
|
759
|
+
+ f"- MACD:{macd_label}\n"
|
|
760
|
+
)
|
|
761
|
+
except Exception:
|
|
762
|
+
pass # Technical fetch failure is non-fatal; basic quote still injected
|
|
763
|
+
|
|
764
|
+
# Fetch additional symbols detected in the same message
|
|
765
|
+
for _xs in _extra_syms:
|
|
766
|
+
try:
|
|
767
|
+
_xr = mdc.quote(_xs)
|
|
768
|
+
if _xr.get("success"):
|
|
769
|
+
_xr = enrich_market_quote(_xs, _xr)
|
|
770
|
+
_xp = _xr.get("price", "N/A")
|
|
771
|
+
_xchg = _xr.get("change_pct", 0)
|
|
772
|
+
_xn = _xr.get("name", _xs)
|
|
773
|
+
_xc = _xr.get("currency", "USD")
|
|
774
|
+
_xsign = "+" if _xchg >= 0 else ""
|
|
775
|
+
_x_label = market_display_label(_xs, _xr)
|
|
776
|
+
_x_exchange = _xr.get("exchange")
|
|
777
|
+
block += (
|
|
778
|
+
f"\n## 📊 {_x_label} 实时行情(来源:{_xr.get('provider', 'API')})\n"
|
|
779
|
+
f"- **交易代码**:{_xs}" + (f"({_x_exchange})\n" if _x_exchange else "\n") +
|
|
780
|
+
f"- **名称**:{_xn}\n"
|
|
781
|
+
f"- **最新价**:{_xc} {_xp}\n"
|
|
782
|
+
f"- **涨跌幅**:{_xsign}{_xchg:.2f}%\n"
|
|
783
|
+
f"- **今日高/低**:{_xr.get('high', 'N/A')} / {_xr.get('low', 'N/A')}\n"
|
|
784
|
+
f"- **成交量**:{_xr.get('volume', 'N/A')}\n"
|
|
785
|
+
)
|
|
786
|
+
except Exception:
|
|
787
|
+
pass # additional symbol failure is non-fatal
|
|
788
|
+
|
|
789
|
+
block += f"\n*⚠️ 以上均为真实市场数据。请严格基于这些数字作答,不要修改或编造任何价格/指标数值。货币单位:{currency}。*\n"
|
|
790
|
+
return block
|
|
791
|
+
|
|
792
|
+
except Exception:
|
|
793
|
+
return ""
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _fetch_snapshot_row_for_symbol(symbol: str, mdc) -> dict:
|
|
797
|
+
quote = {}
|
|
798
|
+
fundamentals = {}
|
|
799
|
+
technical = {}
|
|
800
|
+
warnings: list[str] = []
|
|
801
|
+
errors: list[str] = []
|
|
802
|
+
quality: dict = {}
|
|
803
|
+
stale = False
|
|
804
|
+
try:
|
|
805
|
+
from packages.aria_services.data import DataService
|
|
806
|
+
service = DataService(market_client=mdc, router=False)
|
|
807
|
+
quote_result = service.quote(symbol)
|
|
808
|
+
fund_result = service.fundamentals(symbol)
|
|
809
|
+
tech_result = service.technical_indicators(symbol, days=120)
|
|
810
|
+
quote = quote_result.data or {}
|
|
811
|
+
fundamentals = fund_result.data or {}
|
|
812
|
+
technical = tech_result.data or {}
|
|
813
|
+
warnings.extend(quote_result.warnings + fund_result.warnings + tech_result.warnings)
|
|
814
|
+
errors.extend(quote_result.errors + fund_result.errors + tech_result.errors)
|
|
815
|
+
provider_chain = list(dict.fromkeys(
|
|
816
|
+
str(p) for p in (
|
|
817
|
+
quote_result.provider_chain + fund_result.provider_chain + tech_result.provider_chain
|
|
818
|
+
) if p
|
|
819
|
+
))
|
|
820
|
+
missing_fields = list(dict.fromkeys(
|
|
821
|
+
quote_result.missing_fields + fund_result.missing_fields + tech_result.missing_fields
|
|
822
|
+
))
|
|
823
|
+
stale = bool(quote_result.stale or tech_result.stale)
|
|
824
|
+
quality = {
|
|
825
|
+
"status": "partial" if missing_fields else "ok",
|
|
826
|
+
"stale": stale,
|
|
827
|
+
"providers": provider_chain,
|
|
828
|
+
"missing_fields": missing_fields,
|
|
829
|
+
"warnings": warnings[:5],
|
|
830
|
+
"errors": errors[:5],
|
|
831
|
+
}
|
|
832
|
+
except Exception as exc:
|
|
833
|
+
quote = {"success": False, "error": str(exc)}
|
|
834
|
+
warnings.append(f"data_service: {exc}")
|
|
835
|
+
provider_chain = []
|
|
836
|
+
missing_fields = ["price", "market_cap", "technical"]
|
|
837
|
+
quality = {
|
|
838
|
+
"status": "unavailable",
|
|
839
|
+
"stale": False,
|
|
840
|
+
"providers": [],
|
|
841
|
+
"missing_fields": missing_fields,
|
|
842
|
+
"warnings": warnings[:5],
|
|
843
|
+
"errors": [str(exc)],
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
quote = enrich_market_quote(symbol, quote)
|
|
847
|
+
currency = quote.get("currency") or fundamentals.get("currency") or "USD"
|
|
848
|
+
market_cap = (
|
|
849
|
+
quote.get("market_cap")
|
|
850
|
+
or fundamentals.get("market_cap")
|
|
851
|
+
or fundamentals.get("total_mv")
|
|
852
|
+
)
|
|
853
|
+
if not provider_chain:
|
|
854
|
+
provider_chain = []
|
|
855
|
+
for source in (quote, fundamentals, technical):
|
|
856
|
+
chain = source.get("provider_chain")
|
|
857
|
+
if isinstance(chain, list):
|
|
858
|
+
provider_chain.extend(chain)
|
|
859
|
+
elif source.get("provider"):
|
|
860
|
+
provider_chain.append(source.get("provider"))
|
|
861
|
+
elif source.get("source"):
|
|
862
|
+
provider_chain.append(source.get("source"))
|
|
863
|
+
provider_chain = _clean_provider_chain(provider_chain)
|
|
864
|
+
else:
|
|
865
|
+
provider_chain = _clean_provider_chain(provider_chain)
|
|
866
|
+
if not missing_fields:
|
|
867
|
+
missing_fields = []
|
|
868
|
+
# ── yfinance fallback when price is 0 or missing ────────────────────────
|
|
869
|
+
_price_val = quote.get("price")
|
|
870
|
+
_price_bad = not quote.get("success") or _price_val in (None, "", 0) or float(_price_val or 0) == 0
|
|
871
|
+
if _price_bad:
|
|
872
|
+
try:
|
|
873
|
+
import yfinance as _yf_snap
|
|
874
|
+
_sym_yf = symbol.upper()
|
|
875
|
+
_t = _yf_snap.Ticker(_sym_yf)
|
|
876
|
+
_fi = _t.fast_info
|
|
877
|
+
_yf_price = getattr(_fi, "last_price", None) or getattr(_fi, "previous_close", None)
|
|
878
|
+
if _yf_price and float(_yf_price) > 0:
|
|
879
|
+
_yf_prev = getattr(_fi, "previous_close", _yf_price)
|
|
880
|
+
_yf_chg = (float(_yf_price) - float(_yf_prev)) / float(_yf_prev) * 100 if _yf_prev else 0
|
|
881
|
+
_yf_info = {}
|
|
882
|
+
try:
|
|
883
|
+
_yf_info = _t.info or {}
|
|
884
|
+
except Exception:
|
|
885
|
+
pass
|
|
886
|
+
quote = {
|
|
887
|
+
"success": True,
|
|
888
|
+
"symbol": symbol,
|
|
889
|
+
"name": _yf_info.get("shortName") or _yf_info.get("longName") or symbol,
|
|
890
|
+
"price": round(float(_yf_price), 2),
|
|
891
|
+
"change_pct": round(_yf_chg, 2),
|
|
892
|
+
"currency": _yf_info.get("currency") or "USD",
|
|
893
|
+
"market_cap": _yf_info.get("marketCap") or 0,
|
|
894
|
+
"provider": "yfinance",
|
|
895
|
+
"provider_chain": ["yfinance"],
|
|
896
|
+
}
|
|
897
|
+
if not provider_chain or all("edgar" in p.lower() for p in provider_chain):
|
|
898
|
+
provider_chain = ["yfinance"]
|
|
899
|
+
_price_bad = False
|
|
900
|
+
except Exception:
|
|
901
|
+
pass
|
|
902
|
+
_price_val = quote.get("price")
|
|
903
|
+
if _price_bad or _price_val in (None, "", 0):
|
|
904
|
+
missing_fields.append("price")
|
|
905
|
+
if market_cap in (None, "", 0):
|
|
906
|
+
market_cap = quote.get("market_cap") or market_cap
|
|
907
|
+
if market_cap in (None, "", 0):
|
|
908
|
+
missing_fields.append("market_cap")
|
|
909
|
+
if not technical.get("success"):
|
|
910
|
+
missing_fields.append("technical")
|
|
911
|
+
|
|
912
|
+
price_num = _num_or_none(quote.get("price"))
|
|
913
|
+
chg_num = _num_or_none(quote.get("change_pct"))
|
|
914
|
+
rsi = _num_or_none(technical.get("rsi"))
|
|
915
|
+
macd_hist = _num_or_none(technical.get("macd_hist"))
|
|
916
|
+
ma20 = _num_or_none(technical.get("ma20"))
|
|
917
|
+
ma60 = _num_or_none(technical.get("ma60"))
|
|
918
|
+
bb_upper = _num_or_none(technical.get("bb_upper"))
|
|
919
|
+
bb_lower = _num_or_none(technical.get("bb_lower"))
|
|
920
|
+
support_str, resistance_str = _support_resistance_for_row(
|
|
921
|
+
currency, price_num, ma20, ma60, bb_lower, bb_upper
|
|
922
|
+
)
|
|
923
|
+
signal, signal_score, signal_confidence, signal_label = _snapshot_signal(
|
|
924
|
+
price_num, chg_num, rsi, macd_hist, ma20, ma60
|
|
925
|
+
)
|
|
926
|
+
technical_available = any(v is not None for v in (rsi, macd_hist, ma20, ma60, bb_upper, bb_lower))
|
|
927
|
+
missing_fields = [
|
|
928
|
+
field for field in list(dict.fromkeys(missing_fields))
|
|
929
|
+
if not (
|
|
930
|
+
(field == "technical" and technical_available)
|
|
931
|
+
or (field == "macd" and macd_hist is not None)
|
|
932
|
+
)
|
|
933
|
+
]
|
|
934
|
+
|
|
935
|
+
return {
|
|
936
|
+
"symbol": symbol,
|
|
937
|
+
"name": quote.get("name") or fundamentals.get("name") or symbol,
|
|
938
|
+
"success": bool(quote.get("success")),
|
|
939
|
+
"price": price_num if price_num is not None else quote.get("price"),
|
|
940
|
+
"change_pct": chg_num if chg_num is not None else quote.get("change_pct"),
|
|
941
|
+
"currency": currency,
|
|
942
|
+
"market_cap": market_cap,
|
|
943
|
+
"high": quote.get("high"),
|
|
944
|
+
"low": quote.get("low"),
|
|
945
|
+
"volume": quote.get("volume"),
|
|
946
|
+
"rsi": rsi,
|
|
947
|
+
"macd_hist": macd_hist,
|
|
948
|
+
"ma20": ma20,
|
|
949
|
+
"ma60": ma60,
|
|
950
|
+
"bb_upper": bb_upper,
|
|
951
|
+
"bb_lower": bb_lower,
|
|
952
|
+
"support": support_str,
|
|
953
|
+
"resistance": resistance_str,
|
|
954
|
+
"technical_available": technical_available,
|
|
955
|
+
"technical_provider": technical.get("provider") or technical.get("source") or "",
|
|
956
|
+
"signal": signal,
|
|
957
|
+
"signal_score": signal_score,
|
|
958
|
+
"signal_confidence": signal_confidence,
|
|
959
|
+
"signal_label": signal_label,
|
|
960
|
+
"trend": _market_snapshot_trend(
|
|
961
|
+
quote.get("price"),
|
|
962
|
+
quote.get("high"),
|
|
963
|
+
quote.get("low"),
|
|
964
|
+
quote.get("change_pct"),
|
|
965
|
+
),
|
|
966
|
+
"provider_chain": provider_chain,
|
|
967
|
+
"missing_fields": missing_fields,
|
|
968
|
+
"error": quote.get("error") or "",
|
|
969
|
+
"warnings": warnings,
|
|
970
|
+
"errors": errors,
|
|
971
|
+
"quality": quality,
|
|
972
|
+
"stale": stale,
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _try_handle_multi_market_snapshot(message: str, symbols: list[str]) -> dict:
|
|
977
|
+
if len(symbols) < 2:
|
|
978
|
+
return {"success": False, "error": "not_multi_symbol"}
|
|
979
|
+
_lang = _detect_lang(message)
|
|
980
|
+
_en = _lang == "en"
|
|
981
|
+
if not _has_mdc_lazy():
|
|
982
|
+
return {
|
|
983
|
+
"success": True,
|
|
984
|
+
"response": (
|
|
985
|
+
("Market Snapshot\n\nLocal market data client is unavailable.\n\n"
|
|
986
|
+
f"Run `/quote {' '.join(symbols)}` to retry.")
|
|
987
|
+
if _en else
|
|
988
|
+
("市场快照\n\n当前本地行情客户端未加载,无法获取多标的实时行情。\n\n"
|
|
989
|
+
f"可运行 `/quote {' '.join(symbols)}` 重试。")
|
|
990
|
+
),
|
|
991
|
+
"tools_used": ["market_snapshot"],
|
|
992
|
+
"analysis_complete": True,
|
|
993
|
+
}
|
|
994
|
+
mdc = _get_mdc_lazy()
|
|
995
|
+
rows = [_fetch_snapshot_row_for_symbol(symbol, mdc) for symbol in symbols]
|
|
996
|
+
now = datetime.now().strftime("%Y-%m-%d")
|
|
997
|
+
provider_chain = list(dict.fromkeys(
|
|
998
|
+
provider for row in rows for provider in row.get("provider_chain", [])
|
|
999
|
+
))
|
|
1000
|
+
provider_chain = _clean_provider_chain(provider_chain)
|
|
1001
|
+
missing = sorted(set(
|
|
1002
|
+
f"{row['symbol']}:{field}"
|
|
1003
|
+
for row in rows for field in row.get("missing_fields", [])
|
|
1004
|
+
))
|
|
1005
|
+
stale_symbols = [row["symbol"] for row in rows if row.get("stale")]
|
|
1006
|
+
warnings = [w for row in rows for w in (row.get("warnings") or [])]
|
|
1007
|
+
errors = [e for row in rows for e in (row.get("errors") or [])]
|
|
1008
|
+
ranked = sorted(
|
|
1009
|
+
rows,
|
|
1010
|
+
key=lambda row: (
|
|
1011
|
+
int(row.get("signal_score") or 0),
|
|
1012
|
+
_num_or_none(row.get("change_pct")) or 0,
|
|
1013
|
+
),
|
|
1014
|
+
reverse=True,
|
|
1015
|
+
)
|
|
1016
|
+
strongest = ranked[0] if ranked else {}
|
|
1017
|
+
weakest = ranked[-1] if ranked else {}
|
|
1018
|
+
|
|
1019
|
+
table = [
|
|
1020
|
+
("Market Snapshot" if _en else "市场快照") + " · " + now + (" · Multi-symbol comparison" if _en else " · 多标的对比"),
|
|
1021
|
+
"",
|
|
1022
|
+
]
|
|
1023
|
+
if len(ranked) >= 2:
|
|
1024
|
+
if _en:
|
|
1025
|
+
table.append(
|
|
1026
|
+
f"**Takeaway**: `{strongest.get('symbol')}` is currently stronger than "
|
|
1027
|
+
f"`{weakest.get('symbol')}` by the quantitative snapshot score."
|
|
1028
|
+
)
|
|
1029
|
+
else:
|
|
1030
|
+
table.append(
|
|
1031
|
+
f"**对比结论**:按量化快照分数看,`{strongest.get('symbol')}` "
|
|
1032
|
+
f"当前相对强于 `{weakest.get('symbol')}`。若技术指标缺失,结论仅基于价格/市值快照。"
|
|
1033
|
+
)
|
|
1034
|
+
table.append("")
|
|
1035
|
+
|
|
1036
|
+
if _en:
|
|
1037
|
+
table.extend([
|
|
1038
|
+
"| Symbol | Company | Price | Change | Market Cap | RSI | MACD hist | Signal |",
|
|
1039
|
+
"|---|---|---:|---:|---:|---:|---:|---|",
|
|
1040
|
+
])
|
|
1041
|
+
else:
|
|
1042
|
+
table.extend([
|
|
1043
|
+
"| 代码 | 公司 | 最新价 | 涨跌幅 | 市值 | RSI | MACD hist | 信号 |",
|
|
1044
|
+
"|---|---|---:|---:|---:|---:|---:|---|",
|
|
1045
|
+
])
|
|
1046
|
+
for row in rows:
|
|
1047
|
+
currency = row.get("currency") or "USD"
|
|
1048
|
+
if row.get("success") and row.get("price") not in (None, ""):
|
|
1049
|
+
price = _fmt_money(currency, row.get("price"))
|
|
1050
|
+
change = _num_or_none(row.get("change_pct"))
|
|
1051
|
+
change_text = f"{change:+.2f}%" if change is not None else "—"
|
|
1052
|
+
else:
|
|
1053
|
+
price = "—"
|
|
1054
|
+
change_text = "—"
|
|
1055
|
+
rsi_text = f"{row['rsi']:.1f}" if row.get("rsi") is not None else "—"
|
|
1056
|
+
macd_text = f"{row['macd_hist']:.4f}" if row.get("macd_hist") is not None else "—"
|
|
1057
|
+
sig = row.get("signal") or "—"
|
|
1058
|
+
sig_label = row.get("signal_label") or ""
|
|
1059
|
+
sig_text = f"{sig} / {sig_label}" if sig_label and sig != "—" else sig
|
|
1060
|
+
table.append(
|
|
1061
|
+
f"| {row['symbol']} | {row.get('name') or row['symbol']} | {price} | "
|
|
1062
|
+
f"{change_text} | {_format_compact_market_cap(row.get('market_cap'), currency)} | "
|
|
1063
|
+
f"{rsi_text} | {macd_text} | {sig_text} |"
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
table.append("")
|
|
1067
|
+
table.append("Data" if _en else "数据")
|
|
1068
|
+
table.append(f"- {'sources' if _en else '来源'}: {', '.join(provider_chain) if provider_chain else 'unavailable'}")
|
|
1069
|
+
table.append(f"- stale: {', '.join(stale_symbols) if stale_symbols else 'none'}")
|
|
1070
|
+
if missing:
|
|
1071
|
+
table.append(f"- missing: {', '.join(missing)}")
|
|
1072
|
+
else:
|
|
1073
|
+
table.append("- missing: none")
|
|
1074
|
+
technical_ok = [row["symbol"] for row in rows if row.get("technical_available")]
|
|
1075
|
+
technical_missing = [row["symbol"] for row in rows if not row.get("technical_available")]
|
|
1076
|
+
if _en:
|
|
1077
|
+
table.append(
|
|
1078
|
+
"- technical: "
|
|
1079
|
+
+ (f"available for {', '.join(technical_ok)}" if technical_ok else "unavailable")
|
|
1080
|
+
+ (f"; unavailable for {', '.join(technical_missing)}" if technical_missing else "")
|
|
1081
|
+
)
|
|
1082
|
+
else:
|
|
1083
|
+
table.append(
|
|
1084
|
+
"- 技术指标: "
|
|
1085
|
+
+ (f"{', '.join(technical_ok)} 可用" if technical_ok else "暂不可用")
|
|
1086
|
+
+ (f";{', '.join(technical_missing)} 暂缺" if technical_missing else "")
|
|
1087
|
+
)
|
|
1088
|
+
if warnings:
|
|
1089
|
+
table.append(f"- warnings: {'; '.join(str(w) for w in warnings[:3])}")
|
|
1090
|
+
if errors:
|
|
1091
|
+
table.append(f"- errors: {'; '.join(str(e) for e in errors[:3])}")
|
|
1092
|
+
if _is_market_share_request(message):
|
|
1093
|
+
table.extend(_market_share_note(symbols, english=_en))
|
|
1094
|
+
|
|
1095
|
+
table.append("")
|
|
1096
|
+
table.append("Analysis" if _en else "逐项分析")
|
|
1097
|
+
for row in rows:
|
|
1098
|
+
currency = row.get("currency") or "USD"
|
|
1099
|
+
price_text = _fmt_money(currency, row.get("price"))
|
|
1100
|
+
change = _num_or_none(row.get("change_pct"))
|
|
1101
|
+
change_text = f"{change:+.2f}%" if change is not None else "—"
|
|
1102
|
+
vol = _fmt_int(row.get("volume")) if row.get("volume") else ""
|
|
1103
|
+
sig = row.get("signal") or "—"
|
|
1104
|
+
sig_label = row.get("signal_label") or "指标不足"
|
|
1105
|
+
score = int(row.get("signal_score") or 0)
|
|
1106
|
+
conf = float(row.get("signal_confidence") or 0)
|
|
1107
|
+
table.append("")
|
|
1108
|
+
if _en:
|
|
1109
|
+
table.append(f"### {row.get('name') or row['symbol']} `{row['symbol']}`")
|
|
1110
|
+
table.append(f"- Price: {price_text}, change {change_text}; trend: {row.get('trend') or '—'}.")
|
|
1111
|
+
if row.get("technical_available"):
|
|
1112
|
+
table.append(
|
|
1113
|
+
f"- Technicals: RSI {row.get('rsi') if row.get('rsi') is not None else '—'}, "
|
|
1114
|
+
f"MACD hist {row.get('macd_hist') if row.get('macd_hist') is not None else '—'}; "
|
|
1115
|
+
f"signal `{sig}` ({sig_label}), score {score:+d}, confidence {conf:.0%}."
|
|
1116
|
+
)
|
|
1117
|
+
if row.get("support") or row.get("resistance"):
|
|
1118
|
+
table.append(f"- Levels: support {row.get('support') or '—'}; resistance {row.get('resistance') or '—'}.")
|
|
1119
|
+
else:
|
|
1120
|
+
table.append("- Technicals: unavailable from current data providers; do not infer RSI/MACD.")
|
|
1121
|
+
if vol:
|
|
1122
|
+
table.append(f"- Volume: {vol}.")
|
|
1123
|
+
else:
|
|
1124
|
+
table.append(f"### {row.get('name') or row['symbol']} `{row['symbol']}`")
|
|
1125
|
+
table.append(f"- 价格:{price_text},涨跌幅 {change_text};趋势:{row.get('trend') or '—'}。")
|
|
1126
|
+
if row.get("technical_available"):
|
|
1127
|
+
table.append(
|
|
1128
|
+
f"- 技术:RSI {row.get('rsi') if row.get('rsi') is not None else '—'},"
|
|
1129
|
+
f"MACD hist {row.get('macd_hist') if row.get('macd_hist') is not None else '—'};"
|
|
1130
|
+
f"信号 `{sig}`({sig_label}),量化分 {score:+d},置信度 {conf:.0%}。"
|
|
1131
|
+
)
|
|
1132
|
+
if row.get("support") or row.get("resistance"):
|
|
1133
|
+
table.append(f"- 关键位:支撑 {row.get('support') or '—'};阻力 {row.get('resistance') or '—'}。")
|
|
1134
|
+
else:
|
|
1135
|
+
table.append("- 技术:当前数据源未返回 RSI/MACD/均线,不据此编造技术指标。")
|
|
1136
|
+
if vol:
|
|
1137
|
+
table.append(f"- 成交量:{vol}。")
|
|
1138
|
+
|
|
1139
|
+
table.append("")
|
|
1140
|
+
table.append("Next" if _en else "下一步")
|
|
1141
|
+
table.append("- " + " · ".join(f"`/ta {symbol}`" for symbol in symbols[:4]) + (" — full technical chart" if _en else " — 完整技术图表"))
|
|
1142
|
+
table.append("- " + " · ".join(f"`/report {symbol}`" for symbol in symbols[:4]) + (" — generate research reports" if _en else " — 生成研究报告"))
|
|
1143
|
+
table.append("")
|
|
1144
|
+
table.append("*Not investment advice*" if _en else "*不构成投资建议*")
|
|
1145
|
+
return {
|
|
1146
|
+
"success": True,
|
|
1147
|
+
"response": "\n".join(table),
|
|
1148
|
+
"tools_used": ["market_snapshot"],
|
|
1149
|
+
"analysis_complete": True,
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _render_private_company_analysis(profile_key: str, message: str) -> dict:
|
|
1154
|
+
"""Render a structured analysis for a private company using static profile data."""
|
|
1155
|
+
p = _PRIVATE_COMPANY_PROFILES.get(profile_key, {})
|
|
1156
|
+
if not p:
|
|
1157
|
+
return {"success": False, "error": "no_private_profile"}
|
|
1158
|
+
|
|
1159
|
+
name = p.get("name", profile_key)
|
|
1160
|
+
val = p.get("valuation_usd", "N/A")
|
|
1161
|
+
rev = p.get("rev_est", "N/A")
|
|
1162
|
+
growth = p.get("rev_growth", "N/A")
|
|
1163
|
+
comps = p.get("comparables", [])
|
|
1164
|
+
|
|
1165
|
+
lines = [
|
|
1166
|
+
f"## {name}",
|
|
1167
|
+
f"> ⚠️ **私有公司 — 无公开交易数据** 所有数字均来自公开报道与融资文件,非官方财报。",
|
|
1168
|
+
"",
|
|
1169
|
+
"### 估值与规模",
|
|
1170
|
+
f"| 指标 | 数据 |",
|
|
1171
|
+
f"|------|------|",
|
|
1172
|
+
f"| 最新估值 | **${val}B**({p.get('last_funding', 'N/A')})|",
|
|
1173
|
+
f"| 收入估算 | ~${rev}B/年(YoY +{growth}%)|",
|
|
1174
|
+
f"| 员工数量 | ~{p.get('employees', 'N/A')}k |",
|
|
1175
|
+
f"| 创立时间 | {p.get('founded', 'N/A')} — {p.get('founder', 'N/A')} |",
|
|
1176
|
+
f"| 总部 | {p.get('hq', 'N/A')} |",
|
|
1177
|
+
f"| IPO 状态 | {p.get('ipo_status', 'N/A')} |",
|
|
1178
|
+
"",
|
|
1179
|
+
]
|
|
1180
|
+
|
|
1181
|
+
segs = p.get("segments", [])
|
|
1182
|
+
if segs:
|
|
1183
|
+
lines += ["### 业务板块", ""]
|
|
1184
|
+
for s in segs:
|
|
1185
|
+
lines.append(f"- {s}")
|
|
1186
|
+
lines.append("")
|
|
1187
|
+
|
|
1188
|
+
highlights = p.get("highlights", [])
|
|
1189
|
+
if highlights:
|
|
1190
|
+
lines += ["### 核心亮点", ""]
|
|
1191
|
+
for h in highlights:
|
|
1192
|
+
lines.append(f"✅ {h}")
|
|
1193
|
+
lines.append("")
|
|
1194
|
+
|
|
1195
|
+
risks = p.get("risks", [])
|
|
1196
|
+
if risks:
|
|
1197
|
+
lines += ["### 主要风险", ""]
|
|
1198
|
+
for r in risks:
|
|
1199
|
+
lines.append(f"⚠️ {r}")
|
|
1200
|
+
lines.append("")
|
|
1201
|
+
|
|
1202
|
+
if comps:
|
|
1203
|
+
comp_str = " · ".join(comps)
|
|
1204
|
+
lines += [
|
|
1205
|
+
"### 可比公司(均已上市)",
|
|
1206
|
+
f"> 可对比分析:{comp_str}",
|
|
1207
|
+
f"> 例如:`分析 {comps[0]} 的财务数据` 或 `/ta {comps[0]}` 查看技术面",
|
|
1208
|
+
"",
|
|
1209
|
+
]
|
|
1210
|
+
|
|
1211
|
+
lines += [
|
|
1212
|
+
"---",
|
|
1213
|
+
"*数据来源:公开融资公告、新闻报道。私有公司无 SEC/证监会披露义务,以上估算存在较大不确定性。*",
|
|
1214
|
+
]
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
"success": True,
|
|
1218
|
+
"response": "\n".join(lines),
|
|
1219
|
+
"tools_used": ["private_company_profile"],
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _resolve_etf_snapshot_symbols(message: str) -> list[str]:
|
|
1224
|
+
text = message or ""
|
|
1225
|
+
low = text.lower()
|
|
1226
|
+
if not any(k in low or k in text for k in ("etf", "基金", "交易型开放式")):
|
|
1227
|
+
return []
|
|
1228
|
+
if any(k in low or k in text for k in ("标普500", "标普 500", "s&p 500", "s&p500", "sp500", "spy")):
|
|
1229
|
+
return ["SPY", "VOO", "IVV"]
|
|
1230
|
+
if any(k in low or k in text for k in ("纳斯达克100", "纳斯达克 100", "nasdaq 100", "qqq")):
|
|
1231
|
+
return ["QQQ", "QQQM"]
|
|
1232
|
+
if any(k in low or k in text for k in ("黄金", "gold", "gld")):
|
|
1233
|
+
return ["GLD", "IAU"]
|
|
1234
|
+
return []
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
_MARKET_OVERVIEW_INDICES = {
|
|
1238
|
+
"cn": {
|
|
1239
|
+
"label": "A 股市场",
|
|
1240
|
+
"yf": [("上证综指", "000001.SS"), ("深证成指", "399001.SZ"),
|
|
1241
|
+
("创业板指", "399006.SZ"), ("沪深300", "000300.SS"),
|
|
1242
|
+
("科创50", "000688.SS")],
|
|
1243
|
+
"ak_symbol": "沪深重要指数",
|
|
1244
|
+
"extras": ["北向资金", "涨跌家数"],
|
|
1245
|
+
},
|
|
1246
|
+
"us": {
|
|
1247
|
+
"label": "美股市场",
|
|
1248
|
+
"yf": [("道琼斯", "^DJI"), ("纳斯达克", "^IXIC"), ("标普500", "^GSPC"),
|
|
1249
|
+
("罗素2000", "^RUT"), ("VIX 恐慌指数", "^VIX")],
|
|
1250
|
+
"ak_symbol": None,
|
|
1251
|
+
"extras": ["恐惧贪婪指数"],
|
|
1252
|
+
},
|
|
1253
|
+
"hk": {
|
|
1254
|
+
"label": "港股市场",
|
|
1255
|
+
"yf": [("恒生指数", "^HSI"), ("恒生国企", "^HSCE")],
|
|
1256
|
+
"ak_symbol": None,
|
|
1257
|
+
"extras": [],
|
|
1258
|
+
},
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _http_get_json(url: str, params: dict, timeout: int = 8):
|
|
1263
|
+
"""GET JSON with a proxy-bypass retry.
|
|
1264
|
+
|
|
1265
|
+
A misconfigured/flaky HTTP(S)_PROXY (corporate or local) is a common cause
|
|
1266
|
+
of "ProxyError / connection aborted" on otherwise-reachable data sources.
|
|
1267
|
+
Try the normal request first (respecting the user's proxy), then retry with
|
|
1268
|
+
proxies disabled so the data still loads when only the proxy is broken.
|
|
1269
|
+
"""
|
|
1270
|
+
import requests as _rq
|
|
1271
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
1272
|
+
for proxies in (None, {"http": None, "https": None}):
|
|
1273
|
+
try:
|
|
1274
|
+
r = _rq.get(url, params=params, timeout=timeout,
|
|
1275
|
+
headers=headers, proxies=proxies)
|
|
1276
|
+
r.raise_for_status()
|
|
1277
|
+
return r.json()
|
|
1278
|
+
except Exception:
|
|
1279
|
+
continue
|
|
1280
|
+
return None
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
# 上证综指/深证成指/创业板指/沪深300/科创50 — eastmoney secids (1=SH, 0=SZ)
|
|
1284
|
+
_CN_INDEX_SECIDS = [
|
|
1285
|
+
("上证综指", "1.000001"), ("深证成指", "0.399001"),
|
|
1286
|
+
("创业板指", "0.399006"), ("沪深300", "1.000300"),
|
|
1287
|
+
("科创50", "1.000688"),
|
|
1288
|
+
]
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _fetch_cn_indices_eastmoney() -> list:
|
|
1292
|
+
"""Fetch CN index levels + change% directly from eastmoney ulist endpoint."""
|
|
1293
|
+
secids = ",".join(s for _, s in _CN_INDEX_SECIDS)
|
|
1294
|
+
data = _http_get_json(
|
|
1295
|
+
"https://push2.eastmoney.com/api/qt/ulist.np/get",
|
|
1296
|
+
{"secids": secids, "fields": "f2,f3,f12,f14", "fltt": 2, "invt": 2},
|
|
1297
|
+
)
|
|
1298
|
+
if not data:
|
|
1299
|
+
return []
|
|
1300
|
+
diff = (data.get("data") or {}).get("diff") or []
|
|
1301
|
+
# eastmoney may return a dict keyed by index or a list — normalize to list
|
|
1302
|
+
items = list(diff.values()) if isinstance(diff, dict) else diff
|
|
1303
|
+
disp_by_code = {sid.split(".")[1]: name for name, sid in _CN_INDEX_SECIDS}
|
|
1304
|
+
rows: list = []
|
|
1305
|
+
for it in items:
|
|
1306
|
+
code = str(it.get("f12", ""))
|
|
1307
|
+
name = disp_by_code.get(code) or str(it.get("f14", code))
|
|
1308
|
+
price = it.get("f2")
|
|
1309
|
+
chg = it.get("f3")
|
|
1310
|
+
if price in (None, "-", ""):
|
|
1311
|
+
continue
|
|
1312
|
+
try:
|
|
1313
|
+
rows.append({"name": name, "price": round(float(price), 2),
|
|
1314
|
+
"change_pct": round(float(chg), 2) if chg not in (None, "-", "") else 0.0,
|
|
1315
|
+
"ok": True})
|
|
1316
|
+
except (ValueError, TypeError):
|
|
1317
|
+
continue
|
|
1318
|
+
return rows
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _fetch_overview_indices(market: str) -> list:
|
|
1322
|
+
"""Fetch index levels for a market. Direct eastmoney (CN) / yfinance (US/HK).
|
|
1323
|
+
|
|
1324
|
+
Returns a list of {name, price, change_pct, ok} dicts; entries that could
|
|
1325
|
+
not be fetched are marked ok=False rather than dropped, so the user always
|
|
1326
|
+
sees the full index set and which data is temporarily unavailable.
|
|
1327
|
+
"""
|
|
1328
|
+
cfg = _MARKET_OVERVIEW_INDICES.get(market, {})
|
|
1329
|
+
rows: list = []
|
|
1330
|
+
|
|
1331
|
+
# CN: direct eastmoney ulist endpoint — returns price + change% in one call
|
|
1332
|
+
# and is far more reliable than akshare's stock_zh_index_spot_em.
|
|
1333
|
+
if market == "cn":
|
|
1334
|
+
rows = _fetch_cn_indices_eastmoney()
|
|
1335
|
+
if rows:
|
|
1336
|
+
return rows
|
|
1337
|
+
# fall through to yfinance .SS as a last resort
|
|
1338
|
+
|
|
1339
|
+
# US / HK / CN-fallback via yfinance (5d window so change% always computes)
|
|
1340
|
+
try:
|
|
1341
|
+
import yfinance as yf
|
|
1342
|
+
for name, ticker in cfg.get("yf", []):
|
|
1343
|
+
try:
|
|
1344
|
+
h = yf.Ticker(ticker).history(period="5d")
|
|
1345
|
+
closes = [float(c) for c in h["Close"].tolist() if c == c] # drop NaN
|
|
1346
|
+
if closes:
|
|
1347
|
+
last = closes[-1]
|
|
1348
|
+
prev = closes[-2] if len(closes) >= 2 else last
|
|
1349
|
+
chg = ((last - prev) / prev * 100) if prev else 0.0
|
|
1350
|
+
rows.append({"name": name, "price": round(last, 2),
|
|
1351
|
+
"change_pct": round(chg, 2), "ok": True})
|
|
1352
|
+
else:
|
|
1353
|
+
rows.append({"name": name, "price": 0, "change_pct": 0, "ok": False})
|
|
1354
|
+
except Exception:
|
|
1355
|
+
rows.append({"name": name, "price": 0, "change_pct": 0, "ok": False})
|
|
1356
|
+
except Exception:
|
|
1357
|
+
pass
|
|
1358
|
+
return rows
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _try_handle_market_overview(message: str) -> dict:
|
|
1362
|
+
"""Deterministic whole-market overview for '分析A股/港股/美股市场行情'.
|
|
1363
|
+
|
|
1364
|
+
Answers a market-level question with the right index set instead of
|
|
1365
|
+
mis-parsing a market name into a single stock (the 'A股' → Agilent bug).
|
|
1366
|
+
"""
|
|
1367
|
+
market = _detect_market_overview(message)
|
|
1368
|
+
if not market:
|
|
1369
|
+
return {"success": False, "error": "not_market_overview"}
|
|
1370
|
+
|
|
1371
|
+
cfg = _MARKET_OVERVIEW_INDICES[market]
|
|
1372
|
+
rows = _fetch_overview_indices(market)
|
|
1373
|
+
if not rows:
|
|
1374
|
+
return {"success": False, "error": "no_index_data"}
|
|
1375
|
+
|
|
1376
|
+
ok_rows = [r for r in rows if r.get("ok")]
|
|
1377
|
+
# Build a markdown overview
|
|
1378
|
+
lines = [f"## 📊 {cfg['label']}行情概览",
|
|
1379
|
+
f"*数据时间: {datetime.now().strftime('%Y-%m-%d %H:%M')} · 不构成投资建议*",
|
|
1380
|
+
"",
|
|
1381
|
+
"| 指数 | 最新点位 | 涨跌幅 |",
|
|
1382
|
+
"|------|---------|--------|"]
|
|
1383
|
+
for r in rows:
|
|
1384
|
+
if r.get("ok"):
|
|
1385
|
+
arrow = "🔴" if r["change_pct"] < 0 else ("🟢" if r["change_pct"] > 0 else "⚪")
|
|
1386
|
+
lines.append(f"| {r['name']} | {r['price']:,.2f} | {arrow} {r['change_pct']:+.2f}% |")
|
|
1387
|
+
else:
|
|
1388
|
+
lines.append(f"| {r['name']} | — | 数据暂不可用 |")
|
|
1389
|
+
|
|
1390
|
+
# Breadth summary from the indices we did get
|
|
1391
|
+
if ok_rows:
|
|
1392
|
+
up = sum(1 for r in ok_rows if r["change_pct"] > 0)
|
|
1393
|
+
down = sum(1 for r in ok_rows if r["change_pct"] < 0)
|
|
1394
|
+
avg = sum(r["change_pct"] for r in ok_rows) / len(ok_rows)
|
|
1395
|
+
if avg > 0.5:
|
|
1396
|
+
tone = "整体偏强,多数指数上涨"
|
|
1397
|
+
elif avg < -0.5:
|
|
1398
|
+
tone = "整体偏弱,多数指数下跌"
|
|
1399
|
+
else:
|
|
1400
|
+
tone = "涨跌互现,方向不明"
|
|
1401
|
+
lines += ["", f"**概况**: {tone}({up} 涨 / {down} 跌,均值 {avg:+.2f}%)"]
|
|
1402
|
+
|
|
1403
|
+
# Market-specific next steps
|
|
1404
|
+
_next = {
|
|
1405
|
+
"cn": ["`/north` — 北向资金流向", "`/limitup` — 涨停板复盘",
|
|
1406
|
+
"`/quote 600519` — 个股报价(如贵州茅台)"],
|
|
1407
|
+
"us": ["`/quote AAPL MSFT NVDA` — 龙头个股", "`/sector` — 板块表现",
|
|
1408
|
+
"`fear greed` — 市场情绪指数"],
|
|
1409
|
+
"hk": ["`/quote 0700.HK` — 腾讯等个股", "`/north` — 南向资金"],
|
|
1410
|
+
}.get(market, [])
|
|
1411
|
+
if _next:
|
|
1412
|
+
lines += ["", "**下一步**"] + [f"- {s}" for s in _next]
|
|
1413
|
+
|
|
1414
|
+
return {"success": True, "response": "\n".join(lines),
|
|
1415
|
+
"tools_used": ["market_overview"]}
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _try_handle_market_snapshot_analysis(message: str, history: list = None) -> dict:
|
|
1419
|
+
"""Deterministic path for simple market analysis.
|
|
1420
|
+
|
|
1421
|
+
Local small models tend to mangle injected quote fields into fragments like
|
|
1422
|
+
"N/A/N/A/-1.24%". For snapshot requests, format the data directly.
|
|
1423
|
+
"""
|
|
1424
|
+
if not _is_market_snapshot_request(message, history):
|
|
1425
|
+
return {"success": False, "error": "not_market_snapshot"}
|
|
1426
|
+
|
|
1427
|
+
_etf_symbols = _resolve_etf_snapshot_symbols(message)
|
|
1428
|
+
if len(_etf_symbols) >= 2:
|
|
1429
|
+
return _try_handle_multi_market_snapshot(message, _etf_symbols)
|
|
1430
|
+
|
|
1431
|
+
_symbols = _extract_market_symbols(message)
|
|
1432
|
+
if len(_symbols) >= 2:
|
|
1433
|
+
return _try_handle_multi_market_snapshot(message, _symbols)
|
|
1434
|
+
|
|
1435
|
+
_msg_sym = _extract_market_symbol(message)
|
|
1436
|
+
|
|
1437
|
+
# Private company: PRIVATE:Name — render static profile instead of live data
|
|
1438
|
+
if _msg_sym and _msg_sym.startswith("PRIVATE:"):
|
|
1439
|
+
return _render_private_company_analysis(_msg_sym[len("PRIVATE:"):], message)
|
|
1440
|
+
|
|
1441
|
+
_hist_sym = (_extract_symbol_from_history(history) if history else "") if not _msg_sym else ""
|
|
1442
|
+
|
|
1443
|
+
# Guard: if message names an unrecognised company, don't silently use history symbol
|
|
1444
|
+
if not _msg_sym and _has_unresolved_company_mention(message):
|
|
1445
|
+
return {
|
|
1446
|
+
"success": True,
|
|
1447
|
+
"response": (
|
|
1448
|
+
"## ❓ 无法识别的股票\n\n"
|
|
1449
|
+
"未能将消息中提到的公司/品牌解析为已知股票代码。\n\n"
|
|
1450
|
+
"请提供具体代码后重试,例如:\n"
|
|
1451
|
+
"- A股:`/quote 600519`(贵州茅台)\n"
|
|
1452
|
+
"- 港股:`/quote 0700.HK`(腾讯)\n"
|
|
1453
|
+
"- 美股:`/quote AAPL`\n"
|
|
1454
|
+
"- 欧洲:`/quote MC.PA`(LVMH/路易威登)\n\n"
|
|
1455
|
+
"*提示:如需全局搜索,可输入 `/ta <代码>` 获取完整技术分析。*"
|
|
1456
|
+
),
|
|
1457
|
+
"tools_used": ["market_snapshot"],
|
|
1458
|
+
"analysis_complete": True,
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
symbol = _msg_sym or _hist_sym or "AAPL"
|
|
1462
|
+
def _snapshot_ashare_code(sym: str) -> str:
|
|
1463
|
+
s = str(sym or "").strip().upper()
|
|
1464
|
+
if s.endswith((".SZ", ".SS", ".SH")):
|
|
1465
|
+
s = s.rsplit(".", 1)[0]
|
|
1466
|
+
if s.startswith(("SH", "SZ")) and s[2:].isdigit() and len(s[2:]) == 6:
|
|
1467
|
+
s = s[2:]
|
|
1468
|
+
return s if s.isdigit() and len(s) == 6 else ""
|
|
1469
|
+
|
|
1470
|
+
_ashare_code = _snapshot_ashare_code(symbol)
|
|
1471
|
+
if not _has_mdc_lazy():
|
|
1472
|
+
return {
|
|
1473
|
+
"success": True,
|
|
1474
|
+
"response": (
|
|
1475
|
+
f"## {symbol} 市场快照\n\n"
|
|
1476
|
+
"当前本地行情客户端未加载,无法获取实时行情。\n\n"
|
|
1477
|
+
f"可运行 `/quote {symbol}` 重试。"
|
|
1478
|
+
),
|
|
1479
|
+
"tools_used": ["market_snapshot"],
|
|
1480
|
+
"analysis_complete": True,
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
import time as _time_snap
|
|
1484
|
+
|
|
1485
|
+
def _clean_network_error(raw: str) -> str:
|
|
1486
|
+
"""Convert raw exception strings to readable Chinese messages."""
|
|
1487
|
+
if "Connection aborted" in raw or "RemoteDisconnected" in raw:
|
|
1488
|
+
return "网络连接被中断(服务器关闭连接),请稍后重试"
|
|
1489
|
+
if "Connection refused" in raw:
|
|
1490
|
+
return "连接被拒绝,数据服务暂时不可用"
|
|
1491
|
+
if "timeout" in raw.lower() or "timed out" in raw.lower():
|
|
1492
|
+
return "连接超时,请稍后重试"
|
|
1493
|
+
if "NoneType" in raw or raw.strip() in ("None", ""):
|
|
1494
|
+
return "数据源未返回有效价格"
|
|
1495
|
+
return raw
|
|
1496
|
+
|
|
1497
|
+
quote = {"success": False, "error": "未初始化"}
|
|
1498
|
+
_snapshot_quality = {}
|
|
1499
|
+
import contextlib as _ctxlib_snapshot
|
|
1500
|
+
import io as _io_snapshot
|
|
1501
|
+
for _attempt in range(3):
|
|
1502
|
+
try:
|
|
1503
|
+
mdc = _get_mdc_lazy()
|
|
1504
|
+
try:
|
|
1505
|
+
from packages.aria_services.data import DataService as _SnapshotDataService
|
|
1506
|
+
with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
|
|
1507
|
+
_quote_result = _SnapshotDataService(market_client=mdc, router=False).quote(symbol)
|
|
1508
|
+
quote = _quote_result.data or {}
|
|
1509
|
+
_snapshot_quality = _quote_result.quality or {}
|
|
1510
|
+
if _quote_result.provider_chain:
|
|
1511
|
+
quote.setdefault("provider_chain", _quote_result.provider_chain)
|
|
1512
|
+
quote.setdefault("success", bool(_quote_result.success))
|
|
1513
|
+
if not quote.get("success"):
|
|
1514
|
+
with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
|
|
1515
|
+
raw_quote = mdc.quote(symbol)
|
|
1516
|
+
if raw_quote:
|
|
1517
|
+
quote = raw_quote if isinstance(raw_quote, dict) else (
|
|
1518
|
+
raw_quote.to_dict() if hasattr(raw_quote, "to_dict") else vars(raw_quote)
|
|
1519
|
+
)
|
|
1520
|
+
except Exception:
|
|
1521
|
+
with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
|
|
1522
|
+
quote = mdc.quote(symbol)
|
|
1523
|
+
if quote.get("success"):
|
|
1524
|
+
break
|
|
1525
|
+
_err_str = str(quote.get("error", ""))
|
|
1526
|
+
_err_lower = _err_str.lower()
|
|
1527
|
+
# Clean raw exception strings in-place
|
|
1528
|
+
if any(k in _err_str for k in ("Connection aborted", "RemoteDisconnected",
|
|
1529
|
+
"Connection refused", "timeout")):
|
|
1530
|
+
quote["error"] = _clean_network_error(_err_str)
|
|
1531
|
+
# Retry on connection errors AND rate limits
|
|
1532
|
+
_should_retry = (
|
|
1533
|
+
("rate" in _err_lower or "429" in _err_lower or "too many" in _err_lower)
|
|
1534
|
+
or ("connection aborted" in _err_lower or "remotedisconnected" in _err_lower)
|
|
1535
|
+
)
|
|
1536
|
+
if _should_retry and _attempt < 2:
|
|
1537
|
+
_time_snap.sleep(1 + _attempt) # 1s, 2s
|
|
1538
|
+
continue
|
|
1539
|
+
break
|
|
1540
|
+
except Exception as exc:
|
|
1541
|
+
_raw_exc = str(exc)
|
|
1542
|
+
_exc_lower = _raw_exc.lower()
|
|
1543
|
+
_clean_err = _clean_network_error(_raw_exc)
|
|
1544
|
+
_should_retry = (
|
|
1545
|
+
("rate" in _exc_lower or "429" in _exc_lower or "too many" in _exc_lower)
|
|
1546
|
+
or ("connection aborted" in _exc_lower or "remotedisconnected" in _exc_lower)
|
|
1547
|
+
)
|
|
1548
|
+
if _should_retry and _attempt < 2:
|
|
1549
|
+
_time_snap.sleep(1 + _attempt)
|
|
1550
|
+
continue
|
|
1551
|
+
quote = {"success": False, "error": _clean_err}
|
|
1552
|
+
break
|
|
1553
|
+
|
|
1554
|
+
# Finnhub fallback when primary data source (yfinance) failed or rate-limited
|
|
1555
|
+
# _get_provider_key reads both env vars AND ~/.arthera/providers.json
|
|
1556
|
+
# NOTE: do NOT use dir() — it returns local scope, not module globals.
|
|
1557
|
+
_fh_key = _get_provider_key("finnhub")
|
|
1558
|
+
_fh_tried = False
|
|
1559
|
+
if not quote.get("success") and _fh_key:
|
|
1560
|
+
_fh_tried = True
|
|
1561
|
+
try:
|
|
1562
|
+
import requests as _rq
|
|
1563
|
+
_fh_r = _rq.get(
|
|
1564
|
+
f"https://finnhub.io/api/v1/quote?symbol={symbol}&token={_fh_key}",
|
|
1565
|
+
timeout=6
|
|
1566
|
+
)
|
|
1567
|
+
if _fh_r.status_code == 200:
|
|
1568
|
+
_fh = _fh_r.json()
|
|
1569
|
+
if _fh.get("c"): # current price present
|
|
1570
|
+
quote = {
|
|
1571
|
+
"success": True, "symbol": symbol,
|
|
1572
|
+
"price": round(_fh["c"], 2),
|
|
1573
|
+
"change_pct": round(float(_fh.get("dp") or 0), 2),
|
|
1574
|
+
"high": round(_fh.get("h", 0), 2),
|
|
1575
|
+
"low": round(_fh.get("l", 0), 2),
|
|
1576
|
+
"currency": "USD", "provider": "finnhub",
|
|
1577
|
+
}
|
|
1578
|
+
except Exception:
|
|
1579
|
+
pass
|
|
1580
|
+
|
|
1581
|
+
# akshare fallback for A-shares when both yfinance and eastmoney fail
|
|
1582
|
+
_is_a_share_sym = bool(_ashare_code)
|
|
1583
|
+
if _is_a_share_sym and not quote.get("success"):
|
|
1584
|
+
try:
|
|
1585
|
+
import akshare as _ak
|
|
1586
|
+
from datetime import datetime as _dt2, timedelta as _td2
|
|
1587
|
+
_end_d = _dt2.now().strftime("%Y%m%d")
|
|
1588
|
+
_start_d = (_dt2.now() - _td2(days=7)).strftime("%Y%m%d")
|
|
1589
|
+
_df_q = _ak.stock_zh_a_hist(
|
|
1590
|
+
symbol=_ashare_code, period="daily",
|
|
1591
|
+
start_date=_start_d, end_date=_end_d, adjust=""
|
|
1592
|
+
)
|
|
1593
|
+
if not _df_q.empty:
|
|
1594
|
+
_row = _df_q.iloc[-1]
|
|
1595
|
+
_close = float(_row.get("收盘", 0))
|
|
1596
|
+
_prev = float(_df_q.iloc[-2]["收盘"]) if len(_df_q) >= 2 else _close
|
|
1597
|
+
_chg_p = round((_close - _prev) / _prev * 100, 2) if _prev else 0
|
|
1598
|
+
_name = symbol
|
|
1599
|
+
try:
|
|
1600
|
+
_info_df = _ak.stock_individual_info_em(symbol=_ashare_code)
|
|
1601
|
+
_name = str(_info_df[_info_df["item"] == "股票简称"]["value"].values[0])
|
|
1602
|
+
except Exception:
|
|
1603
|
+
pass
|
|
1604
|
+
quote = {
|
|
1605
|
+
"success": True, "symbol": symbol, "name": _name,
|
|
1606
|
+
"price": _close, "change_pct": _chg_p,
|
|
1607
|
+
"high": float(_row.get("最高", _close)),
|
|
1608
|
+
"low": float(_row.get("最低", _close)),
|
|
1609
|
+
"volume": int(_row.get("成交量", 0)),
|
|
1610
|
+
"currency": "CNY", "provider": "akshare",
|
|
1611
|
+
}
|
|
1612
|
+
except Exception:
|
|
1613
|
+
pass
|
|
1614
|
+
|
|
1615
|
+
def _num(v):
|
|
1616
|
+
try:
|
|
1617
|
+
if v in (None, "", "N/A", "-", "nan"):
|
|
1618
|
+
return None
|
|
1619
|
+
return float(v)
|
|
1620
|
+
except Exception:
|
|
1621
|
+
return None
|
|
1622
|
+
|
|
1623
|
+
price = _num(quote.get("price"))
|
|
1624
|
+
# yfinance fallback when price is 0 or None (e.g. EDGAR source returns 0.00)
|
|
1625
|
+
if price is None or price == 0:
|
|
1626
|
+
try:
|
|
1627
|
+
import yfinance as _yf_single
|
|
1628
|
+
_t_s = _yf_single.Ticker(symbol)
|
|
1629
|
+
_fi_s = _t_s.fast_info
|
|
1630
|
+
_yf_p = getattr(_fi_s, "last_price", None) or getattr(_fi_s, "previous_close", None)
|
|
1631
|
+
if _yf_p and float(_yf_p) > 0:
|
|
1632
|
+
_yf_prev_s = getattr(_fi_s, "previous_close", _yf_p)
|
|
1633
|
+
_yf_chg_s = (float(_yf_p) - float(_yf_prev_s)) / float(_yf_prev_s) * 100 if _yf_prev_s else 0
|
|
1634
|
+
_yf_info_s = {}
|
|
1635
|
+
try:
|
|
1636
|
+
_yf_info_s = _t_s.info or {}
|
|
1637
|
+
except Exception:
|
|
1638
|
+
pass
|
|
1639
|
+
price = round(float(_yf_p), 2)
|
|
1640
|
+
quote = {
|
|
1641
|
+
"success": True, "symbol": symbol,
|
|
1642
|
+
"name": _yf_info_s.get("shortName") or _yf_info_s.get("longName") or symbol,
|
|
1643
|
+
"price": price,
|
|
1644
|
+
"change_pct": round(_yf_chg_s, 2),
|
|
1645
|
+
"currency": _yf_info_s.get("currency") or "USD",
|
|
1646
|
+
"market_cap": _yf_info_s.get("marketCap") or 0,
|
|
1647
|
+
"provider": "yfinance",
|
|
1648
|
+
}
|
|
1649
|
+
except Exception:
|
|
1650
|
+
pass
|
|
1651
|
+
price = _num(quote.get("price"))
|
|
1652
|
+
|
|
1653
|
+
if not quote.get("success") or price is None or price == 0:
|
|
1654
|
+
err = quote.get("error") or "当前数据源未返回有效价格"
|
|
1655
|
+
if "NoneType" in str(err):
|
|
1656
|
+
err = "当前数据源未返回有效价格"
|
|
1657
|
+
is_rate_limit = "rate" in str(err).lower() or "429" in str(err) or "too many" in str(err).lower()
|
|
1658
|
+
if is_rate_limit:
|
|
1659
|
+
if _fh_tried:
|
|
1660
|
+
# Finnhub was tried but also failed — both sources exhausted
|
|
1661
|
+
_hint = "\n\n[提示] yfinance 和 Finnhub 均触发频率限制,请稍等 30 秒后重试。"
|
|
1662
|
+
elif _fh_key:
|
|
1663
|
+
# Key configured but Finnhub wasn't tried (shouldn't happen, but defensive)
|
|
1664
|
+
_hint = "\n\n[提示] 数据源请求频率受限,请稍等 30 秒后重试。"
|
|
1665
|
+
else:
|
|
1666
|
+
# No Finnhub key — suggest configuring one
|
|
1667
|
+
_hint = (
|
|
1668
|
+
"\n\n[提示] 数据源请求频率受限:请稍等 30 秒后重试,"
|
|
1669
|
+
"或配置 Finnhub key 使用备用数据源:`/apikey set finnhub <key>`"
|
|
1670
|
+
"(注册:https://finnhub.io/register)"
|
|
1671
|
+
)
|
|
1672
|
+
else:
|
|
1673
|
+
_hint = ""
|
|
1674
|
+
return {
|
|
1675
|
+
"success": True,
|
|
1676
|
+
"response": (
|
|
1677
|
+
f"## {symbol} 市场快照\n\n"
|
|
1678
|
+
f"当前无法获取有效行情:{err}{_hint}\n\n"
|
|
1679
|
+
f"可运行 `/quote {symbol}` 重试;在数据恢复前不输出 RSI、MACD 或支撑/阻力位。"
|
|
1680
|
+
),
|
|
1681
|
+
"tools_used": ["market_snapshot"],
|
|
1682
|
+
"rate_limited": is_rate_limit,
|
|
1683
|
+
"analysis_complete": True,
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
name = quote.get("name") or symbol
|
|
1687
|
+
currency = quote.get("currency") or "USD"
|
|
1688
|
+
chg = _num(quote.get("change_pct"))
|
|
1689
|
+
high = _num(quote.get("high"))
|
|
1690
|
+
low = _num(quote.get("low"))
|
|
1691
|
+
volume = quote.get("volume")
|
|
1692
|
+
market_cap_raw = _num(quote.get("market_cap"))
|
|
1693
|
+
provider = quote.get("provider") or "market_data_client"
|
|
1694
|
+
sign = "+" if (chg or 0) >= 0 else ""
|
|
1695
|
+
chg_str = f"{sign}{chg:.2f}%" if chg is not None else "—"
|
|
1696
|
+
range_str = f"{currency} {low:,.2f} - {currency} {high:,.2f}" if low is not None and high is not None else ""
|
|
1697
|
+
# Format market cap: T / B / M abbreviation
|
|
1698
|
+
if market_cap_raw and market_cap_raw > 0:
|
|
1699
|
+
if market_cap_raw >= 1e12:
|
|
1700
|
+
_mktcap_str = f"{currency} {market_cap_raw/1e12:.2f}T"
|
|
1701
|
+
elif market_cap_raw >= 1e9:
|
|
1702
|
+
_mktcap_str = f"{currency} {market_cap_raw/1e9:.1f}B"
|
|
1703
|
+
elif market_cap_raw >= 1e6:
|
|
1704
|
+
_mktcap_str = f"{currency} {market_cap_raw/1e6:.0f}M"
|
|
1705
|
+
else:
|
|
1706
|
+
_mktcap_str = f"{currency} {market_cap_raw:,.0f}"
|
|
1707
|
+
else:
|
|
1708
|
+
_mktcap_str = None
|
|
1709
|
+
|
|
1710
|
+
# ── Technical indicators: mdc → akshare(A股) → yfinance → Finnhub ────────
|
|
1711
|
+
ti = {}
|
|
1712
|
+
try:
|
|
1713
|
+
with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
|
|
1714
|
+
ti = mdc.technical_indicators(symbol, days=120)
|
|
1715
|
+
except Exception:
|
|
1716
|
+
ti = {}
|
|
1717
|
+
|
|
1718
|
+
# Akshare fallback for A-shares (6-digit code, no suffix) — more reliable than yfinance for CN
|
|
1719
|
+
_is_a_share = bool(_ashare_code)
|
|
1720
|
+
if (_is_a_share and (not ti.get("success") or ti.get("rsi") is None)):
|
|
1721
|
+
try:
|
|
1722
|
+
import akshare as _ak
|
|
1723
|
+
import numpy as _np_ak
|
|
1724
|
+
from datetime import datetime as _dt, timedelta as _td
|
|
1725
|
+
_ak_start = (_dt.now() - _td(days=200)).strftime("%Y%m%d")
|
|
1726
|
+
_ak_end = _dt.now().strftime("%Y%m%d")
|
|
1727
|
+
_df_ak = _ak.stock_zh_a_hist(
|
|
1728
|
+
symbol=_ashare_code, period="daily",
|
|
1729
|
+
start_date=_ak_start, end_date=_ak_end,
|
|
1730
|
+
adjust="qfq",
|
|
1731
|
+
)
|
|
1732
|
+
_col_map = {
|
|
1733
|
+
"收盘": "Close", "成交量": "Volume",
|
|
1734
|
+
"close": "Close", "volume": "Volume",
|
|
1735
|
+
}
|
|
1736
|
+
_df_ak = _df_ak.rename(columns=_col_map)
|
|
1737
|
+
if "Close" in _df_ak.columns and len(_df_ak) >= 20:
|
|
1738
|
+
_c_ak = _df_ak["Close"].astype(float)
|
|
1739
|
+
_v_ak = _df_ak["Volume"].astype(float) if "Volume" in _df_ak.columns else None
|
|
1740
|
+
# RSI(14)
|
|
1741
|
+
_d_ak = _c_ak.diff()
|
|
1742
|
+
_g_ak = _d_ak.clip(lower=0).rolling(14).mean()
|
|
1743
|
+
_l_ak = (-_d_ak.clip(upper=0)).rolling(14).mean()
|
|
1744
|
+
_rs_ak = _g_ak / _l_ak.replace(0, _np_ak.nan)
|
|
1745
|
+
_rsi_ak = float((100 - 100 / (1 + _rs_ak)).iloc[-1])
|
|
1746
|
+
# MACD
|
|
1747
|
+
_ema12_ak = _c_ak.ewm(span=12).mean()
|
|
1748
|
+
_ema26_ak = _c_ak.ewm(span=26).mean()
|
|
1749
|
+
_macd_ak = _ema12_ak - _ema26_ak
|
|
1750
|
+
_sig_ak = _macd_ak.ewm(span=9).mean()
|
|
1751
|
+
_mhist_ak = float((_macd_ak - _sig_ak).iloc[-1])
|
|
1752
|
+
# MA / BB
|
|
1753
|
+
_ma20_ak = _c_ak.rolling(20).mean()
|
|
1754
|
+
_std20_ak = _c_ak.rolling(20).std()
|
|
1755
|
+
_ma60_ak = _c_ak.rolling(60).mean() if len(_c_ak) >= 60 else _ma20_ak
|
|
1756
|
+
ti = {
|
|
1757
|
+
"success": True,
|
|
1758
|
+
"rsi": round(_rsi_ak, 2) if not _np_ak.isnan(_rsi_ak) else None,
|
|
1759
|
+
"macd_hist": round(_mhist_ak, 4),
|
|
1760
|
+
"ma20": round(float(_ma20_ak.iloc[-1]), 2),
|
|
1761
|
+
"ma60": round(float(_ma60_ak.iloc[-1]), 2),
|
|
1762
|
+
"bb_upper": round(float((_ma20_ak + 2*_std20_ak).iloc[-1]), 2),
|
|
1763
|
+
"bb_lower": round(float((_ma20_ak - 2*_std20_ak).iloc[-1]), 2),
|
|
1764
|
+
"provider": "akshare",
|
|
1765
|
+
}
|
|
1766
|
+
if volume is None and _v_ak is not None:
|
|
1767
|
+
_rv = _v_ak.iloc[-1]
|
|
1768
|
+
if not _np_ak.isnan(_rv):
|
|
1769
|
+
volume = int(_rv)
|
|
1770
|
+
except Exception:
|
|
1771
|
+
pass
|
|
1772
|
+
|
|
1773
|
+
# If mdc returned nothing useful (all None), try yfinance directly
|
|
1774
|
+
if (not ti.get("success") or ti.get("rsi") is None) and not _is_a_share:
|
|
1775
|
+
try:
|
|
1776
|
+
import yfinance as _yf
|
|
1777
|
+
import numpy as _np
|
|
1778
|
+
# A股裸6位代码需要 yfinance 后缀:6/68开头→.SS,其余→.SZ
|
|
1779
|
+
_yf_sym = symbol
|
|
1780
|
+
if symbol.isdigit() and len(symbol) == 6:
|
|
1781
|
+
_yf_sym = symbol + (".SS" if symbol.startswith("6") else ".SZ")
|
|
1782
|
+
_hist = _yf.Ticker(_yf_sym).history(period="6mo")
|
|
1783
|
+
if len(_hist) >= 20:
|
|
1784
|
+
_close = _hist["Close"]
|
|
1785
|
+
_vol = _hist["Volume"]
|
|
1786
|
+
# RSI(14)
|
|
1787
|
+
_d = _close.diff()
|
|
1788
|
+
_g = _d.clip(lower=0).rolling(14).mean()
|
|
1789
|
+
_l = (-_d.clip(upper=0)).rolling(14).mean()
|
|
1790
|
+
_rs = _g / _l.replace(0, _np.nan)
|
|
1791
|
+
_rsi = float((100 - 100 / (1 + _rs)).iloc[-1])
|
|
1792
|
+
# MACD hist
|
|
1793
|
+
_ema12 = _close.ewm(span=12).mean()
|
|
1794
|
+
_ema26 = _close.ewm(span=26).mean()
|
|
1795
|
+
_macd = _ema12 - _ema26
|
|
1796
|
+
_signal = _macd.ewm(span=9).mean()
|
|
1797
|
+
_mhist = float((_macd - _signal).iloc[-1])
|
|
1798
|
+
# Bollinger Bands & MA
|
|
1799
|
+
_ma20 = _close.rolling(20).mean()
|
|
1800
|
+
_std20 = _close.rolling(20).std()
|
|
1801
|
+
_ma60 = _close.rolling(60).mean() if len(_close) >= 60 else _ma20
|
|
1802
|
+
ti = {
|
|
1803
|
+
"success": True,
|
|
1804
|
+
"rsi": round(_rsi, 2) if not _np.isnan(_rsi) else None,
|
|
1805
|
+
"macd_hist": round(_mhist, 4),
|
|
1806
|
+
"ma20": round(float(_ma20.iloc[-1]), 2),
|
|
1807
|
+
"ma60": round(float(_ma60.iloc[-1]), 2),
|
|
1808
|
+
"bb_upper": round(float((_ma20 + 2 * _std20).iloc[-1]), 2),
|
|
1809
|
+
"bb_lower": round(float((_ma20 - 2 * _std20).iloc[-1]), 2),
|
|
1810
|
+
"provider": "yfinance_direct",
|
|
1811
|
+
}
|
|
1812
|
+
# Back-fill volume if missing from quote
|
|
1813
|
+
if volume is None or str(volume) in ("None", "N/A", ""):
|
|
1814
|
+
_recent_vol = _vol.iloc[-1]
|
|
1815
|
+
if not _np.isnan(_recent_vol):
|
|
1816
|
+
volume = int(_recent_vol)
|
|
1817
|
+
except Exception:
|
|
1818
|
+
pass
|
|
1819
|
+
|
|
1820
|
+
# Finnhub candle fallback: when price came from Finnhub but yfinance TA failed,
|
|
1821
|
+
# fetch 6-month daily candles from Finnhub to compute RSI/MACD/MA.
|
|
1822
|
+
# Only attempted for US-style symbols (no A-share 6-digit codes, no .HK).
|
|
1823
|
+
_is_us_sym = bool(symbol and not symbol.isdigit() and "." not in symbol)
|
|
1824
|
+
if (not ti.get("success") or ti.get("rsi") is None) and _fh_key and _is_us_sym:
|
|
1825
|
+
try:
|
|
1826
|
+
import requests as _rq2, time as _t2
|
|
1827
|
+
_to_ts = int(_t2.time())
|
|
1828
|
+
_from_ts = _to_ts - 180 * 86400 # ~6 months
|
|
1829
|
+
_cr = _rq2.get(
|
|
1830
|
+
f"https://finnhub.io/api/v1/stock/candle"
|
|
1831
|
+
f"?symbol={symbol}&resolution=D&from={_from_ts}&to={_to_ts}&token={_fh_key}",
|
|
1832
|
+
timeout=8,
|
|
1833
|
+
)
|
|
1834
|
+
if _cr.status_code == 200:
|
|
1835
|
+
_cd = _cr.json()
|
|
1836
|
+
if _cd.get("s") == "ok" and _cd.get("c") and len(_cd["c"]) >= 20:
|
|
1837
|
+
import numpy as _np2
|
|
1838
|
+
_c = _cd["c"] # close prices list
|
|
1839
|
+
_v = _cd.get("v", [])
|
|
1840
|
+
import statistics as _st
|
|
1841
|
+
# RSI(14) — simple loop (no pandas needed)
|
|
1842
|
+
_gains, _losses = [], []
|
|
1843
|
+
for i in range(1, len(_c)):
|
|
1844
|
+
_delta = _c[i] - _c[i-1]
|
|
1845
|
+
_gains.append(max(_delta, 0))
|
|
1846
|
+
_losses.append(max(-_delta, 0))
|
|
1847
|
+
_ag = sum(_gains[:14]) / 14
|
|
1848
|
+
_al = sum(_losses[:14]) / 14
|
|
1849
|
+
for i in range(14, len(_gains)):
|
|
1850
|
+
_ag = (_ag * 13 + _gains[i]) / 14
|
|
1851
|
+
_al = (_al * 13 + _losses[i]) / 14
|
|
1852
|
+
_rsi_fh = (100 - 100 / (1 + _ag / _al)) if _al else 100
|
|
1853
|
+
# MACD(12,26,9)
|
|
1854
|
+
def _ema_list(prices, span):
|
|
1855
|
+
k, result_ema = 2/(span+1), [prices[0]]
|
|
1856
|
+
for p in prices[1:]: result_ema.append(p*k + result_ema[-1]*(1-k))
|
|
1857
|
+
return result_ema
|
|
1858
|
+
_ema12_fh = _ema_list(_c, 12)
|
|
1859
|
+
_ema26_fh = _ema_list(_c, 26)
|
|
1860
|
+
_macd_fh = [a - b for a, b in zip(_ema12_fh, _ema26_fh)]
|
|
1861
|
+
_sig_fh = _ema_list(_macd_fh, 9)
|
|
1862
|
+
_mhist_fh = _macd_fh[-1] - _sig_fh[-1]
|
|
1863
|
+
# MA20 / MA60
|
|
1864
|
+
_ma20_fh = sum(_c[-20:]) / 20
|
|
1865
|
+
_ma60_fh = sum(_c[-60:]) / 60 if len(_c) >= 60 else _ma20_fh
|
|
1866
|
+
# Bollinger Bands
|
|
1867
|
+
_std20_fh = _st.stdev(_c[-20:])
|
|
1868
|
+
ti = {
|
|
1869
|
+
"success": True,
|
|
1870
|
+
"rsi": round(_rsi_fh, 2),
|
|
1871
|
+
"macd_hist": round(_mhist_fh, 4),
|
|
1872
|
+
"ma20": round(_ma20_fh, 2),
|
|
1873
|
+
"ma60": round(_ma60_fh, 2),
|
|
1874
|
+
"bb_upper": round(_ma20_fh + 2*_std20_fh, 2),
|
|
1875
|
+
"bb_lower": round(_ma20_fh - 2*_std20_fh, 2),
|
|
1876
|
+
"provider": "finnhub_candle",
|
|
1877
|
+
}
|
|
1878
|
+
if volume is None and _v:
|
|
1879
|
+
volume = int(_v[-1]) if _v[-1] else None
|
|
1880
|
+
except Exception:
|
|
1881
|
+
pass
|
|
1882
|
+
|
|
1883
|
+
# Yahoo Finance v8 direct API — different endpoint from yfinance, avoids rate-limit collision.
|
|
1884
|
+
# Used when yfinance (via MDC) AND Finnhub candle both fail to produce TA data.
|
|
1885
|
+
# Only for non-A-share symbols; A-shares use akshare which has its own path above.
|
|
1886
|
+
if (not ti.get("success") or ti.get("rsi") is None) and _is_us_sym:
|
|
1887
|
+
try:
|
|
1888
|
+
import json as _json_yv8, urllib.request as _urlreq_yv8
|
|
1889
|
+
_yv8_url = (
|
|
1890
|
+
f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
|
1891
|
+
"?interval=1d&range=6mo"
|
|
1892
|
+
)
|
|
1893
|
+
_yv8_req = _urlreq_yv8.Request(_yv8_url, headers={
|
|
1894
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
1895
|
+
"Accept": "application/json",
|
|
1896
|
+
})
|
|
1897
|
+
with _urlreq_yv8.urlopen(_yv8_req, timeout=10) as _yv8_resp:
|
|
1898
|
+
_yv8_data = _json_yv8.loads(_yv8_resp.read())
|
|
1899
|
+
_yv8_result = _yv8_data["chart"]["result"][0]
|
|
1900
|
+
_yv8_q = _yv8_result["indicators"]["quote"][0]
|
|
1901
|
+
_c_yv8 = [x for x in _yv8_q.get("close", []) if x is not None]
|
|
1902
|
+
_v_yv8 = [x for x in _yv8_q.get("volume", []) if x is not None]
|
|
1903
|
+
if len(_c_yv8) >= 26:
|
|
1904
|
+
_c = _c_yv8
|
|
1905
|
+
# RSI(14) — Wilder EMA
|
|
1906
|
+
_d = [_c[i] - _c[i-1] for i in range(1, len(_c))]
|
|
1907
|
+
_g = [max(x, 0) for x in _d]; _l = [max(-x, 0) for x in _d]
|
|
1908
|
+
_ag = sum(_g[:14]) / 14; _al = sum(_l[:14]) / 14
|
|
1909
|
+
for i in range(14, len(_g)):
|
|
1910
|
+
_ag = (_ag * 13 + _g[i]) / 14; _al = (_al * 13 + _l[i]) / 14
|
|
1911
|
+
_rsi_yv8 = (100 - 100 / (1 + _ag / _al)) if _al else 100.0
|
|
1912
|
+
# MACD(12,26,9)
|
|
1913
|
+
def _ema_yv8(prices, span):
|
|
1914
|
+
k, r = 2 / (span + 1), [prices[0]]
|
|
1915
|
+
for p in prices[1:]: r.append(p * k + r[-1] * (1 - k))
|
|
1916
|
+
return r
|
|
1917
|
+
_ema12 = _ema_yv8(_c, 12); _ema26 = _ema_yv8(_c, 26)
|
|
1918
|
+
_macd = [a - b for a, b in zip(_ema12, _ema26)]
|
|
1919
|
+
_sig = _ema_yv8(_macd, 9)
|
|
1920
|
+
_mhist_yv8 = _macd[-1] - _sig[-1]
|
|
1921
|
+
# MA20 / MA60 / Bollinger
|
|
1922
|
+
_ma20_yv8 = sum(_c[-20:]) / 20
|
|
1923
|
+
_ma60_yv8 = sum(_c[-60:]) / 60 if len(_c) >= 60 else _ma20_yv8
|
|
1924
|
+
import statistics as _st_yv8
|
|
1925
|
+
_std20_yv8 = _st_yv8.stdev(_c[-20:])
|
|
1926
|
+
ti = {
|
|
1927
|
+
"success": True,
|
|
1928
|
+
"rsi": round(_rsi_yv8, 2),
|
|
1929
|
+
"macd_hist": round(_mhist_yv8, 4),
|
|
1930
|
+
"ma20": round(_ma20_yv8, 2),
|
|
1931
|
+
"ma60": round(_ma60_yv8, 2),
|
|
1932
|
+
"bb_upper": round(_ma20_yv8 + 2 * _std20_yv8, 2),
|
|
1933
|
+
"bb_lower": round(_ma20_yv8 - 2 * _std20_yv8, 2),
|
|
1934
|
+
"provider": "yahoo_v8",
|
|
1935
|
+
}
|
|
1936
|
+
if volume is None and _v_yv8:
|
|
1937
|
+
volume = int(_v_yv8[-1])
|
|
1938
|
+
except Exception:
|
|
1939
|
+
pass
|
|
1940
|
+
|
|
1941
|
+
rsi = _num(ti.get("rsi"))
|
|
1942
|
+
mhist = _num(ti.get("macd_hist"))
|
|
1943
|
+
ma20 = _num(ti.get("ma20"))
|
|
1944
|
+
ma60 = _num(ti.get("ma60"))
|
|
1945
|
+
bbu = _num(ti.get("bb_upper"))
|
|
1946
|
+
bbl = _num(ti.get("bb_lower"))
|
|
1947
|
+
|
|
1948
|
+
if rsi is None:
|
|
1949
|
+
rsi_view = "—"
|
|
1950
|
+
elif rsi >= 70:
|
|
1951
|
+
rsi_view = f"{rsi:.1f},超买风险"
|
|
1952
|
+
elif rsi <= 30:
|
|
1953
|
+
rsi_view = f"{rsi:.1f},超卖反弹可能"
|
|
1954
|
+
else:
|
|
1955
|
+
rsi_view = f"{rsi:.1f},中性"
|
|
1956
|
+
|
|
1957
|
+
if mhist is None:
|
|
1958
|
+
macd_view = "—"
|
|
1959
|
+
else:
|
|
1960
|
+
macd_view = f"{mhist:.4f},{'偏多' if mhist > 0 else '偏空'}"
|
|
1961
|
+
|
|
1962
|
+
supports = [v for v in (bbl, ma60, ma20) if v is not None and v < price]
|
|
1963
|
+
resistances = [v for v in (ma20, ma60, bbu) if v is not None and v > price]
|
|
1964
|
+
supports = sorted(set(round(v, 2) for v in supports), reverse=True)[:3]
|
|
1965
|
+
resistances = sorted(set(round(v, 2) for v in resistances))[:3]
|
|
1966
|
+
support_str = ", ".join(f"{currency} {v:,.2f}" for v in supports)
|
|
1967
|
+
resistance_str = ", ".join(f"{currency} {v:,.2f}" for v in resistances)
|
|
1968
|
+
timeframe_levels = _build_timeframe_levels(
|
|
1969
|
+
locals().get("mdc"),
|
|
1970
|
+
symbol,
|
|
1971
|
+
price,
|
|
1972
|
+
currency,
|
|
1973
|
+
ma20=ma20,
|
|
1974
|
+
ma60=ma60,
|
|
1975
|
+
bb_lower=bbl,
|
|
1976
|
+
bb_upper=bbu,
|
|
1977
|
+
fallback_supports=supports,
|
|
1978
|
+
fallback_resistances=resistances,
|
|
1979
|
+
)
|
|
1980
|
+
|
|
1981
|
+
# ── Signal logic ──────────────────────────────────────────────────────────
|
|
1982
|
+
_enough_data = (rsi is not None) or (mhist is not None)
|
|
1983
|
+
|
|
1984
|
+
_SIGNAL_LABELS: dict[str, dict[str, str]] = {
|
|
1985
|
+
"zh": {
|
|
1986
|
+
"STRONG_BUY": "强势多头 — 趋势与动量共振",
|
|
1987
|
+
"BUY": "偏多 — 量化条件支持上行",
|
|
1988
|
+
"SELL": "偏空 — 量化条件提示下行",
|
|
1989
|
+
"STRONG_SELL": "强势空头 — 趋势与动量共振下行",
|
|
1990
|
+
"CAUTION": "超买 — 等待回调确认",
|
|
1991
|
+
"WATCH": "超卖 — 关注企稳信号",
|
|
1992
|
+
"HOLD+": "短线偏强,控制仓位",
|
|
1993
|
+
"HOLD−": "短线偏弱,守住支撑",
|
|
1994
|
+
"NEUTRAL": "震荡观察,等待方向",
|
|
1995
|
+
"—": "指标数据不足",
|
|
1996
|
+
},
|
|
1997
|
+
"en": {
|
|
1998
|
+
"STRONG_BUY": "Strong bullish — trend and momentum aligned",
|
|
1999
|
+
"BUY": "Bullish — quantitative setup supports upside",
|
|
2000
|
+
"SELL": "Bearish — quantitative setup warns downside",
|
|
2001
|
+
"STRONG_SELL": "Strong bearish — trend and momentum aligned lower",
|
|
2002
|
+
"CAUTION": "Overbought — wait for pullback",
|
|
2003
|
+
"WATCH": "Oversold — watch for stabilization",
|
|
2004
|
+
"HOLD+": "Short-term bias up, manage size",
|
|
2005
|
+
"HOLD−": "Short-term bias down, hold support",
|
|
2006
|
+
"NEUTRAL": "Ranging — wait for direction",
|
|
2007
|
+
"—": "Insufficient indicator data",
|
|
2008
|
+
},
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if _enough_data:
|
|
2012
|
+
_signal_score = 0
|
|
2013
|
+
_signal_reasons = []
|
|
2014
|
+
if ma20 is not None:
|
|
2015
|
+
if price > ma20:
|
|
2016
|
+
_signal_score += 1
|
|
2017
|
+
_signal_reasons.append("price>MA20")
|
|
2018
|
+
else:
|
|
2019
|
+
_signal_score -= 1
|
|
2020
|
+
_signal_reasons.append("price<MA20")
|
|
2021
|
+
if ma60 is not None:
|
|
2022
|
+
if price > ma60:
|
|
2023
|
+
_signal_score += 1
|
|
2024
|
+
_signal_reasons.append("price>MA60")
|
|
2025
|
+
else:
|
|
2026
|
+
_signal_score -= 1
|
|
2027
|
+
_signal_reasons.append("price<MA60")
|
|
2028
|
+
if mhist is not None:
|
|
2029
|
+
if mhist > 0:
|
|
2030
|
+
_signal_score += 1
|
|
2031
|
+
_signal_reasons.append("MACD+")
|
|
2032
|
+
elif mhist < 0:
|
|
2033
|
+
_signal_score -= 1
|
|
2034
|
+
_signal_reasons.append("MACD-")
|
|
2035
|
+
if rsi is not None:
|
|
2036
|
+
if rsi <= 30:
|
|
2037
|
+
_signal_score += 2
|
|
2038
|
+
_signal_reasons.append("RSI oversold")
|
|
2039
|
+
elif rsi <= 40:
|
|
2040
|
+
_signal_score += 1
|
|
2041
|
+
_signal_reasons.append("RSI low")
|
|
2042
|
+
elif rsi >= 75:
|
|
2043
|
+
_signal_score -= 2
|
|
2044
|
+
_signal_reasons.append("RSI very high")
|
|
2045
|
+
elif rsi >= 65:
|
|
2046
|
+
_signal_score -= 1
|
|
2047
|
+
_signal_reasons.append("RSI high")
|
|
2048
|
+
if chg is not None:
|
|
2049
|
+
if chg >= 2:
|
|
2050
|
+
_signal_score += 1
|
|
2051
|
+
_signal_reasons.append("day momentum+")
|
|
2052
|
+
elif chg <= -2:
|
|
2053
|
+
_signal_score -= 1
|
|
2054
|
+
_signal_reasons.append("day momentum-")
|
|
2055
|
+
|
|
2056
|
+
if _signal_score >= 4:
|
|
2057
|
+
_sig_key = "STRONG_BUY"
|
|
2058
|
+
elif _signal_score >= 2:
|
|
2059
|
+
_sig_key = "BUY"
|
|
2060
|
+
elif _signal_score <= -4:
|
|
2061
|
+
_sig_key = "STRONG_SELL"
|
|
2062
|
+
elif _signal_score <= -2:
|
|
2063
|
+
_sig_key = "SELL"
|
|
2064
|
+
elif _signal_score == 1:
|
|
2065
|
+
_sig_key = "HOLD+"
|
|
2066
|
+
elif _signal_score == -1:
|
|
2067
|
+
_sig_key = "HOLD−"
|
|
2068
|
+
else:
|
|
2069
|
+
_sig_key = "NEUTRAL"
|
|
2070
|
+
else:
|
|
2071
|
+
_sig_key = "—"
|
|
2072
|
+
_signal_score = 0
|
|
2073
|
+
_signal_reasons = []
|
|
2074
|
+
_signal_confidence = min(0.82, 0.46 + abs(_signal_score) * 0.07) if _enough_data else 0.0
|
|
2075
|
+
|
|
2076
|
+
signal = _sig_key
|
|
2077
|
+
signal_str = _SIGNAL_LABELS["zh"][_sig_key] # will be overwritten after _lang is resolved below
|
|
2078
|
+
|
|
2079
|
+
# ── Price-action analysis (available even without TA) ─────────────────
|
|
2080
|
+
_range_size = (high - low) if (high is not None and low is not None) else None
|
|
2081
|
+
_price_pos = int((price - low) / _range_size * 100) if _range_size and _range_size > 0 else None
|
|
2082
|
+
_swing_pct = round(_range_size / price * 100, 2) if _range_size and price else None
|
|
2083
|
+
_chg_abs = abs(chg) if chg is not None else None
|
|
2084
|
+
_pa_lines = []
|
|
2085
|
+
if _price_pos is not None:
|
|
2086
|
+
_pos_label = "日内高位" if _price_pos >= 70 else ("日内低位" if _price_pos <= 30 else "日内中段")
|
|
2087
|
+
_pa_lines.append(f"价格位置:{_pos_label}(日内第 {_price_pos} 百分位)")
|
|
2088
|
+
if _swing_pct:
|
|
2089
|
+
_pa_lines.append(f"日内振幅:{_swing_pct:.1f}%{'(波动偏大)' if _swing_pct > 3 else ''}")
|
|
2090
|
+
if chg is not None and _chg_abs is not None:
|
|
2091
|
+
if _chg_abs < 0.01:
|
|
2092
|
+
_pa_lines.append("今日动能:持平")
|
|
2093
|
+
else:
|
|
2094
|
+
_mo = "上涨" if chg > 0 else "下跌"
|
|
2095
|
+
_strength = "(大幅)" if _chg_abs > 2 else ("(温和)" if _chg_abs < 0.5 else "")
|
|
2096
|
+
_pa_lines.append(f"今日动能:{_mo} {_chg_abs:.2f}%{_strength}")
|
|
2097
|
+
|
|
2098
|
+
# ── Build output ──────────────────────────────────────────────────────
|
|
2099
|
+
weekday = datetime.now().weekday()
|
|
2100
|
+
ti_provider = ti.get("provider", "")
|
|
2101
|
+
quote_chain = quote.get("provider_chain") or [provider]
|
|
2102
|
+
data_src = " -> ".join(str(p) for p in quote_chain if p)
|
|
2103
|
+
if ti_provider and ti_provider not in quote_chain:
|
|
2104
|
+
data_src += f" + {ti_provider}"
|
|
2105
|
+
# volume == 0 means "not reported" here (finnhub /quote has no volume),
|
|
2106
|
+
# not "zero trading" — treat as unknown so we hide the row vs showing "0".
|
|
2107
|
+
_vol_str = _fmt_int(volume) if volume else "N/A"
|
|
2108
|
+
_now_str = datetime.now().strftime("%Y-%m-%d")
|
|
2109
|
+
|
|
2110
|
+
# ── Language-aware labels ─────────────────────────────────────────────
|
|
2111
|
+
_lang = _detect_lang(message)
|
|
2112
|
+
_en = _lang == "en"
|
|
2113
|
+
signal_str = _SIGNAL_LABELS.get(_lang, _SIGNAL_LABELS["zh"])[_sig_key]
|
|
2114
|
+
_L = {
|
|
2115
|
+
"disclaimer": "Not investment advice" if _en else "不构成投资建议",
|
|
2116
|
+
"after_hours": "After-hours" if _en else "休市/盘后",
|
|
2117
|
+
"market_open": "Market open" if _en else "盘中",
|
|
2118
|
+
"price_hdr": "Metric" if _en else "指标",
|
|
2119
|
+
"value_hdr": "Value" if _en else "数值",
|
|
2120
|
+
"latest": "Last price" if _en else "最新价",
|
|
2121
|
+
"day_range": "Day range" if _en else "日内区间",
|
|
2122
|
+
"swing": "Swing" if _en else "振幅",
|
|
2123
|
+
"mktcap": "Mkt cap" if _en else "市值",
|
|
2124
|
+
"volume": "Volume" if _en else "成交量",
|
|
2125
|
+
"ta_hdr": "Indicator" if _en else "技术指标",
|
|
2126
|
+
"meaning_hdr": "Meaning" if _en else "含义",
|
|
2127
|
+
"overbought": "Overbought" if _en else "超买",
|
|
2128
|
+
"oversold": "Oversold" if _en else "超卖",
|
|
2129
|
+
"neutral": "Neutral" if _en else "中性",
|
|
2130
|
+
"bull_mom": "Bullish momentum" if _en else "多头动能",
|
|
2131
|
+
"bear_mom": "Bearish momentum" if _en else "空头动能",
|
|
2132
|
+
"above_ma20": "Above MA20 ↑" if _en else "价格高于MA20 ↑",
|
|
2133
|
+
"below_ma20": "Below MA20 ↓" if _en else "价格低于MA20 ↓",
|
|
2134
|
+
"above_ma60": "Above MA60 ↑" if _en else "价格高于MA60 ↑",
|
|
2135
|
+
"below_ma60": "Below MA60 ↓" if _en else "价格低于MA60 ↓",
|
|
2136
|
+
"support": "Support" if _en else "支撑位",
|
|
2137
|
+
"resistance": "Resistance" if _en else "阻力位",
|
|
2138
|
+
"signal_lbl": "**Signal**" if _en else "**信号**",
|
|
2139
|
+
"pa_hdr": "**Price action** (TA indicators unavailable)" if _en else "**价格行动分析**(仅基于价格,TA 指标暂不可用)",
|
|
2140
|
+
"sig_no_ta": "TA indicators unavailable, price action above for reference" if _en else "技术指标暂缺,以上价格行动供参考",
|
|
2141
|
+
"ta_unavail": (f"*TA data unavailable — retry later or run `/ta {symbol}`*") if _en
|
|
2142
|
+
else f"*TA 数据暂时不可用,稍后重试或运行 `/ta {symbol}`*",
|
|
2143
|
+
"ta_hint_fh": (f"*Enable full TA*: set a free Finnhub key → `/apikey set finnhub <KEY>`"
|
|
2144
|
+
f" ([finnhub.io](https://finnhub.io/register))") if _en else
|
|
2145
|
+
(f"*启用完整 TA*:配置免费 Finnhub key → `/apikey set finnhub <KEY>`"
|
|
2146
|
+
f" ([注册](https://finnhub.io/register))"),
|
|
2147
|
+
"data_status": "**Data status**" if _en else "**数据状态**",
|
|
2148
|
+
"stale_warn": "Data may be stale, please retry later" if _en else "数据可能已过期,请稍后重试",
|
|
2149
|
+
"missing": "Missing fields" if _en else "缺少字段",
|
|
2150
|
+
"rate_warn": "Data source rate-limited, will auto-retry" if _en else "数据源请求频率受限,稍后自动重试",
|
|
2151
|
+
"timeout_warn": "Data source request timed out" if _en else "数据源请求超时",
|
|
2152
|
+
"nodata_warn": "No data available for this symbol" if _en else "该标的暂无数据",
|
|
2153
|
+
"next_hdr": "**Next steps**" if _en else "**下一步**",
|
|
2154
|
+
"team_desc": "Deep analysis (fundamental + technical)" if _en else "深度分析(基本面 + 技术面)",
|
|
2155
|
+
"ta_desc": "Open full technical chart" if _en else "打开完整技术图表",
|
|
2156
|
+
"report_desc": "Generate institutional research report" if _en else "生成机构级研究报告",
|
|
2157
|
+
"backtest_desc":"Backtest 1y momentum strategy" if _en else "回测 1 年动量策略",
|
|
2158
|
+
"pos_high": "Upper range" if _en else "日内高位",
|
|
2159
|
+
"pos_low": "Lower range" if _en else "日内低位",
|
|
2160
|
+
"pos_mid": "Mid range" if _en else "日内中段",
|
|
2161
|
+
"pos_pct": "day percentile" if _en else "百分位",
|
|
2162
|
+
"swing_high": "(high volatility)" if _en else "(波动偏大)",
|
|
2163
|
+
"flat": "Flat" if _en else "持平",
|
|
2164
|
+
"rising": "Up" if _en else "上涨",
|
|
2165
|
+
"falling": "Down" if _en else "下跌",
|
|
2166
|
+
"strong": " (sharp)" if _en else "(大幅)",
|
|
2167
|
+
"mild": " (mild)" if _en else "(温和)",
|
|
2168
|
+
"day_momentum": "Momentum" if _en else "今日动能",
|
|
2169
|
+
"day_pos": "Price position" if _en else "价格位置",
|
|
2170
|
+
"day_swing": "Day swing" if _en else "日内振幅",
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
session_note = _L["after_hours"] if weekday >= 5 else _L["market_open"]
|
|
2174
|
+
|
|
2175
|
+
def _money(v: float | int | None) -> str:
|
|
2176
|
+
try:
|
|
2177
|
+
return f"{currency} {float(v):,.2f}"
|
|
2178
|
+
except Exception:
|
|
2179
|
+
return "—"
|
|
2180
|
+
|
|
2181
|
+
if _enough_data:
|
|
2182
|
+
if _sig_key in ("STRONG_BUY", "BUY", "HOLD+"):
|
|
2183
|
+
_bias_label = "bullish" if _en else "偏强"
|
|
2184
|
+
elif _sig_key in ("STRONG_SELL", "SELL", "HOLD−"):
|
|
2185
|
+
_bias_label = "bearish" if _en else "偏弱"
|
|
2186
|
+
else:
|
|
2187
|
+
_bias_label = "range-bound" if _en else "震荡"
|
|
2188
|
+
else:
|
|
2189
|
+
_bias_label = "insufficient TA data" if _en else "指标不足"
|
|
2190
|
+
|
|
2191
|
+
_trend_bits = []
|
|
2192
|
+
if ma20 is not None:
|
|
2193
|
+
_trend_bits.append(("above MA20" if price > ma20 else "below MA20") if _en else ("站上 MA20" if price > ma20 else "低于 MA20"))
|
|
2194
|
+
if ma60 is not None:
|
|
2195
|
+
_trend_bits.append(("above MA60" if price > ma60 else "below MA60") if _en else ("站上 MA60" if price > ma60 else "低于 MA60"))
|
|
2196
|
+
if mhist is not None:
|
|
2197
|
+
_trend_bits.append(("MACD positive" if mhist > 0 else "MACD negative") if _en else ("MACD 偏多" if mhist > 0 else "MACD 偏空"))
|
|
2198
|
+
if rsi is not None:
|
|
2199
|
+
if rsi >= 70:
|
|
2200
|
+
_trend_bits.append("RSI overbought" if _en else "RSI 超买")
|
|
2201
|
+
elif rsi <= 30:
|
|
2202
|
+
_trend_bits.append("RSI oversold" if _en else "RSI 超卖")
|
|
2203
|
+
else:
|
|
2204
|
+
_trend_bits.append(f"RSI {rsi:.1f} neutral" if _en else f"RSI {rsi:.1f} 中性")
|
|
2205
|
+
|
|
2206
|
+
_watch_supports = next((row.get("support") or [] for row in timeframe_levels if row.get("support")), supports)
|
|
2207
|
+
_watch_resistances = next((row.get("resistance") or [] for row in timeframe_levels if row.get("resistance")), resistances)
|
|
2208
|
+
_nearest_support = _watch_supports[0] if _watch_supports else None
|
|
2209
|
+
_nearest_resistance = _watch_resistances[0] if _watch_resistances else None
|
|
2210
|
+
if _en:
|
|
2211
|
+
_summary_line = f"{symbol} is currently {str(_bias_label)}"
|
|
2212
|
+
if _trend_bits:
|
|
2213
|
+
_summary_line += " — " + ", ".join(_trend_bits[:3]) + "."
|
|
2214
|
+
else:
|
|
2215
|
+
_summary_line += "."
|
|
2216
|
+
if _nearest_support is not None and _nearest_resistance is not None:
|
|
2217
|
+
_watch_line = (
|
|
2218
|
+
f"Watch {_money(_nearest_resistance)} for a bullish break; "
|
|
2219
|
+
f"losing {_money(_nearest_support)} raises downside risk."
|
|
2220
|
+
)
|
|
2221
|
+
elif _nearest_resistance is not None:
|
|
2222
|
+
_watch_line = f"Watch {_money(_nearest_resistance)} as the next resistance."
|
|
2223
|
+
elif _nearest_support is not None:
|
|
2224
|
+
_watch_line = f"Watch {_money(_nearest_support)} as the nearest support."
|
|
2225
|
+
else:
|
|
2226
|
+
_watch_line = "No reliable support/resistance from current data."
|
|
2227
|
+
else:
|
|
2228
|
+
_summary_line = f"{symbol} 当前{_bias_label}"
|
|
2229
|
+
if _trend_bits:
|
|
2230
|
+
_summary_line += "," + ",".join(_trend_bits[:3]) + "。"
|
|
2231
|
+
else:
|
|
2232
|
+
_summary_line += "。"
|
|
2233
|
+
if _nearest_support is not None and _nearest_resistance is not None:
|
|
2234
|
+
_watch_line = f"上破 {_money(_nearest_resistance)} 才能转强;跌破 {_money(_nearest_support)} 风险放大。"
|
|
2235
|
+
elif _nearest_resistance is not None:
|
|
2236
|
+
_watch_line = f"重点观察 {_money(_nearest_resistance)} 阻力。"
|
|
2237
|
+
elif _nearest_support is not None:
|
|
2238
|
+
_watch_line = f"重点观察 {_money(_nearest_support)} 支撑。"
|
|
2239
|
+
else:
|
|
2240
|
+
_watch_line = "当前数据不足以给出可靠支撑/阻力。"
|
|
2241
|
+
|
|
2242
|
+
_positive_reasons = []
|
|
2243
|
+
_negative_reasons = []
|
|
2244
|
+
_neutral_reasons = []
|
|
2245
|
+
if ma20 is not None:
|
|
2246
|
+
(_positive_reasons if price > ma20 else _negative_reasons).append("price>MA20" if _en and price > ma20 else "price<MA20" if _en else "价格高于 MA20" if price > ma20 else "价格低于 MA20")
|
|
2247
|
+
if ma60 is not None:
|
|
2248
|
+
(_positive_reasons if price > ma60 else _negative_reasons).append("price>MA60" if _en and price > ma60 else "price<MA60" if _en else "价格高于 MA60" if price > ma60 else "价格低于 MA60")
|
|
2249
|
+
if mhist is not None:
|
|
2250
|
+
(_positive_reasons if mhist > 0 else _negative_reasons).append("MACD positive" if _en and mhist > 0 else "MACD negative" if _en else "MACD 偏多" if mhist > 0 else "MACD 偏空")
|
|
2251
|
+
if rsi is not None:
|
|
2252
|
+
if 30 < rsi < 70:
|
|
2253
|
+
_neutral_reasons.append(f"RSI {rsi:.1f} neutral" if _en else f"RSI {rsi:.1f} 中性")
|
|
2254
|
+
elif rsi <= 30:
|
|
2255
|
+
_positive_reasons.append("RSI oversold" if _en else "RSI 超卖")
|
|
2256
|
+
elif rsi >= 70:
|
|
2257
|
+
_negative_reasons.append("RSI overbought" if _en else "RSI 超买")
|
|
2258
|
+
|
|
2259
|
+
def _join_reasons(items: list[str]) -> str:
|
|
2260
|
+
return ", ".join(items) if _en else "、".join(items)
|
|
2261
|
+
|
|
2262
|
+
lines = []
|
|
2263
|
+
# ── Header ──
|
|
2264
|
+
_header_name = name if name and name.upper() != symbol.upper() else ""
|
|
2265
|
+
if _header_name:
|
|
2266
|
+
lines.append(f"## {_header_name} `{symbol}`")
|
|
2267
|
+
else:
|
|
2268
|
+
lines.append(f"## `{symbol}`")
|
|
2269
|
+
lines.append(f"*{data_src} · {_now_str} · {session_note} · {_L['disclaimer']}*")
|
|
2270
|
+
lines.append("")
|
|
2271
|
+
lines.append(f"**{'Takeaway' if _en else '结论'}**:{_summary_line}")
|
|
2272
|
+
lines.append(f"**{'Watch' if _en else '观察位'}**:{_watch_line}")
|
|
2273
|
+
lines.append("")
|
|
2274
|
+
|
|
2275
|
+
# ── Price table ──
|
|
2276
|
+
_chg_display = chg_str if (chg is not None and abs(chg) >= 0.005) else "—"
|
|
2277
|
+
lines.append(f"| {_L['price_hdr']} | {_L['value_hdr']} |")
|
|
2278
|
+
lines.append("|------|------|")
|
|
2279
|
+
lines.append(f"| {_L['latest']} | **{currency} {price:,.2f}** `{_chg_display}` |")
|
|
2280
|
+
if range_str:
|
|
2281
|
+
swing_cell = f" {_L['swing']} {_swing_pct:.1f}%" if _swing_pct else ""
|
|
2282
|
+
lines.append(f"| {_L['day_range']} | {range_str}{swing_cell} |")
|
|
2283
|
+
if _mktcap_str:
|
|
2284
|
+
lines.append(f"| {_L['mktcap']} | {_mktcap_str} |")
|
|
2285
|
+
if _vol_str != "N/A":
|
|
2286
|
+
lines.append(f"| {_L['volume']} | {_vol_str} |")
|
|
2287
|
+
|
|
2288
|
+
# ── Technical table ──
|
|
2289
|
+
lines.append("")
|
|
2290
|
+
lines.append(f"| {_L['ta_hdr']} | {_L['value_hdr']} | {_L['meaning_hdr']} |")
|
|
2291
|
+
lines.append("|---------|------|------|")
|
|
2292
|
+
if rsi is not None:
|
|
2293
|
+
_rsi_meaning = _L["overbought"] if rsi >= 70 else (_L["oversold"] if rsi <= 30 else _L["neutral"])
|
|
2294
|
+
lines.append(f"| RSI(14) | {rsi_view} | {_rsi_meaning} |")
|
|
2295
|
+
else:
|
|
2296
|
+
lines.append("| RSI(14) | — | — |")
|
|
2297
|
+
if mhist is not None:
|
|
2298
|
+
_macd_meaning = _L["bull_mom"] if mhist > 0 else _L["bear_mom"]
|
|
2299
|
+
lines.append(f"| MACD hist | {mhist:.4f} | {_macd_meaning} |")
|
|
2300
|
+
else:
|
|
2301
|
+
lines.append("| MACD hist | — | — |")
|
|
2302
|
+
if ma20 is not None:
|
|
2303
|
+
lines.append(f"| MA20 | {currency} {ma20:,.2f} | {_L['above_ma20'] if price > ma20 else _L['below_ma20']} |")
|
|
2304
|
+
if ma60 is not None:
|
|
2305
|
+
lines.append(f"| MA60 | {currency} {ma60:,.2f} | {_L['above_ma60'] if price > ma60 else _L['below_ma60']} |")
|
|
2306
|
+
if support_str:
|
|
2307
|
+
lines.append(f"| {_L['support']} | {support_str} | |")
|
|
2308
|
+
if resistance_str:
|
|
2309
|
+
lines.append(f"| {_L['resistance']} | {resistance_str} | |")
|
|
2310
|
+
|
|
2311
|
+
_append_timeframe_levels(lines, timeframe_levels, currency, english=_en)
|
|
2312
|
+
|
|
2313
|
+
# ── Price-action lines (rebuild with language-aware labels) ──
|
|
2314
|
+
_pa_lines_l10n = []
|
|
2315
|
+
if _price_pos is not None:
|
|
2316
|
+
_pos_label = _L["pos_high"] if _price_pos >= 70 else (_L["pos_low"] if _price_pos <= 30 else _L["pos_mid"])
|
|
2317
|
+
_pa_lines_l10n.append(f"{_L['day_pos']}:{_pos_label}({_en and 'day' or '日内第'} {_price_pos} {_L['pos_pct']})")
|
|
2318
|
+
if _swing_pct:
|
|
2319
|
+
_swing_note = _L["swing_high"] if _swing_pct > 3 else ""
|
|
2320
|
+
_pa_lines_l10n.append(f"{_L['day_swing']}:{_swing_pct:.1f}%{_swing_note}")
|
|
2321
|
+
if chg is not None and _chg_abs is not None:
|
|
2322
|
+
if _chg_abs < 0.01:
|
|
2323
|
+
_pa_lines_l10n.append(f"{_L['day_momentum']}:{_L['flat']}")
|
|
2324
|
+
else:
|
|
2325
|
+
_mo = _L["rising"] if chg > 0 else _L["falling"]
|
|
2326
|
+
_strength = _L["strong"] if _chg_abs > 2 else (_L["mild"] if _chg_abs < 0.5 else "")
|
|
2327
|
+
_pa_lines_l10n.append(f"{_L['day_momentum']}:{_mo} {_chg_abs:.2f}%{_strength}")
|
|
2328
|
+
|
|
2329
|
+
# ── Signal ──
|
|
2330
|
+
lines.append("")
|
|
2331
|
+
if _enough_data:
|
|
2332
|
+
_score_detail = (
|
|
2333
|
+
f" · score {_signal_score:+d} · confidence {_signal_confidence:.0%}"
|
|
2334
|
+
if _en else
|
|
2335
|
+
f" · 量化分 {_signal_score:+d} · 置信度 {_signal_confidence:.0%}"
|
|
2336
|
+
)
|
|
2337
|
+
lines.append(f"{_L['signal_lbl']}:`{signal}` — {signal_str}{_score_detail}")
|
|
2338
|
+
_reason_parts = []
|
|
2339
|
+
if _positive_reasons:
|
|
2340
|
+
_reason_parts.append(("support: " if _en else "支撑:") + _join_reasons(_positive_reasons[:3]))
|
|
2341
|
+
if _negative_reasons:
|
|
2342
|
+
_reason_parts.append(("pressure: " if _en else "压力:") + _join_reasons(_negative_reasons[:3]))
|
|
2343
|
+
if _neutral_reasons:
|
|
2344
|
+
_reason_parts.append(("neutral: " if _en else "中性:") + _join_reasons(_neutral_reasons[:2]))
|
|
2345
|
+
if _reason_parts:
|
|
2346
|
+
lines.append(f"**{'Signal drivers' if _en else '信号拆解'}**:" + ("; ".join(_reason_parts) if _en else ";".join(_reason_parts)))
|
|
2347
|
+
else:
|
|
2348
|
+
lines.append(_L["pa_hdr"])
|
|
2349
|
+
for _pal in _pa_lines_l10n:
|
|
2350
|
+
lines.append(f"- {_pal}")
|
|
2351
|
+
lines.append("")
|
|
2352
|
+
lines.append(f"{_L['signal_lbl']}:`{signal}` — {_L['sig_no_ta']}")
|
|
2353
|
+
|
|
2354
|
+
# ── Config hint (show only when TA missing) ──
|
|
2355
|
+
if not _enough_data:
|
|
2356
|
+
lines.append("")
|
|
2357
|
+
if _is_a_share:
|
|
2358
|
+
_ak_hint = (f"> **Full TA data**: akshare should be available — retry or run `/ta {symbol}`"
|
|
2359
|
+
if _en else
|
|
2360
|
+
f"> **完整 TA 数据**:akshare 应已可用,若持续失败请重试或运行 `/ta {symbol}`")
|
|
2361
|
+
lines.append(_ak_hint)
|
|
2362
|
+
elif not _fh_key:
|
|
2363
|
+
lines.append(_L["ta_hint_fh"])
|
|
2364
|
+
else:
|
|
2365
|
+
lines.append(_L["ta_unavail"])
|
|
2366
|
+
|
|
2367
|
+
# ── Prediction hint when the user explicitly asks for forecast/outlook ──
|
|
2368
|
+
if any(k in message.lower() for k in ("预测", "预判", "forecast", "predict", "prediction", "outlook")):
|
|
2369
|
+
_prediction_added = False
|
|
2370
|
+
|
|
2371
|
+
def _append_rule_prediction() -> None:
|
|
2372
|
+
nonlocal _prediction_added
|
|
2373
|
+
if _prediction_added or not _enough_data:
|
|
2374
|
+
return
|
|
2375
|
+
score = 0
|
|
2376
|
+
if ma20 is not None:
|
|
2377
|
+
score += 1 if price > ma20 else -1
|
|
2378
|
+
if ma60 is not None:
|
|
2379
|
+
score += 1 if price > ma60 else -1
|
|
2380
|
+
if mhist is not None:
|
|
2381
|
+
score += 1 if mhist > 0 else -1
|
|
2382
|
+
if rsi is not None:
|
|
2383
|
+
if rsi >= 75:
|
|
2384
|
+
score -= 1
|
|
2385
|
+
elif rsi <= 30:
|
|
2386
|
+
score += 1
|
|
2387
|
+
if _en:
|
|
2388
|
+
direction = "bullish" if score >= 2 else ("bearish" if score <= -2 else "range-bound")
|
|
2389
|
+
confidence = min(0.7, 0.45 + abs(score) * 0.06)
|
|
2390
|
+
lines.append("")
|
|
2391
|
+
lines.append("### Forecast reference")
|
|
2392
|
+
lines.append(f"- Rule-based direction: `{direction}` · confidence: {confidence:.0%}")
|
|
2393
|
+
lines.append("- Prediction model did not return this symbol; this is inferred only from RSI, MACD, and moving averages.")
|
|
2394
|
+
else:
|
|
2395
|
+
direction = "偏强" if score >= 2 else ("偏弱" if score <= -2 else "震荡")
|
|
2396
|
+
confidence = min(0.7, 0.45 + abs(score) * 0.06)
|
|
2397
|
+
lines.append("")
|
|
2398
|
+
lines.append("### 预测参考")
|
|
2399
|
+
lines.append(f"- 规则型方向:`{direction}` · 置信度:{confidence:.0%}")
|
|
2400
|
+
lines.append("- 预测工具当前未返回该标的数据,以上仅由 RSI、MACD 和均线结构推导。")
|
|
2401
|
+
_prediction_added = True
|
|
2402
|
+
|
|
2403
|
+
try:
|
|
2404
|
+
pred_symbol = symbol
|
|
2405
|
+
if _is_a_share and _ashare_code:
|
|
2406
|
+
pred_symbol = ("sh" if _ashare_code.startswith(("6", "9")) else "sz") + _ashare_code
|
|
2407
|
+
from local_finance_tools import _get_predictions
|
|
2408
|
+
import contextlib as _ctxlib_pred
|
|
2409
|
+
import io as _io_pred
|
|
2410
|
+
with _ctxlib_pred.redirect_stdout(_io_pred.StringIO()), _ctxlib_pred.redirect_stderr(_io_pred.StringIO()):
|
|
2411
|
+
pred = _get_predictions({"symbols": [pred_symbol], "prediction_days": 5})
|
|
2412
|
+
preds = pred.get("predictions") or []
|
|
2413
|
+
if preds:
|
|
2414
|
+
p0 = preds[0]
|
|
2415
|
+
direction = str(p0.get("direction") or p0.get("signal") or "neutral")
|
|
2416
|
+
ret = p0.get("predicted_return")
|
|
2417
|
+
conf = p0.get("confidence")
|
|
2418
|
+
lines.append("")
|
|
2419
|
+
lines.append("### 预测参考")
|
|
2420
|
+
bits = [f"方向:`{direction}`"]
|
|
2421
|
+
try:
|
|
2422
|
+
bits.append(f"5日预期收益:{float(ret):+.2%}")
|
|
2423
|
+
except Exception:
|
|
2424
|
+
pass
|
|
2425
|
+
try:
|
|
2426
|
+
bits.append(f"置信度:{float(conf):.0%}")
|
|
2427
|
+
except Exception:
|
|
2428
|
+
pass
|
|
2429
|
+
lines.append("- " + " · ".join(bits))
|
|
2430
|
+
lines.append("- 该预测由本地动量/技术因子模型生成,只作为风险参考,不构成投资建议。")
|
|
2431
|
+
_prediction_added = True
|
|
2432
|
+
elif _enough_data:
|
|
2433
|
+
_append_rule_prediction()
|
|
2434
|
+
except Exception:
|
|
2435
|
+
_append_rule_prediction()
|
|
2436
|
+
|
|
2437
|
+
# ── Data quality — only show when actionable ──
|
|
2438
|
+
_quality_missing = _snapshot_quality.get("missing_fields") or []
|
|
2439
|
+
_quality_warnings = _snapshot_quality.get("warnings") or []
|
|
2440
|
+
_quality_errors = _snapshot_quality.get("errors") or []
|
|
2441
|
+
_quality_status = _snapshot_quality.get("status", "")
|
|
2442
|
+
_show_quality = _quality_status in ("unavailable", "partial", "stale") or bool(_quality_missing)
|
|
2443
|
+
if _snapshot_quality and _show_quality:
|
|
2444
|
+
lines.append("")
|
|
2445
|
+
lines.append(_L["data_status"])
|
|
2446
|
+
if _snapshot_quality.get("stale"):
|
|
2447
|
+
lines.append(f"- {_L['stale_warn']}")
|
|
2448
|
+
if _quality_missing:
|
|
2449
|
+
_missing_map = {"price": "price" if _en else "价格",
|
|
2450
|
+
"volume": "volume" if _en else "成交量",
|
|
2451
|
+
"change": "change %" if _en else "涨跌幅"}
|
|
2452
|
+
# Suppress "price" from missing list when we actually have a price to display —
|
|
2453
|
+
# it means the primary source (yfinance) failed but a fallback (Finnhub) succeeded.
|
|
2454
|
+
_filtered_missing = [
|
|
2455
|
+
f for f in _quality_missing
|
|
2456
|
+
if not (f == "price" and price is not None and price > 0)
|
|
2457
|
+
]
|
|
2458
|
+
_missing_labels = [_missing_map.get(f, f) for f in _filtered_missing]
|
|
2459
|
+
if _missing_labels:
|
|
2460
|
+
lines.append(f"- {_L['missing']}: {', '.join(_missing_labels)}")
|
|
2461
|
+
_user_warnings = []
|
|
2462
|
+
for w in (_quality_warnings + _quality_errors)[:2]:
|
|
2463
|
+
_w = str(w)
|
|
2464
|
+
if "rate" in _w.lower() or "429" in _w.lower() or "too many" in _w.lower():
|
|
2465
|
+
_user_warnings.append(_L["rate_warn"])
|
|
2466
|
+
elif "timeout" in _w.lower():
|
|
2467
|
+
_user_warnings.append(_L["timeout_warn"])
|
|
2468
|
+
elif "not found" in _w.lower() or "no data" in _w.lower():
|
|
2469
|
+
_user_warnings.append(_L["nodata_warn"])
|
|
2470
|
+
for _uw in dict.fromkeys(_user_warnings):
|
|
2471
|
+
lines.append(f"- {_uw}")
|
|
2472
|
+
|
|
2473
|
+
# ── Next actions ──
|
|
2474
|
+
lines.append("")
|
|
2475
|
+
lines.append(_L["next_hdr"])
|
|
2476
|
+
lines.append(f"- `/team {symbol}` — {_L['team_desc']}")
|
|
2477
|
+
lines.append(f"- `/ta {symbol}` — {_L['ta_desc']}")
|
|
2478
|
+
if _is_a_share:
|
|
2479
|
+
lines.append(f"- `/report {symbol}` — {_L['report_desc']}")
|
|
2480
|
+
else:
|
|
2481
|
+
lines.append(f"- `/backtest momentum {symbol} --period 1y` — {_L['backtest_desc']}")
|
|
2482
|
+
|
|
2483
|
+
return {
|
|
2484
|
+
"success": True,
|
|
2485
|
+
"response": "\n".join(lines),
|
|
2486
|
+
"tools_used": ["market_snapshot"],
|
|
2487
|
+
"analysis_complete": True,
|
|
2488
|
+
"symbol": symbol,
|
|
2489
|
+
"price": price,
|
|
2490
|
+
"change_pct": chg,
|
|
2491
|
+
"currency": currency,
|
|
2492
|
+
"name": name,
|
|
2493
|
+
"signal": signal,
|
|
2494
|
+
"signal_score": _signal_score,
|
|
2495
|
+
"signal_confidence": _signal_confidence,
|
|
2496
|
+
"rsi": rsi,
|
|
2497
|
+
"macd_hist": mhist,
|
|
2498
|
+
"ma20": ma20,
|
|
2499
|
+
"ma60": ma60,
|
|
2500
|
+
"bb_upper": bbu,
|
|
2501
|
+
"bb_lower": bbl,
|
|
2502
|
+
"support": support_str,
|
|
2503
|
+
"resistance": resistance_str,
|
|
2504
|
+
"supports": supports,
|
|
2505
|
+
"resistances": resistances,
|
|
2506
|
+
"timeframe_levels": timeframe_levels,
|
|
2507
|
+
"data_src": data_src,
|
|
2508
|
+
"as_of": _now_str,
|
|
2509
|
+
}
|