aria-code 0.167.0__py3-none-win_amd64.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 (601) hide show
  1. aria_bots/__init__.py +7 -0
  2. aria_bots/channels/README.md +97 -0
  3. aria_bots/channels/__init__.py +1 -0
  4. aria_bots/channels/approvals.py +263 -0
  5. aria_bots/channels/conversation.py +280 -0
  6. aria_bots/channels/digest.py +186 -0
  7. aria_bots/channels/registry.py +95 -0
  8. aria_bots/engine.py +144 -0
  9. aria_bots/feishu_bot.py +1385 -0
  10. aria_bots/logistics/__init__.py +1 -0
  11. aria_bots/logistics/carriers.py +253 -0
  12. aria_bots/logistics/freight.py +154 -0
  13. aria_bots/logistics/inventory.py +242 -0
  14. aria_bots/logistics/mcp_server.py +156 -0
  15. aria_bots/logistics/records.py +119 -0
  16. aria_bots/logistics/tenancy.py +152 -0
  17. aria_bots/market.py +91 -0
  18. aria_bots/relay_client.py +326 -0
  19. aria_bots/telegram_bot.py +299 -0
  20. aria_code/__init__.py +111 -0
  21. aria_code/_bin/aria-native.exe +0 -0
  22. aria_code/_bin/aria-runtime.exe +0 -0
  23. aria_code/_version.py +20 -0
  24. aria_code/account_memory.py +296 -0
  25. aria_code/account_session.py +166 -0
  26. aria_code/adk_apps/__init__.py +1 -0
  27. aria_code/adk_apps/aria_code_review/__init__.py +1 -0
  28. aria_code/adk_apps/aria_code_review/agent.py +32 -0
  29. aria_code/adk_apps/aria_research/__init__.py +1 -0
  30. aria_code/adk_apps/aria_research/agent.py +31 -0
  31. aria_code/agents/__init__.py +49 -0
  32. aria_code/agents/base.py +329 -0
  33. aria_code/agents/code_review.py +200 -0
  34. aria_code/agents/deep/__init__.py +37 -0
  35. aria_code/agents/deep/calibration_loop.py +144 -0
  36. aria_code/agents/deep/critic.py +125 -0
  37. aria_code/agents/deep/deepen.py +193 -0
  38. aria_code/agents/deep/models.py +149 -0
  39. aria_code/agents/deep/pipeline.py +164 -0
  40. aria_code/agents/deep/quant_fusion.py +192 -0
  41. aria_code/agents/deep/themes.py +95 -0
  42. aria_code/agents/deep/tiers.py +106 -0
  43. aria_code/agents/engineering/__init__.py +14 -0
  44. aria_code/agents/engineering/coder.py +222 -0
  45. aria_code/agents/engineering/tester.py +145 -0
  46. aria_code/agents/financial/__init__.py +21 -0
  47. aria_code/agents/financial/cashflow_burn.py +119 -0
  48. aria_code/agents/financial/catalyst.py +279 -0
  49. aria_code/agents/financial/corporate_finance.py +175 -0
  50. aria_code/agents/financial/debate.py +224 -0
  51. aria_code/agents/financial/earnings.py +315 -0
  52. aria_code/agents/financial/fundamental.py +178 -0
  53. aria_code/agents/financial/macro.py +111 -0
  54. aria_code/agents/financial/news.py +231 -0
  55. aria_code/agents/financial/risk.py +146 -0
  56. aria_code/agents/financial/sector.py +279 -0
  57. aria_code/agents/financial/strategist.py +121 -0
  58. aria_code/agents/financial/stripe_revenue.py +127 -0
  59. aria_code/agents/financial/synthesis.py +274 -0
  60. aria_code/agents/financial/technical.py +277 -0
  61. aria_code/agents/orchestrator.py +44 -0
  62. aria_code/agents/portfolio_agent.py +392 -0
  63. aria_code/agents/realty/__init__.py +62 -0
  64. aria_code/agents/realty/asset_diagnosis.py +152 -0
  65. aria_code/agents/realty/business_match.py +166 -0
  66. aria_code/agents/realty/cashflow_verify.py +209 -0
  67. aria_code/agents/realty/contract_rules.py +210 -0
  68. aria_code/agents/realty/energy_anomaly.py +189 -0
  69. aria_code/agents/realty/exit_settlement.py +208 -0
  70. aria_code/agents/realty/fulfillment_risk.py +206 -0
  71. aria_code/agents/realty/ops_optimize.py +160 -0
  72. aria_code/agents/realty/revenue_share.py +215 -0
  73. aria_code/agents/registry.py +168 -0
  74. aria_code/agents/signal_scheme.py +111 -0
  75. aria_code/agents/sports/__init__.py +0 -0
  76. aria_code/agents/sports/football_agent.py +197 -0
  77. aria_code/agents/supervisor.py +113 -0
  78. aria_code/agents/team.py +500 -0
  79. aria_code/agents/video/__init__.py +16 -0
  80. aria_code/agents/video/director.py +376 -0
  81. aria_code/agents/video/qa_agent.py +143 -0
  82. aria_code/agents/video/script_doctor.py +112 -0
  83. aria_code/agents/warehouse/__init__.py +25 -0
  84. aria_code/agents/warehouse/contracts.py +26 -0
  85. aria_code/agents/warehouse/dashboard.py +155 -0
  86. aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
  87. aria_code/agents/warehouse/inbound_exceptions.py +25 -0
  88. aria_code/agents/warehouse/inventory_health.py +24 -0
  89. aria_code/agents/warehouse/local_run.py +71 -0
  90. aria_code/agents/warehouse/local_snapshot.py +132 -0
  91. aria_code/agents/warehouse/logistics_cost.py +84 -0
  92. aria_code/agents/warehouse/logistics_sync.py +21 -0
  93. aria_code/agents/warehouse/navigation.schema.json +110 -0
  94. aria_code/agents/warehouse/task_agent.py +172 -0
  95. aria_code/agents/warehouse/workflow.py +31 -0
  96. aria_code/aliyun_data_client.py +4 -0
  97. aria_code/apps/README.md +12 -0
  98. aria_code/apps/__init__.py +2 -0
  99. aria_code/apps/channels/README.md +9 -0
  100. aria_code/apps/channels/__init__.py +17 -0
  101. aria_code/apps/channels/approvals.py +11 -0
  102. aria_code/apps/channels/conversation.py +11 -0
  103. aria_code/apps/channels/digest.py +11 -0
  104. aria_code/apps/channels/intake.py +92 -0
  105. aria_code/apps/channels/registry.py +11 -0
  106. aria_code/apps/channels/tradingview.py +94 -0
  107. aria_code/apps/cli/README.md +13 -0
  108. aria_code/apps/cli/__init__.py +2 -0
  109. aria_code/apps/cli/_optional.py +120 -0
  110. aria_code/apps/cli/app_server.py +656 -0
  111. aria_code/apps/cli/auto_update.py +125 -0
  112. aria_code/apps/cli/bootstrap.py +206 -0
  113. aria_code/apps/cli/broker_render.py +206 -0
  114. aria_code/apps/cli/chat_turn.py +1327 -0
  115. aria_code/apps/cli/cloud_models.py +231 -0
  116. aria_code/apps/cli/codegen_paths.py +29 -0
  117. aria_code/apps/cli/commands/__init__.py +34 -0
  118. aria_code/apps/cli/commands/_business_render.py +78 -0
  119. aria_code/apps/cli/commands/_ui.py +71 -0
  120. aria_code/apps/cli/commands/analysis_cmds.py +378 -0
  121. aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
  122. aria_code/apps/cli/commands/auth_cmds.py +215 -0
  123. aria_code/apps/cli/commands/backtest_cmds.py +2518 -0
  124. aria_code/apps/cli/commands/broker_cmds.py +1236 -0
  125. aria_code/apps/cli/commands/business_workflow_cmds.py +379 -0
  126. aria_code/apps/cli/commands/canvas_cmds.py +77 -0
  127. aria_code/apps/cli/commands/catalog.py +189 -0
  128. aria_code/apps/cli/commands/core_cmds.py +2748 -0
  129. aria_code/apps/cli/commands/data_cmds.py +521 -0
  130. aria_code/apps/cli/commands/diagnostic_cmds.py +285 -0
  131. aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
  132. aria_code/apps/cli/commands/engineering_cmds.py +399 -0
  133. aria_code/apps/cli/commands/file_cmds.py +312 -0
  134. aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
  135. aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
  136. aria_code/apps/cli/commands/market.py +544 -0
  137. aria_code/apps/cli/commands/market_cmds.py +1419 -0
  138. aria_code/apps/cli/commands/market_context.py +432 -0
  139. aria_code/apps/cli/commands/market_render.py +7 -0
  140. aria_code/apps/cli/commands/model_cmds.py +2374 -0
  141. aria_code/apps/cli/commands/ops_cmds.py +721 -0
  142. aria_code/apps/cli/commands/orchestrator_cmds.py +181 -0
  143. aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
  144. aria_code/apps/cli/commands/portfolio_cmds.py +1316 -0
  145. aria_code/apps/cli/commands/report.py +476 -0
  146. aria_code/apps/cli/commands/scaffold_templates.py +617 -0
  147. aria_code/apps/cli/commands/session_cmds.py +236 -0
  148. aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
  149. aria_code/apps/cli/commands/team.py +723 -0
  150. aria_code/apps/cli/commands/team_render.py +8 -0
  151. aria_code/apps/cli/commands/ui_cmds.py +401 -0
  152. aria_code/apps/cli/commands/warehouse_cmds.py +297 -0
  153. aria_code/apps/cli/commands/workflow_cmds.py +629 -0
  154. aria_code/apps/cli/commands/workspace_cmds.py +1543 -0
  155. aria_code/apps/cli/config_paths.py +73 -0
  156. aria_code/apps/cli/config_store.py +158 -0
  157. aria_code/apps/cli/constants.py +88 -0
  158. aria_code/apps/cli/context.py +34 -0
  159. aria_code/apps/cli/conversation_policy.py +53 -0
  160. aria_code/apps/cli/deterministic.py +200 -0
  161. aria_code/apps/cli/direct.py +48 -0
  162. aria_code/apps/cli/earnings_view.py +172 -0
  163. aria_code/apps/cli/endpoint_migration.py +64 -0
  164. aria_code/apps/cli/exec_events.py +255 -0
  165. aria_code/apps/cli/file_references.py +155 -0
  166. aria_code/apps/cli/finance_service_catalog.py +111 -0
  167. aria_code/apps/cli/football_reports.py +231 -0
  168. aria_code/apps/cli/github_app_auth.py +135 -0
  169. aria_code/apps/cli/google_login.py +239 -0
  170. aria_code/apps/cli/handlers/__init__.py +11 -0
  171. aria_code/apps/cli/handlers/broker_handlers.py +122 -0
  172. aria_code/apps/cli/handlers/chart_handlers.py +1314 -0
  173. aria_code/apps/cli/handlers/market_handlers.py +2570 -0
  174. aria_code/apps/cli/handlers/realty_handlers.py +114 -0
  175. aria_code/apps/cli/handlers/sports_handlers.py +22 -0
  176. aria_code/apps/cli/handlers/strategy_advice.py +82 -0
  177. aria_code/apps/cli/headless.py +352 -0
  178. aria_code/apps/cli/helpers.py +326 -0
  179. aria_code/apps/cli/hooks.py +180 -0
  180. aria_code/apps/cli/i18n.py +310 -0
  181. aria_code/apps/cli/intent.py +136 -0
  182. aria_code/apps/cli/intent_router.py +305 -0
  183. aria_code/apps/cli/intent_shadow.py +72 -0
  184. aria_code/apps/cli/intent_signals.py +347 -0
  185. aria_code/apps/cli/lifecycle_hooks.py +48 -0
  186. aria_code/apps/cli/local_tool_schemas.py +550 -0
  187. aria_code/apps/cli/main.py +39 -0
  188. aria_code/apps/cli/market_hours.py +100 -0
  189. aria_code/apps/cli/market_metadata.py +135 -0
  190. aria_code/apps/cli/market_universe.py +487 -0
  191. aria_code/apps/cli/message_processing.py +310 -0
  192. aria_code/apps/cli/mixin_binding.py +32 -0
  193. aria_code/apps/cli/model_catalog.py +652 -0
  194. aria_code/apps/cli/model_probe.py +169 -0
  195. aria_code/apps/cli/model_retirement.py +65 -0
  196. aria_code/apps/cli/native_branding.py +12 -0
  197. aria_code/apps/cli/native_bridge.py +118 -0
  198. aria_code/apps/cli/native_entry.py +58 -0
  199. aria_code/apps/cli/pdf_report.py +977 -0
  200. aria_code/apps/cli/plan_mode.py +139 -0
  201. aria_code/apps/cli/plotly_html.py +15 -0
  202. aria_code/apps/cli/prediction_feedback.py +202 -0
  203. aria_code/apps/cli/preflight.py +502 -0
  204. aria_code/apps/cli/project_aria.py +60 -0
  205. aria_code/apps/cli/prompt_assembly.py +78 -0
  206. aria_code/apps/cli/prompts/__init__.py +0 -0
  207. aria_code/apps/cli/prompts/coding.py +71 -0
  208. aria_code/apps/cli/prompts/select.py +193 -0
  209. aria_code/apps/cli/prompts/system_prompts.py +562 -0
  210. aria_code/apps/cli/prompts/ui.py +436 -0
  211. aria_code/apps/cli/provider_endpoints.py +237 -0
  212. aria_code/apps/cli/provider_keys.py +73 -0
  213. aria_code/apps/cli/providers/__init__.py +1 -0
  214. aria_code/apps/cli/providers/base.py +775 -0
  215. aria_code/apps/cli/providers/chat_routing.py +139 -0
  216. aria_code/apps/cli/providers/collaboration.py +98 -0
  217. aria_code/apps/cli/providers/llm/__init__.py +1 -0
  218. aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
  219. aria_code/apps/cli/providers/llm/ollama_stream.py +1574 -0
  220. aria_code/apps/cli/providers/llm/response_cache.py +60 -0
  221. aria_code/apps/cli/providers/llm/sse_stream.py +298 -0
  222. aria_code/apps/cli/providers/runtime_bridge.py +586 -0
  223. aria_code/apps/cli/providers/vertexai_stream.py +592 -0
  224. aria_code/apps/cli/release_download.py +159 -0
  225. aria_code/apps/cli/review_runner.py +111 -0
  226. aria_code/apps/cli/runtime_consumer.py +769 -0
  227. aria_code/apps/cli/sandbox.py +32 -0
  228. aria_code/apps/cli/session_export.py +120 -0
  229. aria_code/apps/cli/session_jsonl.py +208 -0
  230. aria_code/apps/cli/session_refresh.py +123 -0
  231. aria_code/apps/cli/session_store.py +125 -0
  232. aria_code/apps/cli/skills_catalog.py +273 -0
  233. aria_code/apps/cli/task_isolation.py +211 -0
  234. aria_code/apps/cli/terminal_chrome.py +412 -0
  235. aria_code/apps/cli/todo_tracker.py +190 -0
  236. aria_code/apps/cli/tool_executor.py +932 -0
  237. aria_code/apps/cli/tool_registry.py +34 -0
  238. aria_code/apps/cli/tool_scope.py +113 -0
  239. aria_code/apps/cli/tools/__init__.py +40 -0
  240. aria_code/apps/cli/tools/context.py +46 -0
  241. aria_code/apps/cli/tools/file_tools.py +181 -0
  242. aria_code/apps/cli/tools/market_tools.py +626 -0
  243. aria_code/apps/cli/tools/notebook_tools.py +111 -0
  244. aria_code/apps/cli/tools/system_tools.py +656 -0
  245. aria_code/apps/cli/tools/write_tools.py +961 -0
  246. aria_code/apps/cli/tradingview_bridge.py +529 -0
  247. aria_code/apps/cli/transactions.py +269 -0
  248. aria_code/apps/cli/turn_planning.py +137 -0
  249. aria_code/apps/cli/update_check.py +257 -0
  250. aria_code/apps/cli/updater.py +373 -0
  251. aria_code/apps/cli/utils/__init__.py +0 -0
  252. aria_code/apps/cli/utils/market_detect.py +1663 -0
  253. aria_code/apps/cli/web_login.py +136 -0
  254. aria_code/apps/cli/workflow_runner.py +127 -0
  255. aria_code/apps/cli/workspace_route.py +121 -0
  256. aria_code/apps/daemon/README.md +14 -0
  257. aria_code/apps/vscode/README.md +115 -0
  258. aria_code/apps/vscode/package.json +70 -0
  259. aria_code/aria_cli.py +5244 -0
  260. aria_code/aria_daemon.py +1372 -0
  261. aria_code/aria_feishu_bot.py +16 -0
  262. aria_code/aria_mcp_server.py +61 -0
  263. aria_code/aria_relay_client.py +4 -0
  264. aria_code/aria_telegram_bot.py +11 -0
  265. aria_code/ariarc.py +385 -0
  266. aria_code/artifacts.py +581 -0
  267. aria_code/backtest_engine.py +4 -0
  268. aria_code/backtest_report.py +4 -0
  269. aria_code/brokers/__init__.py +78 -0
  270. aria_code/brokers/automation.py +193 -0
  271. aria_code/brokers/base.py +231 -0
  272. aria_code/brokers/capabilities.py +264 -0
  273. aria_code/brokers/cn/__init__.py +10 -0
  274. aria_code/brokers/cn/easytrader_broker.py +193 -0
  275. aria_code/brokers/cn/futu_broker.py +194 -0
  276. aria_code/brokers/cn/longbridge_broker.py +190 -0
  277. aria_code/brokers/cn/tiger_broker.py +196 -0
  278. aria_code/brokers/cn/xtquant_broker.py +225 -0
  279. aria_code/brokers/config.py +414 -0
  280. aria_code/brokers/intl/__init__.py +5 -0
  281. aria_code/brokers/intl/alpaca_broker.py +183 -0
  282. aria_code/brokers/intl/ibkr_broker.py +215 -0
  283. aria_code/brokers/intl/trading212_broker.py +183 -0
  284. aria_code/brokers/intl/webull_broker.py +156 -0
  285. aria_code/brokers/paper_broker.py +266 -0
  286. aria_code/brokers/planning.py +296 -0
  287. aria_code/brokers/registry.py +213 -0
  288. aria_code/brokers/trading.py +267 -0
  289. aria_code/canva_client.py +4 -0
  290. aria_code/change_store.py +127 -0
  291. aria_code/clients/__init__.py +0 -0
  292. aria_code/clients/aliyun_data_client.py +660 -0
  293. aria_code/clients/aria_relay_client.py +16 -0
  294. aria_code/clients/canva_client.py +284 -0
  295. aria_code/clients/figma_client.py +100 -0
  296. aria_code/clients/football_data_client.py +1671 -0
  297. aria_code/clients/kling_video_client.py +182 -0
  298. aria_code/clients/market_data_client.py +1985 -0
  299. aria_code/clients/openai_image_client.py +242 -0
  300. aria_code/clients/runway_video_client.py +157 -0
  301. aria_code/clients/warehouse_erp_client.py +105 -0
  302. aria_code/cloud_memory.py +190 -0
  303. aria_code/command_safety.py +19 -0
  304. aria_code/computer_use_server.py +276 -0
  305. aria_code/computer_use_tools.py +4 -0
  306. aria_code/dashboard_generator.py +579 -0
  307. aria_code/data_analysis_tools.py +4 -0
  308. aria_code/data_cleaner.py +483 -0
  309. aria_code/data_service.py +513 -0
  310. aria_code/datasources/__init__.py +23 -0
  311. aria_code/datasources/base.py +166 -0
  312. aria_code/datasources/router.py +253 -0
  313. aria_code/datasources/sec_statements.py +217 -0
  314. aria_code/datasources/sources/__init__.py +15 -0
  315. aria_code/datasources/sources/akshare_source.py +269 -0
  316. aria_code/datasources/sources/alpha_vantage_source.py +203 -0
  317. aria_code/datasources/sources/edgar_source.py +219 -0
  318. aria_code/datasources/sources/finnhub_source.py +198 -0
  319. aria_code/datasources/sources/fred_source.py +223 -0
  320. aria_code/datasources/sources/tushare_source.py +143 -0
  321. aria_code/datasources/sources/web_scraper_source.py +278 -0
  322. aria_code/datasources/sources/world_bank_source.py +206 -0
  323. aria_code/datasources/sources/yfinance_source.py +152 -0
  324. aria_code/doctor.py +860 -0
  325. aria_code/domain/__init__.py +0 -0
  326. aria_code/domain/_finance_core/__init__.py +5 -0
  327. aria_code/domain/_finance_core/backtest_engine.py +325 -0
  328. aria_code/domain/_finance_core/performance_stats.py +101 -0
  329. aria_code/domain/backtest_engine.py +75 -0
  330. aria_code/domain/backtest_report.py +488 -0
  331. aria_code/domain/finance_formulas.py +389 -0
  332. aria_code/domain/performance_stats.py +6 -0
  333. aria_code/domain/portfolio_ledger.py +307 -0
  334. aria_code/domain/strategy_vault.py +421 -0
  335. aria_code/evals/__init__.py +37 -0
  336. aria_code/evals/behavior.py +113 -0
  337. aria_code/evals/gap.py +93 -0
  338. aria_code/evals/harness.py +949 -0
  339. aria_code/evals/review_probe.py +132 -0
  340. aria_code/evals/runner.py +425 -0
  341. aria_code/evals/scorecard.py +322 -0
  342. aria_code/evals/summary.py +125 -0
  343. aria_code/external_agent_runner.py +115 -0
  344. aria_code/figma_client.py +4 -0
  345. aria_code/file_analysis_tools.py +4 -0
  346. aria_code/finance_formulas.py +4 -0
  347. aria_code/football_data_client.py +4 -0
  348. aria_code/image_gen_tools.py +4 -0
  349. aria_code/intent_classifier.py +459 -0
  350. aria_code/kling_video_client.py +4 -0
  351. aria_code/licensing.py +151 -0
  352. aria_code/local_finance_tools.py +4 -0
  353. aria_code/local_image_provider.py +4 -0
  354. aria_code/local_llm_provider.py +4 -0
  355. aria_code/macro_tools.py +4 -0
  356. aria_code/markdown_pdf.py +289 -0
  357. aria_code/market_data_client.py +4 -0
  358. aria_code/mcp_client.py +744 -0
  359. aria_code/memory_manager.py +273 -0
  360. aria_code/model_capability.py +512 -0
  361. aria_code/notification_tools.py +4 -0
  362. aria_code/openai_image_client.py +4 -0
  363. aria_code/packages/__init__.py +26 -0
  364. aria_code/packages/adk_bridge/__init__.py +11 -0
  365. aria_code/packages/adk_bridge/code_review_tools.py +49 -0
  366. aria_code/packages/adk_bridge/market_tools.py +125 -0
  367. aria_code/packages/aria_agents/__init__.py +5 -0
  368. aria_code/packages/aria_agents/manifest.py +78 -0
  369. aria_code/packages/aria_core/__init__.py +34 -0
  370. aria_code/packages/aria_core/architecture.py +269 -0
  371. aria_code/packages/aria_core/export.py +147 -0
  372. aria_code/packages/aria_core/manifest.py +65 -0
  373. aria_code/packages/aria_core/paths.py +57 -0
  374. aria_code/packages/aria_core/secure_file.py +68 -0
  375. aria_code/packages/aria_infra/__init__.py +15 -0
  376. aria_code/packages/aria_infra/arthera.py +58 -0
  377. aria_code/packages/aria_infra/doctor.py +246 -0
  378. aria_code/packages/aria_infra/product.py +40 -0
  379. aria_code/packages/aria_mcp/__init__.py +25 -0
  380. aria_code/packages/aria_mcp/bridge.py +232 -0
  381. aria_code/packages/aria_mcp/circuit.py +82 -0
  382. aria_code/packages/aria_mcp/config.py +162 -0
  383. aria_code/packages/aria_mcp/installer.py +95 -0
  384. aria_code/packages/aria_mcp/server.py +1705 -0
  385. aria_code/packages/aria_mcp/tools.py +81 -0
  386. aria_code/packages/aria_sdk/__init__.py +19 -0
  387. aria_code/packages/aria_sdk/client.py +393 -0
  388. aria_code/packages/aria_sdk/providers.py +70 -0
  389. aria_code/packages/aria_sdk/streaming.py +75 -0
  390. aria_code/packages/aria_sdk/types.py +86 -0
  391. aria_code/packages/aria_services/__init__.py +92 -0
  392. aria_code/packages/aria_services/cache.py +60 -0
  393. aria_code/packages/aria_services/context.py +356 -0
  394. aria_code/packages/aria_services/context_checkpoints.py +157 -0
  395. aria_code/packages/aria_services/data.py +11 -0
  396. aria_code/packages/aria_services/provider_health.py +304 -0
  397. aria_code/packages/aria_services/references.py +247 -0
  398. aria_code/packages/aria_services/registry.py +249 -0
  399. aria_code/packages/aria_services/research_protocol.py +137 -0
  400. aria_code/packages/aria_services/research_quality.py +186 -0
  401. aria_code/packages/aria_services/settings.py +160 -0
  402. aria_code/packages/aria_services/usage.py +151 -0
  403. aria_code/packages/aria_skills/__init__.py +46 -0
  404. aria_code/packages/aria_skills/catalog.py +149 -0
  405. aria_code/packages/aria_skills/loader.py +455 -0
  406. aria_code/packages/aria_skills/registry.py +84 -0
  407. aria_code/packages/aria_tools/__init__.py +5 -0
  408. aria_code/packages/aria_tools/financial/__init__.py +5 -0
  409. aria_code/packages/aria_tools/financial/_paths.py +92 -0
  410. aria_code/packages/aria_tools/registry.py +129 -0
  411. aria_code/packages/quant_engine/__init__.py +33 -0
  412. aria_code/packages/quant_engine/_namespace.py +24 -0
  413. aria_code/packages/quant_engine/backtest/__init__.py +10 -0
  414. aria_code/packages/quant_engine/backtest/core.py +53 -0
  415. aria_code/packages/quant_engine/backtest/engine.py +102 -0
  416. aria_code/packages/quant_engine/portfolio/__init__.py +9 -0
  417. aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
  418. aria_code/packages/quant_engine/sports/__init__.py +76 -0
  419. aria_code/packages/quant_engine/sports/calibrator.py +354 -0
  420. aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
  421. aria_code/packages/quant_engine/sports/elo.py +300 -0
  422. aria_code/packages/quant_engine/sports/form.py +188 -0
  423. aria_code/packages/quant_engine/sports/h2h.py +195 -0
  424. aria_code/packages/quant_engine/sports/predictor.py +312 -0
  425. aria_code/packages/quant_engine/sports/tracker.py +665 -0
  426. aria_code/packages/quant_engine/stochastic/__init__.py +31 -0
  427. aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  428. aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
  429. aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  430. aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  431. aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
  432. aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  433. aria_code/packs/__init__.py +85 -0
  434. aria_code/packs/base.py +168 -0
  435. aria_code/packs/finance/__init__.py +250 -0
  436. aria_code/packs/healthcare/__init__.py +40 -0
  437. aria_code/packs/logistics/__init__.py +171 -0
  438. aria_code/packs/payments/__init__.py +139 -0
  439. aria_code/packs/realty/__init__.py +190 -0
  440. aria_code/packs/registry.py +202 -0
  441. aria_code/packs/rules.py +90 -0
  442. aria_code/plan_utils.py +194 -0
  443. aria_code/plugin_loader.py +328 -0
  444. aria_code/portfolio_ledger.py +4 -0
  445. aria_code/preview_server.py +449 -0
  446. aria_code/privacy/__init__.py +5 -0
  447. aria_code/privacy/feedback.py +123 -0
  448. aria_code/project_review.py +278 -0
  449. aria_code/project_review_api.py +235 -0
  450. aria_code/project_review_server.py +18 -0
  451. aria_code/project_tools.py +4 -0
  452. aria_code/providers/__init__.py +30 -0
  453. aria_code/providers/google_video_provider.py +256 -0
  454. aria_code/providers/llm/__init__.py +19 -0
  455. aria_code/providers/llm/anthropic.py +184 -0
  456. aria_code/providers/llm/arthera_gateway.py +90 -0
  457. aria_code/providers/llm/autoconfig.py +213 -0
  458. aria_code/providers/llm/base.py +139 -0
  459. aria_code/providers/llm/custom.py +158 -0
  460. aria_code/providers/llm/ollama.py +137 -0
  461. aria_code/providers/llm/openai_compat.py +350 -0
  462. aria_code/providers/llm/registry.py +457 -0
  463. aria_code/providers/local_image_provider.py +202 -0
  464. aria_code/providers/local_llm_provider.py +549 -0
  465. aria_code/providers/ollama_models.py +79 -0
  466. aria_code/providers/openai_params.py +32 -0
  467. aria_code/providers/tool_messages.py +100 -0
  468. aria_code/realty_data_tools.py +4 -0
  469. aria_code/report_exporters.py +150 -0
  470. aria_code/report_generator.py +1764 -0
  471. aria_code/review_service.py +497 -0
  472. aria_code/runtime/__init__.py +176 -0
  473. aria_code/runtime/acceptance.py +632 -0
  474. aria_code/runtime/agent_loop.py +1851 -0
  475. aria_code/runtime/agent_profiles.py +75 -0
  476. aria_code/runtime/approval.py +71 -0
  477. aria_code/runtime/baseline.py +219 -0
  478. aria_code/runtime/budget.py +232 -0
  479. aria_code/runtime/checkpoints.py +481 -0
  480. aria_code/runtime/consult.py +65 -0
  481. aria_code/runtime/contract.py +282 -0
  482. aria_code/runtime/delivery.py +432 -0
  483. aria_code/runtime/events.py +113 -0
  484. aria_code/runtime/gateway.py +167 -0
  485. aria_code/runtime/lsp.py +484 -0
  486. aria_code/runtime/native_index.py +153 -0
  487. aria_code/runtime/orchestrator.py +55 -0
  488. aria_code/runtime/processes.py +343 -0
  489. aria_code/runtime/project_graph.py +614 -0
  490. aria_code/runtime/repo_map.py +712 -0
  491. aria_code/runtime/research_bundle.py +105 -0
  492. aria_code/runtime/review.py +269 -0
  493. aria_code/runtime/run_state.py +127 -0
  494. aria_code/runtime/run_store.py +522 -0
  495. aria_code/runtime/scoped_runs.py +113 -0
  496. aria_code/runtime/self_healing.py +422 -0
  497. aria_code/runtime/semantic_diff.py +171 -0
  498. aria_code/runtime/subagent.py +721 -0
  499. aria_code/runtime/symbol_edit.py +117 -0
  500. aria_code/runtime/task_brief.py +46 -0
  501. aria_code/runtime/task_context.py +145 -0
  502. aria_code/runtime/task_ledger.py +108 -0
  503. aria_code/runtime/task_orchestrator.py +90 -0
  504. aria_code/runtime/task_outcome.py +33 -0
  505. aria_code/runtime/task_verification.py +266 -0
  506. aria_code/runtime/task_worktree.py +448 -0
  507. aria_code/runtime/tool_executor.py +329 -0
  508. aria_code/runtime/tool_policy.py +107 -0
  509. aria_code/runtime/transactions.py +216 -0
  510. aria_code/runtime/windows_job.py +115 -0
  511. aria_code/runtime/workflows.py +222 -0
  512. aria_code/runtime/worktrees.py +215 -0
  513. aria_code/runway_video_client.py +4 -0
  514. aria_code/safety/__init__.py +27 -0
  515. aria_code/safety/client_isolation.py +178 -0
  516. aria_code/safety/permissions.py +339 -0
  517. aria_code/safety/risk.py +699 -0
  518. aria_code/safety/sandbox.py +148 -0
  519. aria_code/safety/service.py +102 -0
  520. aria_code/service_endpoints.py +14 -0
  521. aria_code/setup_wizard.py +684 -0
  522. aria_code/spreadsheet_tools.py +4 -0
  523. aria_code/strategy_vault.py +4 -0
  524. aria_code/tools/__init__.py +0 -0
  525. aria_code/tools/artifact_tools.py +252 -0
  526. aria_code/tools/broker_tools.py +92 -0
  527. aria_code/tools/build_quant_engine.py +97 -0
  528. aria_code/tools/code_audit_tools.py +192 -0
  529. aria_code/tools/computer_use_tools.py +509 -0
  530. aria_code/tools/data_analysis_tools.py +831 -0
  531. aria_code/tools/enterprise_finance_tools.py +202 -0
  532. aria_code/tools/extended_tools.py +240 -0
  533. aria_code/tools/file_analysis_tools.py +880 -0
  534. aria_code/tools/image_gen_tools.py +114 -0
  535. aria_code/tools/local_finance_tools.py +3488 -0
  536. aria_code/tools/logistics_carriers.py +11 -0
  537. aria_code/tools/logistics_inventory.py +11 -0
  538. aria_code/tools/logistics_io.py +11 -0
  539. aria_code/tools/logistics_tenancy.py +11 -0
  540. aria_code/tools/logistics_tools.py +11 -0
  541. aria_code/tools/macro_tools.py +412 -0
  542. aria_code/tools/notification_tools.py +249 -0
  543. aria_code/tools/project_tools.py +525 -0
  544. aria_code/tools/realty_data_tools.py +662 -0
  545. aria_code/tools/spreadsheet_tools.py +399 -0
  546. aria_code/tools/stripe_tools.py +115 -0
  547. aria_code/tools/video_tools.py +500 -0
  548. aria_code/training/__init__.py +1 -0
  549. aria_code/training/compare.py +117 -0
  550. aria_code/training/prelude_commands.py +111 -0
  551. aria_code/training/prelude_eval.py +390 -0
  552. aria_code/training/prelude_real.py +183 -0
  553. aria_code/training/prelude_train.py +248 -0
  554. aria_code/training/sft_dataset.py +230 -0
  555. aria_code/training/trajectory_dataset.py +263 -0
  556. aria_code/ui/__init__.py +105 -0
  557. aria_code/ui/banner.py +404 -0
  558. aria_code/ui/completer.py +571 -0
  559. aria_code/ui/console.py +285 -0
  560. aria_code/ui/input_box.py +505 -0
  561. aria_code/ui/mentions.py +206 -0
  562. aria_code/ui/picker.py +248 -0
  563. aria_code/ui/render/__init__.py +11 -0
  564. aria_code/ui/render/actions.py +297 -0
  565. aria_code/ui/render/finance.py +1732 -0
  566. aria_code/ui/render/market.py +253 -0
  567. aria_code/ui/render/output.py +954 -0
  568. aria_code/ui/render/responsive.py +75 -0
  569. aria_code/ui/render/team.py +367 -0
  570. aria_code/ui/robot.py +239 -0
  571. aria_code/ui/startup_dashboard.py +110 -0
  572. aria_code/video_analysis.py +147 -0
  573. aria_code/video_editor.py +518 -0
  574. aria_code/video_workflow/__init__.py +8 -0
  575. aria_code/video_workflow/__main__.py +61 -0
  576. aria_code/video_workflow/engines/__init__.py +18 -0
  577. aria_code/video_workflow/engines/base.py +69 -0
  578. aria_code/video_workflow/engines/custom_model.py +204 -0
  579. aria_code/video_workflow/engines/google_veo.py +94 -0
  580. aria_code/video_workflow/engines/local_engine.py +71 -0
  581. aria_code/video_workflow/engines/registry.py +98 -0
  582. aria_code/video_workflow/google_models.py +278 -0
  583. aria_code/video_workflow/music/__init__.py +5 -0
  584. aria_code/video_workflow/music/foley_service.py +132 -0
  585. aria_code/video_workflow/music/service.py +212 -0
  586. aria_code/video_workflow/pipeline.py +620 -0
  587. aria_code/video_workflow/plan.py +202 -0
  588. aria_code/video_workflow/session.py +297 -0
  589. aria_code/workspace/__init__.py +6 -0
  590. aria_code/workspace/files.py +219 -0
  591. aria_code/workspace/verify.py +167 -0
  592. aria_code/workspace_changes.py +199 -0
  593. aria_code/workspace_service.py +196 -0
  594. aria_code/workspace_tools.py +150 -0
  595. aria_code-0.167.0.dist-info/METADATA +330 -0
  596. aria_code-0.167.0.dist-info/RECORD +601 -0
  597. aria_code-0.167.0.dist-info/WHEEL +5 -0
  598. aria_code-0.167.0.dist-info/entry_points.txt +5 -0
  599. aria_code-0.167.0.dist-info/licenses/LICENSE +201 -0
  600. aria_code-0.167.0.dist-info/licenses/NOTICE +49 -0
  601. aria_code-0.167.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1764 @@
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 aria_code.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 _rsi_series(close, n: int = 14):
344
+ """Wilder's smoothed RSI — standard definition (EMA of gains/losses with
345
+ alpha=1/n), not the simple-rolling-mean variant used elsewhere in this
346
+ codebase for lightweight scalar snapshots (data_analysis_tools.py) —
347
+ this one needs to be a stable full series for charting, not just a
348
+ last-value approximation."""
349
+ delta = close.diff()
350
+ gain = delta.clip(lower=0)
351
+ loss = (-delta.clip(upper=0))
352
+ avg_gain = gain.ewm(alpha=1 / n, min_periods=n, adjust=False).mean()
353
+ avg_loss = loss.ewm(alpha=1 / n, min_periods=n, adjust=False).mean()
354
+ rs = avg_gain / avg_loss
355
+ return 100 - (100 / (1 + rs))
356
+
357
+
358
+ def _macd_series(close, fast: int = 12, slow: int = 26, signal: int = 9):
359
+ """Return (macd_line, signal_line, histogram) — standard EMA(12)-EMA(26)
360
+ with a 9-period EMA signal line."""
361
+ ema_fast = close.ewm(span=fast, adjust=False).mean()
362
+ ema_slow = close.ewm(span=slow, adjust=False).mean()
363
+ macd_line = ema_fast - ema_slow
364
+ signal_line = macd_line.ewm(span=signal, adjust=False).mean()
365
+ histogram = macd_line - signal_line
366
+ return macd_line, signal_line, histogram
367
+
368
+
369
+ def generate_indicator_chart(df, symbol: str) -> Optional[str]:
370
+ """Candlestick + volume + RSI(14) + MACD(12,26,9), each its own panel,
371
+ same dark theme as generate_price_chart. For traders who want the
372
+ indicators plotted, not just the raw price — generate_price_chart stays
373
+ minimal (price + MA20/50 only) on purpose since most report callers
374
+ don't need the extra panels."""
375
+ if df is None or df.empty:
376
+ return None
377
+
378
+ close_col = next((c for c in df.columns if c.lower() == "close"), None)
379
+ if not close_col:
380
+ return None
381
+
382
+ import pandas as _pd
383
+ try:
384
+ if not isinstance(df.index, _pd.DatetimeIndex):
385
+ df.index = _pd.to_datetime(df.index)
386
+ except Exception:
387
+ pass
388
+
389
+ df6 = df.tail(126).copy()
390
+
391
+ try:
392
+ import matplotlib
393
+ matplotlib.use("Agg")
394
+ import mplfinance as mpf
395
+
396
+ mc = mpf.make_marketcolors(
397
+ up="#3fb950", down="#f85149", edge="inherit",
398
+ wick={"up": "#3fb950", "down": "#f85149"},
399
+ volume={"up": "#1e4d2b", "down": "#4d1219"},
400
+ )
401
+ style = mpf.make_mpf_style(
402
+ marketcolors=mc,
403
+ facecolor="#0d1117",
404
+ edgecolor="#21262d",
405
+ figcolor="#0d1117",
406
+ gridcolor="#161b22",
407
+ gridstyle="--",
408
+ rc={
409
+ "axes.labelcolor": "#8b949e",
410
+ "axes.edgecolor": "#21262d",
411
+ "xtick.color": "#8b949e",
412
+ "ytick.color": "#8b949e",
413
+ "font.size": 9,
414
+ },
415
+ )
416
+
417
+ has_ohlcv = all(c in df6.columns for c in ("Open", "High", "Low", "Close", "Volume"))
418
+ plot_type = "candle" if has_ohlcv else "line"
419
+ close = df6["Close"]
420
+
421
+ rsi = _rsi_series(close)
422
+ macd_line, signal_line, hist = _macd_series(close)
423
+ hist_colors = ["#3fb950" if v >= 0 else "#f85149" for v in hist.fillna(0)]
424
+
425
+ # Panel numbers must be contiguous from 0 — mplfinance raises
426
+ # "inferred panel list is missing panels" if a number is skipped.
427
+ # Panel 1 is volume, but only when has_ohlcv (Volume column present);
428
+ # without it, RSI/MACD shift down one panel rather than leaving a
429
+ # gap at 1. Confirmed via a real Volume-less df: the earlier
430
+ # hardcoded panel=2/3 crashed with exactly that mplfinance error.
431
+ rsi_panel = 2 if has_ohlcv else 1
432
+ macd_panel = 3 if has_ohlcv else 2
433
+
434
+ # secondary_y=False is required on every one of these: mplfinance's
435
+ # default "auto" heuristic puts a constant series (the 30/70 RSI
436
+ # reference lines) on its own secondary axis when its value range is
437
+ # tiny relative to the panel's other lines, which silently
438
+ # misaligns them against the RSI line itself. Confirmed visually —
439
+ # without this, the dashed 30/70 lines render near the top of the
440
+ # panel instead of at the RSI scale's 30/70 marks.
441
+ addplots = [
442
+ mpf.make_addplot(rsi, panel=rsi_panel, color="#d29922", width=1.1, ylabel="RSI", secondary_y=False),
443
+ mpf.make_addplot([70] * len(df6), panel=rsi_panel, color="#484f58", width=0.7, linestyle="--", secondary_y=False),
444
+ mpf.make_addplot([30] * len(df6), panel=rsi_panel, color="#484f58", width=0.7, linestyle="--", secondary_y=False),
445
+ mpf.make_addplot(macd_line, panel=macd_panel, color="#388bfd", width=1.1, ylabel="MACD", secondary_y=False),
446
+ mpf.make_addplot(signal_line, panel=macd_panel, color="#d29922", width=1.0, secondary_y=False),
447
+ mpf.make_addplot(hist, panel=macd_panel, type="bar", color=hist_colors, width=0.7, alpha=0.6, secondary_y=False),
448
+ ]
449
+
450
+ kwargs: Dict[str, Any] = dict(
451
+ type=plot_type,
452
+ style=style,
453
+ figsize=(11, 9),
454
+ returnfig=True,
455
+ datetime_format="%m/%d",
456
+ xrotation=0,
457
+ addplot=addplots,
458
+ )
459
+ if has_ohlcv:
460
+ kwargs["volume"] = True
461
+ kwargs["volume_panel"] = 1
462
+ kwargs["panel_ratios"] = (3, 1, 1.2, 1.2)
463
+ else:
464
+ kwargs["panel_ratios"] = (3, 1.2, 1.2)
465
+
466
+ fig, axes = mpf.plot(df6, **kwargs)
467
+ axes[0].set_title(f"{symbol} · Price + Volume + RSI + MACD", color="#c9d1d9", fontsize=11, pad=8)
468
+ return _chart_to_b64(fig)
469
+
470
+ except ImportError:
471
+ return None
472
+ except Exception as e:
473
+ logger.debug("[report] indicator chart: %s", e)
474
+ return None
475
+
476
+
477
+ def generate_comparison_chart(price_data: Dict[str, Any], title: str = "") -> Optional[str]:
478
+ """Normalized (base=100) % return line chart across multiple symbols —
479
+ `price_data` is {symbol: df}, each df needing a Close column. Symbols
480
+ with too little history to be meaningfully comparable are silently
481
+ skipped rather than raising, since callers often pass a batch and one
482
+ bad symbol shouldn't kill the whole comparison."""
483
+ if not price_data:
484
+ return None
485
+
486
+ try:
487
+ import matplotlib
488
+ matplotlib.use("Agg")
489
+ import matplotlib.pyplot as plt
490
+ except ImportError:
491
+ return None
492
+
493
+ import pandas as _pd
494
+
495
+ palette = ["#3fb950", "#388bfd", "#d29922", "#f85149", "#8957e5", "#39c5cf", "#e3b341"]
496
+ fig, ax = plt.subplots(figsize=(11, 5.5), facecolor="#0d1117")
497
+ ax.set_facecolor("#0d1117")
498
+
499
+ plotted = 0
500
+ for i, (symbol, df) in enumerate(price_data.items()):
501
+ if df is None or getattr(df, "empty", True):
502
+ continue
503
+ close_col = next((c for c in df.columns if c.lower() == "close"), None)
504
+ if not close_col:
505
+ continue
506
+ close = df[close_col].dropna()
507
+ if len(close) < 2:
508
+ continue
509
+ try:
510
+ if not isinstance(close.index, _pd.DatetimeIndex):
511
+ close.index = _pd.to_datetime(close.index)
512
+ except Exception:
513
+ pass
514
+ close = close.tail(126)
515
+ normalized = close / close.iloc[0] * 100
516
+ color = palette[plotted % len(palette)]
517
+ ax.plot(normalized.index, normalized.values, color=color, linewidth=1.5, label=symbol)
518
+ plotted += 1
519
+
520
+ if plotted == 0:
521
+ plt.close(fig)
522
+ return None
523
+
524
+ ax.axhline(100, color="#484f58", linewidth=0.8, linestyle="--")
525
+ ax.set_title(title or "Normalized Performance Comparison (base=100)", color="#c9d1d9", fontsize=11)
526
+ ax.set_ylabel("Return (base=100)", color="#8b949e")
527
+ ax.tick_params(colors="#8b949e")
528
+ ax.spines[:].set_edgecolor("#21262d")
529
+ ax.grid(color="#161b22", linestyle="--", linewidth=0.5)
530
+ ax.legend(facecolor="#161b22", edgecolor="#21262d", labelcolor="#8b949e", fontsize=9, loc="upper left")
531
+ fig.tight_layout()
532
+ return _chart_to_b64(fig)
533
+
534
+
535
+ def generate_allocation_chart(positions: List[Dict[str, Any]], title: str = "") -> Optional[str]:
536
+ """Pie chart of position weights by market value — `positions` is the
537
+ list shape brokers.trading/registry already return
538
+ ({symbol, quantity, market_value, ...} or similar), not a new schema.
539
+ Positions with no usable value are excluded rather than shown as a
540
+ zero-width slice."""
541
+ if not positions:
542
+ return None
543
+
544
+ rows = []
545
+ for p in positions:
546
+ symbol = str(p.get("symbol") or p.get("ticker") or "?")
547
+ value = p.get("market_value")
548
+ if value is None:
549
+ qty = p.get("quantity") or p.get("qty")
550
+ price = p.get("current_price") or p.get("price")
551
+ if qty is not None and price is not None:
552
+ try:
553
+ value = float(qty) * float(price)
554
+ except (TypeError, ValueError):
555
+ value = None
556
+ try:
557
+ value = float(value) if value is not None else None
558
+ except (TypeError, ValueError):
559
+ value = None
560
+ if value and value > 0:
561
+ rows.append((symbol, value))
562
+
563
+ if not rows:
564
+ return None
565
+
566
+ rows.sort(key=lambda r: r[1], reverse=True)
567
+ labels = [r[0] for r in rows]
568
+ values = [r[1] for r in rows]
569
+
570
+ try:
571
+ import matplotlib
572
+ matplotlib.use("Agg")
573
+ import matplotlib.pyplot as plt
574
+ except ImportError:
575
+ return None
576
+
577
+ palette = ["#3fb950", "#388bfd", "#d29922", "#f85149", "#8957e5",
578
+ "#39c5cf", "#e3b341", "#79c0ff", "#ff7b72", "#a5d6ff"]
579
+ colors = [palette[i % len(palette)] for i in range(len(labels))]
580
+
581
+ fig, ax = plt.subplots(figsize=(7, 7), facecolor="#0d1117")
582
+ wedges, _texts, autotexts = ax.pie(
583
+ values, labels=labels, colors=colors, autopct="%1.1f%%",
584
+ pctdistance=0.8, textprops={"color": "#c9d1d9", "fontsize": 9},
585
+ wedgeprops={"edgecolor": "#0d1117", "linewidth": 1.5},
586
+ )
587
+ for t in autotexts:
588
+ t.set_color("#0d1117")
589
+ t.set_fontweight("bold")
590
+ ax.set_title(title or "Portfolio Allocation by Market Value", color="#c9d1d9", fontsize=11, pad=12)
591
+ fig.tight_layout()
592
+ return _chart_to_b64(fig)
593
+
594
+
595
+ def _generate_svg_line_chart(df, symbol: str) -> Optional[str]:
596
+ """Dependency-free inline SVG fallback for environments without chart libs."""
597
+ try:
598
+ if df is None or df.empty:
599
+ return None
600
+ close_col = next((c for c in df.columns if c.lower() == "close"), None)
601
+ if not close_col:
602
+ return None
603
+ values = [float(v) for v in df[close_col].dropna().tail(126).tolist() if math.isfinite(float(v))]
604
+ if len(values) < 2:
605
+ return None
606
+ width, height, pad = 920, 320, 34
607
+ lo, hi = min(values), max(values)
608
+ if math.isclose(lo, hi):
609
+ lo *= 0.99
610
+ hi *= 1.01
611
+ pts = []
612
+ for i, value in enumerate(values):
613
+ x = pad + (width - pad * 2) * i / max(len(values) - 1, 1)
614
+ y = pad + (height - pad * 2) * (1 - (value - lo) / (hi - lo))
615
+ pts.append(f"{x:.1f},{y:.1f}")
616
+ return f"""
617
+ <svg viewBox="0 0 {width} {height}" role="img" aria-label="{_e(symbol)} price chart" class="inline-chart">
618
+ <rect x="0" y="0" width="{width}" height="{height}" rx="8" fill="#0d1117"/>
619
+ <line x1="{pad}" y1="{height-pad}" x2="{width-pad}" y2="{height-pad}" stroke="#21262d"/>
620
+ <line x1="{pad}" y1="{pad}" x2="{pad}" y2="{height-pad}" stroke="#21262d"/>
621
+ <polyline points="{" ".join(pts)}" fill="none" stroke="#c08050" stroke-width="3"/>
622
+ <text x="{pad}" y="{pad - 10}" fill="#8b949e" font-size="12">{_e(symbol)} Close</text>
623
+ <text x="{width-pad-150}" y="{pad - 10}" fill="#8b949e" font-size="12">{values[-1]:,.2f}</text>
624
+ </svg>"""
625
+ except Exception as e:
626
+ logger.debug("[report] svg fallback: %s", e)
627
+ return None
628
+
629
+
630
+ # ── Number Formatting ─────────────────────────────────────────────────────────
631
+
632
+ def _fmt(val, precision: int = 2, pct: bool = False,
633
+ currency: str = "", na: str = "—") -> str:
634
+ if val is None:
635
+ return na
636
+ try:
637
+ v = float(val)
638
+ except (TypeError, ValueError):
639
+ return na
640
+ if not math.isfinite(v):
641
+ return na
642
+
643
+ if pct:
644
+ return f"{v * 100:.{precision}f}%"
645
+ if currency:
646
+ if abs(v) >= 1e12:
647
+ return f"{currency}{v/1e12:.2f}T"
648
+ if abs(v) >= 1e9:
649
+ return f"{currency}{v/1e9:.2f}B"
650
+ if abs(v) >= 1e6:
651
+ return f"{currency}{v/1e6:.2f}M"
652
+ return f"{currency}{v:,.{precision}f}"
653
+ return f"{v:,.{precision}f}"
654
+
655
+
656
+ def _fmt_metric(val, precision: int = 2, pct: bool = False,
657
+ currency: str = "", zero_is_missing: bool = False) -> str:
658
+ try:
659
+ v = float(val)
660
+ if zero_is_missing and abs(v) < 1e-12:
661
+ return "—"
662
+ except (TypeError, ValueError):
663
+ pass
664
+ return _fmt(val, precision=precision, pct=pct, currency=currency)
665
+
666
+
667
+ def _color_val(val, good_positive: bool = True) -> str:
668
+ """Return HTML color for a numeric value."""
669
+ try:
670
+ v = float(val)
671
+ if v > 0:
672
+ return "#3fb950" if good_positive else "#f85149"
673
+ if v < 0:
674
+ return "#f85149" if good_positive else "#3fb950"
675
+ except (TypeError, ValueError):
676
+ pass
677
+ return "#8b949e"
678
+
679
+
680
+ # ── HTML Builder ──────────────────────────────────────────────────────────────
681
+
682
+ def _e(text: str) -> str:
683
+ """HTML-escape user-facing strings."""
684
+ return html.escape(str(text or ""))
685
+
686
+
687
+ # export_pdf() below renders through weasyprint, which has no color-emoji glyph
688
+ # support and no emoji font in this report's CSS font stack (see font-family
689
+ # declarations above) — any raw emoji that reaches the page renders as a glyph
690
+ # from a mismatched fallback font, visibly offset from the text baseline
691
+ # ("floating" beside/above the surrounding text). Confirmed and fixed for the
692
+ # same root cause in apps/cli/pdf_report.py (2026-07-20); this module is a
693
+ # separate rendering system (per this file's own docstring — three independent
694
+ # HTML/CSS stacks in this codebase, not sharing code) so it needed its own
695
+ # copy of the fix rather than an import. Unlike pdf_report.py's structured
696
+ # signal columns, the risk here is narrower and specific: `analysis` and
697
+ # `key_points` are freeform LLM-authored prose (see _agent_card / synthesis),
698
+ # and models sometimes decorate analysis text with emoji unprompted — this is
699
+ # the one choke point (_md_to_html) all of that freeform text passes through
700
+ # before reaching the page, so stripping here catches it regardless of which
701
+ # caller introduces it next.
702
+ _EMOJI_RE = _re.compile(
703
+ "["
704
+ "\U0001F300-\U0001FAFF"
705
+ "\U00002600-\U000027BF"
706
+ "\U00002B00-\U00002BFF" # ⭐⬜ etc. — distinct block from the ↑/↓/→ arrows
707
+ # (U+2190-21FF) this codebase uses elsewhere for
708
+ # trend indicators; do not widen this to include it
709
+ "\U0001F100-\U0001F1FF" # enclosed alphanumeric supplement (🆕🆓🆙🆒🆗 etc.)
710
+ # + regional indicators (flag pairs), same block —
711
+ # widened 2026-07-21, see matching note in
712
+ # apps/cli/pdf_report.py's _EMOJI_RE
713
+ "\U0000FE0F"
714
+ "\U0000200D"
715
+ "]+"
716
+ )
717
+
718
+
719
+ def _strip_emoji(s: str) -> str:
720
+ return _EMOJI_RE.sub("", str(s or "")).strip()
721
+
722
+
723
+ def _md_to_html(text: str, max_chars: int = 0) -> str:
724
+ """Convert LLM-generated markdown to safe HTML for embedding in reports.
725
+
726
+ Order of operations: strip emoji → escape → fix &lt;br&gt; → tables →
727
+ inline styles → newlines.
728
+ """
729
+ if not text:
730
+ return ""
731
+ if max_chars and len(text) > max_chars:
732
+ text = text[:max_chars] + "…"
733
+
734
+ # 0. Strip emoji first — see _strip_emoji's docstring above (weasyprint
735
+ # can't render them correctly regardless of where in the pipeline they
736
+ # get removed; doing it before escaping keeps this step encoding-agnostic).
737
+ text = _strip_emoji(text)
738
+
739
+ # 1. HTML-escape all user content first (XSS prevention)
740
+ t = html.escape(text)
741
+
742
+ # 2. LLMs sometimes emit literal &lt;br&gt; inside markdown — convert to newline
743
+ t = t.replace("&lt;br&gt;", "\n")
744
+
745
+ # 3. Markdown tables → HTML tables
746
+ # Pre-join continuation lines: table cells may contain \n (from &lt;br&gt;).
747
+ # A non-pipe line immediately following a pipe line is part of that row's cell.
748
+ raw_lines = t.split("\n")
749
+ merged: List[str] = []
750
+ for raw_line in raw_lines:
751
+ if merged and merged[-1].lstrip().startswith("|") and raw_line and not raw_line.lstrip().startswith("|"):
752
+ merged[-1] += "<br>" + raw_line
753
+ else:
754
+ merged.append(raw_line)
755
+
756
+ out_lines: List[str] = []
757
+ i = 0
758
+ while i < len(merged):
759
+ line = merged[i]
760
+ # Detect header row: has | and next line is a separator (|---|---|)
761
+ if (
762
+ "|" in line
763
+ and i + 1 < len(merged)
764
+ and _re.match(r"^\s*\|[\s\-:|]+\|", merged[i + 1])
765
+ ):
766
+ header_cells = [c.strip() for c in line.strip("|").split("|")]
767
+ th = "".join(f"<th>{c}</th>" for c in header_cells)
768
+ i += 2 # skip header + separator
769
+ tbody_rows = ""
770
+ while i < len(merged) and "|" in merged[i] and merged[i].lstrip().startswith("|"):
771
+ cells = [c.strip() for c in merged[i].strip("|").split("|")]
772
+ tbody_rows += "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
773
+ i += 1
774
+ out_lines.append(
775
+ f'<table class="md-table">'
776
+ f"<thead><tr>{th}</tr></thead>"
777
+ f"<tbody>{tbody_rows}</tbody>"
778
+ f"</table>"
779
+ )
780
+ else:
781
+ out_lines.append(line)
782
+ i += 1
783
+ t = "\n".join(out_lines)
784
+
785
+ # 4. Bold: **text** or __text__
786
+ t = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", t, flags=_re.DOTALL)
787
+ t = _re.sub(r"__(.+?)__", r"<strong>\1</strong>", t, flags=_re.DOTALL)
788
+
789
+ # 5. Italic: *text* (not **)
790
+ t = _re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", t)
791
+
792
+ # 6. Inline code
793
+ t = _re.sub(r"`([^`]+)`", r"<code>\1</code>", t)
794
+
795
+ # 7. H2/H3 headers (## Heading → styled paragraph)
796
+ t = _re.sub(r"^#{1,3}\s+(.+)$", r'<strong class="md-h">\1</strong>', t, flags=_re.MULTILINE)
797
+
798
+ # 8. Horizontal rules (--- or ***)
799
+ t = _re.sub(r"\n[-*]{3,}\n", '\n<hr class="md-hr">\n', t)
800
+
801
+ # 9. Newlines → <br> (done last so table/hr markup isn't broken)
802
+ t = t.replace("\n", "<br>\n")
803
+
804
+ return t
805
+
806
+
807
+ def _kpi_card(label: str, value: str, sub: str = "", color: str = "#e6edf3") -> str:
808
+ return f"""
809
+ <div class="kpi-card">
810
+ <div class="kpi-label">{_e(label)}</div>
811
+ <div class="kpi-value" style="color:{color}">{_e(value)}</div>
812
+ {f'<div class="kpi-sub">{_e(sub)}</div>' if sub else ""}
813
+ </div>"""
814
+
815
+
816
+ def _agent_card(agent_name: str, signal: str, confidence: float,
817
+ analysis: str, key_points: List[str]) -> str:
818
+ bg, accent, icon = _sig_style(signal)
819
+ kps = "".join(f'<li>{_md_to_html(kp)}</li>' for kp in (key_points or [])[:4])
820
+ analysis_html = _md_to_html(analysis or "", max_chars=3000)
821
+ return f"""
822
+ <div class="agent-card" style="border-color:{accent}40;">
823
+ <div class="agent-header">
824
+ <span class="agent-name">{_e(agent_name.upper())}</span>
825
+ <span class="agent-signal" style="color:{accent};background:{bg};">
826
+ {icon} {_e(signal)}
827
+ </span>
828
+ <span class="agent-conf" style="color:{accent};">{confidence:.0%}</span>
829
+ </div>
830
+ {f'<ul class="agent-kps">{kps}</ul>' if kps else ""}
831
+ {f'<div class="agent-analysis">{analysis_html}</div>' if analysis_html else ""}
832
+ </div>"""
833
+
834
+
835
+ def _metrics_row(label: str, value: str, highlight: bool = False) -> str:
836
+ style = "background:#161b22;" if highlight else ""
837
+ return (f'<tr style="{style}">'
838
+ f'<td class="metric-label">{_e(label)}</td>'
839
+ f'<td class="metric-value">{_e(value)}</td>'
840
+ f'</tr>')
841
+
842
+
843
+ def _build_html(
844
+ symbol: str,
845
+ fundamentals: Dict,
846
+ price_chart: Optional[str],
847
+ team_result,
848
+ clean_result,
849
+ ) -> str:
850
+ fund = fundamentals
851
+ name = fund.get("company_name", symbol)
852
+ cur = fund.get("currency", "USD")
853
+ cur_sym = "¥" if cur in ("CNY","CNH","HKD") else "$"
854
+ price = fund.get("price")
855
+ prev = fund.get("prev_close")
856
+
857
+ # Price change
858
+ if price and prev and prev > 0:
859
+ chg = price - prev
860
+ chg_pct = chg / prev * 100
861
+ if abs(chg) < 0.001:
862
+ chg_str = "—"
863
+ chg_color = "#8b949e"
864
+ else:
865
+ chg_str = f"{chg:+.2f} ({chg_pct:+.2f}%)"
866
+ chg_color = "#3fb950" if chg >= 0 else "#f85149"
867
+ else:
868
+ chg_str = "—"
869
+ chg_color = "#8b949e"
870
+
871
+ # Final signal from team result
872
+ final_signal = "HOLD"
873
+ confidence = 0.0
874
+ synthesis = ""
875
+ agent_cards = ""
876
+
877
+ if team_result:
878
+ final_signal = team_result.final_signal or "HOLD"
879
+ confidence = team_result.confidence or 0.0
880
+ synthesis = team_result.synthesis or ""
881
+ cards = []
882
+ for r in (team_result.results or []):
883
+ if not r or r.agent == "debate":
884
+ continue
885
+ cards.append(_agent_card(
886
+ r.agent, r.signal or "HOLD", r.confidence,
887
+ r.analysis, r.key_points,
888
+ ))
889
+ agent_cards = "\n".join(cards)
890
+
891
+ sig_bg, sig_accent, sig_icon = _sig_style(final_signal)
892
+
893
+ # KPI cards
894
+ mkt_cap_str = _fmt(fund.get("market_cap"), currency=cur_sym)
895
+ pe_str = _fmt_metric(fund.get("pe_ratio"), precision=1, zero_is_missing=True)
896
+ pb_str = _fmt_metric(fund.get("pb_ratio"), precision=2, zero_is_missing=True)
897
+ beta_str = _fmt(fund.get("beta"), precision=2)
898
+ w52_high = _fmt(fund.get("52w_high"), precision=2, currency=cur_sym)
899
+ w52_low = _fmt(fund.get("52w_low"), precision=2, currency=cur_sym)
900
+
901
+ kpis = "".join([
902
+ _kpi_card("当前价格",
903
+ _fmt(price, precision=2, currency=cur_sym),
904
+ chg_str, chg_color),
905
+ _kpi_card("市值", mkt_cap_str),
906
+ _kpi_card("市盈率 (TTM)", pe_str),
907
+ _kpi_card("市净率", pb_str),
908
+ _kpi_card("Beta", beta_str),
909
+ _kpi_card("52周区间", f"{w52_low} – {w52_high}"),
910
+ ])
911
+
912
+ # Metrics table — two column groups
913
+ roe = _fmt_metric(fund.get("roe"), precision=1, pct=True, zero_is_missing=True)
914
+ roa = _fmt_metric(fund.get("roa"), precision=1, pct=True, zero_is_missing=True)
915
+ gm = _fmt_metric(fund.get("gross_margin"), precision=1, pct=True, zero_is_missing=True)
916
+ om = _fmt_metric(fund.get("operating_margin"), precision=1, pct=True, zero_is_missing=True)
917
+ nm = _fmt_metric(fund.get("net_margin"), precision=1, pct=True, zero_is_missing=True)
918
+ rev_g = _fmt_metric(fund.get("revenue_growth"), precision=1, pct=True, zero_is_missing=True)
919
+ de = _fmt_metric(fund.get("debt_equity"), precision=2, zero_is_missing=True)
920
+ cr = _fmt_metric(fund.get("current_ratio"), precision=2, zero_is_missing=True)
921
+ dy = _fmt_metric(fund.get("dividend_yield"), precision=2, pct=True, zero_is_missing=True)
922
+ at = _fmt(fund.get("analyst_target"), precision=2, currency=cur_sym)
923
+ ac = fund.get("analyst_count") or "—"
924
+ rec = (fund.get("recommendation") or "").upper().replace("_"," ")
925
+ rsi = _fmt_metric(fund.get("rsi"), precision=1)
926
+ macd = _fmt_metric(fund.get("macd"), precision=3)
927
+ signal = _fmt_metric(fund.get("signal"), precision=3)
928
+ ma20 = _fmt(fund.get("ma20"), precision=2, currency=cur_sym)
929
+ ma60 = _fmt(fund.get("ma60"), precision=2, currency=cur_sym)
930
+ bb_upper = _fmt(fund.get("bb_upper"), precision=2, currency=cur_sym)
931
+ bb_lower = _fmt(fund.get("bb_lower"), precision=2, currency=cur_sym)
932
+
933
+ metrics_rows = "".join([
934
+ _metrics_row("收益/盈利", "", highlight=True),
935
+ _metrics_row("ROE", roe),
936
+ _metrics_row("ROA", roa),
937
+ _metrics_row("毛利率", gm),
938
+ _metrics_row("营业利润率", om),
939
+ _metrics_row("净利率", nm),
940
+ _metrics_row("收入增速", rev_g),
941
+ _metrics_row("财务健康", "", highlight=True),
942
+ _metrics_row("负债/权益", de),
943
+ _metrics_row("流动比率", cr),
944
+ _metrics_row("股息率", dy),
945
+ _metrics_row("分析师评级", "", highlight=True),
946
+ _metrics_row("评级", rec or "—"),
947
+ _metrics_row("目标价", at),
948
+ _metrics_row("覆盖分析师", str(ac)),
949
+ _metrics_row("技术指标", "", highlight=True),
950
+ _metrics_row("RSI(14)", rsi),
951
+ _metrics_row("MACD", macd),
952
+ _metrics_row("Signal", signal),
953
+ _metrics_row("MA20", ma20),
954
+ _metrics_row("MA60", ma60),
955
+ _metrics_row("布林上轨", bb_upper),
956
+ _metrics_row("布林下轨", bb_lower),
957
+ ])
958
+
959
+ # Chart
960
+ chart_section = ""
961
+ if price_chart and price_chart.lstrip().startswith("<svg"):
962
+ chart_section = price_chart
963
+ elif price_chart:
964
+ chart_section = (
965
+ f'<img src="data:image/png;base64,{price_chart}" '
966
+ f'style="width:100%;border-radius:6px;" alt="Price Chart"/>'
967
+ )
968
+ else:
969
+ chart_section = '<p class="no-data">图表暂不可用:历史价格数据不足或本地绘图库不可用。</p>'
970
+
971
+ # Synthesis
972
+ synthesis_html = ""
973
+ if synthesis:
974
+ synthesis_html = f"""
975
+ <section class="card">
976
+ <div class="card-header">综合结论</div>
977
+ <div class="card-body synthesis-body">{_md_to_html(synthesis)}</div>
978
+ <div class="synthesis-footer">
979
+ <span style="color:{sig_accent}">{sig_icon} {_e(final_signal)}</span>
980
+ &nbsp;&nbsp;置信度&nbsp;{confidence:.0%}
981
+ &nbsp;&nbsp;|&nbsp;&nbsp;耗时&nbsp;{getattr(team_result,"elapsed_sec",0):.1f}s
982
+ </div>
983
+ </section>"""
984
+
985
+ # Agent grid
986
+ agent_section = ""
987
+ if agent_cards:
988
+ agent_section = f"""
989
+ <section class="card">
990
+ <div class="card-header">多 Agent 研究团队</div>
991
+ <div class="agent-grid">{agent_cards}</div>
992
+ </section>"""
993
+
994
+ # Data quality section
995
+ quality_html = ""
996
+ if clean_result:
997
+ q = clean_result
998
+ if getattr(q, "df", None) is not None and not q.df.empty:
999
+ qc = "#3fb950" if q.quality_score >= 90 else (
1000
+ "#d29922" if q.quality_score >= 70 else "#f85149")
1001
+ quality_html = f"""
1002
+ <section class="quality-bar">
1003
+ <span>数据质量</span>
1004
+ <span class="quality-score" style="color:{qc}">
1005
+ {q.quality_score:.0f}/100
1006
+ </span>
1007
+ <span class="quality-detail">
1008
+ {q.outlier_count} 异常 · {q.fill_count} 填充 ·
1009
+ {q.real_gap_days} 缺口天
1010
+ </span>
1011
+ </section>"""
1012
+ else:
1013
+ quality_html = """
1014
+ <section class="quality-bar">
1015
+ <span>数据质量</span>
1016
+ <span class="quality-score" style="color:#d29922">数据不足</span>
1017
+ <span class="quality-detail">未取得足够历史行情,指标和图表已降级显示</span>
1018
+ </section>"""
1019
+
1020
+ # Description
1021
+ desc_html = ""
1022
+ desc = (fund.get("description") or "").strip()
1023
+ if desc:
1024
+ desc_html = f"""
1025
+ <section class="card">
1026
+ <div class="card-header">公司简介</div>
1027
+ <div class="card-body" style="font-size:13px;color:#8b949e;line-height:1.7">
1028
+ {_e(desc)}
1029
+ </div>
1030
+ </section>"""
1031
+
1032
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
1033
+ sector = fund.get("sector", "")
1034
+ exch = fund.get("exchange", "")
1035
+ sub = " · ".join(filter(None, [sector, exch, cur]))
1036
+ source_chain = fund.get("data_provider_chain") or []
1037
+ source_text = " → ".join(source_chain) if source_chain else "公开市场数据源"
1038
+ warnings = fund.get("data_warnings") or []
1039
+ warning_text = ""
1040
+ if warnings:
1041
+ warning_text = " · 数据降级: " + ";".join(str(w)[:120] for w in warnings[:3])
1042
+
1043
+ return _HTML_TEMPLATE.replace("{{CSS}}", _CSS) \
1044
+ .replace("{{SYMBOL}}", _e(symbol)) \
1045
+ .replace("{{COMPANY_NAME}}", _e(name)) \
1046
+ .replace("{{SUBTITLE}}", _e(sub)) \
1047
+ .replace("{{TIMESTAMP}}", _e(ts)) \
1048
+ .replace("{{SIGNAL}}", _e(final_signal)) \
1049
+ .replace("{{SIGNAL_BG}}", sig_bg) \
1050
+ .replace("{{SIGNAL_ACCENT}}", sig_accent) \
1051
+ .replace("{{SIGNAL_ICON}}", sig_icon) \
1052
+ .replace("{{CONFIDENCE}}", f"{confidence:.0%}") \
1053
+ .replace("{{KPI_CARDS}}", kpis) \
1054
+ .replace("{{CHART_SECTION}}", chart_section) \
1055
+ .replace("{{METRICS_ROWS}}", metrics_rows) \
1056
+ .replace("{{AGENT_SECTION}}", agent_section) \
1057
+ .replace("{{SYNTHESIS}}", synthesis_html) \
1058
+ .replace("{{DESC_SECTION}}", desc_html) \
1059
+ .replace("{{QUALITY_BAR}}", quality_html) \
1060
+ .replace("{{DATA_SOURCE}}", _e(source_text + warning_text))
1061
+
1062
+
1063
+ # ── Public API ────────────────────────────────────────────────────────────────
1064
+
1065
+ async def generate_report(
1066
+ symbol: str,
1067
+ team_result = None,
1068
+ output_dir: Optional[Path] = None,
1069
+ ) -> Optional[Path]:
1070
+ """
1071
+ Main entry point.
1072
+
1073
+ 1. Fetch + clean price data via data_cleaner
1074
+ 2. Fetch fundamentals
1075
+ 3. Generate price chart
1076
+ 4. Render HTML with all data + agent analysis
1077
+ 5. Write to output_dir / {SYMBOL}_report_{timestamp}.html
1078
+ """
1079
+ import pandas as _pd
1080
+
1081
+ ts_dt = datetime.now()
1082
+ if output_dir:
1083
+ out_dir = Path(output_dir)
1084
+ out_dir.mkdir(parents=True, exist_ok=True)
1085
+ artifact = None
1086
+ else:
1087
+ artifact = create_user_artifact("reports/market", symbol, f"{symbol}_market_report", ".html", timestamp=ts_dt)
1088
+ out_dir = artifact.directory
1089
+
1090
+ logger.info("[report] generating %s", symbol)
1091
+
1092
+ # Fetch data (run in thread to avoid blocking the event loop)
1093
+ loop = asyncio.get_event_loop()
1094
+
1095
+ try:
1096
+ df, clean_result, fundamentals = await loop.run_in_executor(
1097
+ None, lambda: _fetch_report_data_sync(symbol)
1098
+ )
1099
+ except Exception as e:
1100
+ logger.error("[report] data fetch failed: %s", e)
1101
+ df = _pd.DataFrame()
1102
+ clean_result = None
1103
+ import re as _re
1104
+ _is_ashare = bool(_re.match(r"^[036]\d{5}$", symbol))
1105
+ fundamentals = {"company_name": symbol, "symbol": symbol,
1106
+ "currency": "CNY" if _is_ashare else "USD"}
1107
+
1108
+ # Generate chart (CPU-bound, run in thread)
1109
+ price_chart = None
1110
+ if not df.empty:
1111
+ try:
1112
+ price_chart = await loop.run_in_executor(
1113
+ None, lambda: generate_price_chart(df, symbol, fundamentals)
1114
+ )
1115
+ except Exception as e:
1116
+ logger.debug("[report] chart failed: %s", e)
1117
+
1118
+ # Render
1119
+ try:
1120
+ report_html = _build_html(symbol, fundamentals, price_chart,
1121
+ team_result, clean_result)
1122
+ except Exception as e:
1123
+ logger.error("[report] render failed: %s", e)
1124
+ return None
1125
+
1126
+ ts = ts_dt.strftime("%Y%m%d_%H%M")
1127
+ out_f = artifact.path if artifact else out_dir / f"{symbol}_report_{ts}.html"
1128
+ out_f.write_text(report_html, encoding="utf-8")
1129
+
1130
+ if artifact:
1131
+ missing_fields = [
1132
+ key for key in ("price", "market_cap", "pe_ratio", "pb_ratio", "roe", "rsi", "macd")
1133
+ if fundamentals.get(key) in (None, "", 0)
1134
+ ]
1135
+ status = "complete" if not df.empty and fundamentals.get("price") else "data_unavailable"
1136
+ if status == "complete" and missing_fields:
1137
+ status = "partial"
1138
+ provider_chain = fundamentals.get("data_provider_chain") or []
1139
+ warnings = fundamentals.get("data_warnings") or []
1140
+ data_quality = fundamentals.get("data_quality") or {}
1141
+ write_artifact_metadata(artifact, {
1142
+ "kind": "market_report",
1143
+ "status": data_quality.get("status") or status,
1144
+ "symbol": symbol,
1145
+ "created_at": ts_dt.isoformat(timespec="seconds"),
1146
+ "data": {
1147
+ "provider_chain": provider_chain,
1148
+ "warnings": warnings,
1149
+ "errors": data_quality.get("errors") or [],
1150
+ "stale": bool(data_quality.get("stale", False)),
1151
+ "quality": data_quality,
1152
+ "missing_fields": missing_fields,
1153
+ "rows": int(len(df)) if df is not None else 0,
1154
+ "quality_score": getattr(clean_result, "quality_score", None),
1155
+ "chart_rendered": bool(price_chart),
1156
+ },
1157
+ "model": {
1158
+ "team_result": bool(team_result),
1159
+ },
1160
+ })
1161
+ raw_records = []
1162
+ try:
1163
+ raw_records = df.reset_index().tail(370).to_dict(orient="records") if df is not None and not df.empty else []
1164
+ except Exception:
1165
+ raw_records = []
1166
+ write_artifact_raw_data(artifact, {
1167
+ "symbol": symbol,
1168
+ "fundamentals": fundamentals,
1169
+ "prices": raw_records,
1170
+ })
1171
+
1172
+ logger.info("[report] saved: %s", out_f)
1173
+ return out_f
1174
+
1175
+
1176
+ # ── PDF Export ────────────────────────────────────────────────────────────────
1177
+
1178
+ # Headless Chromium-family binaries, in preference order. macOS almost always
1179
+ # has one of these installed, which makes PDF export zero-dependency there —
1180
+ # and Chrome's print engine renders our dark-theme HTML exactly like the
1181
+ # browser Cmd+P output the report was designed against.
1182
+ _CHROME_CANDIDATES = (
1183
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1184
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1185
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1186
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
1187
+ "google-chrome", "chromium", "chromium-browser", "msedge",
1188
+ )
1189
+
1190
+
1191
+ def _find_chrome() -> Optional[str]:
1192
+ import shutil
1193
+ for cand in _CHROME_CANDIDATES:
1194
+ if "/" in cand:
1195
+ if Path(cand).exists():
1196
+ return cand
1197
+ elif shutil.which(cand):
1198
+ return shutil.which(cand)
1199
+ return None
1200
+
1201
+
1202
+ def _export_pdf_via_chrome(html_path: Path, pdf_path: Path) -> bool:
1203
+ chrome = _find_chrome()
1204
+ if not chrome:
1205
+ return False
1206
+ import subprocess as _sp
1207
+ import tempfile
1208
+ # 隔离的 user-data-dir:不碰用户浏览器配置,也避免与已运行实例抢锁
1209
+ with tempfile.TemporaryDirectory(prefix="aria_pdf_") as tmp:
1210
+ cmd = [
1211
+ chrome, "--headless=new", "--disable-gpu",
1212
+ f"--user-data-dir={tmp}",
1213
+ "--no-pdf-header-footer",
1214
+ f"--print-to-pdf={pdf_path}",
1215
+ html_path.resolve().as_uri(),
1216
+ ]
1217
+ try:
1218
+ r = _sp.run(cmd, capture_output=True, timeout=90)
1219
+ if r.returncode != 0 or not pdf_path.exists():
1220
+ # 旧版 Chrome 不认 --headless=new,回退旧旗标再试一次
1221
+ cmd[1] = "--headless"
1222
+ r = _sp.run(cmd, capture_output=True, timeout=90)
1223
+ if r.returncode == 0 and pdf_path.exists() and pdf_path.stat().st_size > 0:
1224
+ logger.info("[report] pdf via headless chrome: %s", pdf_path)
1225
+ return True
1226
+ except Exception as e:
1227
+ logger.debug("[report] headless chrome failed: %s", e)
1228
+ return False
1229
+
1230
+
1231
+ def _export_pdf_via_webkit(html_path: Path, pdf_path: Path) -> bool:
1232
+ """macOS 原生 WebKit 引擎(pyobjc)——无需任何浏览器。
1233
+
1234
+ 用离屏 WKWebView 加载 HTML,macOS 11+ 的 createPDFWithConfiguration
1235
+ 直接吐 PDF 字节。CLI 进程没有事件循环,这里手动泵 NSRunLoop。
1236
+ """
1237
+ import sys
1238
+ if sys.platform != "darwin":
1239
+ return False
1240
+ try:
1241
+ import objc # noqa: F401
1242
+ from Foundation import NSDate, NSRunLoop, NSURL
1243
+ from WebKit import WKWebView, WKWebViewConfiguration, WKPDFConfiguration
1244
+ from AppKit import NSApplication # 初始化 GUI 上下文(离屏亦需)
1245
+ except ImportError:
1246
+ return False
1247
+
1248
+ try:
1249
+ NSApplication.sharedApplication()
1250
+ from Foundation import NSMakeRect
1251
+ view = WKWebView.alloc().initWithFrame_configuration_(
1252
+ NSMakeRect(0, 0, 1024, 1400), WKWebViewConfiguration.alloc().init()
1253
+ )
1254
+ url = NSURL.fileURLWithPath_(str(html_path.resolve()))
1255
+ view.loadFileURL_allowingReadAccessToURL_(
1256
+ url, NSURL.fileURLWithPath_(str(html_path.resolve().parent))
1257
+ )
1258
+
1259
+ runloop = NSRunLoop.currentRunLoop()
1260
+ deadline = 30.0
1261
+ while view.isLoading() and deadline > 0:
1262
+ runloop.runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.05))
1263
+ deadline -= 0.05
1264
+ if view.isLoading():
1265
+ return False
1266
+ # 渲染余量(inline base64 图片解码等)
1267
+ for _ in range(10):
1268
+ runloop.runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.05))
1269
+
1270
+ result: dict = {}
1271
+
1272
+ def _done(data, error):
1273
+ result["data"], result["error"] = data, error
1274
+
1275
+ view.createPDFWithConfiguration_completionHandler_(
1276
+ WKPDFConfiguration.alloc().init(), _done
1277
+ )
1278
+ deadline = 30.0
1279
+ while "data" not in result and deadline > 0:
1280
+ runloop.runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.05))
1281
+ deadline -= 0.05
1282
+
1283
+ data = result.get("data")
1284
+ if data is None or result.get("error") is not None:
1285
+ logger.debug("[report] webkit pdf error: %s", result.get("error"))
1286
+ return False
1287
+ data.writeToFile_atomically_(str(pdf_path), True)
1288
+ ok = pdf_path.exists() and pdf_path.stat().st_size > 0
1289
+ if ok:
1290
+ logger.info("[report] pdf via native WebKit: %s", pdf_path)
1291
+ return ok
1292
+ except Exception as e:
1293
+ logger.debug("[report] webkit engine failed: %s", e)
1294
+ return False
1295
+
1296
+
1297
+ def export_pdf(html_path: Path) -> Optional[Path]:
1298
+ """
1299
+ Convert an HTML report to PDF alongside the source file.
1300
+ Engine order: headless Chrome/Edge/Chromium → macOS native WebKit
1301
+ (pyobjc, no browser needed) → weasyprint → wkhtmltopdf.
1302
+ Returns the PDF path on success, None if no engine is available.
1303
+ """
1304
+ pdf_path = html_path.with_suffix(".pdf")
1305
+
1306
+ if _export_pdf_via_chrome(html_path, pdf_path):
1307
+ return pdf_path
1308
+
1309
+ if _export_pdf_via_webkit(html_path, pdf_path):
1310
+ return pdf_path
1311
+
1312
+ try:
1313
+ import weasyprint
1314
+ weasyprint.HTML(filename=str(html_path)).write_pdf(str(pdf_path))
1315
+ logger.info("[report] pdf via weasyprint: %s", pdf_path)
1316
+ return pdf_path
1317
+ except ImportError:
1318
+ pass
1319
+ except Exception as e:
1320
+ logger.debug("[report] weasyprint failed: %s", e)
1321
+
1322
+ import shutil
1323
+ import subprocess as _sp
1324
+ if shutil.which("wkhtmltopdf"):
1325
+ try:
1326
+ r = _sp.run(
1327
+ ["wkhtmltopdf", "--quiet", "--print-media-type",
1328
+ str(html_path), str(pdf_path)],
1329
+ capture_output=True, timeout=60,
1330
+ )
1331
+ if r.returncode == 0 and pdf_path.exists():
1332
+ logger.info("[report] pdf via wkhtmltopdf: %s", pdf_path)
1333
+ return pdf_path
1334
+ except Exception as e:
1335
+ logger.debug("[report] wkhtmltopdf failed: %s", e)
1336
+
1337
+ return None
1338
+
1339
+
1340
+ def html_string_to_pdf(html: str, pdf_path: Path) -> Optional[Path]:
1341
+ """任意 HTML 字符串 → PDF(供研报以外的调用方复用同一引擎链)。"""
1342
+ import tempfile
1343
+ pdf_path = Path(pdf_path)
1344
+ pdf_path.parent.mkdir(parents=True, exist_ok=True)
1345
+ with tempfile.NamedTemporaryFile(
1346
+ "w", suffix=".html", delete=False, encoding="utf-8"
1347
+ ) as f:
1348
+ f.write(html)
1349
+ tmp_html = Path(f.name)
1350
+ try:
1351
+ out = export_pdf(tmp_html)
1352
+ if out and out.exists():
1353
+ out.replace(pdf_path)
1354
+ return pdf_path
1355
+ return None
1356
+ finally:
1357
+ tmp_html.unlink(missing_ok=True)
1358
+
1359
+
1360
+ # ── Reports Index ─────────────────────────────────────────────────────────────
1361
+
1362
+ def update_reports_index(reports_root: Path) -> Optional[Path]:
1363
+ """
1364
+ Scan reports_root recursively for *_report_*.html files and write index.html.
1365
+ Returns the index path on success.
1366
+ """
1367
+ import re as _ri
1368
+
1369
+ index_path = reports_root / "index.html"
1370
+ entries: List[Dict] = []
1371
+
1372
+ for f in sorted(reports_root.rglob("*_report_*.html"), reverse=True):
1373
+ if f.name == "index.html":
1374
+ continue
1375
+ m = _ri.match(r"^(.+?)_report_(\d{8})_(\d{4})\.html$", f.name)
1376
+ if not m:
1377
+ continue
1378
+ sym, ds, ts_ = m.group(1), m.group(2), m.group(3)
1379
+ dt_str = f"{ds[:4]}-{ds[4:6]}-{ds[6:8]} {ts_[:2]}:{ts_[2:]}"
1380
+ size_kb = max(1, f.stat().st_size // 1024)
1381
+
1382
+ signal = "HOLD"
1383
+ try:
1384
+ snip = f.read_text(encoding="utf-8", errors="ignore")[500:3000]
1385
+ sm = _ri.search(r'\b(STRONG_BUY|STRONG_SELL|BUY|SELL|HOLD)\b', snip)
1386
+ if sm:
1387
+ signal = sm.group(1)
1388
+ except Exception:
1389
+ pass
1390
+
1391
+ try:
1392
+ rel = f.relative_to(reports_root)
1393
+ except ValueError:
1394
+ rel = f.name
1395
+ entries.append({"symbol": sym.upper(), "datetime": dt_str,
1396
+ "signal": signal, "size_kb": size_kb,
1397
+ "href": str(rel).replace("\\", "/")})
1398
+
1399
+ _SIG_COLOR = {"STRONG_BUY": "#3fb950", "BUY": "#3fb950",
1400
+ "HOLD": "#79c0ff", "SELL": "#f85149", "STRONG_SELL": "#f85149"}
1401
+
1402
+ rows_html = ""
1403
+ for e in entries:
1404
+ sc = _SIG_COLOR.get(e["signal"], "#8b949e")
1405
+ rows_html += (
1406
+ f'<tr>'
1407
+ f'<td><a href="{html.escape(e["href"])}" class="sym">'
1408
+ f'{html.escape(e["symbol"])}</a></td>'
1409
+ f'<td style="color:{sc};font-weight:700">{e["signal"]}</td>'
1410
+ f'<td class="dim">{html.escape(e["datetime"])}</td>'
1411
+ f'<td class="dim">{e["size_kb"]}KB</td>'
1412
+ f'</tr>\n'
1413
+ )
1414
+
1415
+ idx_html = f"""<!DOCTYPE html>
1416
+ <html lang="zh-CN">
1417
+ <head>
1418
+ <meta charset="utf-8">
1419
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1420
+ <title>Aria Code — 研报索引</title>
1421
+ <style>
1422
+ *{{box-sizing:border-box;margin:0;padding:0}}
1423
+ body{{background:#010409;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
1424
+ font-size:14px;padding:28px 24px;max-width:900px;margin:0 auto}}
1425
+ h1{{font-size:22px;font-weight:700;margin-bottom:4px}}
1426
+ .sub{{color:#8b949e;font-size:12px;margin-bottom:24px}}
1427
+ table{{width:100%;border-collapse:collapse}}
1428
+ th{{text-align:left;padding:8px 12px;border-bottom:2px solid #30363d;
1429
+ color:#8b949e;font-size:11px;letter-spacing:.6px;text-transform:uppercase}}
1430
+ td{{padding:9px 12px;border-bottom:1px solid #21262d}}
1431
+ tr:hover td{{background:#161b22}}
1432
+ a.sym{{color:#58a6ff;font-weight:700;text-decoration:none;font-size:15px}}
1433
+ a.sym:hover{{text-decoration:underline}}
1434
+ .dim{{color:#8b949e;font-size:12px}}
1435
+ </style>
1436
+ </head>
1437
+ <body>
1438
+ <h1>Aria Code 研报索引</h1>
1439
+ <p class="sub">{len(entries)} 份报告 · 更新于 {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
1440
+ <table>
1441
+ <thead><tr><th>标的</th><th>信号</th><th>生成时间</th><th>大小</th></tr></thead>
1442
+ <tbody>
1443
+ {rows_html}
1444
+ </tbody>
1445
+ </table>
1446
+ </body>
1447
+ </html>"""
1448
+
1449
+ index_path.write_text(idx_html, encoding="utf-8")
1450
+ logger.info("[report] index updated: %s (%d reports)", index_path, len(entries))
1451
+ return index_path
1452
+
1453
+
1454
+ # ── HTML Template & CSS ───────────────────────────────────────────────────────
1455
+
1456
+ _CSS = """
1457
+ :root {
1458
+ --bg0: #010409;
1459
+ --bg1: #0d1117;
1460
+ --bg2: #161b22;
1461
+ --bg3: #21262d;
1462
+ --text1: #e6edf3;
1463
+ --text2: #8b949e;
1464
+ --green: #3fb950;
1465
+ --red: #f85149;
1466
+ --blue: #388bfd;
1467
+ --purple: #8957e5;
1468
+ --orange: #f0883e;
1469
+ --border: #21262d;
1470
+ --radius: 8px;
1471
+ }
1472
+ * { box-sizing:border-box; margin:0; padding:0; }
1473
+ body {
1474
+ background: var(--bg0);
1475
+ color: var(--text1);
1476
+ /* STHeiti before PingFang SC/-apple-system: this stylesheet feeds
1477
+ export_pdf() (weasyprint). PingFang ships as a multi-face .ttc, and
1478
+ weasyprint has to extract/resubset a single face into a standalone
1479
+ CID-keyed CFF font to embed it — poppler-based PDF readers parse the
1480
+ result fine, but some other viewers mis-render it as substituted
1481
+ glyphs (see the matching note in apps/cli/pdf_report.py, same root
1482
+ cause, same fix, independently applied here since this is a separate
1483
+ rendering system). STHeiti is a single-file font, no .ttc-extraction
1484
+ step involved. */
1485
+ font-family: "STHeiti", -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", "Helvetica Neue", sans-serif;
1486
+ font-size: 14px;
1487
+ line-height: 1.6;
1488
+ padding: 28px 24px;
1489
+ max-width: 1180px;
1490
+ margin: 0 auto;
1491
+ }
1492
+ /* ── Header ── */
1493
+ .report-header {
1494
+ display: flex;
1495
+ align-items: flex-start;
1496
+ justify-content: space-between;
1497
+ border-bottom: 1px solid var(--border);
1498
+ padding-bottom: 18px;
1499
+ margin-bottom: 20px;
1500
+ flex-wrap: wrap;
1501
+ gap: 12px;
1502
+ }
1503
+ .header-left h1 {
1504
+ font-size: 26px;
1505
+ font-weight: 700;
1506
+ letter-spacing: -0.5px;
1507
+ color: var(--text1);
1508
+ }
1509
+ .header-left h1 .ticker {
1510
+ color: var(--green);
1511
+ margin-right: 10px;
1512
+ font-size: 28px;
1513
+ }
1514
+ .header-left .subtitle {
1515
+ color: var(--text2);
1516
+ font-size: 12px;
1517
+ margin-top: 4px;
1518
+ }
1519
+ .signal-badge {
1520
+ display: inline-flex;
1521
+ align-items: center;
1522
+ gap: 8px;
1523
+ padding: 10px 20px;
1524
+ border-radius: var(--radius);
1525
+ font-size: 18px;
1526
+ font-weight: 700;
1527
+ letter-spacing: 1px;
1528
+ border: 1px solid;
1529
+ }
1530
+ .report-meta {
1531
+ color: var(--text2);
1532
+ font-size: 12px;
1533
+ text-align: right;
1534
+ }
1535
+ /* ── KPI strip ── */
1536
+ .kpi-strip {
1537
+ display: flex;
1538
+ gap: 10px;
1539
+ flex-wrap: wrap;
1540
+ margin-bottom: 20px;
1541
+ }
1542
+ .kpi-card {
1543
+ background: var(--bg2);
1544
+ border: 1px solid var(--border);
1545
+ border-radius: var(--radius);
1546
+ padding: 12px 16px;
1547
+ flex: 1;
1548
+ min-width: 130px;
1549
+ }
1550
+ .kpi-label { font-size:11px; color:var(--text2); margin-bottom:4px; }
1551
+ .kpi-value { font-size:18px; font-weight:700; color:var(--text1); }
1552
+ .kpi-sub { font-size:11px; color:var(--text2); margin-top:3px; }
1553
+ /* ── Cards ── */
1554
+ .card {
1555
+ background: var(--bg1);
1556
+ border: 1px solid var(--border);
1557
+ border-radius: var(--radius);
1558
+ margin-bottom: 16px;
1559
+ overflow: hidden;
1560
+ }
1561
+ .card-header {
1562
+ background: var(--bg2);
1563
+ border-bottom: 1px solid var(--border);
1564
+ padding: 10px 16px;
1565
+ font-size: 12px;
1566
+ font-weight: 600;
1567
+ letter-spacing: 0.8px;
1568
+ text-transform: uppercase;
1569
+ color: var(--text2);
1570
+ }
1571
+ .card-body {
1572
+ padding: 16px;
1573
+ }
1574
+ /* ── Chart ── */
1575
+ .chart-card {
1576
+ background: var(--bg1);
1577
+ border: 1px solid var(--border);
1578
+ border-radius: var(--radius);
1579
+ padding: 4px;
1580
+ margin-bottom: 16px;
1581
+ }
1582
+ .no-data {
1583
+ color: var(--text2);
1584
+ font-style: italic;
1585
+ padding: 20px;
1586
+ text-align: center;
1587
+ }
1588
+ /* ── Two-column layout ── */
1589
+ .two-col {
1590
+ display: grid;
1591
+ grid-template-columns: 1fr 1fr;
1592
+ gap: 16px;
1593
+ margin-bottom: 16px;
1594
+ }
1595
+ @media (max-width: 700px) { .two-col { grid-template-columns: 1fr; } }
1596
+ /* ── Metrics table ── */
1597
+ .metrics-table {
1598
+ width: 100%;
1599
+ border-collapse: collapse;
1600
+ font-size: 13px;
1601
+ }
1602
+ .metrics-table td {
1603
+ padding: 6px 12px;
1604
+ border-bottom: 1px solid var(--bg3);
1605
+ }
1606
+ .metric-label { color: var(--text2); }
1607
+ .metric-value { color: var(--text1); text-align: right; font-weight: 500; }
1608
+ /* ── Agent cards ── */
1609
+ .agent-grid {
1610
+ display: grid;
1611
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
1612
+ gap: 12px;
1613
+ padding: 14px;
1614
+ }
1615
+ .agent-card {
1616
+ background: var(--bg2);
1617
+ border: 1px solid;
1618
+ border-radius: var(--radius);
1619
+ padding: 12px;
1620
+ }
1621
+ .agent-header {
1622
+ display: flex;
1623
+ align-items: center;
1624
+ gap: 10px;
1625
+ margin-bottom: 8px;
1626
+ }
1627
+ .agent-name { font-weight:700; font-size:12px; color:var(--text2);
1628
+ letter-spacing:0.8px; text-transform:uppercase; }
1629
+ .agent-signal { font-size:12px; font-weight:700; padding:2px 8px;
1630
+ border-radius:4px; }
1631
+ .agent-conf { font-size:12px; font-weight:600; margin-left:auto; }
1632
+ .agent-kps { padding-left:16px; margin-bottom:8px; }
1633
+ .agent-kps li { font-size:12px; color:var(--text2); margin-bottom:3px; }
1634
+ .agent-analysis { font-size:12px; color:var(--text2);
1635
+ border-top:1px solid var(--bg3); padding-top:8px;
1636
+ margin-top:4px; line-height:1.65; }
1637
+ .inline-chart { width:100%; height:auto; border-radius:6px; display:block; }
1638
+ /* ── Synthesis ── */
1639
+ .synthesis-body {
1640
+ font-size: 14px;
1641
+ line-height: 1.75;
1642
+ color: var(--text1);
1643
+ }
1644
+ /* ── Markdown elements ── */
1645
+ .md-table { width:100%; border-collapse:collapse; font-size:12px;
1646
+ margin:8px 0; border:1px solid var(--bg3); }
1647
+ .md-table th { background:var(--bg3); color:var(--text2); padding:5px 8px;
1648
+ text-align:left; font-weight:600; border:1px solid var(--border); }
1649
+ .md-table td { padding:4px 8px; border:1px solid var(--bg3); color:var(--text2); }
1650
+ .md-table tr:nth-child(even) { background:var(--bg2); }
1651
+ .md-h { display:block; color:var(--text1); margin:8px 0 4px; font-size:13px; }
1652
+ .md-hr { border:none; border-top:1px solid var(--border); margin:8px 0; }
1653
+ code { background:var(--bg3); color:var(--orange); padding:1px 4px;
1654
+ border-radius:3px; font-size:11px; font-family:monospace; }
1655
+ .synthesis-footer {
1656
+ border-top: 1px solid var(--border);
1657
+ padding: 10px 16px;
1658
+ font-size: 12px;
1659
+ color: var(--text2);
1660
+ display: flex;
1661
+ gap: 16px;
1662
+ align-items: center;
1663
+ }
1664
+ /* ── Quality bar ── */
1665
+ .quality-bar {
1666
+ display: flex;
1667
+ align-items: center;
1668
+ gap: 14px;
1669
+ background: var(--bg2);
1670
+ border: 1px solid var(--border);
1671
+ border-radius: var(--radius);
1672
+ padding: 10px 16px;
1673
+ font-size: 12px;
1674
+ color: var(--text2);
1675
+ margin-bottom: 16px;
1676
+ }
1677
+ .quality-score { font-size: 18px; font-weight: 700; }
1678
+ .quality-detail { color: var(--text2); font-size: 11px; }
1679
+ /* ── Footer ── */
1680
+ .report-footer {
1681
+ margin-top: 28px;
1682
+ padding-top: 14px;
1683
+ border-top: 1px solid var(--border);
1684
+ font-size: 11px;
1685
+ color: var(--text2);
1686
+ line-height: 1.8;
1687
+ }
1688
+ @media print {
1689
+ body { background:#fff; color:#000; padding:16px; }
1690
+ .signal-badge, .agent-card { border-color:#ccc !important; }
1691
+ .card, .kpi-card { background:#f8f8f8 !important; border-color:#ddd; }
1692
+ }
1693
+ """
1694
+
1695
+ _HTML_TEMPLATE = """<!DOCTYPE html>
1696
+ <html lang="zh-CN">
1697
+ <head>
1698
+ <meta charset="utf-8">
1699
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1700
+ <title>{{SYMBOL}} — Aria Research Report</title>
1701
+ <style>{{CSS}}</style>
1702
+ </head>
1703
+ <body>
1704
+
1705
+ <!-- Header -->
1706
+ <div class="report-header">
1707
+ <div class="header-left">
1708
+ <h1><span class="ticker">{{SYMBOL}}</span>{{COMPANY_NAME}}</h1>
1709
+ <div class="subtitle">{{SUBTITLE}}</div>
1710
+ </div>
1711
+ <div>
1712
+ <div class="signal-badge"
1713
+ style="background:{{SIGNAL_BG}};color:{{SIGNAL_ACCENT}};border-color:{{SIGNAL_ACCENT}}40;">
1714
+ {{SIGNAL_ICON}} {{SIGNAL}} &nbsp; {{CONFIDENCE}}
1715
+ </div>
1716
+ <div class="report-meta" style="margin-top:8px;">
1717
+ Aria Code Research<br>{{TIMESTAMP}}
1718
+ </div>
1719
+ </div>
1720
+ </div>
1721
+
1722
+ <!-- KPI Strip -->
1723
+ <div class="kpi-strip">
1724
+ {{KPI_CARDS}}
1725
+ </div>
1726
+
1727
+ <!-- Quality Bar -->
1728
+ {{QUALITY_BAR}}
1729
+
1730
+ <!-- Price Chart -->
1731
+ <div class="chart-card">
1732
+ {{CHART_SECTION}}
1733
+ </div>
1734
+
1735
+ <!-- Two-column: Agent analysis + Metrics table -->
1736
+ <div class="two-col">
1737
+ <div>
1738
+ {{AGENT_SECTION}}
1739
+ </div>
1740
+ <div>
1741
+ <section class="card">
1742
+ <div class="card-header">关键财务指标</div>
1743
+ <table class="metrics-table">
1744
+ <tbody>
1745
+ {{METRICS_ROWS}}
1746
+ </tbody>
1747
+ </table>
1748
+ </section>
1749
+ {{DESC_SECTION}}
1750
+ </div>
1751
+ </div>
1752
+
1753
+ <!-- Synthesis -->
1754
+ {{SYNTHESIS}}
1755
+
1756
+ <!-- Footer -->
1757
+ <div class="report-footer">
1758
+ <strong>免责声明</strong>:本报告由 Aria Code AI 系统自动生成,仅供参考,不构成任何投资建议或买卖推荐。
1759
+ 数据源:{{DATA_SOURCE}}。存在延迟,请以交易所官方数据为准。
1760
+ 投资有风险,入市需谨慎。&nbsp;·&nbsp; Aria Code &nbsp;·&nbsp; {{TIMESTAMP}}
1761
+ </div>
1762
+
1763
+ </body>
1764
+ </html>"""