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.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
@@ -0,0 +1,1309 @@
1
+ """Stock chart analysis handlers extracted from aria_cli.py."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import time
7
+ from datetime import datetime
8
+ from typing import Callable
9
+
10
+ from apps.cli.plotly_html import plotly_script_tag
11
+
12
+
13
+ def _normalise_history_frame(hist):
14
+ """Return a clean OHLCV frame with title-case columns, or None."""
15
+ if hist is None or getattr(hist, "empty", True):
16
+ return None
17
+ try:
18
+ hist = hist.copy()
19
+ if getattr(hist, "columns", None) is not None:
20
+ try:
21
+ import pandas as _pd
22
+ if isinstance(hist.columns, _pd.MultiIndex):
23
+ hist.columns = hist.columns.droplevel(-1)
24
+ except Exception:
25
+ pass
26
+ hist.columns = [str(c).title() for c in hist.columns]
27
+ if "Close" not in hist.columns:
28
+ return None
29
+ hist = hist.dropna(subset=["Close"]).copy()
30
+ return hist if not hist.empty else None
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def _normalise_chart_symbol(symbol: str) -> str:
36
+ """Normalize common A-share inputs for chart providers."""
37
+ raw = (symbol or "").strip().upper()
38
+ if not raw:
39
+ return raw
40
+ if re.match(r"^\d{6}$", raw):
41
+ return f"{raw}.SS" if raw.startswith(("6", "9")) else f"{raw}.SZ"
42
+ m = re.match(r"^(?:SH|SZ)([036]\d{5}|68\d{4})$", raw)
43
+ if m:
44
+ code = m.group(1)
45
+ return f"{code}.SS" if raw.startswith("SH") else f"{code}.SZ"
46
+ if raw.endswith(".SH"):
47
+ return raw[:-3] + ".SS"
48
+ return raw
49
+
50
+
51
+ def _ashare_plain_symbol(symbol: str) -> str:
52
+ """Return 6-digit A-share code for akshare, or empty string."""
53
+ raw = (symbol or "").strip().upper()
54
+ m = re.match(r"^(?:SH|SZ)?([036]\d{5}|68\d{4})(?:\.(?:SS|SH|SZ))?$", raw)
55
+ return m.group(1) if m else ""
56
+
57
+
58
+ def _days_for_period(period: str) -> int:
59
+ return {
60
+ "1mo": 35, "1m": 35,
61
+ "3mo": 100, "3m": 100,
62
+ "6mo": 185, "6m": 185,
63
+ "ytd": 370,
64
+ "1y": 370,
65
+ "2y": 740,
66
+ "3y": 1100,
67
+ "5y": 1830,
68
+ "max": 7300,
69
+ }.get((period or "1y").lower(), 370)
70
+
71
+
72
+ def _fetch_akshare_history_frame(symbol: str, period: str):
73
+ """Fetch A-share history via akshare; returns (frame, currency, error)."""
74
+ code = _ashare_plain_symbol(symbol)
75
+ if not code:
76
+ return None, None, "not an A-share symbol"
77
+ try:
78
+ import pandas as _pd
79
+ import akshare as _ak
80
+ from datetime import datetime as _dt, timedelta as _td
81
+
82
+ end_date = _dt.now().strftime("%Y%m%d")
83
+ start_date = (_dt.now() - _td(days=_days_for_period(period))).strftime("%Y%m%d")
84
+ frame = _ak.stock_zh_a_hist(
85
+ symbol=code,
86
+ period="daily",
87
+ start_date=start_date,
88
+ end_date=end_date,
89
+ adjust="qfq",
90
+ )
91
+ if frame is None or frame.empty:
92
+ return None, None, "empty akshare result"
93
+ frame = frame.rename(columns={
94
+ "日期": "Date",
95
+ "开盘": "Open",
96
+ "最高": "High",
97
+ "最低": "Low",
98
+ "收盘": "Close",
99
+ "成交量": "Volume",
100
+ "成交额": "Turnover",
101
+ "振幅": "Amplitude",
102
+ "涨跌幅": "ChangePct",
103
+ "涨跌额": "Change",
104
+ "换手率": "TurnoverRate",
105
+ })
106
+ if "Date" in frame.columns:
107
+ frame["Date"] = _pd.to_datetime(frame["Date"])
108
+ frame = frame.set_index("Date")
109
+ frame = frame.sort_index()
110
+ for col in ("Open", "High", "Low", "Close", "Volume"):
111
+ if col in frame.columns:
112
+ frame[col] = _pd.to_numeric(frame[col], errors="coerce")
113
+ frame = _normalise_history_frame(frame)
114
+ return frame, "CNY", "" if frame is not None else "akshare frame missing Close"
115
+ except Exception as exc:
116
+ return None, None, str(exc)
117
+
118
+
119
+ def _fetch_mdc_history_frame(symbol: str, period: str, interval: str = "1d"):
120
+ """Fetch history through the unified MarketDataClient provider chain."""
121
+ code = _ashare_plain_symbol(symbol)
122
+ if not code:
123
+ return None, None, "", "not an A-share symbol"
124
+ try:
125
+ import pandas as _pd
126
+ from market_data_client import get_mdc
127
+
128
+ result = get_mdc().history(code, days=_days_for_period(period), interval=interval)
129
+ if not result or not result.get("success"):
130
+ return None, None, "", str((result or {}).get("error") or "empty MarketDataClient result")
131
+ records = result.get("data") or []
132
+ if not records:
133
+ return None, None, "", "MarketDataClient returned no bars"
134
+ frame = _pd.DataFrame(records).rename(columns={
135
+ "date": "Date",
136
+ "open": "Open",
137
+ "high": "High",
138
+ "low": "Low",
139
+ "close": "Close",
140
+ "volume": "Volume",
141
+ })
142
+ if "Date" in frame.columns:
143
+ frame["Date"] = _pd.to_datetime(frame["Date"])
144
+ frame = frame.set_index("Date")
145
+ for col in ("Open", "High", "Low", "Close", "Volume"):
146
+ if col in frame.columns:
147
+ frame[col] = _pd.to_numeric(frame[col], errors="coerce")
148
+ frame = _normalise_history_frame(frame)
149
+ provider_chain = result.get("provider_chain") or [result.get("provider", "market_data_client")]
150
+ provider = " → ".join(str(item) for item in provider_chain if item) or "market_data_client"
151
+ return frame, "CNY", provider, "" if frame is not None else "MarketDataClient frame missing Close"
152
+ except Exception as exc:
153
+ return None, None, "", str(exc)
154
+
155
+
156
+ def _fetch_yahoo_chart_frame(symbol: str, period: str, interval: str = "1d"):
157
+ """Fetch Yahoo chart API data without yfinance; returns (frame, currency, error)."""
158
+ try:
159
+ import pandas as _pd
160
+ import requests as _req
161
+ p2 = int(time.time())
162
+ days_by_period = {
163
+ "1mo": 35, "3mo": 100, "6mo": 185, "ytd": 370,
164
+ "1y": 370, "2y": 740, "3y": 1100, "5y": 1830, "max": 7300,
165
+ }
166
+ p1 = p2 - days_by_period.get(period, 370) * 86400
167
+ url = (
168
+ f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
169
+ f"?period1={p1}&period2={p2}&interval={interval}&events=history"
170
+ f"&includeAdjustedClose=true"
171
+ )
172
+ resp = _req.get(url, timeout=20, headers={"User-Agent": "Mozilla/5.0"})
173
+ resp.raise_for_status()
174
+ result = (resp.json().get("chart", {}).get("result") or [None])[0]
175
+ if not result:
176
+ return None, None, "empty Yahoo Chart result"
177
+ quote = ((result.get("indicators") or {}).get("quote") or [{}])[0]
178
+ timestamps = result.get("timestamp") or []
179
+ frame = _pd.DataFrame({
180
+ "Open": quote.get("open", []),
181
+ "High": quote.get("high", []),
182
+ "Low": quote.get("low", []),
183
+ "Close": quote.get("close", []),
184
+ "Volume": quote.get("volume", []),
185
+ }, index=_pd.to_datetime(timestamps, unit="s"))
186
+ frame = _normalise_history_frame(frame)
187
+ meta = result.get("meta") or {}
188
+ return frame, meta.get("currency"), ""
189
+ except Exception as exc:
190
+ return None, None, str(exc)
191
+
192
+
193
+ def _fmt_num(value, digits: int = 2, prefix: str = "") -> str:
194
+ try:
195
+ if value is None or (hasattr(value, "__class__") and str(value) == "nan"):
196
+ return "N/A"
197
+ return f"{prefix}{float(value):,.{digits}f}"
198
+ except Exception:
199
+ return "N/A"
200
+
201
+
202
+ def _fmt_int(value) -> str:
203
+ try:
204
+ return f"{int(float(value)):,}"
205
+ except Exception:
206
+ return "N/A"
207
+
208
+
209
+ def _write_ta_png_artifact(record, hist, symbol: str, name: str, currency: str,
210
+ is_ashare: bool, ma20, ma60, rsi14, macd_v, macd_s_val) -> tuple[str, str]:
211
+ """Write a compact PNG TA chart next to the HTML artifact when possible."""
212
+ try:
213
+ import matplotlib
214
+ matplotlib.use("Agg")
215
+ import matplotlib.pyplot as plt
216
+ from matplotlib.patches import Rectangle
217
+ import pandas as _pd
218
+ import math as _math
219
+ except Exception as exc:
220
+ return "", f"PNG 依赖不可用: {exc}"
221
+
222
+ try:
223
+ png_path = record.path.with_suffix(".png")
224
+ plot_df = hist.tail(180).copy()
225
+ x = list(range(len(plot_df)))
226
+ inc_color = "#dc2626" if is_ashare else "#16a34a"
227
+ dec_color = "#16a34a" if is_ashare else "#dc2626"
228
+
229
+ fig = plt.figure(figsize=(14, 9), dpi=150)
230
+ gs = fig.add_gridspec(4, 1, height_ratios=[4.2, 1.0, 1.2, 1.2], hspace=0.08)
231
+ ax_price = fig.add_subplot(gs[0])
232
+ ax_vol = fig.add_subplot(gs[1], sharex=ax_price)
233
+ ax_rsi = fig.add_subplot(gs[2], sharex=ax_price)
234
+ ax_macd = fig.add_subplot(gs[3], sharex=ax_price)
235
+
236
+ for i, row in enumerate(plot_df.itertuples()):
237
+ open_v = float(getattr(row, "Open", getattr(row, "Close")))
238
+ high_v = float(getattr(row, "High", getattr(row, "Close")))
239
+ low_v = float(getattr(row, "Low", getattr(row, "Close")))
240
+ close_v = float(getattr(row, "Close"))
241
+ color = inc_color if close_v >= open_v else dec_color
242
+ ax_price.vlines(i, low_v, high_v, color=color, linewidth=0.8)
243
+ body_low = min(open_v, close_v)
244
+ body_h = max(abs(close_v - open_v), max(close_v * 0.001, 0.01))
245
+ ax_price.add_patch(Rectangle((i - 0.32, body_low), 0.64, body_h,
246
+ facecolor=color, edgecolor=color, linewidth=0.6))
247
+
248
+ for col, color, label, lw in (
249
+ ("MA20", "#f59e0b", "MA20", 1.2),
250
+ ("MA60", "#ef4444", "MA60", 1.2),
251
+ ("BB_UP", "#6366f1", "BB upper", 0.8),
252
+ ("BB_LO", "#6366f1", "BB lower", 0.8),
253
+ ):
254
+ if col in plot_df.columns:
255
+ ax_price.plot(x, plot_df[col].astype(float), color=color, linewidth=lw,
256
+ linestyle="--" if col.startswith("BB_") else "-", label=label)
257
+
258
+ if "Volume" in plot_df.columns:
259
+ closes = plot_df["Close"].astype(float).tolist()
260
+ vols = plot_df["Volume"].fillna(0).astype(float).tolist()
261
+ colors = [inc_color if i == 0 or closes[i] >= closes[i - 1] else dec_color for i in range(len(closes))]
262
+ ax_vol.bar(x, vols, color=colors, width=0.75, alpha=0.65)
263
+
264
+ if "RSI14" in plot_df.columns:
265
+ ax_rsi.plot(x, plot_df["RSI14"].astype(float), color="#8b5cf6", linewidth=1.1)
266
+ ax_rsi.axhline(70, color=dec_color, linestyle=":", linewidth=0.8)
267
+ ax_rsi.axhline(30, color=inc_color, linestyle=":", linewidth=0.8)
268
+ ax_rsi.set_ylim(0, 100)
269
+
270
+ if "MACD_HIS" in plot_df.columns:
271
+ hist_vals = plot_df["MACD_HIS"].astype(float).tolist()
272
+ colors = [inc_color if v >= 0 else dec_color for v in hist_vals]
273
+ ax_macd.bar(x, hist_vals, color=colors, width=0.75, alpha=0.65)
274
+ if "MACD" in plot_df.columns:
275
+ ax_macd.plot(x, plot_df["MACD"].astype(float), color="#2563eb", linewidth=1.0, label="MACD")
276
+ if "MACD_SIG" in plot_df.columns:
277
+ ax_macd.plot(x, plot_df["MACD_SIG"].astype(float), color="#f59e0b", linewidth=1.0, linestyle="--", label="Signal")
278
+ ax_macd.axhline(0, color="#94a3b8", linewidth=0.7)
279
+
280
+ tick_step = max(1, len(plot_df) // 8)
281
+ tick_pos = list(range(0, len(plot_df), tick_step))
282
+ tick_labels = []
283
+ for idx in tick_pos:
284
+ raw = plot_df.index[idx]
285
+ tick_labels.append(raw.strftime("%Y-%m-%d") if hasattr(raw, "strftime") else str(raw)[:10])
286
+ ax_macd.set_xticks(tick_pos)
287
+ ax_macd.set_xticklabels(tick_labels, rotation=25, ha="right", fontsize=8)
288
+ for ax in (ax_price, ax_vol, ax_rsi, ax_macd):
289
+ ax.grid(True, color="#e5e7eb", linewidth=0.6, alpha=0.7)
290
+ ax.spines["top"].set_visible(False)
291
+ ax.spines["right"].set_visible(False)
292
+ ax_price.legend(loc="upper left", fontsize=8, ncols=4)
293
+ ax_price.set_ylabel(currency)
294
+ ax_vol.set_ylabel("Volume")
295
+ ax_rsi.set_ylabel("RSI")
296
+ ax_macd.set_ylabel("MACD")
297
+ title = f"{name} ({symbol}) TA"
298
+ subtitle = f"MA20={ma20:.2f}" if ma20 else "MA20=—"
299
+ if ma60:
300
+ subtitle += f" MA60={ma60:.2f}"
301
+ if rsi14:
302
+ subtitle += f" RSI={rsi14:.1f}"
303
+ if macd_v is not None and macd_s_val is not None:
304
+ subtitle += f" MACD={macd_v:.3f}/{macd_s_val:.3f}"
305
+ fig.suptitle(f"{title}\n{subtitle}", fontsize=12)
306
+ fig.savefig(png_path, bbox_inches="tight", facecolor="white")
307
+ plt.close(fig)
308
+ if not png_path.exists() or png_path.stat().st_size <= 0:
309
+ return "", "PNG 文件未生成"
310
+ return str(png_path), ""
311
+ except Exception as exc:
312
+ try:
313
+ plt.close("all")
314
+ except Exception:
315
+ pass
316
+ return "", str(exc)
317
+
318
+
319
+ def _review_chart(symbol: str, last_close: float, high_52w: float, low_52w: float,
320
+ rsi14, ma20, ma60, bb_up, bb_lo, sup3, res3, n_bars: int) -> list[str]:
321
+ """
322
+ 自审函数:检查图表数据质量,返回问题列表(空列表 = 通过)。
323
+ 在 cmd_chart 中调用,用于发现并反馈图表异常。
324
+ """
325
+ issues = []
326
+ if last_close <= 0:
327
+ issues.append("价格数据异常(收盘价 ≤ 0)")
328
+ if n_bars < 20:
329
+ issues.append(f"历史数据不足 20 根 K 线(仅 {n_bars} 根),指标不可靠")
330
+ if rsi14 is None:
331
+ issues.append("RSI 计算失败(数据可能不足 14 根)")
332
+ elif not (0 < rsi14 < 100):
333
+ issues.append(f"RSI 值异常: {rsi14:.1f}(应在 0-100 之间)")
334
+ if ma20 and last_close > 0:
335
+ if abs(ma20 / last_close - 1) > 0.5:
336
+ issues.append(f"MA20 偏离价格超 50%,数据可能存在复权误差(MA20={ma20:.2f} 价格={last_close:.2f})")
337
+ if bb_up and bb_lo and bb_up <= bb_lo:
338
+ issues.append("布林带上下轨计算倒置(BB_UP <= BB_LO)")
339
+ if sup3 and min(sup3) >= last_close:
340
+ issues.append("支撑位计算有误(支撑位不应高于现价)")
341
+ if res3 and max(res3) <= last_close:
342
+ issues.append("阻力位计算有误(阻力位不应低于现价)")
343
+ price_range_pct = (high_52w - low_52w) / low_52w * 100 if low_52w > 0 else 0
344
+ if price_range_pct > 1000:
345
+ issues.append(f"52周价格波动超 1000%({price_range_pct:.0f}%),可能存在股票分拆/复权问题")
346
+ return issues
347
+
348
+
349
+ def handle_multi_stock_comparison_direct(symbols: list[str], period: str = "1y") -> dict:
350
+ """Generate a normalized multi-symbol performance comparison chart."""
351
+ try:
352
+ import pandas as _pd
353
+ except Exception as exc:
354
+ return {"success": False, "error": f"缺少依赖: {exc}"}
355
+
356
+ _PERIOD_MAP = {
357
+ "1m": "1mo", "3m": "3mo", "6m": "6mo",
358
+ "1y": "1y", "2y": "2y", "3y": "3y", "5y": "5y",
359
+ "ytd": "ytd", "max": "max",
360
+ }
361
+ period = _PERIOD_MAP.get(str(period or "1y").lower(), str(period or "1y").lower())
362
+ clean_symbols: list[str] = []
363
+ for sym in symbols or []:
364
+ normalized = _normalise_chart_symbol(str(sym or "").strip().upper())
365
+ if normalized and normalized not in clean_symbols:
366
+ clean_symbols.append(normalized)
367
+ if len(clean_symbols) < 2:
368
+ return {"success": False, "error": "至少需要两个标的才能生成对比图"}
369
+
370
+ series: dict[str, _pd.Series] = {}
371
+ raw_rows: dict[str, list[dict]] = {}
372
+ providers: dict[str, str] = {}
373
+ errors: dict[str, str] = {}
374
+
375
+ for symbol in clean_symbols:
376
+ hist = None
377
+ currency = None
378
+ provider = ""
379
+ err = ""
380
+ if _ashare_plain_symbol(symbol):
381
+ hist, currency, provider, err = _fetch_mdc_history_frame(symbol, period, "1d")
382
+ if hist is None or hist.empty:
383
+ hist, currency, err = _fetch_yahoo_chart_frame(symbol, period, "1d")
384
+ provider = "Yahoo Chart API" if hist is not None and not hist.empty else provider
385
+ hist = _normalise_history_frame(hist)
386
+ if hist is None or hist.empty or "Close" not in hist.columns:
387
+ errors[symbol] = err or "empty history"
388
+ continue
389
+ closes = _pd.to_numeric(hist["Close"], errors="coerce").dropna()
390
+ if len(closes) < 5:
391
+ errors[symbol] = "not enough close prices"
392
+ continue
393
+ series[symbol] = closes
394
+ providers[symbol] = provider or "market data"
395
+ rows = []
396
+ for idx, row in hist.tail(370).iterrows():
397
+ rows.append({
398
+ "date": idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx),
399
+ "open": None if _pd.isna(row.get("Open")) else float(row.get("Open")),
400
+ "high": None if _pd.isna(row.get("High")) else float(row.get("High")),
401
+ "low": None if _pd.isna(row.get("Low")) else float(row.get("Low")),
402
+ "close": None if _pd.isna(row.get("Close")) else float(row.get("Close")),
403
+ "volume": None if _pd.isna(row.get("Volume")) else float(row.get("Volume")),
404
+ "currency": currency,
405
+ })
406
+ raw_rows[symbol] = rows
407
+
408
+ if len(series) < 2:
409
+ return {
410
+ "success": False,
411
+ "error": "可用历史数据不足,无法生成对比图",
412
+ "errors": errors,
413
+ }
414
+
415
+ closes_df = _pd.concat(series, axis=1).dropna(how="all").ffill().dropna()
416
+ normalized_df = closes_df.divide(closes_df.iloc[0]).multiply(100.0)
417
+ returns_df = closes_df.pct_change().dropna()
418
+
419
+ metrics = []
420
+ for symbol in normalized_df.columns:
421
+ norm = normalized_df[symbol].dropna()
422
+ rets = returns_df[symbol].dropna() if symbol in returns_df else _pd.Series(dtype=float)
423
+ drawdown = norm / norm.cummax() - 1.0
424
+ metrics.append({
425
+ "symbol": symbol,
426
+ "total_return_pct": round(float(norm.iloc[-1] - 100.0), 2),
427
+ "volatility_pct": round(float(rets.std() * (252 ** 0.5) * 100), 2) if len(rets) else 0.0,
428
+ "max_drawdown_pct": round(float(drawdown.min() * 100), 2) if len(drawdown) else 0.0,
429
+ "last_close": round(float(closes_df[symbol].dropna().iloc[-1]), 4),
430
+ })
431
+ metrics.sort(key=lambda row: row["total_return_pct"], reverse=True)
432
+
433
+ dates = [idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx) for idx in normalized_df.index]
434
+ traces = []
435
+ for symbol in normalized_df.columns:
436
+ traces.append({
437
+ "x": dates,
438
+ "y": [None if _pd.isna(v) else round(float(v), 4) for v in normalized_df[symbol].tolist()],
439
+ "type": "scatter",
440
+ "mode": "lines",
441
+ "name": symbol,
442
+ "line": {"width": 2},
443
+ })
444
+
445
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", "_".join(normalized_df.columns))
446
+ from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
447
+ artifact = create_user_artifact("stock-charts", safe, f"{safe}_comparison", ".html")
448
+ out_file = artifact.path
449
+ metrics_rows = "".join(
450
+ "<tr>"
451
+ f"<td>{m['symbol']}</td>"
452
+ f"<td>{m['total_return_pct']:+.2f}%</td>"
453
+ f"<td>{m['volatility_pct']:.2f}%</td>"
454
+ f"<td>{m['max_drawdown_pct']:.2f}%</td>"
455
+ f"<td>{m['last_close']}</td>"
456
+ "</tr>"
457
+ for m in metrics
458
+ )
459
+ html_doc = f"""<!doctype html>
460
+ <html lang="zh-CN">
461
+ <head>
462
+ <meta charset="utf-8">
463
+ <title>{' vs '.join(normalized_df.columns)} comparison</title>
464
+ {plotly_script_tag()}
465
+ <style>
466
+ body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;color:#111827}}
467
+ h1{{font-size:20px;margin:0 0 8px}}
468
+ .meta{{color:#6b7280;margin-bottom:18px}}
469
+ #chart{{height:620px}}
470
+ table{{border-collapse:collapse;margin-top:18px;font-size:13px}}
471
+ th,td{{border-bottom:1px solid #e5e7eb;padding:8px 12px;text-align:right}}
472
+ th:first-child,td:first-child{{text-align:left}}
473
+ </style>
474
+ </head>
475
+ <body>
476
+ <h1>{' / '.join(normalized_df.columns)} 标准化收益对比</h1>
477
+ <div class="meta">Period: {period} · base=100 · providers: {', '.join(sorted(set(providers.values())))}</div>
478
+ <div id="chart"></div>
479
+ <table>
480
+ <thead><tr><th>Symbol</th><th>Total Return</th><th>Ann. Vol</th><th>Max Drawdown</th><th>Last Close</th></tr></thead>
481
+ <tbody>{metrics_rows}</tbody>
482
+ </table>
483
+ <script>
484
+ const traces = {json.dumps(traces, ensure_ascii=False)};
485
+ Plotly.newPlot("chart", traces, {{
486
+ margin: {{l: 48, r: 24, t: 18, b: 42}},
487
+ hovermode: "x unified",
488
+ yaxis: {{title: "Normalized value (base=100)", gridcolor: "#eef2f7"}},
489
+ xaxis: {{type: "date", gridcolor: "#eef2f7"}},
490
+ legend: {{orientation: "h", y: 1.08}},
491
+ paper_bgcolor: "#fff",
492
+ plot_bgcolor: "#fff"
493
+ }}, {{responsive: true, displaylogo: false}});
494
+ </script>
495
+ </body>
496
+ </html>"""
497
+ out_file.write_text(html_doc, encoding="utf-8")
498
+ write_artifact_metadata(artifact, {
499
+ "kind": "stock_comparison_chart",
500
+ "status": "complete",
501
+ "symbols": list(normalized_df.columns),
502
+ "created_at": datetime.now().isoformat(timespec="seconds"),
503
+ "data": {"period": period, "provider_chain": sorted(set(providers.values())), "errors": errors},
504
+ "metrics": metrics,
505
+ "outputs": {"html": str(out_file)},
506
+ })
507
+ write_artifact_raw_data(artifact, {
508
+ "symbols": list(normalized_df.columns),
509
+ "prices": raw_rows,
510
+ "normalized": {
511
+ symbol: [
512
+ {"date": date, "value": value}
513
+ for date, value in zip(dates, traces[idx]["y"])
514
+ ]
515
+ for idx, symbol in enumerate(normalized_df.columns)
516
+ },
517
+ })
518
+ return {
519
+ "success": True,
520
+ "chart_path": str(out_file),
521
+ "symbols": list(normalized_df.columns),
522
+ "metrics": metrics,
523
+ "provider": ", ".join(sorted(set(providers.values()))),
524
+ "errors": errors,
525
+ }
526
+
527
+
528
+ def handle_stock_chart_analysis_direct(symbol: str, period: str = "1y") -> dict:
529
+ """
530
+ 生成专业股票分析图表 (HTML),并自审数据质量。
531
+ 四面板:K线+均线+布林带 / 成交量 / RSI(14) / MACD柱状图
532
+ A股(.SS/.SZ)自动切换红涨绿跌配色。
533
+ """
534
+ import html as _html
535
+ import math
536
+ try:
537
+ import pandas as _pd
538
+ except Exception as exc:
539
+ return {"success": False, "error": f"缺少依赖: {exc}"}
540
+ try:
541
+ import yfinance as _yf
542
+ except Exception:
543
+ _yf = None
544
+
545
+ _PERIOD_MAP = {
546
+ "1m": "1mo", "3m": "3mo", "6m": "6mo",
547
+ "1y": "1y", "2y": "2y", "3y": "3y", "5y": "5y",
548
+ "ytd": "ytd", "max": "max",
549
+ }
550
+ period = _PERIOD_MAP.get(period.lower(), period)
551
+ symbol = _normalise_chart_symbol(symbol)
552
+
553
+ # A股判断(影响K线颜色惯例)
554
+ is_ashare = bool(_ashare_plain_symbol(symbol))
555
+
556
+ # ── 获取历史数据 ────────────────────────────────────────────────────────────
557
+ hist = None
558
+ provider = "Yahoo Finance"
559
+ provider_currency = None
560
+ err1 = ""
561
+ mdc_err = ""
562
+ ak_err = ""
563
+ chart_err = ""
564
+ ticker = None
565
+
566
+ if is_ashare:
567
+ hist, provider_currency, mdc_provider, mdc_err = _fetch_mdc_history_frame(symbol, period, "1d")
568
+ if hist is not None and not hist.empty:
569
+ provider = mdc_provider or "market_data_client"
570
+
571
+ if is_ashare and (hist is None or hist.empty):
572
+ hist, provider_currency, ak_err = _fetch_akshare_history_frame(symbol, period)
573
+ if hist is not None and not hist.empty:
574
+ provider = "akshare"
575
+
576
+ if (hist is None or hist.empty) and _yf is not None:
577
+ try:
578
+ ticker = _yf.Ticker(symbol)
579
+ hist = ticker.history(period=period, interval="1d", auto_adjust=True)
580
+ hist = _normalise_history_frame(hist)
581
+ if hist is not None and not hist.empty:
582
+ provider = "Yahoo Finance"
583
+ except Exception as exc:
584
+ err1 = str(exc)
585
+
586
+ if hist is None or hist.empty:
587
+ hist, provider_currency, chart_err = _fetch_yahoo_chart_frame(symbol, period, "1d")
588
+ if hist is None or hist.empty:
589
+ return {
590
+ "success": False,
591
+ "error": (
592
+ f"无法获取 {symbol} 数据: "
593
+ f"MarketDataClient={mdc_err or 'skipped'}; "
594
+ f"akshare={ak_err or 'skipped'}; "
595
+ f"yfinance={err1 or ('missing' if _yf is None else 'empty')}; "
596
+ f"YahooChart={chart_err or 'empty'}"
597
+ ),
598
+ }
599
+ provider = "Yahoo Chart API"
600
+
601
+ if hist is None or hist.empty:
602
+ return {"success": False, "error": f"无法获取 {symbol} 历史数据"}
603
+
604
+ hist = hist.dropna(subset=["Close"]).copy()
605
+
606
+ # ── 指标计算 ────────────────────────────────────────────────────────────────
607
+ hist["MA20"] = hist["Close"].rolling(20).mean()
608
+ hist["MA60"] = hist["Close"].rolling(60).mean()
609
+ _std20 = hist["Close"].rolling(20).std()
610
+ hist["BB_UP"] = hist["MA20"] + 2 * _std20
611
+ hist["BB_LO"] = hist["MA20"] - 2 * _std20
612
+ _delta = hist["Close"].diff()
613
+ _gain = _delta.clip(lower=0).rolling(14).mean()
614
+ _loss = (-_delta.clip(upper=0)).rolling(14).mean()
615
+ hist["RSI14"] = 100 - (100 / (1 + _gain / _loss.replace(0, _pd.NA)))
616
+ _ema12 = hist["Close"].ewm(span=12, adjust=False).mean()
617
+ _ema26 = hist["Close"].ewm(span=26, adjust=False).mean()
618
+ hist["MACD"] = _ema12 - _ema26
619
+ hist["MACD_SIG"]= hist["MACD"].ewm(span=9, adjust=False).mean()
620
+ hist["MACD_HIS"]= hist["MACD"] - hist["MACD_SIG"]
621
+
622
+ last = hist.iloc[-1]
623
+ last_close = float(last["Close"])
624
+ high_52w = float(hist["High"].max()) if "High" in hist.columns else float(hist["Close"].max())
625
+ low_52w = float(hist["Low"].min()) if "Low" in hist.columns else float(hist["Close"].min())
626
+ ma20 = float(last["MA20"]) if _pd.notna(last["MA20"]) else None
627
+ ma60 = float(last["MA60"]) if _pd.notna(last["MA60"]) else None
628
+ bb_up = float(last["BB_UP"]) if _pd.notna(last["BB_UP"]) else None
629
+ bb_lo = float(last["BB_LO"]) if _pd.notna(last["BB_LO"]) else None
630
+ rsi14 = float(last["RSI14"]) if _pd.notna(last["RSI14"]) else None
631
+ macd_v = float(last["MACD"]) if _pd.notna(last["MACD"]) else None
632
+ macd_s_val = float(last["MACD_SIG"]) if _pd.notna(last["MACD_SIG"]) else None
633
+
634
+ # ── 支撑/阻力(10棒摆动点,去重后取最近3个)──────────────────────────────
635
+ _sup_lvls: list[float] = []
636
+ _res_lvls: list[float] = []
637
+ if "High" in hist.columns and "Low" in hist.columns and len(hist) >= 20:
638
+ _win = 10 # 10棒窗口过滤噪音,比5棒更稳健
639
+ _h = hist["High"].values
640
+ _l = hist["Low"].values
641
+ for i in range(_win, len(hist) - _win):
642
+ if float(_h[i]) == float(max(_h[i - _win:i + _win + 1])):
643
+ _res_lvls.append(float(_h[i]))
644
+ if float(_l[i]) == float(min(_l[i - _win:i + _win + 1])):
645
+ _sup_lvls.append(float(_l[i]))
646
+ # MA 作为动态支撑/阻力
647
+ if ma20:
648
+ (_sup_lvls if last_close > ma20 else _res_lvls).append(ma20)
649
+ if ma60:
650
+ (_sup_lvls if last_close > ma60 else _res_lvls).append(ma60)
651
+ # 布林带
652
+ if bb_lo:
653
+ _sup_lvls.append(bb_lo)
654
+ if bb_up:
655
+ _res_lvls.append(bb_up)
656
+ sup3 = sorted(set(round(v, 2) for v in _sup_lvls if v < last_close), reverse=True)[:3]
657
+ res3 = sorted(set(round(v, 2) for v in _res_lvls if v > last_close))[:3]
658
+
659
+ # ── 基本面 ──────────────────────────────────────────────────────────────────
660
+ info = {}
661
+ if ticker is None and _yf is not None:
662
+ try:
663
+ ticker = _yf.Ticker(symbol)
664
+ except Exception:
665
+ ticker = None
666
+ try:
667
+ info = ticker.get_info() or {} if ticker is not None else {}
668
+ except Exception:
669
+ pass
670
+ name = info.get("longName") or info.get("shortName") or symbol
671
+ currency = info.get("currency") or provider_currency or ("CNY" if is_ashare else "USD")
672
+ pe = info.get("trailingPE")
673
+ pb = info.get("priceToBook")
674
+ roe = info.get("returnOnEquity")
675
+ div_yield = info.get("trailingAnnualDividendYield") or info.get("dividendYield")
676
+ market_cap = info.get("marketCap")
677
+
678
+ def _fv(v, mult=1.0, pct=False):
679
+ if v is None or (isinstance(v, float) and (math.isnan(v) or v == 0)):
680
+ return "—"
681
+ x = float(v) * mult
682
+ return f"{x:.2f}%" if pct else f"{x:,.2f}"
683
+
684
+ def _mcap(v):
685
+ if not v:
686
+ return "—"
687
+ if v >= 1e12: return f"{v/1e12:.2f}T"
688
+ if v >= 1e9: return f"{v/1e9:.1f}B"
689
+ if v >= 1e8: return f"{v/1e8:.1f}亿"
690
+ return f"{v:,.0f}"
691
+
692
+ trend = ("偏多" if ma20 and ma60 and last_close > ma20 > ma60 else
693
+ "偏空" if ma20 and ma60 and last_close < ma20 < ma60 else "震荡")
694
+ rsi_view = ("超买" if rsi14 and rsi14 >= 70 else "超卖" if rsi14 and rsi14 <= 30 else "中性")
695
+ momentum = "MACD↑多" if macd_v and macd_s_val and macd_v > macd_s_val else "MACD↓弱"
696
+
697
+ # ── K线颜色惯例 ────────────────────────────────────────────────────────────
698
+ # 中国A股:红涨绿跌 | 美股/港股/加密:绿涨红跌
699
+ if is_ashare:
700
+ inc_color = "#dc2626" # 红 = 涨
701
+ dec_color = "#16a34a" # 绿 = 跌
702
+ vol_up_c = "rgba(220,38,38,0.75)"
703
+ vol_dn_c = "rgba(22,163,74,0.75)"
704
+ macd_pos = "rgba(220,38,38,0.75)"
705
+ macd_neg = "rgba(22,163,74,0.75)"
706
+ else:
707
+ inc_color = "#16a34a" # 绿 = 涨
708
+ dec_color = "#dc2626" # 红 = 跌
709
+ vol_up_c = "rgba(22,163,74,0.75)"
710
+ vol_dn_c = "rgba(220,38,38,0.75)"
711
+ macd_pos = "rgba(22,163,74,0.75)"
712
+ macd_neg = "rgba(220,38,38,0.75)"
713
+
714
+ # ── 序列化 ──────────────────────────────────────────────────────────────────
715
+ def _ser(col):
716
+ values = []
717
+ for v in hist[col]:
718
+ try:
719
+ if v is None:
720
+ values.append(None)
721
+ continue
722
+ if hasattr(v, "__class__") and str(v) in {"<NA>", "NaT"}:
723
+ values.append(None)
724
+ continue
725
+ fv = float(v)
726
+ if math.isnan(fv):
727
+ values.append(None)
728
+ else:
729
+ values.append(round(fv, 4))
730
+ except Exception:
731
+ values.append(None)
732
+ return json.dumps(values)
733
+
734
+ def _ser_int(col):
735
+ if col not in hist.columns:
736
+ return "[]"
737
+ return json.dumps([None if (v is None or (isinstance(v, float) and math.isnan(v)))
738
+ else int(float(v)) for v in hist[col]])
739
+
740
+ x_dates = json.dumps([idx.strftime("%Y-%m-%d") for idx in hist.index])
741
+ open_s = _ser("Open") if "Open" in hist.columns else _ser("Close")
742
+ high_s = _ser("High") if "High" in hist.columns else _ser("Close")
743
+ low_s = _ser("Low") if "Low" in hist.columns else _ser("Close")
744
+ close_s = _ser("Close")
745
+ vol_s = _ser_int("Volume")
746
+ ma20_s = _ser("MA20")
747
+ ma60_s = _ser("MA60")
748
+ bbup_s = _ser("BB_UP")
749
+ bblo_s = _ser("BB_LO")
750
+ rsi_s = _ser("RSI14")
751
+ macd_s2 = _ser("MACD")
752
+ macds_s = _ser("MACD_SIG")
753
+ macdh_s = _ser("MACD_HIS")
754
+
755
+ # 成交量/MACD颜色(JSON 串)
756
+ closes = hist["Close"].values
757
+ vol_colors = json.dumps([
758
+ vol_up_c if (i > 0 and not math.isnan(float(closes[i])) and
759
+ float(closes[i]) >= float(closes[i-1])) else vol_dn_c
760
+ for i in range(len(closes))
761
+ ])
762
+ macd_colors = json.dumps([
763
+ macd_pos if (v is not None and not math.isnan(float(v)) and float(v) >= 0) else macd_neg
764
+ for v in hist["MACD_HIS"].values
765
+ ])
766
+
767
+ # 支撑/阻力 shapes(只绘制在价格面板 y轴内)
768
+ sup_shapes = "".join(
769
+ f'{{type:"line",xref:"paper",x0:0,x1:1,yref:"y",y0:{v},y1:{v},'
770
+ f'line:{{color:"#22c55e",width:1.2,dash:"dot"}}}},'
771
+ for v in sup3
772
+ )
773
+ res_shapes = "".join(
774
+ f'{{type:"line",xref:"paper",x0:0,x1:1,yref:"y",y0:{v},y1:{v},'
775
+ f'line:{{color:"#f97316",width:1.2,dash:"dot"}}}},'
776
+ for v in res3
777
+ )
778
+
779
+ # ── 自审 ────────────────────────────────────────────────────────────────────
780
+ review_issues = _review_chart(
781
+ symbol, last_close, high_52w, low_52w,
782
+ rsi14, ma20, ma60, bb_up, bb_lo, sup3, res3, len(hist)
783
+ )
784
+
785
+ # ── 生成 HTML ────────────────────────────────────────────────────────────────
786
+ safe_sym = re.sub(r"[^A-Za-z0-9_.-]+", "_", symbol)
787
+ from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
788
+ _artifact = create_user_artifact("stock-charts", symbol, f"{safe_sym}_chart", ".html")
789
+ out_file = _artifact.path
790
+
791
+ # 配色标注
792
+ color_note = "红涨绿跌(A股惯例)" if is_ashare else "绿涨红跌(国际惯例)"
793
+ rsi_color = "red" if rsi14 and rsi14 >= 70 else ("green" if rsi14 and rsi14 <= 30 else "")
794
+ cg = lambda ok: "green" if ok else "red"
795
+
796
+ cards_html = f"""
797
+ <div class="card"><div class="lbl">最新收盘</div><div class="val">{currency} {last_close:,.2f}</div></div>
798
+ <div class="card"><div class="lbl">52周区间</div><div class="val small">{low_52w:,.2f} — {high_52w:,.2f}</div></div>
799
+ <div class="card"><div class="lbl">MA20</div><div class="val {cg(ma20 and last_close>ma20)}">{f'{ma20:,.2f}' if ma20 else '—'}</div></div>
800
+ <div class="card"><div class="lbl">MA60</div><div class="val {cg(ma60 and last_close>ma60)}">{f'{ma60:,.2f}' if ma60 else '—'}</div></div>
801
+ <div class="card"><div class="lbl">布林上/下轨</div><div class="val small">{f'{bb_up:,.2f}' if bb_up else '—'} / {f'{bb_lo:,.2f}' if bb_lo else '—'}</div></div>
802
+ <div class="card"><div class="lbl">RSI(14)</div><div class="val {rsi_color}">{f'{rsi14:.1f}' if rsi14 else '—'} {rsi_view}</div></div>
803
+ <div class="card"><div class="lbl">趋势 / 动能</div><div class="val">{trend} · {momentum}</div></div>
804
+ <div class="card"><div class="lbl">P/E</div><div class="val">{_fv(pe)}</div></div>
805
+ <div class="card"><div class="lbl">P/B</div><div class="val">{_fv(pb)}</div></div>
806
+ <div class="card"><div class="lbl">ROE</div><div class="val">{_fv(roe, 100, pct=True)}</div></div>
807
+ <div class="card"><div class="lbl">股息率</div><div class="val">{_fv(div_yield, 100, pct=True)}</div></div>
808
+ <div class="card"><div class="lbl">市值</div><div class="val">{_mcap(market_cap)}</div></div>"""
809
+ if sup3:
810
+ cards_html += f'\n <div class="card sup"><div class="lbl">支撑位</div><div class="val small">{" / ".join(str(v) for v in sup3)}</div></div>'
811
+ if res3:
812
+ cards_html += f'\n <div class="card res"><div class="lbl">阻力位</div><div class="val small">{" / ".join(str(v) for v in res3)}</div></div>'
813
+
814
+ warn_html = ""
815
+ if review_issues:
816
+ warn_items = "".join(f"<li>{_html.escape(iss)}</li>" for iss in review_issues)
817
+ warn_html = f'<div class="warn"><strong>⚠ 图表自审发现 {len(review_issues)} 个问题:</strong><ul>{warn_items}</ul></div>'
818
+
819
+ html_doc = f"""<!doctype html>
820
+ <html lang="zh-CN">
821
+ <head>
822
+ <meta charset="utf-8">
823
+ <meta name="viewport" content="width=device-width,initial-scale=1">
824
+ <title>{_html.escape(name)} ({_html.escape(symbol)}) 分析图表</title>
825
+ {plotly_script_tag()}
826
+ <style>
827
+ *{{box-sizing:border-box}}
828
+ body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f0f2f5;color:#17202a}}
829
+ main{{max-width:1320px;margin:0 auto;padding:18px 20px}}
830
+ h1{{margin:0 0 2px;font-size:21px;font-weight:700}}
831
+ .meta{{color:#6b7280;font-size:11.5px;margin-bottom:12px}}
832
+ .cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:7px;margin-bottom:12px}}
833
+ .card{{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:9px 11px}}
834
+ .card.sup{{border-left:3px solid #22c55e}} .card.res{{border-left:3px solid #f97316}}
835
+ .lbl{{color:#6b7280;font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:.3px}}
836
+ .val{{font-size:14px;font-weight:700;margin-top:2px}} .val.small{{font-size:11.5px;font-weight:600}}
837
+ .green{{color:#16a34a}} .red{{color:#dc2626}}
838
+ #chart{{background:#fff;border:1px solid #e5e7eb;border-radius:10px}}
839
+ .footer{{color:#9ca3af;font-size:11px;margin-top:8px;text-align:center}}
840
+ .warn{{background:#fef3c7;border:1px solid #f59e0b;border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:12px;color:#92400e}}
841
+ .warn ul{{margin:4px 0 0 16px;padding:0}}
842
+ </style>
843
+ </head>
844
+ <body>
845
+ <main>
846
+ <h1>{_html.escape(name)} <span style="font-weight:400;color:#6b7280">({_html.escape(symbol)})</span></h1>
847
+ <p class="meta">生成: {datetime.now():%Y-%m-%d %H:%M} · 数据: Yahoo Finance · 周期: {period} · 配色: {color_note} · Aria Code</p>
848
+ {warn_html}<div class="cards">{cards_html}
849
+ </div>
850
+ <div id="chart"></div>
851
+ <p class="footer">⚠ 仅供参考,不构成投资建议。&nbsp; 绿虚线=支撑位 &nbsp;|&nbsp; 橙虚线=阻力位</p>
852
+ </main>
853
+ <script>
854
+ const x = {x_dates};
855
+ const op = {open_s};
856
+ const hi = {high_s};
857
+ const lo = {low_s};
858
+ const cl = {close_s};
859
+ const vol = {vol_s};
860
+ const volClr = {vol_colors};
861
+ const ma20 = {ma20_s};
862
+ const ma60 = {ma60_s};
863
+ const bbUp = {bbup_s};
864
+ const bbLo = {bblo_s};
865
+ const rsi = {rsi_s};
866
+ const macd = {macd_s2};
867
+ const macdSg = {macds_s};
868
+ const macdHi = {macdh_s};
869
+ const macdHiClr = {macd_colors};
870
+
871
+ const traces = [
872
+ /* K线 */
873
+ {{x,open:op,high:hi,low:lo,close:cl,type:"candlestick",name:"K线",
874
+ increasing:{{line:{{color:"{inc_color}"}},fillcolor:"{inc_color}"}},
875
+ decreasing:{{line:{{color:"{dec_color}"}},fillcolor:"{dec_color}"}},
876
+ yaxis:"y",whiskerwidth:0.3}},
877
+ /* 布林上轨 */
878
+ {{x,y:bbUp,type:"scatter",mode:"lines",name:"BB上轨",
879
+ line:{{color:"rgba(99,102,241,0.6)",width:1}},yaxis:"y"}},
880
+ /* 布林下轨(填充,hover隐藏避免重复) */
881
+ {{x,y:bbLo,type:"scatter",mode:"lines",name:"BB下轨",
882
+ line:{{color:"rgba(99,102,241,0.6)",width:1}},
883
+ fill:"tonexty",fillcolor:"rgba(99,102,241,0.07)",
884
+ showlegend:false,hoverinfo:"skip",yaxis:"y"}},
885
+ /* MA20 */
886
+ {{x,y:ma20,type:"scatter",mode:"lines",name:"MA20",
887
+ line:{{color:"#f59e0b",width:1.5}},yaxis:"y"}},
888
+ /* MA60 */
889
+ {{x,y:ma60,type:"scatter",mode:"lines",name:"MA60",
890
+ line:{{color:"#ef4444",width:1.5,dash:"dot"}},yaxis:"y"}},
891
+ /* 成交量 */
892
+ {{x,y:vol,type:"bar",name:"成交量",marker:{{color:volClr}},yaxis:"y2",showlegend:false}},
893
+ /* RSI */
894
+ {{x,y:rsi,type:"scatter",mode:"lines",name:"RSI(14)",
895
+ line:{{color:"#8b5cf6",width:1.5}},yaxis:"y3"}},
896
+ /* MACD 柱 */
897
+ {{x,y:macdHi,type:"bar",name:"MACD柱",marker:{{color:macdHiClr}},yaxis:"y4",showlegend:false}},
898
+ /* MACD 线 */
899
+ {{x,y:macd,type:"scatter",mode:"lines",name:"MACD",
900
+ line:{{color:"#2563eb",width:1.5}},yaxis:"y4"}},
901
+ /* Signal 线 */
902
+ {{x,y:macdSg,type:"scatter",mode:"lines",name:"Signal",
903
+ line:{{color:"#f59e0b",width:1.5,dash:"dot"}},yaxis:"y4"}}
904
+ ];
905
+
906
+ const layout = {{
907
+ height:820,
908
+ /* 右边距加大:确保Y轴完整数字不被截断 */
909
+ margin:{{l:8,r:80,t:14,b:28}},
910
+ paper_bgcolor:"#fff",plot_bgcolor:"#fff",
911
+ hovermode:"x unified",
912
+ legend:{{orientation:"h",y:1.025,x:0,font:{{size:11}},bgcolor:"rgba(255,255,255,0.8)"}},
913
+ xaxis:{{domain:[0,1],type:"date",rangeslider:{{visible:false}},
914
+ gridcolor:"#f1f5f9",showgrid:true}},
915
+ /* 面板分配:价格60% / 成交量11% / RSI10% / MACD11% */
916
+ yaxis: {{domain:[0.25,1], side:"right",gridcolor:"#f1f5f9",
917
+ title:{{text:"价格 ({currency})",font:{{size:11}}}},tickfont:{{size:11}}}},
918
+ yaxis2:{{domain:[0.145,0.22],side:"right",gridcolor:"#f1f5f9",
919
+ showticklabels:false,title:""}},
920
+ yaxis3:{{domain:[0.075,0.135],side:"right",range:[0,100],gridcolor:"#f1f5f9",
921
+ title:{{text:"RSI",font:{{size:11}}}},tickfont:{{size:10}}}},
922
+ yaxis4:{{domain:[0,0.065], side:"right",gridcolor:"#f1f5f9",
923
+ title:{{text:"MACD",font:{{size:11}}}},tickfont:{{size:10}}}},
924
+ shapes:[
925
+ {{type:"line",xref:"paper",x0:0,x1:1,yref:"y3",y0:70,y1:70,
926
+ line:{{color:"rgba(220,38,38,0.6)",width:1,dash:"dot"}}}},
927
+ {{type:"line",xref:"paper",x0:0,x1:1,yref:"y3",y0:30,y1:30,
928
+ line:{{color:"rgba(22,163,74,0.6)",width:1,dash:"dot"}}}},
929
+ {{type:"line",xref:"paper",x0:0,x1:1,yref:"y4",y0:0,y1:0,
930
+ line:{{color:"#94a3b8",width:0.8}}}},
931
+ {sup_shapes}{res_shapes}
932
+ ]
933
+ }};
934
+ Plotly.newPlot("chart",traces,layout,{{responsive:true,displaylogo:false,
935
+ modeBarButtonsToRemove:["autoScale2d","lasso2d","select2d"]}});
936
+ </script>
937
+ </body>
938
+ </html>"""
939
+
940
+ out_file.write_text(html_doc, encoding="utf-8")
941
+
942
+ png_path, png_error = _write_ta_png_artifact(
943
+ _artifact, hist, symbol, name, currency, is_ashare,
944
+ ma20, ma60, rsi14, macd_v, macd_s_val,
945
+ )
946
+
947
+ _raw_prices = []
948
+ try:
949
+ _raw_prices = hist.reset_index().tail(370).to_dict(orient="records")
950
+ except Exception:
951
+ _raw_prices = []
952
+ write_artifact_metadata(_artifact, {
953
+ "kind": "stock_chart",
954
+ "status": "complete",
955
+ "symbol": symbol,
956
+ "created_at": datetime.now().isoformat(timespec="seconds"),
957
+ "data": {
958
+ "provider_chain": [provider],
959
+ "rows": int(len(hist)),
960
+ "panels": ["candlestick", "bollinger", "ma20", "ma60", "volume", "rsi14", "macd"],
961
+ "color_convention": "ashare_red_up" if is_ashare else "western_green_up",
962
+ },
963
+ "review": {"issues": review_issues, "passed": len(review_issues) == 0},
964
+ "outputs": {
965
+ "html": str(out_file),
966
+ "png": png_path,
967
+ "png_error": png_error,
968
+ },
969
+ "metrics": {
970
+ "last_close": last_close, "high_52w": high_52w, "low_52w": low_52w,
971
+ "trend": trend, "rsi14": rsi14, "momentum": momentum,
972
+ "support": sup3, "resistance": res3,
973
+ },
974
+ })
975
+ write_artifact_raw_data(_artifact, {
976
+ "symbol": symbol, "provider": provider, "info": info, "prices": _raw_prices,
977
+ })
978
+ return {
979
+ "success": True,
980
+ "chart_path": str(out_file),
981
+ "png_path": png_path,
982
+ "png_error": png_error,
983
+ "response": f"图表已生成:{out_file.name}",
984
+ "symbol": symbol,
985
+ "name": name,
986
+ "last_close": last_close,
987
+ "trend": trend,
988
+ "rsi": rsi14,
989
+ "momentum": momentum,
990
+ "support": sup3,
991
+ "resistance": res3,
992
+ "review_issues": review_issues,
993
+ "provider": provider,
994
+ }
995
+
996
+
997
+ def handle_stock_chart_analysis(
998
+ message: str,
999
+ *,
1000
+ is_chart_request: Callable[[str], bool],
1001
+ extract_symbol: Callable[[str], str],
1002
+ ) -> dict:
1003
+ """Deterministic path for stock analysis + chart requests.
1004
+
1005
+ This avoids weak local models writing fake scripts or leaking pseudo tool
1006
+ calls. It fetches historical data, computes common indicators, writes a
1007
+ standalone HTML chart, and returns a concise Markdown analysis.
1008
+ """
1009
+ if not is_chart_request(message):
1010
+ return {"success": False, "error": "not_stock_chart_analysis"}
1011
+
1012
+ symbol = extract_symbol(message) or "AAPL"
1013
+ period = "1y"
1014
+ interval = "1d"
1015
+
1016
+ try:
1017
+ import html as _html
1018
+ import pandas as _pd
1019
+ except Exception as exc:
1020
+ return {
1021
+ "success": False,
1022
+ "error": f"缺少图表分析依赖:{exc}",
1023
+ "response": "当前环境缺少 `pandas`,无法生成股票图表。",
1024
+ }
1025
+ try:
1026
+ import yfinance as _yf
1027
+ except Exception:
1028
+ _yf = None
1029
+
1030
+ symbol = _normalise_chart_symbol(symbol)
1031
+ provider = "Yahoo Finance"
1032
+ provider_currency = None
1033
+ ticker = None
1034
+ hist = None
1035
+ yahoo_error = ""
1036
+ chart_error = ""
1037
+ mdc_error = ""
1038
+ ak_error = ""
1039
+
1040
+ if _ashare_plain_symbol(symbol):
1041
+ hist, provider_currency, mdc_provider, mdc_error = _fetch_mdc_history_frame(symbol, period, interval)
1042
+ if hist is not None and not hist.empty:
1043
+ provider = mdc_provider or "market_data_client"
1044
+
1045
+ if _ashare_plain_symbol(symbol) and (hist is None or hist.empty):
1046
+ hist, provider_currency, ak_error = _fetch_akshare_history_frame(symbol, period)
1047
+ if hist is not None and not hist.empty:
1048
+ provider = "akshare"
1049
+
1050
+ if (hist is None or hist.empty) and _yf is not None:
1051
+ try:
1052
+ ticker = _yf.Ticker(symbol)
1053
+ hist = ticker.history(period=period, interval=interval, auto_adjust=False)
1054
+ hist = _normalise_history_frame(hist)
1055
+ if hist is not None and not hist.empty:
1056
+ provider = "Yahoo Finance"
1057
+ except Exception as exc:
1058
+ hist = None
1059
+ yahoo_error = str(exc)
1060
+ elif _yf is None:
1061
+ yahoo_error = "yfinance missing"
1062
+
1063
+ if hist is None or hist.empty:
1064
+ hist, provider_currency, chart_error = _fetch_yahoo_chart_frame(symbol, period, interval)
1065
+ if hist is not None and not hist.empty:
1066
+ provider = "Yahoo Chart API"
1067
+
1068
+ if hist is None or hist.empty:
1069
+ try:
1070
+ stooq_symbol = symbol.lower()
1071
+ if "." not in stooq_symbol:
1072
+ stooq_symbol = f"{stooq_symbol}.us"
1073
+ url = f"https://stooq.com/q/d/l/?s={stooq_symbol}&i=d"
1074
+ hist = _pd.read_csv(url)
1075
+ if hist is not None and not hist.empty:
1076
+ hist["Date"] = _pd.to_datetime(hist["Date"])
1077
+ hist = hist.set_index("Date").sort_index().tail(260)
1078
+ hist = _normalise_history_frame(hist)
1079
+ provider = "Stooq"
1080
+ except Exception as exc:
1081
+ return {
1082
+ "success": False,
1083
+ "error": (
1084
+ f"获取 {symbol} 历史行情失败:"
1085
+ f"MarketDataClient={mdc_error or 'skipped'}; "
1086
+ f"akshare={ak_error or 'skipped'}; "
1087
+ f"Yahoo={yahoo_error or 'empty'}; "
1088
+ f"YahooChart={chart_error or 'empty'}; "
1089
+ f"Stooq={exc}"
1090
+ ),
1091
+ "response": f"无法获取 {symbol} 历史行情,图表未生成。请稍后重试,或检查网络/数据源访问。",
1092
+ }
1093
+
1094
+ if hist is None or hist.empty or "Close" not in hist.columns:
1095
+ return {
1096
+ "success": False,
1097
+ "error": (
1098
+ f"{symbol} 历史行情为空:"
1099
+ f"MarketDataClient={mdc_error or 'skipped'}; "
1100
+ f"akshare={ak_error or 'skipped'}; "
1101
+ f"Yahoo={yahoo_error or 'empty'}; "
1102
+ f"YahooChart={chart_error or 'empty'}"
1103
+ ),
1104
+ "response": f"没有拿到 {symbol} 的可用历史行情,图表未生成。请稍后重试,或检查网络/数据源访问。",
1105
+ }
1106
+
1107
+ hist = hist.dropna(subset=["Close"]).copy()
1108
+ hist["MA20"] = hist["Close"].rolling(20).mean()
1109
+ hist["MA50"] = hist["Close"].rolling(50).mean()
1110
+ hist["MA200"] = hist["Close"].rolling(200).mean()
1111
+ delta = hist["Close"].diff()
1112
+ gain = delta.clip(lower=0).rolling(14).mean()
1113
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
1114
+ rs = gain / loss.replace(0, _pd.NA)
1115
+ hist["RSI14"] = 100 - (100 / (1 + rs))
1116
+ ema12 = hist["Close"].ewm(span=12, adjust=False).mean()
1117
+ ema26 = hist["Close"].ewm(span=26, adjust=False).mean()
1118
+ hist["MACD"] = ema12 - ema26
1119
+ hist["MACD_SIGNAL"] = hist["MACD"].ewm(span=9, adjust=False).mean()
1120
+
1121
+ last = hist.iloc[-1]
1122
+ first_close = hist["Close"].iloc[0]
1123
+ last_close = float(last["Close"])
1124
+ ytd_like_return = (last_close / float(first_close) - 1) * 100 if first_close else 0
1125
+ ma20 = float(last["MA20"]) if _pd.notna(last["MA20"]) else None
1126
+ ma50 = float(last["MA50"]) if _pd.notna(last["MA50"]) else None
1127
+ ma200 = float(last["MA200"]) if _pd.notna(last["MA200"]) else None
1128
+ rsi14 = float(last["RSI14"]) if _pd.notna(last["RSI14"]) else None
1129
+ macd = float(last["MACD"]) if _pd.notna(last["MACD"]) else None
1130
+ macd_sig = float(last["MACD_SIGNAL"]) if _pd.notna(last["MACD_SIGNAL"]) else None
1131
+ high_52w = float(hist["High"].max()) if "High" in hist else float(hist["Close"].max())
1132
+ low_52w = float(hist["Low"].min()) if "Low" in hist else float(hist["Close"].min())
1133
+
1134
+ info = {}
1135
+ try:
1136
+ if ticker is None and _yf is not None:
1137
+ ticker = _yf.Ticker(symbol)
1138
+ info = ticker.get_info() or {} if ticker is not None else {}
1139
+ except Exception:
1140
+ info = {}
1141
+ name = info.get("longName") or info.get("shortName") or symbol
1142
+ pe = info.get("trailingPE")
1143
+ market_cap = info.get("marketCap")
1144
+ currency = info.get("currency") or provider_currency or ("CNY" if _ashare_plain_symbol(symbol) else "USD")
1145
+
1146
+ if ma20 and ma50 and last_close > ma20 > ma50:
1147
+ trend = "偏多"
1148
+ elif ma20 and ma50 and last_close < ma20 < ma50:
1149
+ trend = "偏空"
1150
+ else:
1151
+ trend = "震荡/中性"
1152
+ momentum = "MACD偏多" if macd is not None and macd_sig is not None and macd > macd_sig else "MACD偏弱"
1153
+ rsi_view = "超买" if rsi14 is not None and rsi14 >= 70 else ("超卖" if rsi14 is not None and rsi14 <= 30 else "中性")
1154
+
1155
+ safe_symbol = re.sub(r"[^A-Za-z0-9_.-]+", "_", symbol)
1156
+ from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
1157
+ _artifact = create_user_artifact("stock-charts", symbol, f"{safe_symbol}_analysis_chart", ".html")
1158
+ out_file = _artifact.path
1159
+
1160
+ x = [idx.strftime("%Y-%m-%d") for idx in hist.index]
1161
+ close = [None if _pd.isna(v) else round(float(v), 4) for v in hist["Close"]]
1162
+ volume = [None if _pd.isna(v) else int(float(v)) for v in hist.get("Volume", _pd.Series(index=hist.index, dtype=float))]
1163
+ ma20_arr = [None if _pd.isna(v) else round(float(v), 4) for v in hist["MA20"]]
1164
+ ma50_arr = [None if _pd.isna(v) else round(float(v), 4) for v in hist["MA50"]]
1165
+ rsi_arr = [None if _pd.isna(v) else round(float(v), 4) for v in hist["RSI14"]]
1166
+
1167
+ html_doc = f"""<!doctype html>
1168
+ <html lang="zh-CN">
1169
+ <head>
1170
+ <meta charset="utf-8">
1171
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1172
+ <title>{_html.escape(symbol)} 股票分析图表</title>
1173
+ {plotly_script_tag()}
1174
+ <style>
1175
+ body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f8fa; color: #17202a; }}
1176
+ main {{ max-width: 1180px; margin: 0 auto; padding: 28px; }}
1177
+ h1 {{ margin: 0 0 6px; font-size: 28px; }}
1178
+ .meta {{ color: #667085; margin-bottom: 18px; }}
1179
+ .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 18px 0; }}
1180
+ .metric {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }}
1181
+ .label {{ color: #667085; font-size: 12px; }}
1182
+ .value {{ font-size: 18px; font-weight: 650; margin-top: 4px; }}
1183
+ #chart {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; }}
1184
+ .note {{ color: #667085; font-size: 13px; margin-top: 14px; }}
1185
+ </style>
1186
+ </head>
1187
+ <body>
1188
+ <main>
1189
+ <h1>{_html.escape(name)} ({_html.escape(symbol)})</h1>
1190
+ <div class="meta">生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} · 数据:{_html.escape(provider)} · 周期:{period}</div>
1191
+ <section class="grid">
1192
+ <div class="metric"><div class="label">最新收盘</div><div class="value">{currency} {_fmt_num(last_close)}</div></div>
1193
+ <div class="metric"><div class="label">近一年区间</div><div class="value">{_fmt_num(low_52w)} - {_fmt_num(high_52w)}</div></div>
1194
+ <div class="metric"><div class="label">MA20 / MA50</div><div class="value">{_fmt_num(ma20)} / {_fmt_num(ma50)}</div></div>
1195
+ <div class="metric"><div class="label">RSI14</div><div class="value">{_fmt_num(rsi14)}</div></div>
1196
+ <div class="metric"><div class="label">P/E</div><div class="value">{_fmt_num(pe)}</div></div>
1197
+ <div class="metric"><div class="label">成交量</div><div class="value">{_fmt_int(last.get("Volume"))}</div></div>
1198
+ </section>
1199
+ <div id="chart"></div>
1200
+ <p class="note">图表包含收盘价、MA20、MA50、成交量和 RSI14。该文件为本地 HTML,可直接在浏览器打开。</p>
1201
+ </main>
1202
+ <script>
1203
+ const x = {json.dumps(x)};
1204
+ const close = {json.dumps(close)};
1205
+ const volume = {json.dumps(volume)};
1206
+ const ma20 = {json.dumps(ma20_arr)};
1207
+ const ma50 = {json.dumps(ma50_arr)};
1208
+ const rsi = {json.dumps(rsi_arr)};
1209
+ const data = [
1210
+ {{x, y: close, type: "scatter", mode: "lines", name: "Close", line: {{color: "#2563eb", width: 2}}, yaxis: "y"}},
1211
+ {{x, y: ma20, type: "scatter", mode: "lines", name: "MA20", line: {{color: "#f59e0b", width: 1.5}}, yaxis: "y"}},
1212
+ {{x, y: ma50, type: "scatter", mode: "lines", name: "MA50", line: {{color: "#10b981", width: 1.5}}, yaxis: "y"}},
1213
+ {{x, y: volume, type: "bar", name: "Volume", marker: {{color: "rgba(100,116,139,0.35)"}}, yaxis: "y2"}},
1214
+ {{x, y: rsi, type: "scatter", mode: "lines", name: "RSI14", line: {{color: "#dc2626", width: 1.5}}, yaxis: "y3"}}
1215
+ ];
1216
+ const layout = {{
1217
+ height: 720,
1218
+ margin: {{l: 62, r: 30, t: 28, b: 42}},
1219
+ paper_bgcolor: "#fff",
1220
+ plot_bgcolor: "#fff",
1221
+ hovermode: "x unified",
1222
+ legend: {{orientation: "h", y: 1.04}},
1223
+ xaxis: {{domain: [0, 1], rangeslider: {{visible: false}}, gridcolor: "#eef2f7"}},
1224
+ yaxis: {{domain: [0.36, 1], title: "Price", gridcolor: "#eef2f7"}},
1225
+ yaxis2: {{domain: [0.18, 0.31], title: "Volume", gridcolor: "#eef2f7"}},
1226
+ yaxis3: {{domain: [0, 0.13], title: "RSI", range: [0, 100], gridcolor: "#eef2f7"}},
1227
+ shapes: [
1228
+ {{type: "line", xref: "paper", x0: 0, x1: 1, yref: "y3", y0: 70, y1: 70, line: {{color: "#ef4444", dash: "dot"}}}},
1229
+ {{type: "line", xref: "paper", x0: 0, x1: 1, yref: "y3", y0: 30, y1: 30, line: {{color: "#22c55e", dash: "dot"}}}}
1230
+ ]
1231
+ }};
1232
+ Plotly.newPlot("chart", data, layout, {{responsive: true, displaylogo: false}});
1233
+ </script>
1234
+ </body>
1235
+ </html>
1236
+ """
1237
+ out_file.write_text(html_doc, encoding="utf-8")
1238
+
1239
+ _raw_prices = []
1240
+ try:
1241
+ _raw_prices = hist.reset_index().tail(370).to_dict(orient="records")
1242
+ except Exception:
1243
+ _raw_prices = []
1244
+ write_artifact_metadata(_artifact, {
1245
+ "kind": "stock_chart_analysis",
1246
+ "status": "complete",
1247
+ "symbol": symbol,
1248
+ "created_at": datetime.now().isoformat(timespec="seconds"),
1249
+ "data": {
1250
+ "provider_chain": [provider],
1251
+ "rows": int(len(hist)),
1252
+ "missing_fields": [
1253
+ k for k, v in {
1254
+ "ma20": ma20,
1255
+ "ma50": ma50,
1256
+ "ma200": ma200,
1257
+ "rsi14": rsi14,
1258
+ "macd": macd,
1259
+ "macd_signal": macd_sig,
1260
+ "pe": pe,
1261
+ "market_cap": market_cap,
1262
+ }.items()
1263
+ if v is None
1264
+ ],
1265
+ },
1266
+ "metrics": {
1267
+ "last_close": last_close,
1268
+ "trend": trend,
1269
+ "rsi14": rsi14,
1270
+ "momentum": momentum,
1271
+ "ytd_like_return": ytd_like_return,
1272
+ },
1273
+ })
1274
+ write_artifact_raw_data(_artifact, {
1275
+ "symbol": symbol,
1276
+ "provider": provider,
1277
+ "info": info,
1278
+ "prices": _raw_prices,
1279
+ })
1280
+
1281
+ market_cap_text = "—"
1282
+ if market_cap:
1283
+ market_cap_text = f"{currency} {market_cap / 1e12:.2f}T" if market_cap >= 1e12 else f"{currency} {market_cap / 1e9:.1f}B"
1284
+
1285
+ response = (
1286
+ f"## {name} ({symbol}) 股票分析\n\n"
1287
+ f"已生成图表:[{out_file.name}]({out_file})\n\n"
1288
+ f"| 指标 | 数值 |\n"
1289
+ f"| --- | --- |\n"
1290
+ f"| 最新收盘 | {currency} {_fmt_num(last_close)} |\n"
1291
+ f"| 近一年涨跌幅 | {ytd_like_return:+.2f}% |\n"
1292
+ f"| 近一年高/低 | {_fmt_num(high_52w)} / {_fmt_num(low_52w)} |\n"
1293
+ f"| MA20 / MA50 / MA200 | {_fmt_num(ma20)} / {_fmt_num(ma50)} / {_fmt_num(ma200)} |\n"
1294
+ f"| RSI14 | {_fmt_num(rsi14)}({rsi_view}) |\n"
1295
+ f"| MACD | {_fmt_num(macd)} / signal {_fmt_num(macd_sig)}({momentum}) |\n"
1296
+ f"| P/E / 市值 | {_fmt_num(pe)} / {market_cap_text} |\n\n"
1297
+ f"**结论**:当前技术结构为 **{trend}**。"
1298
+ f"RSI 处于{rsi_view}区间,{momentum}。"
1299
+ f"若价格能稳定站上 MA20 和 MA50,短线结构会更健康;若跌破 MA50 或放量下行,需要降低仓位和预期。\n\n"
1300
+ f"**风险**:该分析基于 {provider} 历史行情和常用技术指标,不构成投资建议;财报、产品周期、利率和大盘风险都会影响股价。"
1301
+ )
1302
+ return {
1303
+ "success": True,
1304
+ "response": response,
1305
+ "provider": "deterministic",
1306
+ "tools_used": ["stock_chart", provider, "html_chart"],
1307
+ "chart_path": str(out_file),
1308
+ "symbol": symbol,
1309
+ }