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
report_generator.py ADDED
@@ -0,0 +1,1314 @@
1
+ """
2
+ report_generator.py — 专业研报生成引擎
3
+ =======================================
4
+ 输入: symbol + TeamResult(可选)
5
+ 输出: 单文件 HTML(内嵌图表 base64)→ 可直接在浏览器打印为 PDF
6
+
7
+ 特性:
8
+ · Bloomberg 风格暗色主题
9
+ · mplfinance K线图 + 成交量(fallback: 收盘价折线)
10
+ · 数据清洗质量报告(来自 data_cleaner.py)
11
+ · 多 Agent 分析卡片
12
+ · 关键财务指标表格
13
+ · 完全离线,无外部 CDN 依赖
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import base64
20
+ import html
21
+ import io
22
+ import logging
23
+ import os
24
+ import math
25
+ import re as _re
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional, Tuple
29
+
30
+ from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
31
+
32
+ try:
33
+ import pandas as pd
34
+ except ImportError:
35
+ pd = None # type: ignore
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _history_records_to_df(records: List[Dict[str, Any]]):
41
+ import pandas as _pd
42
+ if not records:
43
+ return _pd.DataFrame()
44
+ df = _pd.DataFrame(records)
45
+ if df.empty or "date" not in df.columns:
46
+ return _pd.DataFrame()
47
+ df["date"] = _pd.to_datetime(df["date"], errors="coerce")
48
+ df = df.dropna(subset=["date"]).set_index("date").sort_index()
49
+ rename = {
50
+ "open": "Open", "high": "High", "low": "Low",
51
+ "close": "Close", "volume": "Volume",
52
+ }
53
+ df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
54
+ for col in ("Open", "High", "Low", "Close", "Volume"):
55
+ if col not in df.columns:
56
+ df[col] = 0
57
+ df[col] = _pd.to_numeric(df[col], errors="coerce")
58
+ df = df.dropna(subset=["Close"])
59
+ return df[["Open", "High", "Low", "Close", "Volume"]]
60
+
61
+
62
+ def _merge_present(target: Dict[str, Any], source: Dict[str, Any], keys: List[str]) -> None:
63
+ for key in keys:
64
+ value = source.get(key)
65
+ if value not in (None, "", [], {}):
66
+ target[key] = value
67
+
68
+
69
+ def _fetch_report_data_sync(symbol: str) -> Tuple[Any, Any, Dict[str, Any]]:
70
+ """Fetch report data with fallback providers and source diagnostics."""
71
+ from data_cleaner import CleanResult, clean_price_series, get_clean_prices, get_fundamentals
72
+ import pandas as _pd
73
+
74
+ provider_chain: List[str] = []
75
+ data_warnings: List[str] = []
76
+ df = _pd.DataFrame()
77
+ clean_result = CleanResult(df, quality_score=0.0)
78
+
79
+ try:
80
+ df, clean_result = get_clean_prices(symbol, period="1y")
81
+ if df is not None and not df.empty:
82
+ provider_chain.append("data_cleaner")
83
+ except Exception as exc:
84
+ data_warnings.append(f"data_cleaner prices: {exc}")
85
+ df = _pd.DataFrame()
86
+ clean_result = CleanResult(df, quality_score=0.0)
87
+
88
+ fundamentals: Dict[str, Any]
89
+ try:
90
+ fundamentals = get_fundamentals(symbol)
91
+ provider_chain.append("fundamentals")
92
+ except Exception as exc:
93
+ data_warnings.append(f"fundamentals: {exc}")
94
+ fundamentals = {
95
+ "company_name": symbol,
96
+ "symbol": symbol,
97
+ "currency": "CNY" if str(symbol).isdigit() and len(str(symbol)) == 6 else "USD",
98
+ }
99
+
100
+ try:
101
+ from packages.aria_services.data import DataService
102
+ bundle = DataService().bundle(symbol, history_days=370, technical_days=120)
103
+
104
+ hist = bundle.history
105
+ if (df is None or df.empty) and hist.get("success"):
106
+ fallback_df = _history_records_to_df(hist.get("data") or [])
107
+ if not fallback_df.empty:
108
+ df = fallback_df
109
+ clean_result = clean_price_series(df, symbol)
110
+ elif not hist.get("success"):
111
+ data_warnings.append(hist.get("error") or "history unavailable")
112
+
113
+ quote = bundle.quote
114
+ if quote.get("success"):
115
+ _merge_present(
116
+ fundamentals,
117
+ quote,
118
+ ["price", "prev_close", "open", "high", "low", "volume", "turnover",
119
+ "market_cap", "currency", "name"],
120
+ )
121
+ q_name = quote.get("name")
122
+ cur_name = fundamentals.get("company_name", "")
123
+ # Prefer Chinese name from market data over English fallback from yfinance.
124
+ # Override when: name is missing, is the bare symbol, or is pure ASCII
125
+ # (yfinance fallback) while the quote provides a localized name.
126
+ if q_name and (
127
+ cur_name in (None, "", symbol)
128
+ or (cur_name.isascii() and not q_name.isascii())
129
+ ):
130
+ fundamentals["company_name"] = q_name
131
+ else:
132
+ data_warnings.append(quote.get("error") or "quote unavailable")
133
+
134
+ fund = bundle.fundamentals
135
+ if fund.get("success"):
136
+ _merge_present(
137
+ fundamentals,
138
+ fund,
139
+ ["sector", "industry", "market_cap", "pe_ratio", "pe_ttm",
140
+ "pb_ratio", "pb", "ps_ratio", "roe", "revenue", "net_income",
141
+ "eps", "dividend_yield", "52w_high", "52w_low", "description"],
142
+ )
143
+
144
+ ti = bundle.technical
145
+ if ti.get("success"):
146
+ _merge_present(
147
+ fundamentals,
148
+ ti,
149
+ ["rsi", "macd", "signal", "ma5", "ma10", "ma20", "ma60",
150
+ "bb_upper", "bb_lower", "price"],
151
+ )
152
+ else:
153
+ data_warnings.append(ti.get("error") or "technical indicators unavailable")
154
+
155
+ provider_chain.extend(bundle.provider_chain)
156
+ data_warnings.extend(bundle.warnings)
157
+ data_warnings.extend(bundle.errors)
158
+ if bundle.missing_fields:
159
+ data_warnings.append("missing fields: " + ", ".join(bundle.missing_fields))
160
+ fundamentals["data_status"] = bundle.status
161
+ fundamentals["data_quality"] = bundle.quality
162
+ fundamentals["data_stale"] = bool(bundle.quality.get("stale") if bundle.quality else False)
163
+ except Exception as exc:
164
+ data_warnings.append(f"data_service: {exc}")
165
+
166
+ if df is not None and not df.empty:
167
+ try:
168
+ close_series = df["Close"].dropna()
169
+ last_close = float(close_series.iloc[-1])
170
+ fundamentals.setdefault("price", last_close)
171
+ if len(close_series) >= 2:
172
+ fundamentals.setdefault("prev_close", float(close_series.iloc[-2]))
173
+ trailing_year = close_series.tail(252)
174
+ fundamentals.setdefault("52w_high", float(trailing_year.max()))
175
+ fundamentals.setdefault("52w_low", float(trailing_year.min()))
176
+ except Exception:
177
+ pass
178
+
179
+ fundamentals["data_provider_chain"] = list(dict.fromkeys(str(p) for p in provider_chain if p))
180
+ fundamentals["data_warnings"] = data_warnings[:6]
181
+ fundamentals.setdefault("company_name", symbol)
182
+ fundamentals.setdefault("symbol", symbol)
183
+ return df, clean_result, fundamentals
184
+
185
+ # ── Signal styles ─────────────────────────────────────────────────────────────
186
+ _SIGNAL_COLOR = {
187
+ "STRONG_BUY": ("#0d3b1e", "#3fb950", "▲▲"),
188
+ "BUY": ("#0d3b1e", "#3fb950", "▲ "),
189
+ "HOLD": ("#1f2836", "#79c0ff", "─ "),
190
+ "SELL": ("#3d1218", "#f85149", "▼ "),
191
+ "STRONG_SELL":("#3d1218", "#f85149", "▼▼"),
192
+ }
193
+
194
+
195
+ def _sig_style(signal: str):
196
+ return _SIGNAL_COLOR.get((signal or "HOLD").upper(),
197
+ ("#1f2836", "#79c0ff", "─ "))
198
+
199
+
200
+ # ── Chart Generation ──────────────────────────────────────────────────────────
201
+
202
+ def _chart_to_b64(fig) -> str:
203
+ import matplotlib.pyplot as plt
204
+ buf = io.BytesIO()
205
+ fig.savefig(buf, format="png", dpi=130, bbox_inches="tight",
206
+ facecolor=fig.get_facecolor())
207
+ buf.seek(0)
208
+ b64 = base64.b64encode(buf.read()).decode()
209
+ plt.close(fig)
210
+ return b64
211
+
212
+
213
+ def generate_price_chart(df, symbol: str, fundamentals: Dict) -> Optional[str]:
214
+ """
215
+ Returns base64-encoded PNG of a dark-theme candlestick chart.
216
+ Falls back to a line chart if mplfinance is unavailable.
217
+ """
218
+ if df is None or df.empty:
219
+ return None
220
+
221
+ # Need at minimum Close column
222
+ close_col = next((c for c in df.columns if c.lower() == "close"), None)
223
+ if not close_col:
224
+ return None
225
+
226
+ # Ensure DatetimeIndex
227
+ import pandas as _pd
228
+ try:
229
+ if not isinstance(df.index, _pd.DatetimeIndex):
230
+ df.index = _pd.to_datetime(df.index)
231
+ except Exception:
232
+ pass
233
+
234
+ # Use last 6 months
235
+ df6 = df.tail(126).copy()
236
+
237
+ # ── mplfinance path ────────────────────────────────────────────────────
238
+ try:
239
+ import matplotlib
240
+ matplotlib.use("Agg")
241
+ import mplfinance as mpf
242
+ import matplotlib.pyplot as plt
243
+
244
+ mc = mpf.make_marketcolors(
245
+ up="#3fb950", down="#f85149", edge="inherit",
246
+ wick={"up": "#3fb950", "down": "#f85149"},
247
+ volume={"up": "#1e4d2b", "down": "#4d1219"},
248
+ )
249
+ style = mpf.make_mpf_style(
250
+ marketcolors=mc,
251
+ facecolor="#0d1117",
252
+ edgecolor="#21262d",
253
+ figcolor="#0d1117",
254
+ gridcolor="#161b22",
255
+ gridstyle="--",
256
+ rc={
257
+ "axes.labelcolor": "#8b949e",
258
+ "axes.edgecolor": "#21262d",
259
+ "xtick.color": "#8b949e",
260
+ "ytick.color": "#8b949e",
261
+ "font.size": 9,
262
+ },
263
+ )
264
+
265
+ has_ohlcv = all(c in df6.columns for c in ("Open", "High", "Low", "Close", "Volume"))
266
+ plot_type = "candle" if has_ohlcv else "line"
267
+ addplots = []
268
+
269
+ if "Close" in df6.columns:
270
+ ma20 = df6["Close"].rolling(20).mean()
271
+ ma50 = df6["Close"].rolling(50).mean()
272
+ if not ma20.dropna().empty:
273
+ addplots.append(mpf.make_addplot(ma20, color="#388bfd", width=1.2,
274
+ label="MA20"))
275
+ if not ma50.dropna().empty:
276
+ addplots.append(mpf.make_addplot(ma50, color="#8957e5", width=1.2,
277
+ label="MA50"))
278
+
279
+ kwargs: Dict[str, Any] = dict(
280
+ type=plot_type,
281
+ style=style,
282
+ figsize=(11, 5.5),
283
+ returnfig=True,
284
+ datetime_format="%m/%d",
285
+ xrotation=0,
286
+ )
287
+ if has_ohlcv:
288
+ kwargs["volume"] = True
289
+ kwargs["volume_panel"] = 1
290
+ kwargs["panel_ratios"] = (3, 1)
291
+ if addplots:
292
+ kwargs["addplot"] = addplots
293
+
294
+ fig, axes = mpf.plot(df6, **kwargs)
295
+
296
+ # Title
297
+ price = fundamentals.get("price")
298
+ p_str = f" ${price:,.2f}" if price else ""
299
+ axes[0].set_title(
300
+ f"{symbol}{p_str} · 6-Month Price History",
301
+ color="#c9d1d9", fontsize=11, pad=8,
302
+ )
303
+
304
+ return _chart_to_b64(fig)
305
+
306
+ except ImportError:
307
+ pass
308
+ except Exception as e:
309
+ logger.debug("[report] mplfinance chart: %s", e)
310
+
311
+ # ── matplotlib fallback: line chart ────────────────────────────────────
312
+ try:
313
+ import matplotlib
314
+ matplotlib.use("Agg")
315
+ import matplotlib.pyplot as plt
316
+
317
+ fig, ax = plt.subplots(figsize=(11, 4), facecolor="#0d1117")
318
+ ax.set_facecolor("#0d1117")
319
+ close = df6["Close"] if "Close" in df6.columns else df6.iloc[:, 0]
320
+ ax.plot(df6.index, close, color="#3fb950", linewidth=1.5)
321
+
322
+ # MA lines
323
+ ma20 = close.rolling(20).mean()
324
+ ma50 = close.rolling(50).mean()
325
+ ax.plot(df6.index, ma20, color="#388bfd", linewidth=1.0, alpha=0.8, label="MA20")
326
+ ax.plot(df6.index, ma50, color="#8957e5", linewidth=1.0, alpha=0.8, label="MA50")
327
+
328
+ ax.set_title(f"{symbol} · 6-Month Close Price",
329
+ color="#c9d1d9", fontsize=11)
330
+ ax.tick_params(colors="#8b949e")
331
+ ax.spines[:].set_edgecolor("#21262d")
332
+ ax.grid(color="#161b22", linestyle="--", linewidth=0.5)
333
+ ax.legend(facecolor="#161b22", edgecolor="#21262d",
334
+ labelcolor="#8b949e", fontsize=9)
335
+ fig.tight_layout()
336
+ return _chart_to_b64(fig)
337
+
338
+ except Exception as e:
339
+ logger.debug("[report] matplotlib fallback: %s", e)
340
+ return _generate_svg_line_chart(df6, symbol)
341
+
342
+
343
+ def _generate_svg_line_chart(df, symbol: str) -> Optional[str]:
344
+ """Dependency-free inline SVG fallback for environments without chart libs."""
345
+ try:
346
+ if df is None or df.empty:
347
+ return None
348
+ close_col = next((c for c in df.columns if c.lower() == "close"), None)
349
+ if not close_col:
350
+ return None
351
+ values = [float(v) for v in df[close_col].dropna().tail(126).tolist() if math.isfinite(float(v))]
352
+ if len(values) < 2:
353
+ return None
354
+ width, height, pad = 920, 320, 34
355
+ lo, hi = min(values), max(values)
356
+ if math.isclose(lo, hi):
357
+ lo *= 0.99
358
+ hi *= 1.01
359
+ pts = []
360
+ for i, value in enumerate(values):
361
+ x = pad + (width - pad * 2) * i / max(len(values) - 1, 1)
362
+ y = pad + (height - pad * 2) * (1 - (value - lo) / (hi - lo))
363
+ pts.append(f"{x:.1f},{y:.1f}")
364
+ return f"""
365
+ <svg viewBox="0 0 {width} {height}" role="img" aria-label="{_e(symbol)} price chart" class="inline-chart">
366
+ <rect x="0" y="0" width="{width}" height="{height}" rx="8" fill="#0d1117"/>
367
+ <line x1="{pad}" y1="{height-pad}" x2="{width-pad}" y2="{height-pad}" stroke="#21262d"/>
368
+ <line x1="{pad}" y1="{pad}" x2="{pad}" y2="{height-pad}" stroke="#21262d"/>
369
+ <polyline points="{" ".join(pts)}" fill="none" stroke="#c08050" stroke-width="3"/>
370
+ <text x="{pad}" y="{pad - 10}" fill="#8b949e" font-size="12">{_e(symbol)} Close</text>
371
+ <text x="{width-pad-150}" y="{pad - 10}" fill="#8b949e" font-size="12">{values[-1]:,.2f}</text>
372
+ </svg>"""
373
+ except Exception as e:
374
+ logger.debug("[report] svg fallback: %s", e)
375
+ return None
376
+
377
+
378
+ # ── Number Formatting ─────────────────────────────────────────────────────────
379
+
380
+ def _fmt(val, precision: int = 2, pct: bool = False,
381
+ currency: str = "", na: str = "—") -> str:
382
+ if val is None:
383
+ return na
384
+ try:
385
+ v = float(val)
386
+ except (TypeError, ValueError):
387
+ return na
388
+ if not math.isfinite(v):
389
+ return na
390
+
391
+ if pct:
392
+ return f"{v * 100:.{precision}f}%"
393
+ if currency:
394
+ if abs(v) >= 1e12:
395
+ return f"{currency}{v/1e12:.2f}T"
396
+ if abs(v) >= 1e9:
397
+ return f"{currency}{v/1e9:.2f}B"
398
+ if abs(v) >= 1e6:
399
+ return f"{currency}{v/1e6:.2f}M"
400
+ return f"{currency}{v:,.{precision}f}"
401
+ return f"{v:,.{precision}f}"
402
+
403
+
404
+ def _fmt_metric(val, precision: int = 2, pct: bool = False,
405
+ currency: str = "", zero_is_missing: bool = False) -> str:
406
+ try:
407
+ v = float(val)
408
+ if zero_is_missing and abs(v) < 1e-12:
409
+ return "—"
410
+ except (TypeError, ValueError):
411
+ pass
412
+ return _fmt(val, precision=precision, pct=pct, currency=currency)
413
+
414
+
415
+ def _color_val(val, good_positive: bool = True) -> str:
416
+ """Return HTML color for a numeric value."""
417
+ try:
418
+ v = float(val)
419
+ if v > 0:
420
+ return "#3fb950" if good_positive else "#f85149"
421
+ if v < 0:
422
+ return "#f85149" if good_positive else "#3fb950"
423
+ except (TypeError, ValueError):
424
+ pass
425
+ return "#8b949e"
426
+
427
+
428
+ # ── HTML Builder ──────────────────────────────────────────────────────────────
429
+
430
+ def _e(text: str) -> str:
431
+ """HTML-escape user-facing strings."""
432
+ return html.escape(str(text or ""))
433
+
434
+
435
+ def _md_to_html(text: str, max_chars: int = 0) -> str:
436
+ """Convert LLM-generated markdown to safe HTML for embedding in reports.
437
+
438
+ Order of operations: escape → fix &lt;br&gt; → tables → inline styles → newlines.
439
+ """
440
+ if not text:
441
+ return ""
442
+ if max_chars and len(text) > max_chars:
443
+ text = text[:max_chars] + "…"
444
+
445
+ # 1. HTML-escape all user content first (XSS prevention)
446
+ t = html.escape(text)
447
+
448
+ # 2. LLMs sometimes emit literal &lt;br&gt; inside markdown — convert to newline
449
+ t = t.replace("&lt;br&gt;", "\n")
450
+
451
+ # 3. Markdown tables → HTML tables
452
+ # Pre-join continuation lines: table cells may contain \n (from &lt;br&gt;).
453
+ # A non-pipe line immediately following a pipe line is part of that row's cell.
454
+ raw_lines = t.split("\n")
455
+ merged: List[str] = []
456
+ for raw_line in raw_lines:
457
+ if merged and merged[-1].lstrip().startswith("|") and raw_line and not raw_line.lstrip().startswith("|"):
458
+ merged[-1] += "<br>" + raw_line
459
+ else:
460
+ merged.append(raw_line)
461
+
462
+ out_lines: List[str] = []
463
+ i = 0
464
+ while i < len(merged):
465
+ line = merged[i]
466
+ # Detect header row: has | and next line is a separator (|---|---|)
467
+ if (
468
+ "|" in line
469
+ and i + 1 < len(merged)
470
+ and _re.match(r"^\s*\|[\s\-:|]+\|", merged[i + 1])
471
+ ):
472
+ header_cells = [c.strip() for c in line.strip("|").split("|")]
473
+ th = "".join(f"<th>{c}</th>" for c in header_cells)
474
+ i += 2 # skip header + separator
475
+ tbody_rows = ""
476
+ while i < len(merged) and "|" in merged[i] and merged[i].lstrip().startswith("|"):
477
+ cells = [c.strip() for c in merged[i].strip("|").split("|")]
478
+ tbody_rows += "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
479
+ i += 1
480
+ out_lines.append(
481
+ f'<table class="md-table">'
482
+ f"<thead><tr>{th}</tr></thead>"
483
+ f"<tbody>{tbody_rows}</tbody>"
484
+ f"</table>"
485
+ )
486
+ else:
487
+ out_lines.append(line)
488
+ i += 1
489
+ t = "\n".join(out_lines)
490
+
491
+ # 4. Bold: **text** or __text__
492
+ t = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", t, flags=_re.DOTALL)
493
+ t = _re.sub(r"__(.+?)__", r"<strong>\1</strong>", t, flags=_re.DOTALL)
494
+
495
+ # 5. Italic: *text* (not **)
496
+ t = _re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", t)
497
+
498
+ # 6. Inline code
499
+ t = _re.sub(r"`([^`]+)`", r"<code>\1</code>", t)
500
+
501
+ # 7. H2/H3 headers (## Heading → styled paragraph)
502
+ t = _re.sub(r"^#{1,3}\s+(.+)$", r'<strong class="md-h">\1</strong>', t, flags=_re.MULTILINE)
503
+
504
+ # 8. Horizontal rules (--- or ***)
505
+ t = _re.sub(r"\n[-*]{3,}\n", '\n<hr class="md-hr">\n', t)
506
+
507
+ # 9. Newlines → <br> (done last so table/hr markup isn't broken)
508
+ t = t.replace("\n", "<br>\n")
509
+
510
+ return t
511
+
512
+
513
+ def _kpi_card(label: str, value: str, sub: str = "", color: str = "#e6edf3") -> str:
514
+ return f"""
515
+ <div class="kpi-card">
516
+ <div class="kpi-label">{_e(label)}</div>
517
+ <div class="kpi-value" style="color:{color}">{_e(value)}</div>
518
+ {f'<div class="kpi-sub">{_e(sub)}</div>' if sub else ""}
519
+ </div>"""
520
+
521
+
522
+ def _agent_card(agent_name: str, signal: str, confidence: float,
523
+ analysis: str, key_points: List[str]) -> str:
524
+ bg, accent, icon = _sig_style(signal)
525
+ kps = "".join(f'<li>{_md_to_html(kp)}</li>' for kp in (key_points or [])[:4])
526
+ analysis_html = _md_to_html(analysis or "", max_chars=3000)
527
+ return f"""
528
+ <div class="agent-card" style="border-color:{accent}40;">
529
+ <div class="agent-header">
530
+ <span class="agent-name">{_e(agent_name.upper())}</span>
531
+ <span class="agent-signal" style="color:{accent};background:{bg};">
532
+ {icon} {_e(signal)}
533
+ </span>
534
+ <span class="agent-conf" style="color:{accent};">{confidence:.0%}</span>
535
+ </div>
536
+ {f'<ul class="agent-kps">{kps}</ul>' if kps else ""}
537
+ {f'<div class="agent-analysis">{analysis_html}</div>' if analysis_html else ""}
538
+ </div>"""
539
+
540
+
541
+ def _metrics_row(label: str, value: str, highlight: bool = False) -> str:
542
+ style = "background:#161b22;" if highlight else ""
543
+ return (f'<tr style="{style}">'
544
+ f'<td class="metric-label">{_e(label)}</td>'
545
+ f'<td class="metric-value">{_e(value)}</td>'
546
+ f'</tr>')
547
+
548
+
549
+ def _build_html(
550
+ symbol: str,
551
+ fundamentals: Dict,
552
+ price_chart: Optional[str],
553
+ team_result,
554
+ clean_result,
555
+ ) -> str:
556
+ fund = fundamentals
557
+ name = fund.get("company_name", symbol)
558
+ cur = fund.get("currency", "USD")
559
+ cur_sym = "¥" if cur in ("CNY","CNH","HKD") else "$"
560
+ price = fund.get("price")
561
+ prev = fund.get("prev_close")
562
+
563
+ # Price change
564
+ if price and prev and prev > 0:
565
+ chg = price - prev
566
+ chg_pct = chg / prev * 100
567
+ if abs(chg) < 0.001:
568
+ chg_str = "—"
569
+ chg_color = "#8b949e"
570
+ else:
571
+ chg_str = f"{chg:+.2f} ({chg_pct:+.2f}%)"
572
+ chg_color = "#3fb950" if chg >= 0 else "#f85149"
573
+ else:
574
+ chg_str = "—"
575
+ chg_color = "#8b949e"
576
+
577
+ # Final signal from team result
578
+ final_signal = "HOLD"
579
+ confidence = 0.0
580
+ synthesis = ""
581
+ agent_cards = ""
582
+
583
+ if team_result:
584
+ final_signal = team_result.final_signal or "HOLD"
585
+ confidence = team_result.confidence or 0.0
586
+ synthesis = team_result.synthesis or ""
587
+ cards = []
588
+ for r in (team_result.results or []):
589
+ if not r or r.agent == "debate":
590
+ continue
591
+ cards.append(_agent_card(
592
+ r.agent, r.signal or "HOLD", r.confidence,
593
+ r.analysis, r.key_points,
594
+ ))
595
+ agent_cards = "\n".join(cards)
596
+
597
+ sig_bg, sig_accent, sig_icon = _sig_style(final_signal)
598
+
599
+ # KPI cards
600
+ mkt_cap_str = _fmt(fund.get("market_cap"), currency=cur_sym)
601
+ pe_str = _fmt_metric(fund.get("pe_ratio"), precision=1, zero_is_missing=True)
602
+ pb_str = _fmt_metric(fund.get("pb_ratio"), precision=2, zero_is_missing=True)
603
+ beta_str = _fmt(fund.get("beta"), precision=2)
604
+ w52_high = _fmt(fund.get("52w_high"), precision=2, currency=cur_sym)
605
+ w52_low = _fmt(fund.get("52w_low"), precision=2, currency=cur_sym)
606
+
607
+ kpis = "".join([
608
+ _kpi_card("当前价格",
609
+ _fmt(price, precision=2, currency=cur_sym),
610
+ chg_str, chg_color),
611
+ _kpi_card("市值", mkt_cap_str),
612
+ _kpi_card("市盈率 (TTM)", pe_str),
613
+ _kpi_card("市净率", pb_str),
614
+ _kpi_card("Beta", beta_str),
615
+ _kpi_card("52周区间", f"{w52_low} – {w52_high}"),
616
+ ])
617
+
618
+ # Metrics table — two column groups
619
+ roe = _fmt_metric(fund.get("roe"), precision=1, pct=True, zero_is_missing=True)
620
+ roa = _fmt_metric(fund.get("roa"), precision=1, pct=True, zero_is_missing=True)
621
+ gm = _fmt_metric(fund.get("gross_margin"), precision=1, pct=True, zero_is_missing=True)
622
+ om = _fmt_metric(fund.get("operating_margin"), precision=1, pct=True, zero_is_missing=True)
623
+ nm = _fmt_metric(fund.get("net_margin"), precision=1, pct=True, zero_is_missing=True)
624
+ rev_g = _fmt_metric(fund.get("revenue_growth"), precision=1, pct=True, zero_is_missing=True)
625
+ de = _fmt_metric(fund.get("debt_equity"), precision=2, zero_is_missing=True)
626
+ cr = _fmt_metric(fund.get("current_ratio"), precision=2, zero_is_missing=True)
627
+ dy = _fmt_metric(fund.get("dividend_yield"), precision=2, pct=True, zero_is_missing=True)
628
+ at = _fmt(fund.get("analyst_target"), precision=2, currency=cur_sym)
629
+ ac = fund.get("analyst_count") or "—"
630
+ rec = (fund.get("recommendation") or "").upper().replace("_"," ")
631
+ rsi = _fmt_metric(fund.get("rsi"), precision=1)
632
+ macd = _fmt_metric(fund.get("macd"), precision=3)
633
+ signal = _fmt_metric(fund.get("signal"), precision=3)
634
+ ma20 = _fmt(fund.get("ma20"), precision=2, currency=cur_sym)
635
+ ma60 = _fmt(fund.get("ma60"), precision=2, currency=cur_sym)
636
+ bb_upper = _fmt(fund.get("bb_upper"), precision=2, currency=cur_sym)
637
+ bb_lower = _fmt(fund.get("bb_lower"), precision=2, currency=cur_sym)
638
+
639
+ metrics_rows = "".join([
640
+ _metrics_row("收益/盈利", "", highlight=True),
641
+ _metrics_row("ROE", roe),
642
+ _metrics_row("ROA", roa),
643
+ _metrics_row("毛利率", gm),
644
+ _metrics_row("营业利润率", om),
645
+ _metrics_row("净利率", nm),
646
+ _metrics_row("收入增速", rev_g),
647
+ _metrics_row("财务健康", "", highlight=True),
648
+ _metrics_row("负债/权益", de),
649
+ _metrics_row("流动比率", cr),
650
+ _metrics_row("股息率", dy),
651
+ _metrics_row("分析师评级", "", highlight=True),
652
+ _metrics_row("评级", rec or "—"),
653
+ _metrics_row("目标价", at),
654
+ _metrics_row("覆盖分析师", str(ac)),
655
+ _metrics_row("技术指标", "", highlight=True),
656
+ _metrics_row("RSI(14)", rsi),
657
+ _metrics_row("MACD", macd),
658
+ _metrics_row("Signal", signal),
659
+ _metrics_row("MA20", ma20),
660
+ _metrics_row("MA60", ma60),
661
+ _metrics_row("布林上轨", bb_upper),
662
+ _metrics_row("布林下轨", bb_lower),
663
+ ])
664
+
665
+ # Chart
666
+ chart_section = ""
667
+ if price_chart and price_chart.lstrip().startswith("<svg"):
668
+ chart_section = price_chart
669
+ elif price_chart:
670
+ chart_section = (
671
+ f'<img src="data:image/png;base64,{price_chart}" '
672
+ f'style="width:100%;border-radius:6px;" alt="Price Chart"/>'
673
+ )
674
+ else:
675
+ chart_section = '<p class="no-data">图表暂不可用:历史价格数据不足或本地绘图库不可用。</p>'
676
+
677
+ # Synthesis
678
+ synthesis_html = ""
679
+ if synthesis:
680
+ synthesis_html = f"""
681
+ <section class="card">
682
+ <div class="card-header">综合结论</div>
683
+ <div class="card-body synthesis-body">{_md_to_html(synthesis)}</div>
684
+ <div class="synthesis-footer">
685
+ <span style="color:{sig_accent}">{sig_icon} {_e(final_signal)}</span>
686
+ &nbsp;&nbsp;置信度&nbsp;{confidence:.0%}
687
+ &nbsp;&nbsp;|&nbsp;&nbsp;耗时&nbsp;{getattr(team_result,"elapsed_sec",0):.1f}s
688
+ </div>
689
+ </section>"""
690
+
691
+ # Agent grid
692
+ agent_section = ""
693
+ if agent_cards:
694
+ agent_section = f"""
695
+ <section class="card">
696
+ <div class="card-header">多 Agent 研究团队</div>
697
+ <div class="agent-grid">{agent_cards}</div>
698
+ </section>"""
699
+
700
+ # Data quality section
701
+ quality_html = ""
702
+ if clean_result:
703
+ q = clean_result
704
+ if getattr(q, "df", None) is not None and not q.df.empty:
705
+ qc = "#3fb950" if q.quality_score >= 90 else (
706
+ "#d29922" if q.quality_score >= 70 else "#f85149")
707
+ quality_html = f"""
708
+ <section class="quality-bar">
709
+ <span>数据质量</span>
710
+ <span class="quality-score" style="color:{qc}">
711
+ {q.quality_score:.0f}/100
712
+ </span>
713
+ <span class="quality-detail">
714
+ {q.outlier_count} 异常 · {q.fill_count} 填充 ·
715
+ {q.real_gap_days} 缺口天
716
+ </span>
717
+ </section>"""
718
+ else:
719
+ quality_html = """
720
+ <section class="quality-bar">
721
+ <span>数据质量</span>
722
+ <span class="quality-score" style="color:#d29922">数据不足</span>
723
+ <span class="quality-detail">未取得足够历史行情,指标和图表已降级显示</span>
724
+ </section>"""
725
+
726
+ # Description
727
+ desc_html = ""
728
+ desc = (fund.get("description") or "").strip()
729
+ if desc:
730
+ desc_html = f"""
731
+ <section class="card">
732
+ <div class="card-header">公司简介</div>
733
+ <div class="card-body" style="font-size:13px;color:#8b949e;line-height:1.7">
734
+ {_e(desc)}
735
+ </div>
736
+ </section>"""
737
+
738
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
739
+ sector = fund.get("sector", "")
740
+ exch = fund.get("exchange", "")
741
+ sub = " · ".join(filter(None, [sector, exch, cur]))
742
+ source_chain = fund.get("data_provider_chain") or []
743
+ source_text = " → ".join(source_chain) if source_chain else "公开市场数据源"
744
+ warnings = fund.get("data_warnings") or []
745
+ warning_text = ""
746
+ if warnings:
747
+ warning_text = " · 数据降级: " + ";".join(str(w)[:120] for w in warnings[:3])
748
+
749
+ return _HTML_TEMPLATE.replace("{{CSS}}", _CSS) \
750
+ .replace("{{SYMBOL}}", _e(symbol)) \
751
+ .replace("{{COMPANY_NAME}}", _e(name)) \
752
+ .replace("{{SUBTITLE}}", _e(sub)) \
753
+ .replace("{{TIMESTAMP}}", _e(ts)) \
754
+ .replace("{{SIGNAL}}", _e(final_signal)) \
755
+ .replace("{{SIGNAL_BG}}", sig_bg) \
756
+ .replace("{{SIGNAL_ACCENT}}", sig_accent) \
757
+ .replace("{{SIGNAL_ICON}}", sig_icon) \
758
+ .replace("{{CONFIDENCE}}", f"{confidence:.0%}") \
759
+ .replace("{{KPI_CARDS}}", kpis) \
760
+ .replace("{{CHART_SECTION}}", chart_section) \
761
+ .replace("{{METRICS_ROWS}}", metrics_rows) \
762
+ .replace("{{AGENT_SECTION}}", agent_section) \
763
+ .replace("{{SYNTHESIS}}", synthesis_html) \
764
+ .replace("{{DESC_SECTION}}", desc_html) \
765
+ .replace("{{QUALITY_BAR}}", quality_html) \
766
+ .replace("{{DATA_SOURCE}}", _e(source_text + warning_text))
767
+
768
+
769
+ # ── Public API ────────────────────────────────────────────────────────────────
770
+
771
+ async def generate_report(
772
+ symbol: str,
773
+ team_result = None,
774
+ output_dir: Optional[Path] = None,
775
+ ) -> Optional[Path]:
776
+ """
777
+ Main entry point.
778
+
779
+ 1. Fetch + clean price data via data_cleaner
780
+ 2. Fetch fundamentals
781
+ 3. Generate price chart
782
+ 4. Render HTML with all data + agent analysis
783
+ 5. Write to output_dir / {SYMBOL}_report_{timestamp}.html
784
+ """
785
+ import pandas as _pd
786
+
787
+ ts_dt = datetime.now()
788
+ if output_dir:
789
+ out_dir = Path(output_dir)
790
+ out_dir.mkdir(parents=True, exist_ok=True)
791
+ artifact = None
792
+ else:
793
+ artifact = create_user_artifact("reports/market", symbol, f"{symbol}_market_report", ".html", timestamp=ts_dt)
794
+ out_dir = artifact.directory
795
+
796
+ logger.info("[report] generating %s", symbol)
797
+
798
+ # Fetch data (run in thread to avoid blocking the event loop)
799
+ loop = asyncio.get_event_loop()
800
+
801
+ try:
802
+ df, clean_result, fundamentals = await loop.run_in_executor(
803
+ None, lambda: _fetch_report_data_sync(symbol)
804
+ )
805
+ except Exception as e:
806
+ logger.error("[report] data fetch failed: %s", e)
807
+ df = _pd.DataFrame()
808
+ clean_result = None
809
+ import re as _re
810
+ _is_ashare = bool(_re.match(r"^[036]\d{5}$", symbol))
811
+ fundamentals = {"company_name": symbol, "symbol": symbol,
812
+ "currency": "CNY" if _is_ashare else "USD"}
813
+
814
+ # Generate chart (CPU-bound, run in thread)
815
+ price_chart = None
816
+ if not df.empty:
817
+ try:
818
+ price_chart = await loop.run_in_executor(
819
+ None, lambda: generate_price_chart(df, symbol, fundamentals)
820
+ )
821
+ except Exception as e:
822
+ logger.debug("[report] chart failed: %s", e)
823
+
824
+ # Render
825
+ try:
826
+ report_html = _build_html(symbol, fundamentals, price_chart,
827
+ team_result, clean_result)
828
+ except Exception as e:
829
+ logger.error("[report] render failed: %s", e)
830
+ return None
831
+
832
+ ts = ts_dt.strftime("%Y%m%d_%H%M")
833
+ out_f = artifact.path if artifact else out_dir / f"{symbol}_report_{ts}.html"
834
+ out_f.write_text(report_html, encoding="utf-8")
835
+
836
+ if artifact:
837
+ missing_fields = [
838
+ key for key in ("price", "market_cap", "pe_ratio", "pb_ratio", "roe", "rsi", "macd")
839
+ if fundamentals.get(key) in (None, "", 0)
840
+ ]
841
+ status = "complete" if not df.empty and fundamentals.get("price") else "data_unavailable"
842
+ if status == "complete" and missing_fields:
843
+ status = "partial"
844
+ provider_chain = fundamentals.get("data_provider_chain") or []
845
+ warnings = fundamentals.get("data_warnings") or []
846
+ data_quality = fundamentals.get("data_quality") or {}
847
+ write_artifact_metadata(artifact, {
848
+ "kind": "market_report",
849
+ "status": data_quality.get("status") or status,
850
+ "symbol": symbol,
851
+ "created_at": ts_dt.isoformat(timespec="seconds"),
852
+ "data": {
853
+ "provider_chain": provider_chain,
854
+ "warnings": warnings,
855
+ "errors": data_quality.get("errors") or [],
856
+ "stale": bool(data_quality.get("stale", False)),
857
+ "quality": data_quality,
858
+ "missing_fields": missing_fields,
859
+ "rows": int(len(df)) if df is not None else 0,
860
+ "quality_score": getattr(clean_result, "quality_score", None),
861
+ "chart_rendered": bool(price_chart),
862
+ },
863
+ "model": {
864
+ "team_result": bool(team_result),
865
+ },
866
+ })
867
+ raw_records = []
868
+ try:
869
+ raw_records = df.reset_index().tail(370).to_dict(orient="records") if df is not None and not df.empty else []
870
+ except Exception:
871
+ raw_records = []
872
+ write_artifact_raw_data(artifact, {
873
+ "symbol": symbol,
874
+ "fundamentals": fundamentals,
875
+ "prices": raw_records,
876
+ })
877
+
878
+ logger.info("[report] saved: %s", out_f)
879
+ return out_f
880
+
881
+
882
+ # ── PDF Export ────────────────────────────────────────────────────────────────
883
+
884
+ def export_pdf(html_path: Path) -> Optional[Path]:
885
+ """
886
+ Convert an HTML report to PDF alongside the source file.
887
+ Tries weasyprint (pure Python) first, then wkhtmltopdf binary.
888
+ Returns the PDF path on success, None if neither tool is available.
889
+ """
890
+ pdf_path = html_path.with_suffix(".pdf")
891
+
892
+ try:
893
+ import weasyprint
894
+ weasyprint.HTML(filename=str(html_path)).write_pdf(str(pdf_path))
895
+ logger.info("[report] pdf via weasyprint: %s", pdf_path)
896
+ return pdf_path
897
+ except ImportError:
898
+ pass
899
+ except Exception as e:
900
+ logger.debug("[report] weasyprint failed: %s", e)
901
+
902
+ import shutil, subprocess as _sp
903
+ if shutil.which("wkhtmltopdf"):
904
+ try:
905
+ r = _sp.run(
906
+ ["wkhtmltopdf", "--quiet", "--print-media-type",
907
+ str(html_path), str(pdf_path)],
908
+ capture_output=True, timeout=60,
909
+ )
910
+ if r.returncode == 0 and pdf_path.exists():
911
+ logger.info("[report] pdf via wkhtmltopdf: %s", pdf_path)
912
+ return pdf_path
913
+ except Exception as e:
914
+ logger.debug("[report] wkhtmltopdf failed: %s", e)
915
+
916
+ return None
917
+
918
+
919
+ # ── Reports Index ─────────────────────────────────────────────────────────────
920
+
921
+ def update_reports_index(reports_root: Path) -> Optional[Path]:
922
+ """
923
+ Scan reports_root recursively for *_report_*.html files and write index.html.
924
+ Returns the index path on success.
925
+ """
926
+ import re as _ri
927
+
928
+ index_path = reports_root / "index.html"
929
+ entries: List[Dict] = []
930
+
931
+ for f in sorted(reports_root.rglob("*_report_*.html"), reverse=True):
932
+ if f.name == "index.html":
933
+ continue
934
+ m = _ri.match(r"^(.+?)_report_(\d{8})_(\d{4})\.html$", f.name)
935
+ if not m:
936
+ continue
937
+ sym, ds, ts_ = m.group(1), m.group(2), m.group(3)
938
+ dt_str = f"{ds[:4]}-{ds[4:6]}-{ds[6:8]} {ts_[:2]}:{ts_[2:]}"
939
+ size_kb = max(1, f.stat().st_size // 1024)
940
+
941
+ signal = "HOLD"
942
+ try:
943
+ snip = f.read_text(encoding="utf-8", errors="ignore")[500:3000]
944
+ sm = _ri.search(r'\b(STRONG_BUY|STRONG_SELL|BUY|SELL|HOLD)\b', snip)
945
+ if sm:
946
+ signal = sm.group(1)
947
+ except Exception:
948
+ pass
949
+
950
+ try:
951
+ rel = f.relative_to(reports_root)
952
+ except ValueError:
953
+ rel = f.name
954
+ entries.append({"symbol": sym.upper(), "datetime": dt_str,
955
+ "signal": signal, "size_kb": size_kb,
956
+ "href": str(rel).replace("\\", "/")})
957
+
958
+ _SIG_COLOR = {"STRONG_BUY": "#3fb950", "BUY": "#3fb950",
959
+ "HOLD": "#79c0ff", "SELL": "#f85149", "STRONG_SELL": "#f85149"}
960
+
961
+ rows_html = ""
962
+ for e in entries:
963
+ sc = _SIG_COLOR.get(e["signal"], "#8b949e")
964
+ rows_html += (
965
+ f'<tr>'
966
+ f'<td><a href="{html.escape(e["href"])}" class="sym">'
967
+ f'{html.escape(e["symbol"])}</a></td>'
968
+ f'<td style="color:{sc};font-weight:700">{e["signal"]}</td>'
969
+ f'<td class="dim">{html.escape(e["datetime"])}</td>'
970
+ f'<td class="dim">{e["size_kb"]}KB</td>'
971
+ f'</tr>\n'
972
+ )
973
+
974
+ idx_html = f"""<!DOCTYPE html>
975
+ <html lang="zh-CN">
976
+ <head>
977
+ <meta charset="utf-8">
978
+ <meta name="viewport" content="width=device-width,initial-scale=1">
979
+ <title>Aria Code — 研报索引</title>
980
+ <style>
981
+ *{{box-sizing:border-box;margin:0;padding:0}}
982
+ body{{background:#010409;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
983
+ font-size:14px;padding:28px 24px;max-width:900px;margin:0 auto}}
984
+ h1{{font-size:22px;font-weight:700;margin-bottom:4px}}
985
+ .sub{{color:#8b949e;font-size:12px;margin-bottom:24px}}
986
+ table{{width:100%;border-collapse:collapse}}
987
+ th{{text-align:left;padding:8px 12px;border-bottom:2px solid #30363d;
988
+ color:#8b949e;font-size:11px;letter-spacing:.6px;text-transform:uppercase}}
989
+ td{{padding:9px 12px;border-bottom:1px solid #21262d}}
990
+ tr:hover td{{background:#161b22}}
991
+ a.sym{{color:#58a6ff;font-weight:700;text-decoration:none;font-size:15px}}
992
+ a.sym:hover{{text-decoration:underline}}
993
+ .dim{{color:#8b949e;font-size:12px}}
994
+ </style>
995
+ </head>
996
+ <body>
997
+ <h1>Aria Code 研报索引</h1>
998
+ <p class="sub">{len(entries)} 份报告 · 更新于 {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
999
+ <table>
1000
+ <thead><tr><th>标的</th><th>信号</th><th>生成时间</th><th>大小</th></tr></thead>
1001
+ <tbody>
1002
+ {rows_html}
1003
+ </tbody>
1004
+ </table>
1005
+ </body>
1006
+ </html>"""
1007
+
1008
+ index_path.write_text(idx_html, encoding="utf-8")
1009
+ logger.info("[report] index updated: %s (%d reports)", index_path, len(entries))
1010
+ return index_path
1011
+
1012
+
1013
+ # ── HTML Template & CSS ───────────────────────────────────────────────────────
1014
+
1015
+ _CSS = """
1016
+ :root {
1017
+ --bg0: #010409;
1018
+ --bg1: #0d1117;
1019
+ --bg2: #161b22;
1020
+ --bg3: #21262d;
1021
+ --text1: #e6edf3;
1022
+ --text2: #8b949e;
1023
+ --green: #3fb950;
1024
+ --red: #f85149;
1025
+ --blue: #388bfd;
1026
+ --purple: #8957e5;
1027
+ --orange: #f0883e;
1028
+ --border: #21262d;
1029
+ --radius: 8px;
1030
+ }
1031
+ * { box-sizing:border-box; margin:0; padding:0; }
1032
+ body {
1033
+ background: var(--bg0);
1034
+ color: var(--text1);
1035
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif;
1036
+ font-size: 14px;
1037
+ line-height: 1.6;
1038
+ padding: 28px 24px;
1039
+ max-width: 1180px;
1040
+ margin: 0 auto;
1041
+ }
1042
+ /* ── Header ── */
1043
+ .report-header {
1044
+ display: flex;
1045
+ align-items: flex-start;
1046
+ justify-content: space-between;
1047
+ border-bottom: 1px solid var(--border);
1048
+ padding-bottom: 18px;
1049
+ margin-bottom: 20px;
1050
+ flex-wrap: wrap;
1051
+ gap: 12px;
1052
+ }
1053
+ .header-left h1 {
1054
+ font-size: 26px;
1055
+ font-weight: 700;
1056
+ letter-spacing: -0.5px;
1057
+ color: var(--text1);
1058
+ }
1059
+ .header-left h1 .ticker {
1060
+ color: var(--green);
1061
+ margin-right: 10px;
1062
+ font-size: 28px;
1063
+ }
1064
+ .header-left .subtitle {
1065
+ color: var(--text2);
1066
+ font-size: 12px;
1067
+ margin-top: 4px;
1068
+ }
1069
+ .signal-badge {
1070
+ display: inline-flex;
1071
+ align-items: center;
1072
+ gap: 8px;
1073
+ padding: 10px 20px;
1074
+ border-radius: var(--radius);
1075
+ font-size: 18px;
1076
+ font-weight: 700;
1077
+ letter-spacing: 1px;
1078
+ border: 1px solid;
1079
+ }
1080
+ .report-meta {
1081
+ color: var(--text2);
1082
+ font-size: 12px;
1083
+ text-align: right;
1084
+ }
1085
+ /* ── KPI strip ── */
1086
+ .kpi-strip {
1087
+ display: flex;
1088
+ gap: 10px;
1089
+ flex-wrap: wrap;
1090
+ margin-bottom: 20px;
1091
+ }
1092
+ .kpi-card {
1093
+ background: var(--bg2);
1094
+ border: 1px solid var(--border);
1095
+ border-radius: var(--radius);
1096
+ padding: 12px 16px;
1097
+ flex: 1;
1098
+ min-width: 130px;
1099
+ }
1100
+ .kpi-label { font-size:11px; color:var(--text2); margin-bottom:4px; }
1101
+ .kpi-value { font-size:18px; font-weight:700; color:var(--text1); }
1102
+ .kpi-sub { font-size:11px; color:var(--text2); margin-top:3px; }
1103
+ /* ── Cards ── */
1104
+ .card {
1105
+ background: var(--bg1);
1106
+ border: 1px solid var(--border);
1107
+ border-radius: var(--radius);
1108
+ margin-bottom: 16px;
1109
+ overflow: hidden;
1110
+ }
1111
+ .card-header {
1112
+ background: var(--bg2);
1113
+ border-bottom: 1px solid var(--border);
1114
+ padding: 10px 16px;
1115
+ font-size: 12px;
1116
+ font-weight: 600;
1117
+ letter-spacing: 0.8px;
1118
+ text-transform: uppercase;
1119
+ color: var(--text2);
1120
+ }
1121
+ .card-body {
1122
+ padding: 16px;
1123
+ }
1124
+ /* ── Chart ── */
1125
+ .chart-card {
1126
+ background: var(--bg1);
1127
+ border: 1px solid var(--border);
1128
+ border-radius: var(--radius);
1129
+ padding: 4px;
1130
+ margin-bottom: 16px;
1131
+ }
1132
+ .no-data {
1133
+ color: var(--text2);
1134
+ font-style: italic;
1135
+ padding: 20px;
1136
+ text-align: center;
1137
+ }
1138
+ /* ── Two-column layout ── */
1139
+ .two-col {
1140
+ display: grid;
1141
+ grid-template-columns: 1fr 1fr;
1142
+ gap: 16px;
1143
+ margin-bottom: 16px;
1144
+ }
1145
+ @media (max-width: 700px) { .two-col { grid-template-columns: 1fr; } }
1146
+ /* ── Metrics table ── */
1147
+ .metrics-table {
1148
+ width: 100%;
1149
+ border-collapse: collapse;
1150
+ font-size: 13px;
1151
+ }
1152
+ .metrics-table td {
1153
+ padding: 6px 12px;
1154
+ border-bottom: 1px solid var(--bg3);
1155
+ }
1156
+ .metric-label { color: var(--text2); }
1157
+ .metric-value { color: var(--text1); text-align: right; font-weight: 500; }
1158
+ /* ── Agent cards ── */
1159
+ .agent-grid {
1160
+ display: grid;
1161
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
1162
+ gap: 12px;
1163
+ padding: 14px;
1164
+ }
1165
+ .agent-card {
1166
+ background: var(--bg2);
1167
+ border: 1px solid;
1168
+ border-radius: var(--radius);
1169
+ padding: 12px;
1170
+ }
1171
+ .agent-header {
1172
+ display: flex;
1173
+ align-items: center;
1174
+ gap: 10px;
1175
+ margin-bottom: 8px;
1176
+ }
1177
+ .agent-name { font-weight:700; font-size:12px; color:var(--text2);
1178
+ letter-spacing:0.8px; text-transform:uppercase; }
1179
+ .agent-signal { font-size:12px; font-weight:700; padding:2px 8px;
1180
+ border-radius:4px; }
1181
+ .agent-conf { font-size:12px; font-weight:600; margin-left:auto; }
1182
+ .agent-kps { padding-left:16px; margin-bottom:8px; }
1183
+ .agent-kps li { font-size:12px; color:var(--text2); margin-bottom:3px; }
1184
+ .agent-analysis { font-size:12px; color:var(--text2);
1185
+ border-top:1px solid var(--bg3); padding-top:8px;
1186
+ margin-top:4px; line-height:1.65; }
1187
+ .inline-chart { width:100%; height:auto; border-radius:6px; display:block; }
1188
+ /* ── Synthesis ── */
1189
+ .synthesis-body {
1190
+ font-size: 14px;
1191
+ line-height: 1.75;
1192
+ color: var(--text1);
1193
+ }
1194
+ /* ── Markdown elements ── */
1195
+ .md-table { width:100%; border-collapse:collapse; font-size:12px;
1196
+ margin:8px 0; border:1px solid var(--bg3); }
1197
+ .md-table th { background:var(--bg3); color:var(--text2); padding:5px 8px;
1198
+ text-align:left; font-weight:600; border:1px solid var(--border); }
1199
+ .md-table td { padding:4px 8px; border:1px solid var(--bg3); color:var(--text2); }
1200
+ .md-table tr:nth-child(even) { background:var(--bg2); }
1201
+ .md-h { display:block; color:var(--text1); margin:8px 0 4px; font-size:13px; }
1202
+ .md-hr { border:none; border-top:1px solid var(--border); margin:8px 0; }
1203
+ code { background:var(--bg3); color:var(--orange); padding:1px 4px;
1204
+ border-radius:3px; font-size:11px; font-family:monospace; }
1205
+ .synthesis-footer {
1206
+ border-top: 1px solid var(--border);
1207
+ padding: 10px 16px;
1208
+ font-size: 12px;
1209
+ color: var(--text2);
1210
+ display: flex;
1211
+ gap: 16px;
1212
+ align-items: center;
1213
+ }
1214
+ /* ── Quality bar ── */
1215
+ .quality-bar {
1216
+ display: flex;
1217
+ align-items: center;
1218
+ gap: 14px;
1219
+ background: var(--bg2);
1220
+ border: 1px solid var(--border);
1221
+ border-radius: var(--radius);
1222
+ padding: 10px 16px;
1223
+ font-size: 12px;
1224
+ color: var(--text2);
1225
+ margin-bottom: 16px;
1226
+ }
1227
+ .quality-score { font-size: 18px; font-weight: 700; }
1228
+ .quality-detail { color: var(--text2); font-size: 11px; }
1229
+ /* ── Footer ── */
1230
+ .report-footer {
1231
+ margin-top: 28px;
1232
+ padding-top: 14px;
1233
+ border-top: 1px solid var(--border);
1234
+ font-size: 11px;
1235
+ color: var(--text2);
1236
+ line-height: 1.8;
1237
+ }
1238
+ @media print {
1239
+ body { background:#fff; color:#000; padding:16px; }
1240
+ .signal-badge, .agent-card { border-color:#ccc !important; }
1241
+ .card, .kpi-card { background:#f8f8f8 !important; border-color:#ddd; }
1242
+ }
1243
+ """
1244
+
1245
+ _HTML_TEMPLATE = """<!DOCTYPE html>
1246
+ <html lang="zh-CN">
1247
+ <head>
1248
+ <meta charset="utf-8">
1249
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1250
+ <title>{{SYMBOL}} — Aria Research Report</title>
1251
+ <style>{{CSS}}</style>
1252
+ </head>
1253
+ <body>
1254
+
1255
+ <!-- Header -->
1256
+ <div class="report-header">
1257
+ <div class="header-left">
1258
+ <h1><span class="ticker">{{SYMBOL}}</span>{{COMPANY_NAME}}</h1>
1259
+ <div class="subtitle">{{SUBTITLE}}</div>
1260
+ </div>
1261
+ <div>
1262
+ <div class="signal-badge"
1263
+ style="background:{{SIGNAL_BG}};color:{{SIGNAL_ACCENT}};border-color:{{SIGNAL_ACCENT}}40;">
1264
+ {{SIGNAL_ICON}} {{SIGNAL}} &nbsp; {{CONFIDENCE}}
1265
+ </div>
1266
+ <div class="report-meta" style="margin-top:8px;">
1267
+ Aria Code Research<br>{{TIMESTAMP}}
1268
+ </div>
1269
+ </div>
1270
+ </div>
1271
+
1272
+ <!-- KPI Strip -->
1273
+ <div class="kpi-strip">
1274
+ {{KPI_CARDS}}
1275
+ </div>
1276
+
1277
+ <!-- Quality Bar -->
1278
+ {{QUALITY_BAR}}
1279
+
1280
+ <!-- Price Chart -->
1281
+ <div class="chart-card">
1282
+ {{CHART_SECTION}}
1283
+ </div>
1284
+
1285
+ <!-- Two-column: Agent analysis + Metrics table -->
1286
+ <div class="two-col">
1287
+ <div>
1288
+ {{AGENT_SECTION}}
1289
+ </div>
1290
+ <div>
1291
+ <section class="card">
1292
+ <div class="card-header">关键财务指标</div>
1293
+ <table class="metrics-table">
1294
+ <tbody>
1295
+ {{METRICS_ROWS}}
1296
+ </tbody>
1297
+ </table>
1298
+ </section>
1299
+ {{DESC_SECTION}}
1300
+ </div>
1301
+ </div>
1302
+
1303
+ <!-- Synthesis -->
1304
+ {{SYNTHESIS}}
1305
+
1306
+ <!-- Footer -->
1307
+ <div class="report-footer">
1308
+ <strong>免责声明</strong>:本报告由 Aria Code AI 系统自动生成,仅供参考,不构成任何投资建议或买卖推荐。
1309
+ 数据源:{{DATA_SOURCE}}。存在延迟,请以交易所官方数据为准。
1310
+ 投资有风险,入市需谨慎。&nbsp;·&nbsp; Aria Code &nbsp;·&nbsp; {{TIMESTAMP}}
1311
+ </div>
1312
+
1313
+ </body>
1314
+ </html>"""