aria-code 4.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
local_finance_tools.py ADDED
@@ -0,0 +1,3221 @@
1
+ """
2
+ local_finance_tools.py — Fully-offline financial tool implementations for Aria Code.
3
+
4
+ Purpose
5
+ -------
6
+ When running in local_mode (no Arthera backend), this module provides drop-in
7
+ replacements for every ARIA_TOOLS entry using open-source data libraries:
8
+
9
+ A股 data → akshare
10
+ US/Global → yfinance
11
+ Crypto → ccxt
12
+ Backtesting → vectorbt (or pandas fallback)
13
+ Technical → pandas_ta (or ta-lib if installed)
14
+ Risk → scipy / numpy
15
+
16
+ Each tool follows the same contract as the remote Aria tools:
17
+ handler(params: dict) -> dict (always returns a dict, never raises)
18
+
19
+ Registration
20
+ ------------
21
+ Call ``register_local_finance_tools(LOCAL_TOOLS, LOCAL_TOOL_SCHEMAS)`` at
22
+ startup to extend the CLI's tool registry. The function is idempotent.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import traceback
31
+ from importlib.util import find_spec
32
+ from datetime import datetime, timedelta
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Alibaba Cloud data client (optional — degrades gracefully when offline)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ try:
45
+ from aliyun_data_client import (
46
+ AliyunDataClient,
47
+ cloud_get_quote_sync,
48
+ cloud_get_history_sync,
49
+ cloud_get_factors_sync,
50
+ cloud_get_ai_signal_sync,
51
+ )
52
+ _HAS_CLOUD = True
53
+ logger.debug("Alibaba Cloud data client loaded ✓")
54
+ except ImportError:
55
+ _HAS_CLOUD = False
56
+ logger.debug("aliyun_data_client not found — cloud features disabled")
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Optional dependency guards
60
+ # ---------------------------------------------------------------------------
61
+
62
+ try:
63
+ import yfinance as yf
64
+ _HAS_YF = True
65
+ except ImportError:
66
+ _HAS_YF = False
67
+
68
+ try:
69
+ import akshare as ak
70
+ _HAS_AK = True
71
+ except ImportError:
72
+ _HAS_AK = False
73
+
74
+
75
+ def _ak_retry(fn, *args, _tries: int = 3, _delay: float = 0.8, **kwargs):
76
+ """Call an akshare function with retries, bypassing a broken proxy.
77
+
78
+ Two transient failure modes are handled:
79
+ 1. akshare hits numbered eastmoney hosts (NN.push2.eastmoney.com) that go
80
+ down individually — a retry usually lands on a healthy host.
81
+ 2. A misconfigured HTTP(S)_PROXY raises ProxyError even though the source
82
+ is directly reachable. On a proxy/connection error we retry with the
83
+ proxy env vars temporarily cleared (akshare uses requests trust_env),
84
+ then restore them so the rest of the app is unaffected.
85
+ """
86
+ import os as _os
87
+ import time as _t
88
+ _PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy")
89
+ last_exc = None
90
+ for _i in range(_tries):
91
+ _saved = {}
92
+ _bypass = _i > 0 # first attempt honours the proxy; retries bypass it
93
+ if _bypass:
94
+ for _v in _PROXY_VARS:
95
+ if _v in _os.environ:
96
+ _saved[_v] = _os.environ.pop(_v)
97
+ try:
98
+ return fn(*args, **kwargs)
99
+ except Exception as exc: # noqa: BLE001 — akshare raises many types
100
+ last_exc = exc
101
+ if _i < _tries - 1:
102
+ _t.sleep(_delay)
103
+ finally:
104
+ for _v, _val in _saved.items():
105
+ _os.environ[_v] = _val
106
+ raise last_exc
107
+
108
+ try:
109
+ import ccxt
110
+ _HAS_CCXT = True
111
+ except ImportError:
112
+ _HAS_CCXT = False
113
+
114
+ _HAS_TA = find_spec("pandas_ta") is not None
115
+
116
+ try:
117
+ import vectorbt as vbt
118
+ _HAS_VBT = True
119
+ except ImportError:
120
+ _HAS_VBT = False
121
+
122
+ try:
123
+ from scipy import stats as sp_stats
124
+ _HAS_SCIPY = True
125
+ except ImportError:
126
+ _HAS_SCIPY = False
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Helper utilities
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def _safe(fn, params, *args, **kwargs):
134
+ """Wrap a tool handler so it never raises — returns error dict instead."""
135
+ try:
136
+ return fn(params, *args, **kwargs)
137
+ except Exception as exc:
138
+ logger.debug("Local finance tool error: %s", traceback.format_exc())
139
+ return {"success": False, "error": str(exc), "traceback": traceback.format_exc()[-500:]}
140
+
141
+
142
+ def _parse_date(s: Optional[str], default_days_back: int = 365) -> str:
143
+ if s:
144
+ return s
145
+ return (datetime.today() - timedelta(days=default_days_back)).strftime("%Y-%m-%d")
146
+
147
+
148
+ def _today() -> str:
149
+ return datetime.today().strftime("%Y-%m-%d")
150
+
151
+
152
+ def _is_ashare(symbol: str) -> bool:
153
+ s = symbol.strip().lower()
154
+ return (
155
+ s.startswith("sh") or s.startswith("sz")
156
+ or (len(s) == 6 and s.isdigit())
157
+ or s.endswith(".ss") or s.endswith(".sz")
158
+ )
159
+
160
+
161
+ def _normalise_ashare(symbol: str) -> str:
162
+ s = symbol.strip().lower().replace(".ss", "").replace(".sz", "")
163
+ s = s.replace("sh", "").replace("sz", "")
164
+ if len(s) == 6 and s.isdigit():
165
+ prefix = "sh" if s.startswith("6") else "sz"
166
+ return prefix + s
167
+ return s
168
+
169
+
170
+ def _get_pandas_ta():
171
+ if not _HAS_TA:
172
+ return None
173
+ try:
174
+ import pandas_ta as ta
175
+ except Exception:
176
+ return None
177
+ return ta
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # 1. get_market_data
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _get_market_data(params: dict) -> dict:
185
+ symbol = params.get("symbol", "AAPL").upper()
186
+ period = params.get("period", "1y") # yfinance period
187
+ interval = params.get("interval", "1d")
188
+ start = params.get("start")
189
+ end = params.get("end", _today())
190
+
191
+ if _is_ashare(symbol):
192
+ return _get_ashare_data(symbol, start or _parse_date(None, 365), end)
193
+
194
+ # ── Try Alibaba Cloud first (for US/global symbols) ────────────────
195
+ if _HAS_CLOUD:
196
+ try:
197
+ cloud_q = cloud_get_quote_sync(symbol)
198
+ if cloud_q and cloud_q.get("price"):
199
+ return {
200
+ "success": True,
201
+ "symbol": symbol,
202
+ "latest_close": round(float(cloud_q.get("price", 0)), 4),
203
+ "change_pct": round(float(cloud_q.get("change_percent", 0)), 3),
204
+ "volume": int(cloud_q.get("volume", 0)),
205
+ "high": round(float(cloud_q.get("high", 0)), 4),
206
+ "low": round(float(cloud_q.get("low", 0)), 4),
207
+ "open": round(float(cloud_q.get("open", 0)), 4),
208
+ "name": cloud_q.get("name", symbol),
209
+ "market": cloud_q.get("market", "US"),
210
+ "provider": "aliyun_cloud",
211
+ }
212
+ except Exception as exc:
213
+ logger.debug("Cloud quote failed for %s: %s — falling back to yfinance", symbol, exc)
214
+
215
+ if not _HAS_YF:
216
+ return {"success": False, "error": "yfinance not installed: pip install yfinance"}
217
+
218
+ try:
219
+ tkr = yf.Ticker(symbol)
220
+ if start:
221
+ hist = tkr.history(start=start, end=end, interval=interval)
222
+ else:
223
+ hist = tkr.history(period=period, interval=interval)
224
+
225
+ if hist.empty:
226
+ return {"success": False, "error": f"No data for {symbol}"}
227
+
228
+ info = tkr.fast_info
229
+ latest = hist.iloc[-1]
230
+ prev = hist.iloc[-2] if len(hist) > 1 else latest
231
+ chg = (latest["Close"] - prev["Close"]) / prev["Close"] * 100
232
+
233
+ return {
234
+ "success": True,
235
+ "symbol": symbol,
236
+ "latest_close": round(float(latest["Close"]), 4),
237
+ "change_pct": round(float(chg), 3),
238
+ "volume": int(latest["Volume"]),
239
+ "high_52w": round(float(info.year_high), 4) if hasattr(info, "year_high") else None,
240
+ "low_52w": round(float(info.year_low), 4) if hasattr(info, "year_low") else None,
241
+ "market_cap": getattr(info, "market_cap", None),
242
+ "currency": getattr(info, "currency", "USD"),
243
+ "bars": len(hist),
244
+ "history_tail": _df_tail(hist, 5),
245
+ "provider": "yfinance",
246
+ }
247
+ except Exception as exc:
248
+ return {"success": False, "error": str(exc)}
249
+
250
+
251
+ def _get_ashare_data(symbol: str, start: str, end: str) -> dict:
252
+ # ── Try Alibaba Cloud akshare_data_server first ────────────────────
253
+ if _HAS_CLOUD:
254
+ try:
255
+ cloud_hist = cloud_get_history_sync(symbol, start=start, end=end)
256
+ if cloud_hist and cloud_hist.get("data"):
257
+ rows = cloud_hist["data"]
258
+ if rows:
259
+ latest = rows[-1]
260
+ prev = rows[-2] if len(rows) > 1 else latest
261
+ cl, pc = float(latest.get("close", 0)), float(prev.get("close", 0) or 0.0001)
262
+ chg = (cl - pc) / pc * 100
263
+ # Build history_tail
264
+ tail = [
265
+ {k: v for k, v in r.items() if k in ("date", "open", "high", "low", "close", "volume")}
266
+ for r in rows[-5:]
267
+ ]
268
+ return {
269
+ "success": True,
270
+ "symbol": symbol,
271
+ "latest_close": round(cl, 3),
272
+ "change_pct": round(chg, 3),
273
+ "volume": int(latest.get("volume", 0)),
274
+ "bars": len(rows),
275
+ "history_tail": tail,
276
+ "provider": "aliyun_data",
277
+ }
278
+ except Exception as exc:
279
+ logger.debug("Cloud history failed for %s: %s — falling back to akshare", symbol, exc)
280
+
281
+ if not _HAS_AK:
282
+ return {"success": False, "error": "akshare not installed: pip install akshare"}
283
+
284
+ norm = _normalise_ashare(symbol)
285
+ code = norm[2:] # strip sh/sz prefix
286
+ try:
287
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
288
+ start_date=start.replace("-", ""),
289
+ end_date=end.replace("-", ""),
290
+ adjust="qfq")
291
+ if df is None or df.empty:
292
+ return {"success": False, "error": f"No A-share data for {symbol}"}
293
+
294
+ df = df.rename(columns={
295
+ "日期": "date", "开盘": "open", "收盘": "close",
296
+ "最高": "high", "最低": "low", "成交量": "volume",
297
+ "成交额": "amount", "换手率": "turnover_rate",
298
+ })
299
+ latest = df.iloc[-1]
300
+ prev = df.iloc[-2] if len(df) > 1 else latest
301
+ chg = (latest["close"] - prev["close"]) / prev["close"] * 100
302
+
303
+ return {
304
+ "success": True,
305
+ "symbol": symbol,
306
+ "latest_close": round(float(latest["close"]), 3),
307
+ "change_pct": round(float(chg), 3),
308
+ "volume": int(latest["volume"]),
309
+ "turnover_rate": float(latest.get("turnover_rate", 0) or 0),
310
+ "bars": len(df),
311
+ "history_tail": _df_tail(df.rename(columns={"close": "Close", "volume": "Volume"}), 5),
312
+ "provider": "akshare",
313
+ }
314
+ except Exception as exc:
315
+ return {"success": False, "error": str(exc)}
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # 2. get_crypto_data
320
+ # ---------------------------------------------------------------------------
321
+
322
+ def _get_crypto_data(params: dict) -> dict:
323
+ symbol = params.get("symbol", "BTC/USDT").upper().replace("-", "/")
324
+ exchange = params.get("exchange", "binance")
325
+ timeframe = params.get("timeframe", "1d")
326
+ limit = int(params.get("limit", 100))
327
+
328
+ if not _HAS_CCXT:
329
+ # Fallback to yfinance for common crypto tickers
330
+ if _HAS_YF:
331
+ yf_sym = symbol.replace("/", "-") + ("" if symbol.endswith("USD") else "")
332
+ return _get_market_data({"symbol": yf_sym, "period": "3mo"})
333
+ return {"success": False, "error": "ccxt not installed: pip install ccxt"}
334
+
335
+ def _yf_crypto_fallback(reason: str) -> dict:
336
+ """Fall back to yfinance when the exchange is unreachable (region block, etc.)."""
337
+ if not _HAS_YF:
338
+ return {"success": False, "error": reason}
339
+ # BTC/USDT → BTC-USD ; strip stablecoin quote to USD for yfinance
340
+ base = symbol.split("/")[0]
341
+ yf_sym = f"{base}-USD"
342
+ res = _get_market_data({"symbol": yf_sym, "period": "3mo"})
343
+ if res.get("success"):
344
+ res["provider"] = "yfinance (ccxt fallback)"
345
+ res["note"] = f"{exchange} 不可用,已回退 yfinance: {reason[:60]}"
346
+ return res
347
+
348
+ try:
349
+ ex_class = getattr(ccxt, exchange.lower(), None)
350
+ if ex_class is None:
351
+ return {"success": False, "error": f"Unknown exchange: {exchange}"}
352
+ ex = ex_class({"enableRateLimit": True})
353
+ try:
354
+ ohlcv = ex.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
355
+ except Exception as net_exc:
356
+ # Network / region block (e.g. Binance 451) → yfinance fallback
357
+ return _yf_crypto_fallback(str(net_exc))
358
+ if not ohlcv:
359
+ return _yf_crypto_fallback("Empty OHLCV data")
360
+
361
+ df = pd.DataFrame(ohlcv, columns=["ts", "open", "high", "low", "close", "volume"])
362
+ df["date"] = pd.to_datetime(df["ts"], unit="ms")
363
+ latest = df.iloc[-1]
364
+ prev = df.iloc[-2]
365
+ chg = (latest["close"] - prev["close"]) / prev["close"] * 100
366
+ vol_avg = df["volume"].tail(20).mean()
367
+
368
+ # Ticker for bid/ask
369
+ try:
370
+ ticker = ex.fetch_ticker(symbol)
371
+ except Exception:
372
+ ticker = {}
373
+
374
+ return {
375
+ "success": True,
376
+ "symbol": symbol,
377
+ "exchange": exchange,
378
+ "latest_close": round(float(latest["close"]), 6),
379
+ "change_pct_24h": round(float(chg), 3),
380
+ "volume_24h": float(latest["volume"]),
381
+ "volume_avg_20d": round(float(vol_avg), 2),
382
+ "bid": ticker.get("bid"),
383
+ "ask": ticker.get("ask"),
384
+ "bars": len(df),
385
+ "provider": "ccxt",
386
+ }
387
+ except Exception as exc:
388
+ return _yf_crypto_fallback(str(exc))
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # 3. get_forex_data
393
+ # ---------------------------------------------------------------------------
394
+
395
+ def _get_forex_data(params: dict) -> dict:
396
+ pair = params.get("pair", "EURUSD=X")
397
+ period = params.get("period", "3mo")
398
+ if not _HAS_YF:
399
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
400
+ # Normalise to yfinance format
401
+ p = pair.replace("/", "").upper()
402
+ if not p.endswith("=X"):
403
+ p += "=X"
404
+ return _get_market_data({"symbol": p, "period": period})
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # 4. calculate_factors
409
+ # ---------------------------------------------------------------------------
410
+
411
+ def _calculate_factors(params: dict) -> dict:
412
+ symbol = params.get("symbol", "AAPL")
413
+ period = params.get("period", "1y")
414
+
415
+ # ── Try Alibaba Cloud enhanced factor engine first ─────────────────
416
+ if _HAS_CLOUD:
417
+ try:
418
+ cloud_factors = cloud_get_factors_sync(symbol)
419
+ if cloud_factors and cloud_factors.get("success"):
420
+ cloud_factors["provider"] = "aliyun_cloud"
421
+ return cloud_factors
422
+ except Exception as exc:
423
+ logger.debug("Cloud factors failed for %s: %s — computing locally", symbol, exc)
424
+
425
+ if _is_ashare(symbol) and _HAS_AK:
426
+ data_result = _get_ashare_data(
427
+ symbol,
428
+ _parse_date(None, 365),
429
+ _today(),
430
+ )
431
+ elif _HAS_YF:
432
+ data_result = _get_market_data({"symbol": symbol, "period": period})
433
+ else:
434
+ return {"success": False, "error": "No data source available"}
435
+
436
+ if not data_result.get("success"):
437
+ return data_result
438
+
439
+ # Re-fetch full history for factor computation
440
+ if _is_ashare(symbol) and _HAS_AK:
441
+ norm = _normalise_ashare(symbol)
442
+ code = norm[2:]
443
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
444
+ start_date=_parse_date(None, 365).replace("-", ""),
445
+ end_date=_today().replace("-", ""),
446
+ adjust="qfq")
447
+ df = df.rename(columns={"收盘": "Close", "成交量": "Volume",
448
+ "开盘": "Open", "最高": "High", "最低": "Low"})
449
+ else:
450
+ tkr = yf.Ticker(symbol)
451
+ df = tkr.history(period=period)
452
+
453
+ if df is None or len(df) < 20:
454
+ return {"success": False, "error": "Insufficient history for factors"}
455
+
456
+ close = df["Close"].astype(float)
457
+ volume = df["Volume"].astype(float)
458
+ ret = close.pct_change().dropna()
459
+
460
+ factors: Dict[str, Any] = {"symbol": symbol}
461
+
462
+ # ── Price momentum ─────────────────────────────────────────────────────
463
+ for n in (5, 10, 20, 60, 120):
464
+ if len(close) > n:
465
+ factors[f"return_{n}d"] = round(float(close.pct_change(n).iloc[-1]), 5)
466
+
467
+ # ── Moving averages ────────────────────────────────────────────────────
468
+ for n in (5, 10, 20, 60, 120, 200):
469
+ if len(close) >= n:
470
+ ma = close.rolling(n).mean().iloc[-1]
471
+ factors[f"ma_{n}"] = round(float(ma), 4)
472
+ factors[f"ma_{n}_gap"] = round(float(close.iloc[-1] / ma - 1), 5)
473
+
474
+ # ── Volatility ─────────────────────────────────────────────────────────
475
+ for n in (10, 20, 60):
476
+ if len(ret) >= n:
477
+ factors[f"volatility_{n}d"] = round(float(ret.tail(n).std() * np.sqrt(252)), 5)
478
+
479
+ # ── Volume ─────────────────────────────────────────────────────────────
480
+ if len(volume) >= 20:
481
+ vol_ma20 = volume.rolling(20).mean().iloc[-1]
482
+ factors["volume_ratio_20d"] = round(float(volume.iloc[-1] / vol_ma20), 3) if vol_ma20 > 0 else None
483
+
484
+ # ── RSI ────────────────────────────────────────────────────────────────
485
+ if _HAS_TA and len(close) >= 14:
486
+ ta = _get_pandas_ta()
487
+ if ta is not None:
488
+ rsi = ta.rsi(close, length=14)
489
+ if rsi is not None and not rsi.empty:
490
+ factors["rsi_14"] = round(float(rsi.iloc[-1]), 2)
491
+ else:
492
+ factors["rsi_14"] = None
493
+ else:
494
+ factors["rsi_14"] = None
495
+ else:
496
+ # Manual RSI
497
+ delta = close.diff()
498
+ gain = delta.clip(lower=0).rolling(14).mean()
499
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
500
+ rs = gain / loss.replace(0, np.nan)
501
+ rsi_v = (100 - 100 / (1 + rs)).iloc[-1]
502
+ factors["rsi_14"] = round(float(rsi_v), 2) if not np.isnan(rsi_v) else None
503
+
504
+ # ── MACD ───────────────────────────────────────────────────────────────
505
+ if len(close) >= 26:
506
+ ema12 = close.ewm(span=12, adjust=False).mean()
507
+ ema26 = close.ewm(span=26, adjust=False).mean()
508
+ macd = ema12 - ema26
509
+ sig = macd.ewm(span=9, adjust=False).mean()
510
+ factors["macd"] = round(float(macd.iloc[-1]), 5)
511
+ factors["macd_signal"] = round(float(sig.iloc[-1]), 5)
512
+ factors["macd_hist"] = round(float((macd - sig).iloc[-1]), 5)
513
+
514
+ # ── Bollinger Bands ────────────────────────────────────────────────────
515
+ if len(close) >= 20:
516
+ bb_ma = close.rolling(20).mean()
517
+ bb_std = close.rolling(20).std()
518
+ bb_up = bb_ma + 2 * bb_std
519
+ bb_lo = bb_ma - 2 * bb_std
520
+ prc = close.iloc[-1]
521
+ factors["bb_position"] = round(float((prc - bb_lo.iloc[-1]) /
522
+ (bb_up.iloc[-1] - bb_lo.iloc[-1] + 1e-8)), 4)
523
+ factors["bb_upper"] = round(float(bb_up.iloc[-1]), 4)
524
+ factors["bb_lower"] = round(float(bb_lo.iloc[-1]), 4)
525
+
526
+ # ── Beta vs market ─────────────────────────────────────────────────────
527
+ if _HAS_YF and not _is_ashare(symbol) and len(ret) >= 60:
528
+ try:
529
+ bench = yf.Ticker("SPY").history(period=period)["Close"].pct_change().dropna()
530
+ aligned = pd.DataFrame({"asset": ret, "bench": bench}).dropna().tail(60)
531
+ if len(aligned) >= 30:
532
+ beta_v = float(np.cov(aligned["asset"], aligned["bench"])[0, 1] /
533
+ np.var(aligned["bench"]))
534
+ factors["beta_60d"] = round(beta_v, 3)
535
+ except Exception:
536
+ pass
537
+
538
+ # ── Trend score ────────────────────────────────────────────────────────
539
+ trend = 0
540
+ for ma_key, w in [("ma_5_gap", 0.15), ("ma_20_gap", 0.35), ("ma_60_gap", 0.50)]:
541
+ v = factors.get(ma_key, 0.0) or 0.0
542
+ trend += w * (1 if v > 0 else -1)
543
+ factors["trend_score"] = round(trend, 3)
544
+
545
+ factors["provider"] = "local"
546
+ return {"success": True, **factors}
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # 5. backtest_strategy
551
+ # ---------------------------------------------------------------------------
552
+
553
+ def _backtest_strategy(params: dict) -> dict:
554
+ """
555
+ Simple backtest engine. Supports:
556
+ strategy = "sma_cross" | "rsi_mean_revert" | "momentum" | "buy_hold"
557
+ """
558
+ symbol = params.get("symbol", "AAPL")
559
+ strategy = params.get("strategy", "sma_cross").lower().replace(" ", "_").replace("-", "_")
560
+ start = params.get("start", _parse_date(None, 3 * 365))
561
+ end = params.get("end", _today())
562
+ fast = int(params.get("fast_period", 20))
563
+ slow = int(params.get("slow_period", 60))
564
+ rsi_lo = float(params.get("rsi_oversold", 30))
565
+ rsi_hi = float(params.get("rsi_overbought", 70))
566
+ mom_n = int(params.get("momentum_period", 20))
567
+
568
+ # Fetch data
569
+ if _is_ashare(symbol) and _HAS_AK:
570
+ norm = _normalise_ashare(symbol)
571
+ code = norm[2:]
572
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
573
+ start_date=start.replace("-", ""),
574
+ end_date=end.replace("-", ""),
575
+ adjust="qfq")
576
+ df = df.rename(columns={"收盘": "Close", "成交量": "Volume",
577
+ "日期": "Date", "开盘": "Open",
578
+ "最高": "High", "最低": "Low"})
579
+ df["Date"] = pd.to_datetime(df["Date"])
580
+ df = df.set_index("Date").sort_index()
581
+ elif _HAS_YF:
582
+ df = yf.Ticker(symbol).history(start=start, end=end)
583
+ else:
584
+ return {"success": False, "error": "No data source available"}
585
+
586
+ if df is None or len(df) < max(slow, 60):
587
+ return {"success": False, "error": f"Insufficient data: {len(df) if df is not None else 0} bars"}
588
+
589
+ close = df["Close"].astype(float)
590
+
591
+ # ── Signal generation ─────────────────────────────────────────────────
592
+ if strategy in ("sma_cross", "ma_cross"):
593
+ ma_f = close.rolling(fast).mean()
594
+ ma_s = close.rolling(slow).mean()
595
+ signal = (ma_f > ma_s).astype(int)
596
+
597
+ elif strategy in ("rsi_mean_revert", "rsi"):
598
+ delta = close.diff()
599
+ gain = delta.clip(lower=0).rolling(14).mean()
600
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
601
+ rsi = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
602
+ signal = pd.Series(0, index=rsi.index)
603
+ in_pos = False
604
+ for i in range(len(rsi)):
605
+ r = rsi.iloc[i]
606
+ if np.isnan(r):
607
+ signal.iloc[i] = 0
608
+ elif r < rsi_lo and not in_pos:
609
+ in_pos = True
610
+ signal.iloc[i] = 1
611
+ elif r > rsi_hi and in_pos:
612
+ in_pos = False
613
+ signal.iloc[i] = 0
614
+ else:
615
+ signal.iloc[i] = int(in_pos)
616
+
617
+ elif strategy in ("momentum", "mom"):
618
+ ret_n = close.pct_change(mom_n)
619
+ signal = (ret_n > 0).astype(int)
620
+
621
+ else: # buy_hold
622
+ signal = pd.Series(1, index=close.index)
623
+
624
+ # ── Vectorbt backtest (if available) ─────────────────────────────────
625
+ if _HAS_VBT:
626
+ try:
627
+ pf = vbt.Portfolio.from_signals(
628
+ close, signal.shift(1).fillna(0) == 1,
629
+ signal.shift(1).fillna(0) == 0,
630
+ freq="D",
631
+ )
632
+ stats = pf.stats()
633
+ return {
634
+ "success": True,
635
+ "symbol": symbol,
636
+ "strategy": strategy,
637
+ "start": str(df.index[0].date()),
638
+ "end": str(df.index[-1].date()),
639
+ "bars": len(df),
640
+ "total_return": round(float(stats.get("Total Return [%]", 0) / 100), 4),
641
+ "annual_return": round(float(stats.get("Annualized Return [%]", 0) / 100), 4),
642
+ "sharpe_ratio": round(float(stats.get("Sharpe Ratio", 0) or 0), 3),
643
+ "sortino_ratio": round(float(stats.get("Sortino Ratio", 0) or 0), 3),
644
+ "max_drawdown": round(float(stats.get("Max Drawdown [%]", 0) / 100), 4),
645
+ "win_rate": round(float(stats.get("Win Rate [%]", 0) / 100), 3),
646
+ "total_trades": int(stats.get("Total Trades", 0) or 0),
647
+ "provider": "vectorbt",
648
+ }
649
+ except Exception as exc:
650
+ logger.debug("vectorbt failed, falling back to manual: %s", exc)
651
+
652
+ # ── Manual pandas backtest fallback ──────────────────────────────────
653
+ sig = signal.shift(1).fillna(0)
654
+ ret = close.pct_change().fillna(0)
655
+ port_ret = (ret * sig).fillna(0)
656
+ equity = (1 + port_ret).cumprod()
657
+
658
+ total_r = float(equity.iloc[-1] - 1)
659
+ n_years = max((df.index[-1] - df.index[0]).days / 365.25, 0.01)
660
+ annual_r = float((1 + total_r) ** (1 / n_years) - 1)
661
+ rf = 0.04 # risk-free rate
662
+ excess = port_ret - rf / 252
663
+ sharpe = float(excess.mean() / port_ret.std() * np.sqrt(252)) if port_ret.std() > 0 else 0.0
664
+ neg_ret = port_ret[port_ret < 0]
665
+ sortino = float(excess.mean() / neg_ret.std() * np.sqrt(252)) if len(neg_ret) > 0 and neg_ret.std() > 0 else 0.0
666
+
667
+ # Max drawdown
668
+ peak = equity.cummax()
669
+ dd = (equity - peak) / peak
670
+ max_dd = float(dd.min())
671
+
672
+ # Trade stats
673
+ trades = (sig.diff() != 0) & (sig == 1)
674
+ exits = (sig.diff() != 0) & (sig == 0)
675
+ n_trades = int(trades.sum())
676
+ trade_rets = []
677
+ entry_date = None
678
+ entry_price = None
679
+ for date, row in sig.items():
680
+ if row == 1 and entry_price is None:
681
+ entry_price = float(close.loc[date])
682
+ entry_date = date
683
+ elif row == 0 and entry_price is not None:
684
+ trade_rets.append(float(close.loc[date]) / entry_price - 1)
685
+ entry_price = None
686
+
687
+ win_rate = sum(1 for r in trade_rets if r > 0) / len(trade_rets) if trade_rets else 0.0
688
+
689
+ # Benchmark: buy-and-hold
690
+ bh_ret = float(close.iloc[-1] / close.iloc[0] - 1)
691
+
692
+ return {
693
+ "success": True,
694
+ "symbol": symbol,
695
+ "strategy": strategy,
696
+ "start": str(df.index[0].date()),
697
+ "end": str(df.index[-1].date()),
698
+ "bars": len(df),
699
+ "total_return": round(total_r, 4),
700
+ "annual_return": round(annual_r, 4),
701
+ "sharpe_ratio": round(sharpe, 3),
702
+ "sortino_ratio": round(sortino, 3),
703
+ "max_drawdown": round(max_dd, 4),
704
+ "win_rate": round(win_rate, 3),
705
+ "total_trades": n_trades,
706
+ "benchmark_return": round(bh_ret, 4),
707
+ "alpha": round(annual_r - bh_ret / n_years, 4),
708
+ "provider": "pandas",
709
+ }
710
+
711
+
712
+ # ---------------------------------------------------------------------------
713
+ # 6. get_risk_metrics
714
+ # ---------------------------------------------------------------------------
715
+
716
+ def _get_risk_metrics(params: dict) -> dict:
717
+ symbol = params.get("symbol", "AAPL")
718
+ period = params.get("period", "1y")
719
+ conf_level = float(params.get("confidence", 0.95))
720
+
721
+ if _is_ashare(symbol) and _HAS_AK:
722
+ data = _get_ashare_data(symbol, _parse_date(None, 365), _today())
723
+ if not data.get("success"):
724
+ return data
725
+ norm = _normalise_ashare(symbol)
726
+ code = norm[2:]
727
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
728
+ start_date=_parse_date(None, 365).replace("-", ""),
729
+ end_date=_today().replace("-", ""),
730
+ adjust="qfq")
731
+ df = df.rename(columns={"收盘": "Close"})
732
+ close = df["Close"].astype(float)
733
+ elif _HAS_YF:
734
+ close = yf.Ticker(symbol).history(period=period)["Close"].astype(float)
735
+ else:
736
+ return {"success": False, "error": "No data source"}
737
+
738
+ if close is None or len(close) < 30:
739
+ return {"success": False, "error": "Insufficient data"}
740
+
741
+ ret = close.pct_change().dropna()
742
+ mu = float(ret.mean())
743
+ sig = float(ret.std())
744
+
745
+ # VaR (parametric)
746
+ if _HAS_SCIPY:
747
+ var_daily = float(-sp_stats.norm.ppf(1 - conf_level, mu, sig))
748
+ else:
749
+ var_daily = float(-np.percentile(ret, (1 - conf_level) * 100))
750
+
751
+ var_monthly = var_daily * np.sqrt(21)
752
+
753
+ # CVaR (Expected Shortfall)
754
+ losses = -ret
755
+ cvar = float(losses[losses >= var_daily].mean())
756
+
757
+ # Max drawdown
758
+ equity = (1 + ret).cumprod()
759
+ peak = equity.cummax()
760
+ dd = (equity - peak) / peak
761
+ max_dd = float(dd.min())
762
+
763
+ # Calmar
764
+ annual_ret = mu * 252
765
+ calmar = annual_ret / abs(max_dd) if max_dd != 0 else 0.0
766
+
767
+ # Downside deviation (Sortino denominator)
768
+ neg_ret = ret[ret < 0]
769
+ down_dev = float(neg_ret.std() * np.sqrt(252)) if len(neg_ret) > 0 else 0.0
770
+
771
+ # Skewness / kurtosis
772
+ skew_v = float(ret.skew()) if _HAS_SCIPY else 0.0
773
+ kurt_v = float(ret.kurtosis()) if _HAS_SCIPY else 0.0
774
+
775
+ return {
776
+ "success": True,
777
+ "symbol": symbol,
778
+ "confidence_level": conf_level,
779
+ "var_daily": round(var_daily, 5),
780
+ "var_monthly": round(float(var_monthly), 5),
781
+ "cvar_daily": round(cvar, 5),
782
+ "max_drawdown": round(max_dd, 5),
783
+ "annual_volatility": round(float(sig * np.sqrt(252)), 5),
784
+ "annual_return": round(float(annual_ret), 5),
785
+ "sharpe_ratio": round(float((annual_ret - 0.04) / (sig * np.sqrt(252))), 3) if sig > 0 else 0.0,
786
+ "calmar_ratio": round(float(calmar), 3),
787
+ "downside_deviation": round(down_dev, 5),
788
+ "skewness": round(skew_v, 3),
789
+ "kurtosis": round(kurt_v, 3),
790
+ "provider": "local",
791
+ }
792
+
793
+
794
+ # ---------------------------------------------------------------------------
795
+ # 7. optimize_positions
796
+ # ---------------------------------------------------------------------------
797
+
798
+ def _optimize_positions(params: dict) -> dict:
799
+ symbols = params.get("symbols", ["AAPL", "MSFT", "GOOGL"])
800
+ period = params.get("period", "1y")
801
+ method = params.get("method", "max_sharpe") # max_sharpe | min_var | equal_weight
802
+ rf = float(params.get("risk_free_rate", 0.04))
803
+
804
+ if isinstance(symbols, str):
805
+ symbols = [s.strip() for s in symbols.split(",")]
806
+
807
+ # Fetch returns
808
+ prices = {}
809
+ for sym in symbols:
810
+ if _is_ashare(sym) and _HAS_AK:
811
+ norm = _normalise_ashare(sym)
812
+ code = norm[2:]
813
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
814
+ start_date=_parse_date(None, 365).replace("-", ""),
815
+ end_date=_today().replace("-", ""),
816
+ adjust="qfq")
817
+ df = df.rename(columns={"收盘": "Close"})
818
+ prices[sym] = df["Close"].astype(float).values
819
+ elif _HAS_YF:
820
+ prices[sym] = yf.Ticker(sym).history(period=period)["Close"].astype(float).values
821
+
822
+ if not prices:
823
+ return {"success": False, "error": "Could not fetch prices"}
824
+
825
+ # Align length
826
+ min_len = min(len(v) for v in prices.values())
827
+ ret_mat = np.column_stack([prices[s][-min_len:] for s in symbols])
828
+ ret_mat = np.diff(np.log(ret_mat), axis=0)
829
+
830
+ mu_vec = ret_mat.mean(axis=0) * 252
831
+ cov_mat = np.cov(ret_mat.T) * 252
832
+ n = len(symbols)
833
+
834
+ if method == "equal_weight":
835
+ weights = np.ones(n) / n
836
+ elif method == "min_var":
837
+ # Analytical min-variance
838
+ try:
839
+ inv_cov = np.linalg.inv(cov_mat + 1e-8 * np.eye(n))
840
+ ones = np.ones(n)
841
+ w = inv_cov @ ones
842
+ weights = w / w.sum()
843
+ except np.linalg.LinAlgError:
844
+ weights = np.ones(n) / n
845
+ else: # max_sharpe — gradient-free grid search
846
+ best_sharpe = -np.inf
847
+ weights = np.ones(n) / n
848
+ rng = np.random.default_rng(42)
849
+ for _ in range(10000):
850
+ w_try = rng.dirichlet(np.ones(n))
851
+ p_ret = float(w_try @ mu_vec)
852
+ p_vol = float(np.sqrt(w_try @ cov_mat @ w_try))
853
+ sharpe = (p_ret - rf) / p_vol if p_vol > 0 else -np.inf
854
+ if sharpe > best_sharpe:
855
+ best_sharpe = sharpe
856
+ weights = w_try
857
+
858
+ # Portfolio metrics
859
+ p_ret = float(weights @ mu_vec)
860
+ p_vol = float(np.sqrt(weights @ cov_mat @ weights))
861
+ sharpe = (p_ret - rf) / p_vol if p_vol > 0 else 0.0
862
+
863
+ return {
864
+ "success": True,
865
+ "method": method,
866
+ "symbols": symbols,
867
+ "weights": {sym: round(float(w), 4) for sym, w in zip(symbols, weights)},
868
+ "portfolio_return": round(p_ret, 4),
869
+ "portfolio_vol": round(p_vol, 4),
870
+ "sharpe_ratio": round(sharpe, 3),
871
+ "provider": "local",
872
+ }
873
+
874
+
875
+ # ---------------------------------------------------------------------------
876
+ # 8. get_sector_performance (A股 + US)
877
+ # ---------------------------------------------------------------------------
878
+
879
+ def _get_sector_performance(params: dict) -> dict:
880
+ market = params.get("market", "cn").lower() # cn | us
881
+
882
+ if market == "cn" and _HAS_AK:
883
+ try:
884
+ df = _ak_retry(ak.stock_board_industry_name_em)
885
+ if df is None or df.empty:
886
+ raise ValueError("empty sector data")
887
+ df = df.rename(columns={
888
+ "板块名称": "sector", "最新价": "price",
889
+ "涨跌幅": "change_pct", "成交额": "amount",
890
+ "上涨家数": "rising", "下跌家数": "falling",
891
+ })
892
+ top = df.nlargest(5, "change_pct")[["sector", "change_pct"]].to_dict("records")
893
+ bottom = df.nsmallest(5, "change_pct")[["sector", "change_pct"]].to_dict("records")
894
+ return {
895
+ "success": True,
896
+ "market": "cn",
897
+ "date": _today(),
898
+ "top_sectors": top,
899
+ "bottom_sectors": bottom,
900
+ "total_sectors": len(df),
901
+ "provider": "akshare",
902
+ }
903
+ except Exception as exc:
904
+ return {"success": False, "error": str(exc)}
905
+
906
+ elif _HAS_YF:
907
+ sector_etfs = {
908
+ "Technology": "XLK", "Healthcare": "XLV",
909
+ "Financials": "XLF", "Consumer Disc": "XLY",
910
+ "Industrials": "XLI", "Energy": "XLE",
911
+ "Utilities": "XLU", "Real Estate": "XLRE",
912
+ "Materials": "XLB", "Comm Services": "XLC",
913
+ "Consumer Staples": "XLP",
914
+ }
915
+ perf = []
916
+ for name, etf in sector_etfs.items():
917
+ try:
918
+ h = yf.Ticker(etf).history(period="5d")
919
+ if len(h) >= 2:
920
+ chg = (h["Close"].iloc[-1] - h["Close"].iloc[-2]) / h["Close"].iloc[-2] * 100
921
+ perf.append({"sector": name, "etf": etf, "change_pct": round(float(chg), 2)})
922
+ except Exception:
923
+ pass
924
+ perf.sort(key=lambda x: x["change_pct"], reverse=True)
925
+ return {
926
+ "success": True,
927
+ "market": "us",
928
+ "date": _today(),
929
+ "sectors": perf,
930
+ "top_sectors": perf[:3],
931
+ "bottom_sectors": perf[-3:],
932
+ "provider": "yfinance",
933
+ }
934
+
935
+ return {"success": False, "error": "akshare / yfinance not available: 运行 pip install akshare yfinance 或 /install akshare yfinance"}
936
+
937
+
938
+ # ---------------------------------------------------------------------------
939
+ # 9. A股 northbound fund flow (北向资金)
940
+ # ---------------------------------------------------------------------------
941
+
942
+ def _get_northbound_flow(params: dict) -> dict:
943
+ if not _HAS_AK:
944
+ return {"success": False, "error": "akshare not installed: 运行 pip install akshare 或 /install akshare"}
945
+ try:
946
+ # stock_hsgt_fund_flow_summary_em returns today's 沪深港通 summary.
947
+ # 成交净买额 is already in 亿元 — no further scaling needed.
948
+ df = _ak_retry(ak.stock_hsgt_fund_flow_summary_em)
949
+ north = df[df["资金方向"] == "北向"]
950
+ if north.empty:
951
+ return {"success": False, "error": "No northbound data in response"}
952
+ sh_flow = float(north[north["板块"] == "沪股通"]["成交净买额"].sum())
953
+ sz_flow = float(north[north["板块"] == "深股通"]["成交净买额"].sum())
954
+ total = round(sh_flow + sz_flow, 2)
955
+ return {
956
+ "success": True,
957
+ "latest_net_buy_yi": total,
958
+ "sh_net_buy_yi": round(sh_flow, 2),
959
+ "sz_net_buy_yi": round(sz_flow, 2),
960
+ "total_net_buy_yi": total,
961
+ "trend": "inflow" if total > 0 else "outflow",
962
+ "provider": "akshare",
963
+ }
964
+ except Exception as exc:
965
+ return {"success": False, "error": str(exc)}
966
+
967
+
968
+ # ---------------------------------------------------------------------------
969
+ # 10. screen_ashare — 选股筛选器
970
+ # ---------------------------------------------------------------------------
971
+
972
+ def _screen_ashare(params: dict) -> dict:
973
+ """Screen A-share stocks by fundamental & technical criteria."""
974
+ min_roe = float(params.get("min_roe", 10))
975
+ max_pe = float(params.get("max_pe", 50))
976
+ min_revenue_gr = float(params.get("min_revenue_growth", 10))
977
+ min_market_cap = float(params.get("min_market_cap_yi", 0)) # 亿元
978
+ limit = int(params.get("limit", 20))
979
+
980
+ # Primary: direct eastmoney clist (host-rotating, proxy-resilient, small
981
+ # paged query). Far more reliable than akshare's full-market spot endpoint.
982
+ try:
983
+ from market_data_client import screen_ashare as _em_screen
984
+ _em = _em_screen(max_pe=max_pe, min_market_cap_yi=min_market_cap, limit=limit)
985
+ if _em.get("success") and _em.get("stocks"):
986
+ _em["criteria"] = params
987
+ return _em
988
+ except Exception:
989
+ pass # fall through to akshare
990
+
991
+ if not _HAS_AK:
992
+ return {"success": False, "error": "akshare not installed: 运行 pip install akshare 或 /install akshare"}
993
+
994
+ try:
995
+ # A股实时行情
996
+ df = _ak_retry(ak.stock_zh_a_spot_em)
997
+ if df is None or df.empty:
998
+ return {"success": False, "error": "No spot data"}
999
+
1000
+ df = df.rename(columns={
1001
+ "代码": "code", "名称": "name", "最新价": "price",
1002
+ "涨跌幅": "change_pct", "总市值": "market_cap",
1003
+ "市盈率-动态": "pe_dynamic", "市净率": "pb",
1004
+ "成交量": "volume", "换手率": "turnover_rate",
1005
+ })
1006
+
1007
+ # Basic filters
1008
+ df = df[~df["name"].str.contains("ST|退", na=False)]
1009
+ df = df[df["price"].notna() & (df["price"] > 0)]
1010
+
1011
+ if "pe_dynamic" in df.columns:
1012
+ df = df[df["pe_dynamic"].between(0.1, max_pe, inclusive="both")]
1013
+
1014
+ if "market_cap" in df.columns and min_market_cap > 0:
1015
+ df = df[df["market_cap"] >= min_market_cap * 1e8]
1016
+
1017
+ # Score on momentum
1018
+ if "change_pct" in df.columns:
1019
+ df["score"] = df["change_pct"].fillna(0)
1020
+ df = df.nlargest(limit, "score")
1021
+
1022
+ cols = ["code", "name", "price", "change_pct", "pe_dynamic", "pb",
1023
+ "market_cap", "turnover_rate"]
1024
+ cols = [c for c in cols if c in df.columns]
1025
+ result_df = df[cols].head(limit)
1026
+
1027
+ # Format market_cap to 亿元
1028
+ if "market_cap" in result_df.columns:
1029
+ result_df = result_df.copy()
1030
+ result_df["market_cap_yi"] = (result_df["market_cap"] / 1e8).round(1)
1031
+
1032
+ stocks = result_df.to_dict("records")
1033
+ return {
1034
+ "success": True,
1035
+ "count": len(stocks),
1036
+ "stocks": stocks,
1037
+ "criteria": params,
1038
+ "provider": "akshare",
1039
+ }
1040
+ except Exception as exc:
1041
+ return {"success": False, "error": str(exc)}
1042
+
1043
+
1044
+ # ---------------------------------------------------------------------------
1045
+ # 11. get_limit_up_pool — 涨停板池
1046
+ # ---------------------------------------------------------------------------
1047
+
1048
+ def _get_limit_up_pool(params: dict) -> dict:
1049
+ date = params.get("date", _today())
1050
+ if not _HAS_AK:
1051
+ return {"success": False, "error": "akshare not installed: 运行 pip install akshare 或 /install akshare"}
1052
+ try:
1053
+ date_str = date.replace("-", "")
1054
+ df = _ak_retry(ak.stock_zt_pool_em, date=date_str)
1055
+ if df is None or df.empty:
1056
+ return {"success": True, "count": 0, "stocks": [], "date": date}
1057
+
1058
+ df = df.rename(columns={
1059
+ "代码": "code", "名称": "name",
1060
+ "涨停统计": "limit_streak", "连续涨停": "consecutive",
1061
+ "首次封板时间": "first_lock_time", "涨停类型": "limit_type",
1062
+ })
1063
+ cols = [c for c in ["code", "name", "limit_streak", "consecutive",
1064
+ "first_lock_time", "limit_type"] if c in df.columns]
1065
+ return {
1066
+ "success": True,
1067
+ "date": date,
1068
+ "count": len(df),
1069
+ "stocks": df[cols].head(50).to_dict("records"),
1070
+ "provider": "akshare",
1071
+ }
1072
+ except Exception as exc:
1073
+ return {"success": False, "error": str(exc)}
1074
+
1075
+
1076
+ # ---------------------------------------------------------------------------
1077
+ # 12. get_market_indices
1078
+ # ---------------------------------------------------------------------------
1079
+
1080
+ def _get_market_indices(params: dict) -> dict:
1081
+ indices = {
1082
+ # US
1083
+ "S&P 500": "^GSPC", "NASDAQ": "^IXIC", "Dow Jones": "^DJI",
1084
+ "VIX": "^VIX", "Russell 2000": "^RUT",
1085
+ # CN
1086
+ "上证综指": "000001.SS", "深证成指": "399001.SZ", "创业板": "399006.SZ",
1087
+ # Global
1088
+ "Nikkei 225": "^N225", "FTSE 100": "^FTSE", "DAX": "^GDAXI",
1089
+ "Hang Seng": "^HSI",
1090
+ # Commodities
1091
+ "Gold": "GC=F", "Crude Oil": "CL=F", "Bitcoin": "BTC-USD",
1092
+ }
1093
+
1094
+ if not _HAS_YF:
1095
+ if _HAS_AK:
1096
+ # A股 indices only
1097
+ try:
1098
+ df = ak.stock_zh_index_spot_em()
1099
+ return {"success": True, "indices": df.head(10).to_dict("records"), "provider": "akshare"}
1100
+ except Exception as exc:
1101
+ return {"success": False, "error": str(exc)}
1102
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
1103
+
1104
+ results = []
1105
+ for name, ticker in indices.items():
1106
+ try:
1107
+ h = yf.Ticker(ticker).history(period="2d")
1108
+ if len(h) >= 1:
1109
+ latest = float(h["Close"].iloc[-1])
1110
+ chg = (float(h["Close"].iloc[-1]) - float(h["Close"].iloc[0])) / float(h["Close"].iloc[0]) * 100 if len(h) >= 2 else 0.0
1111
+ results.append({"name": name, "ticker": ticker,
1112
+ "price": round(latest, 2), "change_pct": round(chg, 2)})
1113
+ except Exception:
1114
+ pass
1115
+
1116
+ return {"success": True, "indices": results, "date": _today(), "provider": "yfinance"}
1117
+
1118
+
1119
+ # ---------------------------------------------------------------------------
1120
+ # 13. analyze_news (local sentiment via keyword scoring)
1121
+ # ---------------------------------------------------------------------------
1122
+
1123
+ def _get_data_key(service: str) -> str:
1124
+ """Read a data service API key from env var or ~/.arthera/providers.json."""
1125
+ _DATA_ENV = {
1126
+ "finnhub": "FINNHUB_API_KEY",
1127
+ "newsapi": "NEWS_API_KEY",
1128
+ "brave": "BRAVE_SEARCH_API_KEY",
1129
+ "alphavantage": "ALPHA_VANTAGE_API_KEY",
1130
+ "coingecko": "COINGECKO_API_KEY",
1131
+ "twelvedata": "TWELVEDATA_API_KEY",
1132
+ }
1133
+ env_var = _DATA_ENV.get(service, "")
1134
+ if env_var:
1135
+ val = os.getenv(env_var, "")
1136
+ if val:
1137
+ return val
1138
+ # Fall back to providers.json
1139
+ try:
1140
+ import pathlib as _pl, json as _json
1141
+ pf = _pl.Path.home() / ".arthera" / "providers.json"
1142
+ if pf.exists():
1143
+ raw = _json.loads(pf.read_text(encoding="utf-8"))
1144
+ entry = raw.get("data", {}).get(service, {})
1145
+ if entry.get("api_key"):
1146
+ return entry["api_key"]
1147
+ except Exception:
1148
+ pass
1149
+ return ""
1150
+
1151
+
1152
+ def _fetch_news_finnhub(symbol: str, limit: int) -> list:
1153
+ """Fetch stock news from Finnhub API."""
1154
+ key = _get_data_key("finnhub")
1155
+ if not key:
1156
+ return []
1157
+ try:
1158
+ import requests as _req, datetime as _dt
1159
+ end_dt = _dt.date.today().isoformat()
1160
+ start_dt = (_dt.date.today() - _dt.timedelta(days=7)).isoformat()
1161
+ url = (f"https://finnhub.io/api/v1/company-news"
1162
+ f"?symbol={symbol}&from={start_dt}&to={end_dt}&token={key}")
1163
+ resp = _req.get(url, timeout=8)
1164
+ if resp.status_code == 200:
1165
+ items = resp.json()[:limit]
1166
+ return [{"title": a.get("headline", ""), "source": a.get("source", ""),
1167
+ "time": str(a.get("datetime", "")), "url": a.get("url", "")}
1168
+ for a in items]
1169
+ except Exception:
1170
+ pass
1171
+ return []
1172
+
1173
+
1174
+ def _fetch_news_newsapi(query: str, limit: int) -> list:
1175
+ """Fetch news from NewsAPI.org."""
1176
+ key = _get_data_key("newsapi")
1177
+ if not key:
1178
+ return []
1179
+ try:
1180
+ import requests as _req
1181
+ url = (f"https://newsapi.org/v2/everything"
1182
+ f"?q={query}&language=en&sortBy=publishedAt&pageSize={limit}&apiKey={key}")
1183
+ resp = _req.get(url, timeout=8)
1184
+ if resp.status_code == 200:
1185
+ data = resp.json()
1186
+ return [{"title": a.get("title", ""), "source": a.get("source", {}).get("name", ""),
1187
+ "time": a.get("publishedAt", "")[:10], "url": a.get("url", "")}
1188
+ for a in data.get("articles", [])[:limit]]
1189
+ except Exception:
1190
+ pass
1191
+ return []
1192
+
1193
+
1194
+ def _analyze_news(params: dict) -> dict:
1195
+ symbol = params.get("symbol", "")
1196
+ query = params.get("query", params.get("topic", symbol))
1197
+ topic = query or symbol
1198
+ limit = int(params.get("limit", 5))
1199
+
1200
+ # ── 1. A股 news via akshare (no key needed) ───────────────────────────────
1201
+ if _HAS_AK and topic and _is_ashare(topic):
1202
+ try:
1203
+ norm = _normalise_ashare(topic) if _is_ashare(topic) else topic
1204
+ code = norm[2:] if len(norm) > 2 else norm
1205
+ df = ak.stock_news_em(symbol=code)
1206
+ if df is not None and not df.empty:
1207
+ df = df.head(limit)
1208
+ news_list = []
1209
+ for _, row in df.iterrows():
1210
+ title = str(row.get("新闻标题", row.get("title", "")))
1211
+ score = _score_sentiment(title)
1212
+ news_list.append({
1213
+ "title": title,
1214
+ "time": str(row.get("发布时间", "")),
1215
+ "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"),
1216
+ "score": score,
1217
+ })
1218
+ avg_score = sum(n["score"] for n in news_list) / len(news_list) if news_list else 0
1219
+ return {
1220
+ "success": True, "symbol": topic, "news": news_list,
1221
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1222
+ "avg_score": round(avg_score, 3), "provider": "akshare",
1223
+ }
1224
+ except Exception as exc:
1225
+ pass # fall through to other providers
1226
+
1227
+ # ── 2. Finnhub (if key available) ────────────────────────────────────────
1228
+ if symbol and not _is_ashare(symbol):
1229
+ articles = _fetch_news_finnhub(symbol.upper(), limit)
1230
+ if articles:
1231
+ news_list = []
1232
+ for a in articles:
1233
+ score = _score_sentiment(a["title"])
1234
+ news_list.append({**a, "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"), "score": score})
1235
+ avg_score = sum(n["score"] for n in news_list) / len(news_list) if news_list else 0
1236
+ return {
1237
+ "success": True, "symbol": symbol, "news": news_list,
1238
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1239
+ "avg_score": round(avg_score, 3), "provider": "finnhub",
1240
+ }
1241
+
1242
+ # ── 3. NewsAPI (if key available) ─────────────────────────────────────────
1243
+ search_query = topic or symbol or "market"
1244
+ articles = _fetch_news_newsapi(search_query, limit)
1245
+ if articles:
1246
+ news_list = []
1247
+ for a in articles:
1248
+ score = _score_sentiment(a["title"])
1249
+ news_list.append({**a, "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"), "score": score})
1250
+ avg_score = sum(n["score"] for n in news_list) / len(news_list) if news_list else 0
1251
+ return {
1252
+ "success": True, "symbol": topic, "news": news_list,
1253
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1254
+ "avg_score": round(avg_score, 3), "provider": "newsapi",
1255
+ }
1256
+
1257
+ # ── 4. yfinance news (free, no key, US/HK/global stocks) ─────────────────
1258
+ yf_sym = symbol.upper() if symbol else ""
1259
+ if yf_sym and not _is_ashare(yf_sym):
1260
+ try:
1261
+ import yfinance as yf
1262
+ ticker = yf.Ticker(yf_sym)
1263
+ raw_news = ticker.news or []
1264
+ news_list = []
1265
+ for item in raw_news[:limit]:
1266
+ content = item.get("content", {})
1267
+ title = (
1268
+ content.get("title")
1269
+ or item.get("title", "")
1270
+ )
1271
+ pub = (
1272
+ content.get("pubDate")
1273
+ or item.get("providerPublishTime", "")
1274
+ )
1275
+ url = (
1276
+ content.get("canonicalUrl", {}).get("url")
1277
+ or item.get("link", "")
1278
+ )
1279
+ provider = (
1280
+ content.get("provider", {}).get("displayName")
1281
+ or item.get("publisher", "")
1282
+ )
1283
+ if not title:
1284
+ continue
1285
+ score = _score_sentiment(title)
1286
+ news_list.append({
1287
+ "title": title,
1288
+ "time": str(pub),
1289
+ "url": url,
1290
+ "publisher": provider,
1291
+ "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"),
1292
+ "score": score,
1293
+ })
1294
+ if news_list:
1295
+ avg_score = sum(n["score"] for n in news_list) / len(news_list)
1296
+ return {
1297
+ "success": True, "symbol": yf_sym, "news": news_list,
1298
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1299
+ "avg_score": round(avg_score, 3), "provider": "yfinance",
1300
+ }
1301
+ except Exception:
1302
+ pass
1303
+
1304
+ # ── 5. web_search fallback — search "[symbol] news" ──────────────────────
1305
+ ws_query = f"{topic or symbol} stock news latest" if (topic or symbol) else ""
1306
+ if ws_query:
1307
+ ws_result = _web_search({"query": ws_query, "max_results": limit})
1308
+ if ws_result.get("success") and ws_result.get("results"):
1309
+ news_list = []
1310
+ for item in ws_result["results"]:
1311
+ title = item.get("title", "")
1312
+ score = _score_sentiment(title)
1313
+ news_list.append({
1314
+ "title": title,
1315
+ "url": item.get("url", ""),
1316
+ "publisher": item.get("source", ""),
1317
+ "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"),
1318
+ "score": score,
1319
+ })
1320
+ if news_list:
1321
+ avg_score = sum(n["score"] for n in news_list) / len(news_list)
1322
+ return {
1323
+ "success": True, "symbol": topic or symbol, "news": news_list,
1324
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1325
+ "avg_score": round(avg_score, 3), "provider": ws_result.get("provider", "web_search"),
1326
+ }
1327
+
1328
+ # ── 6. No data available ──────────────────────────────────────────────────
1329
+ tip = "配置数据服务 key: /apikey set finnhub <key> 或 /apikey set newsapi <key>;或设置 BRAVE_SEARCH_API_KEY 启用网页搜索"
1330
+ return {"success": False, "error": tip}
1331
+
1332
+
1333
+ def _score_sentiment(text: str) -> float:
1334
+ """Keyword-based sentiment scorer (0.0 = neutral, +1 = very positive, -1 = very negative)."""
1335
+ pos = ["上涨", "涨停", "突破", "创新高", "利好", "增长", "盈利", "bull", "beat", "growth", "record", "profit", "rally", "buy", "upgrade"]
1336
+ neg = ["下跌", "跌停", "亏损", "利空", "减少", "违规", "被罚", "风险", "bear", "miss", "loss", "decline", "sell", "downgrade", "fraud"]
1337
+ t = text.lower()
1338
+ score = sum(1 for w in pos if w in t) - sum(1 for w in neg if w in t)
1339
+ return float(max(-1, min(1, score * 0.25)))
1340
+
1341
+
1342
+ # ---------------------------------------------------------------------------
1343
+ # 14. get_commodities_data — gold, silver, oil, gas, copper, wheat, etc.
1344
+ # ---------------------------------------------------------------------------
1345
+
1346
+ # Common commodity keywords → yfinance futures tickers
1347
+ _COMMODITY_MAP: Dict[str, str] = {
1348
+ # Precious metals
1349
+ "gold": "GC=F", "silver": "SI=F", "platinum": "PL=F", "palladium": "PA=F",
1350
+ # Energy
1351
+ "oil": "CL=F", "crude": "CL=F", "crude oil": "CL=F",
1352
+ "brent": "BZ=F", "natural gas": "NG=F", "natgas": "NG=F", "gas": "NG=F",
1353
+ "gasoline": "RB=F", "heating oil": "HO=F",
1354
+ # Base metals
1355
+ "copper": "HG=F", "aluminum": "ALI=F", "nickel": "NI=F", "zinc": "ZNC=F",
1356
+ # Agricultural
1357
+ "wheat": "ZW=F", "corn": "ZC=F", "soybean": "ZS=F", "soybeans": "ZS=F",
1358
+ "coffee": "KC=F", "cocoa": "CC=F", "sugar": "SB=F", "cotton": "CT=F",
1359
+ "rice": "ZR=F", "oats": "ZO=F",
1360
+ # Livestock
1361
+ "cattle": "LE=F", "hogs": "HE=F",
1362
+ # Softs / other
1363
+ "lumber": "LBS=F", "rubber": "rubber", "iron ore": "TIO=F",
1364
+ # China-traded (A股 futures) — map to closest international proxy
1365
+ "螺纹钢": "HG=F", "铁矿石": "TIO=F", "铜": "HG=F",
1366
+ "黄金": "GC=F", "白银": "SI=F", "原油": "CL=F", "天然气": "NG=F",
1367
+ }
1368
+
1369
+
1370
+ def _get_commodities_data(params: dict) -> dict:
1371
+ """Commodity spot/futures price lookup via yfinance."""
1372
+ commodity = str(params.get("commodity", "gold")).strip().lower()
1373
+
1374
+ if not _HAS_YF:
1375
+ return {"success": False, "error": "yfinance not installed: pip install yfinance"}
1376
+
1377
+ # Resolve ticker
1378
+ ticker = _COMMODITY_MAP.get(commodity)
1379
+ if ticker is None:
1380
+ # Try direct uppercase (user may have passed ticker like GC=F)
1381
+ ticker = commodity.upper()
1382
+
1383
+ try:
1384
+ tkr = yf.Ticker(ticker)
1385
+ hist = tkr.history(period="5d")
1386
+ if hist.empty:
1387
+ # Fallback: try without =F suffix
1388
+ alt = commodity.upper().replace("=F", "") + "=F"
1389
+ hist = yf.Ticker(alt).history(period="5d")
1390
+ if hist.empty:
1391
+ return {
1392
+ "success": False,
1393
+ "error": f"No data for commodity '{commodity}' (ticker={ticker}). "
1394
+ "Try using the yfinance ticker directly, e.g. GC=F for gold.",
1395
+ }
1396
+ ticker = alt
1397
+
1398
+ latest = float(hist["Close"].iloc[-1])
1399
+ prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else latest
1400
+ chg = (latest - prev) / prev * 100 if prev else 0.0
1401
+ high_5d = float(hist["High"].max())
1402
+ low_5d = float(hist["Low"].min())
1403
+ vol_5d = float(hist["Volume"].mean())
1404
+
1405
+ # Longer term context
1406
+ hist_1y = tkr.history(period="1y")
1407
+ high_52w = float(hist_1y["High"].max()) if not hist_1y.empty else None
1408
+ low_52w = float(hist_1y["Low"].min()) if not hist_1y.empty else None
1409
+ ret_1y = float((hist_1y["Close"].iloc[-1] / hist_1y["Close"].iloc[0] - 1)) \
1410
+ if len(hist_1y) > 1 else None
1411
+
1412
+ info = tkr.fast_info
1413
+ currency = getattr(info, "currency", "USD")
1414
+
1415
+ return {
1416
+ "success": True,
1417
+ "commodity": commodity,
1418
+ "ticker": ticker,
1419
+ "latest_price": round(latest, 4),
1420
+ "change_pct": round(chg, 3),
1421
+ "currency": currency,
1422
+ "high_5d": round(high_5d, 4),
1423
+ "low_5d": round(low_5d, 4),
1424
+ "volume_5d_avg": int(vol_5d),
1425
+ "high_52w": round(high_52w, 4) if high_52w else None,
1426
+ "low_52w": round(low_52w, 4) if low_52w else None,
1427
+ "return_1y": round(ret_1y, 4) if ret_1y is not None else None,
1428
+ "pct_from_52w_high": round((latest / high_52w - 1), 4) if high_52w else None,
1429
+ "provider": "yfinance",
1430
+ }
1431
+ except Exception as exc:
1432
+ return {"success": False, "error": str(exc)}
1433
+
1434
+
1435
+ # ---------------------------------------------------------------------------
1436
+ # 15. get_futures_data — generic futures via yfinance
1437
+ # ---------------------------------------------------------------------------
1438
+
1439
+ def _get_futures_data(params: dict) -> dict:
1440
+ """Futures contract data (equity index futures, VIX, etc.)"""
1441
+ contract = str(params.get("contract", params.get("symbol", "ES=F"))).strip().upper()
1442
+
1443
+ # Common index futures shortcuts
1444
+ _FUTURES_MAP = {
1445
+ "SP500": "ES=F", "SPX": "ES=F", "S&P": "ES=F",
1446
+ "NQ": "NQ=F", "NASDAQ": "NQ=F",
1447
+ "DOW": "YM=F", "DJIA": "YM=F",
1448
+ "RUSSELL": "RTY=F", "RUT": "RTY=F",
1449
+ "VIX": "^VIX",
1450
+ "NIKKEI": "NK=F", "DAX": "FDAX=F",
1451
+ "HSI": "HSI=F",
1452
+ }
1453
+ ticker = _FUTURES_MAP.get(contract, contract)
1454
+ if not ticker.endswith("=F") and not ticker.startswith("^"):
1455
+ ticker = ticker + "=F"
1456
+
1457
+ return _get_market_data({"symbol": ticker, "period": "5d"})
1458
+
1459
+
1460
+ # ---------------------------------------------------------------------------
1461
+ # 15b. get_bonds_data (US Treasury yields via yfinance)
1462
+ # ---------------------------------------------------------------------------
1463
+
1464
+ def _get_bonds_data(params: dict) -> dict:
1465
+ if not _HAS_YF:
1466
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
1467
+ tickers = {
1468
+ "2Y": "^IRX", "5Y": "^FVX", "10Y": "^TNX", "30Y": "^TYX",
1469
+ }
1470
+ results = {}
1471
+ for tenor, sym in tickers.items():
1472
+ try:
1473
+ h = yf.Ticker(sym).history(period="5d")
1474
+ if not h.empty:
1475
+ results[tenor] = round(float(h["Close"].iloc[-1]), 3)
1476
+ except Exception:
1477
+ pass
1478
+ if not results:
1479
+ return {"success": False, "error": "Could not fetch yield data"}
1480
+ # Yield curve shape
1481
+ if "2Y" in results and "10Y" in results:
1482
+ results["10Y_2Y_spread"] = round(results["10Y"] - results["2Y"], 3)
1483
+ results["curve_shape"] = "normal" if results["10Y_2Y_spread"] > 0 else "inverted"
1484
+ return {"success": True, "yields": results, "provider": "yfinance"}
1485
+
1486
+
1487
+ # ---------------------------------------------------------------------------
1488
+ # 16. get_ai_signal — Alibaba Cloud DeepSeek-powered signal
1489
+ # ---------------------------------------------------------------------------
1490
+
1491
+ def _get_ai_signal(params: dict) -> dict:
1492
+ """
1493
+ AI trading signal from the Alibaba Cloud quant backend.
1494
+ Returns: action (BUY/SELL/HOLD), confidence, reasoning, stop_loss, take_profit.
1495
+ Falls back to calculate_factors when cloud is unavailable.
1496
+ """
1497
+ symbol = params.get("symbol", "600519")
1498
+ market = params.get("market", "CN" if _is_ashare(symbol) else "US")
1499
+
1500
+ if _HAS_CLOUD:
1501
+ try:
1502
+ result = cloud_get_ai_signal_sync(symbol, market=market)
1503
+ if result and result.get("action"):
1504
+ return {"success": True, **result, "provider": "aliyun_cloud"}
1505
+ except Exception as exc:
1506
+ logger.debug("Cloud AI signal failed: %s", exc)
1507
+
1508
+ # Local fallback: derive a simple signal from factors
1509
+ factors = _calculate_factors({"symbol": symbol})
1510
+ if not factors.get("success"):
1511
+ return factors
1512
+
1513
+ rsi = factors.get("rsi_14", 50)
1514
+ macd_h = factors.get("macd_hist", 0) or 0
1515
+ trend = factors.get("trend_score", 0) or 0
1516
+ vol_r = factors.get("volume_ratio_20d", 1.0) or 1.0
1517
+
1518
+ score = 0
1519
+ if rsi is not None:
1520
+ score += (0.4 if rsi < 40 else -0.4 if rsi > 70 else 0)
1521
+ score += (0.3 if macd_h > 0 else -0.3 if macd_h < 0 else 0)
1522
+ score += trend * 0.3
1523
+
1524
+ action = "BUY" if score > 0.3 else "SELL" if score < -0.3 else "HOLD"
1525
+ confidence = min(abs(score), 1.0)
1526
+
1527
+ return {
1528
+ "success": True,
1529
+ "symbol": symbol,
1530
+ "action": action,
1531
+ "confidence": round(confidence, 3),
1532
+ "reasoning": f"RSI={rsi}, MACD_hist={macd_h:.5f}, trend={trend:.3f}, vol_ratio={vol_r:.2f}",
1533
+ "stop_loss": None,
1534
+ "take_profit": None,
1535
+ "provider": "local_fallback",
1536
+ }
1537
+
1538
+
1539
+ # ---------------------------------------------------------------------------
1540
+ # 17. get_market_insights — AI narrative analysis
1541
+ # ---------------------------------------------------------------------------
1542
+
1543
+ def _get_market_insights(params: dict) -> dict:
1544
+ """
1545
+ Narrative market insights from Alibaba Cloud AI service.
1546
+ symbols: list of stock codes or comma-separated string.
1547
+ """
1548
+ raw = params.get("symbols", params.get("symbol", "sh600519"))
1549
+ if isinstance(raw, str):
1550
+ symbols = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
1551
+ else:
1552
+ symbols = list(raw)
1553
+ market = params.get("market", "CN")
1554
+
1555
+ if _HAS_CLOUD:
1556
+ try:
1557
+ from aliyun_data_client import run_async
1558
+ result = run_async(AliyunDataClient.get().get_market_insights(symbols, market=market))
1559
+ if result:
1560
+ return {"success": True, **result, "provider": "aliyun_cloud"}
1561
+ except Exception as exc:
1562
+ logger.debug("Cloud market insights failed: %s", exc)
1563
+
1564
+ # Local fallback: multi-stock factor summary
1565
+ summaries = []
1566
+ for sym in symbols[:5]:
1567
+ f = _calculate_factors({"symbol": sym})
1568
+ if f.get("success"):
1569
+ summaries.append({
1570
+ "symbol": sym,
1571
+ "rsi_14": f.get("rsi_14"),
1572
+ "trend_score": f.get("trend_score"),
1573
+ "macd_hist": f.get("macd_hist"),
1574
+ "vol_ratio": f.get("volume_ratio_20d"),
1575
+ })
1576
+ if not summaries:
1577
+ return {"success": False, "error": "Could not compute local factors"}
1578
+
1579
+ return {
1580
+ "success": True,
1581
+ "symbols": symbols,
1582
+ "summaries": summaries,
1583
+ "note": "Cloud AI not available — showing local factor summary",
1584
+ "provider": "local_fallback",
1585
+ }
1586
+
1587
+
1588
+ # ---------------------------------------------------------------------------
1589
+ # 18. get_predictions — ML model predictions from cloud
1590
+ # ---------------------------------------------------------------------------
1591
+
1592
+ def _get_predictions(params: dict) -> dict:
1593
+ """
1594
+ ML-powered stock return predictions from Alibaba Cloud QuantEngine.
1595
+ Falls back to momentum signal locally.
1596
+ """
1597
+ raw = params.get("symbols", params.get("symbol", "sh600519"))
1598
+ if isinstance(raw, str):
1599
+ symbols = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
1600
+ else:
1601
+ symbols = list(raw)
1602
+ days = int(params.get("prediction_days", 5))
1603
+ market = params.get("market", "CN")
1604
+
1605
+ if _HAS_CLOUD:
1606
+ try:
1607
+ from aliyun_data_client import run_async
1608
+ result = run_async(AliyunDataClient.get().get_predictions(symbols, prediction_days=days, market=market))
1609
+ if result and result.get("predictions"):
1610
+ return {"success": True, **result, "provider": "aliyun_cloud"}
1611
+ except Exception as exc:
1612
+ logger.debug("Cloud predictions failed: %s", exc)
1613
+
1614
+ # Local fallback: simple momentum prediction
1615
+ preds = []
1616
+ for sym in symbols[:5]:
1617
+ f = _calculate_factors({"symbol": sym})
1618
+ if f.get("success"):
1619
+ r5 = f.get("return_5d", 0) or 0
1620
+ r20 = f.get("return_20d", 0) or 0
1621
+ predicted_return = round((r5 * 0.4 + r20 * 0.6) * (days / 20), 4)
1622
+ preds.append({
1623
+ "symbol": sym,
1624
+ "predicted_return": predicted_return,
1625
+ "confidence": 0.5,
1626
+ "method": "momentum",
1627
+ })
1628
+
1629
+ if not preds:
1630
+ return {"success": False, "error": "No data for predictions"}
1631
+
1632
+ return {
1633
+ "success": True,
1634
+ "predictions": preds,
1635
+ "prediction_days": days,
1636
+ "provider": "local_fallback",
1637
+ }
1638
+
1639
+
1640
+ # ---------------------------------------------------------------------------
1641
+ # 19. cloud_backtest — advanced ML-powered backtest via Alibaba Cloud
1642
+ # ---------------------------------------------------------------------------
1643
+
1644
+ def _cloud_backtest(params: dict) -> dict:
1645
+ """
1646
+ Full ML-powered backtest via Alibaba Cloud QuantEngine.
1647
+ Falls back to local pandas backtest when cloud is unavailable.
1648
+ """
1649
+ raw = params.get("symbols", params.get("symbol", "sh600519"))
1650
+ if isinstance(raw, str):
1651
+ symbols = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
1652
+ else:
1653
+ symbols = list(raw)
1654
+
1655
+ strategy_cfg = params.get("strategy_config", {
1656
+ "model_type": params.get("model_type", "lightgbm"),
1657
+ "backtest_period_months": params.get("months", 12),
1658
+ "rebalance_freq": params.get("rebalance_freq", "weekly"),
1659
+ "top_k": params.get("top_k", 3),
1660
+ "use_enhanced_factors": True,
1661
+ "use_dynamic_position": True,
1662
+ })
1663
+ start = params.get("start", "")
1664
+ end = params.get("end", "")
1665
+ market = params.get("market", "CN")
1666
+
1667
+ if _HAS_CLOUD:
1668
+ try:
1669
+ from aliyun_data_client import run_async
1670
+ result = run_async(
1671
+ AliyunDataClient.get().run_backtest(
1672
+ symbols, strategy_cfg,
1673
+ start_date=start, end_date=end, market=market,
1674
+ )
1675
+ )
1676
+ if result and result.get("status") in ("completed", "running"):
1677
+ out = {"success": True, "provider": "aliyun_cloud"}
1678
+ r = result.get("result") or {}
1679
+ perf = r.get("performance") or {}
1680
+ out.update({
1681
+ "backtest_id": result.get("backtest_id"),
1682
+ "status": result.get("status"),
1683
+ "total_return": perf.get("total_return"),
1684
+ "annual_return": perf.get("annualized_return"),
1685
+ "sharpe_ratio": perf.get("sharpe_ratio"),
1686
+ "max_drawdown": perf.get("max_drawdown"),
1687
+ "win_rate": perf.get("win_rate"),
1688
+ "total_trades": perf.get("total_trades"),
1689
+ "equity_curve": r.get("equity_curve"),
1690
+ })
1691
+ return out
1692
+ except Exception as exc:
1693
+ logger.debug("Cloud backtest failed: %s", exc)
1694
+
1695
+ # Local fallback: run simple pandas backtest for first symbol
1696
+ return _backtest_strategy({
1697
+ "symbol": symbols[0] if symbols else "sh600519",
1698
+ "strategy": params.get("strategy", "sma_cross"),
1699
+ "start": start or _parse_date(None, 730),
1700
+ "end": end or _today(),
1701
+ })
1702
+
1703
+
1704
+ # ---------------------------------------------------------------------------
1705
+ # Helper: format dataframe tail for display
1706
+ # ---------------------------------------------------------------------------
1707
+
1708
+ def _df_tail(df: pd.DataFrame, n: int = 5) -> List[Dict]:
1709
+ cols = [c for c in ["date", "Close", "Open", "High", "Low", "Volume"]
1710
+ if c in df.columns]
1711
+ sub = df[cols].tail(n)
1712
+ records = []
1713
+ for _, row in sub.iterrows():
1714
+ rec = {}
1715
+ for c in cols:
1716
+ v = row[c]
1717
+ try:
1718
+ if isinstance(v, (float, np.floating)):
1719
+ rec[c] = round(float(v), 4)
1720
+ elif hasattr(v, "item"):
1721
+ rec[c] = v.item()
1722
+ else:
1723
+ rec[c] = str(v)
1724
+ except Exception:
1725
+ rec[c] = str(v)
1726
+ records.append(rec)
1727
+ return records
1728
+
1729
+
1730
+
1731
+ # OpenAI/Ollama tool schemas for local finance tools
1732
+ LOCAL_FINANCE_TOOL_SCHEMAS = [
1733
+ {
1734
+ "type": "function",
1735
+ "function": {
1736
+ "name": "get_market_data",
1737
+ "description": "Get stock/ETF quotes and OHLCV history. Supports US stocks (AAPL), A-share (600519 or sh600519), ETFs, indices.",
1738
+ "parameters": {
1739
+ "type": "object",
1740
+ "properties": {
1741
+ "symbol": {"type": "string", "description": "Ticker symbol, e.g. AAPL, sh600519, BTC-USD"},
1742
+ "period": {"type": "string", "description": "yfinance period: 1d 5d 1mo 3mo 6mo 1y 2y 5y ytd max"},
1743
+ "interval": {"type": "string", "description": "Bar interval: 1m 5m 15m 30m 1h 1d 1wk 1mo"},
1744
+ "start": {"type": "string", "description": "Start date YYYY-MM-DD (overrides period)"},
1745
+ "end": {"type": "string", "description": "End date YYYY-MM-DD"},
1746
+ },
1747
+ "required": ["symbol"],
1748
+ },
1749
+ },
1750
+ },
1751
+ {
1752
+ "type": "function",
1753
+ "function": {
1754
+ "name": "get_crypto_data",
1755
+ "description": "Cryptocurrency OHLCV data. Uses ccxt if available, else yfinance.",
1756
+ "parameters": {
1757
+ "type": "object",
1758
+ "properties": {
1759
+ "symbol": {"type": "string", "description": "Pair, e.g. BTC/USDT or ETH/USDT"},
1760
+ "exchange": {"type": "string", "description": "Exchange name (binance, okx, bybit, coinbase)"},
1761
+ "timeframe": {"type": "string", "description": "Candle timeframe: 1m 5m 1h 4h 1d"},
1762
+ "limit": {"type": "integer", "description": "Number of bars"},
1763
+ },
1764
+ "required": ["symbol"],
1765
+ },
1766
+ },
1767
+ },
1768
+ {
1769
+ "type": "function",
1770
+ "function": {
1771
+ "name": "calculate_factors",
1772
+ "description": "Compute technical and quantitative factors: RSI, MACD, Bollinger, MA gaps, volatility, trend score, beta.",
1773
+ "parameters": {
1774
+ "type": "object",
1775
+ "properties": {
1776
+ "symbol": {"type": "string", "description": "Ticker symbol"},
1777
+ "period": {"type": "string", "description": "Lookback period (yfinance format)"},
1778
+ },
1779
+ "required": ["symbol"],
1780
+ },
1781
+ },
1782
+ },
1783
+ {
1784
+ "type": "function",
1785
+ "function": {
1786
+ "name": "backtest_strategy",
1787
+ "description": "Run a strategy backtest. Returns Sharpe, max drawdown, win rate, trade count, alpha vs benchmark.",
1788
+ "parameters": {
1789
+ "type": "object",
1790
+ "properties": {
1791
+ "symbol": {"type": "string"},
1792
+ "strategy": {"type": "string", "description": "sma_cross | rsi_mean_revert | momentum | buy_hold"},
1793
+ "start": {"type": "string", "description": "Start date YYYY-MM-DD"},
1794
+ "end": {"type": "string", "description": "End date YYYY-MM-DD"},
1795
+ "fast_period": {"type": "integer", "description": "Fast MA period (default 20)"},
1796
+ "slow_period": {"type": "integer", "description": "Slow MA period (default 60)"},
1797
+ "rsi_oversold": {"type": "number", "description": "RSI oversold threshold (default 30)"},
1798
+ "rsi_overbought": {"type": "number", "description": "RSI overbought threshold (default 70)"},
1799
+ "momentum_period": {"type": "integer", "description": "Momentum lookback bars (default 20)"},
1800
+ },
1801
+ "required": ["symbol"],
1802
+ },
1803
+ },
1804
+ },
1805
+ {
1806
+ "type": "function",
1807
+ "function": {
1808
+ "name": "get_risk_metrics",
1809
+ "description": "Portfolio / stock risk metrics: VaR, CVaR, drawdown, Sharpe, Sortino, Calmar, skewness.",
1810
+ "parameters": {
1811
+ "type": "object",
1812
+ "properties": {
1813
+ "symbol": {"type": "string"},
1814
+ "period": {"type": "string", "description": "Data lookback (default 1y)"},
1815
+ "confidence": {"type": "number", "description": "VaR confidence level (default 0.95)"},
1816
+ },
1817
+ "required": ["symbol"],
1818
+ },
1819
+ },
1820
+ },
1821
+ {
1822
+ "type": "function",
1823
+ "function": {
1824
+ "name": "optimize_positions",
1825
+ "description": "Portfolio weight optimisation using Markowitz / max-Sharpe / min-variance.",
1826
+ "parameters": {
1827
+ "type": "object",
1828
+ "properties": {
1829
+ "symbols": {"type": "array", "items": {"type": "string"}, "description": "List of ticker symbols"},
1830
+ "method": {"type": "string", "description": "max_sharpe | min_var | equal_weight"},
1831
+ "period": {"type": "string", "description": "History period (default 1y)"},
1832
+ "risk_free_rate": {"type": "number", "description": "Annual risk-free rate (default 0.04)"},
1833
+ },
1834
+ "required": ["symbols"],
1835
+ },
1836
+ },
1837
+ },
1838
+ {
1839
+ "type": "function",
1840
+ "function": {
1841
+ "name": "get_sector_performance",
1842
+ "description": "Sector performance ranking. market='cn' uses akshare industry data; market='us' uses SPDR ETFs.",
1843
+ "parameters": {
1844
+ "type": "object",
1845
+ "properties": {
1846
+ "market": {"type": "string", "description": "cn | us (default cn)"},
1847
+ },
1848
+ "required": [],
1849
+ },
1850
+ },
1851
+ },
1852
+ {
1853
+ "type": "function",
1854
+ "function": {
1855
+ "name": "get_northbound_flow",
1856
+ "description": "北向资金 (Shanghai-HK / Shenzhen-HK Connect) net buy amount in 亿元 via akshare.",
1857
+ "parameters": {
1858
+ "type": "object",
1859
+ "properties": {
1860
+ "days": {"type": "integer", "description": "Number of trading days to show (default 10)"},
1861
+ },
1862
+ "required": [],
1863
+ },
1864
+ },
1865
+ },
1866
+ {
1867
+ "type": "function",
1868
+ "function": {
1869
+ "name": "screen_ashare",
1870
+ "description": "A股选股筛选器. Filters by PE, market cap, ST/退市 exclusion, momentum ranking.",
1871
+ "parameters": {
1872
+ "type": "object",
1873
+ "properties": {
1874
+ "max_pe": {"type": "number", "description": "Max dynamic PE ratio (default 50)"},
1875
+ "min_market_cap_yi": {"type": "number", "description": "Min market cap in 亿元 (default 0)"},
1876
+ "limit": {"type": "integer", "description": "Max stocks to return (default 20)"},
1877
+ },
1878
+ "required": [],
1879
+ },
1880
+ },
1881
+ },
1882
+ {
1883
+ "type": "function",
1884
+ "function": {
1885
+ "name": "get_limit_up_pool",
1886
+ "description": "Today's A股 limit-up stock pool (涨停板) via akshare. Includes consecutive limit-up count.",
1887
+ "parameters": {
1888
+ "type": "object",
1889
+ "properties": {
1890
+ "date": {"type": "string", "description": "Date YYYY-MM-DD (default today)"},
1891
+ },
1892
+ "required": [],
1893
+ },
1894
+ },
1895
+ },
1896
+ {
1897
+ "type": "function",
1898
+ "function": {
1899
+ "name": "get_market_indices",
1900
+ "description": "Major global market indices: S&P 500, NASDAQ, 上证综指, Nikkei, Gold, BTC, etc.",
1901
+ "parameters": {"type": "object", "properties": {}, "required": []},
1902
+ },
1903
+ },
1904
+ {
1905
+ "type": "function",
1906
+ "function": {
1907
+ "name": "analyze_news",
1908
+ "description": "Fetch and sentiment-score recent news for a stock or topic.",
1909
+ "parameters": {
1910
+ "type": "object",
1911
+ "properties": {
1912
+ "symbol": {"type": "string", "description": "Stock code or company name"},
1913
+ "limit": {"type": "integer", "description": "Number of articles (default 5)"},
1914
+ },
1915
+ "required": ["symbol"],
1916
+ },
1917
+ },
1918
+ },
1919
+ {
1920
+ "type": "function",
1921
+ "function": {
1922
+ "name": "get_bonds_data",
1923
+ "description": "US Treasury yield curve (2Y, 5Y, 10Y, 30Y) and 10Y-2Y spread.",
1924
+ "parameters": {"type": "object", "properties": {}, "required": []},
1925
+ },
1926
+ },
1927
+ {
1928
+ "type": "function",
1929
+ "function": {
1930
+ "name": "get_commodities_data",
1931
+ "description": (
1932
+ "Commodity spot/futures price, 52-week range and 1-year return. "
1933
+ "Supports: gold, silver, oil, crude, brent, natgas, copper, wheat, corn, soybean, coffee, "
1934
+ "cotton, cattle, lumber, 黄金, 原油, 铜, etc. "
1935
+ "Also accepts yfinance tickers directly (e.g. GC=F, CL=F, NG=F)."
1936
+ ),
1937
+ "parameters": {
1938
+ "type": "object",
1939
+ "properties": {
1940
+ "commodity": {
1941
+ "type": "string",
1942
+ "description": "Commodity name (gold, oil, copper…) or yfinance ticker (GC=F)"
1943
+ },
1944
+ },
1945
+ "required": ["commodity"],
1946
+ },
1947
+ },
1948
+ },
1949
+ {
1950
+ "type": "function",
1951
+ "function": {
1952
+ "name": "get_futures_data",
1953
+ "description": "Equity index and other futures: S&P (ES=F), NASDAQ (NQ=F), Nikkei, VIX, etc.",
1954
+ "parameters": {
1955
+ "type": "object",
1956
+ "properties": {
1957
+ "contract": {
1958
+ "type": "string",
1959
+ "description": "Futures contract name or ticker: SP500, NQ, DOW, VIX, ES=F, NQ=F…"
1960
+ },
1961
+ },
1962
+ "required": ["contract"],
1963
+ },
1964
+ },
1965
+ },
1966
+ # ── New cloud-backed tools ────────────────────────────────────────────
1967
+ {
1968
+ "type": "function",
1969
+ "function": {
1970
+ "name": "get_ai_signal",
1971
+ "description": (
1972
+ "AI-powered trading signal (BUY/SELL/HOLD) with confidence, reasoning, stop-loss and "
1973
+ "take-profit levels. Uses Alibaba Cloud DeepSeek + QuantEngine. "
1974
+ "Falls back to local factor-based signal when cloud unavailable."
1975
+ ),
1976
+ "parameters": {
1977
+ "type": "object",
1978
+ "properties": {
1979
+ "symbol": {"type": "string", "description": "Stock code, e.g. sh600519, AAPL"},
1980
+ "market": {"type": "string", "description": "CN | US (auto-detected from symbol)"},
1981
+ },
1982
+ "required": ["symbol"],
1983
+ },
1984
+ },
1985
+ },
1986
+ {
1987
+ "type": "function",
1988
+ "function": {
1989
+ "name": "get_market_insights",
1990
+ "description": (
1991
+ "AI narrative market insights for a basket of stocks. Returns sentiment, key risks, "
1992
+ "opportunities and macro context. Powered by Alibaba Cloud AI."
1993
+ ),
1994
+ "parameters": {
1995
+ "type": "object",
1996
+ "properties": {
1997
+ "symbols": {
1998
+ "type": "array",
1999
+ "items": {"type": "string"},
2000
+ "description": "List of stock codes, e.g. ['sh600519', 'sz000858']"
2001
+ },
2002
+ "market": {"type": "string", "description": "CN | US (default CN)"},
2003
+ },
2004
+ "required": ["symbols"],
2005
+ },
2006
+ },
2007
+ },
2008
+ {
2009
+ "type": "function",
2010
+ "function": {
2011
+ "name": "get_predictions",
2012
+ "description": (
2013
+ "ML model return predictions for a list of stocks. "
2014
+ "Uses Alibaba Cloud LightGBM/XGBoost ensemble. "
2015
+ "Returns predicted_return and confidence for each symbol."
2016
+ ),
2017
+ "parameters": {
2018
+ "type": "object",
2019
+ "properties": {
2020
+ "symbols": {
2021
+ "type": "array",
2022
+ "items": {"type": "string"},
2023
+ "description": "List of stock codes"
2024
+ },
2025
+ "prediction_days": {"type": "integer", "description": "Forecast horizon in days (default 5)"},
2026
+ "market": {"type": "string", "description": "CN | US (default CN)"},
2027
+ },
2028
+ "required": ["symbols"],
2029
+ },
2030
+ },
2031
+ },
2032
+ {
2033
+ "type": "function",
2034
+ "function": {
2035
+ "name": "cloud_backtest",
2036
+ "description": (
2037
+ "Full ML-powered backtest on Alibaba Cloud QuantEngine. "
2038
+ "Supports rebalance_freq (daily/weekly/monthly), dynamic position sizing, "
2039
+ "model_type (lightgbm/xgboost/ensemble). Falls back to local pandas backtest."
2040
+ ),
2041
+ "parameters": {
2042
+ "type": "object",
2043
+ "properties": {
2044
+ "symbols": {
2045
+ "type": "array",
2046
+ "items": {"type": "string"},
2047
+ "description": "List of stock codes for backtest universe"
2048
+ },
2049
+ "model_type": {"type": "string", "description": "lightgbm | xgboost | ensemble (default lightgbm)"},
2050
+ "months": {"type": "integer", "description": "Backtest period in months (default 12)"},
2051
+ "rebalance_freq": {"type": "string", "description": "daily | weekly | monthly (default weekly)"},
2052
+ "top_k": {"type": "integer", "description": "Top-K stocks to hold per period (default 3)"},
2053
+ "start": {"type": "string", "description": "Start date YYYY-MM-DD"},
2054
+ "end": {"type": "string", "description": "End date YYYY-MM-DD"},
2055
+ "market": {"type": "string", "description": "CN | US (default CN)"},
2056
+ },
2057
+ "required": [],
2058
+ },
2059
+ },
2060
+ },
2061
+ # ── web_search ────────────────────────────────────────────────────────────
2062
+ {
2063
+ "type": "function",
2064
+ "function": {
2065
+ "name": "web_search",
2066
+ "description": (
2067
+ "Search the web for current information. "
2068
+ "USE THIS PROACTIVELY when the user asks about: recent news, latest earnings, "
2069
+ "new IPO stocks (e.g. SPCX/SpaceX), price targets, analyst upgrades/downgrades, "
2070
+ "M&A deals, regulatory decisions, macro events, or anything that may have changed "
2071
+ "after your training cutoff. Do NOT rely on training data for current events — "
2072
+ "always search first. Chain with web_fetch to read full articles."
2073
+ ),
2074
+ "parameters": {
2075
+ "type": "object",
2076
+ "properties": {
2077
+ "query": {"type": "string", "description": "Search query, include ticker and topic, e.g. 'SPCX SpaceX earnings Q1 2026'"},
2078
+ "max_results": {"type": "integer", "description": "Number of results (default 5, max 10)"},
2079
+ },
2080
+ "required": ["query"],
2081
+ },
2082
+ },
2083
+ },
2084
+ # ── web_fetch ─────────────────────────────────────────────────────────────
2085
+ {
2086
+ "type": "function",
2087
+ "function": {
2088
+ "name": "web_fetch",
2089
+ "description": (
2090
+ "Fetch a URL and return the page text. Use after web_search to read full article content, "
2091
+ "SEC filings, earnings reports, or any webpage. Automatically strips HTML tags."
2092
+ ),
2093
+ "parameters": {
2094
+ "type": "object",
2095
+ "properties": {
2096
+ "url": {"type": "string", "description": "Full URL to fetch"},
2097
+ "timeout": {"type": "integer", "description": "Timeout seconds (default 15)"},
2098
+ },
2099
+ "required": ["url"],
2100
+ },
2101
+ },
2102
+ },
2103
+ # ── get_forex_data ────────────────────────────────────────────────────────
2104
+ {
2105
+ "type": "function",
2106
+ "function": {
2107
+ "name": "get_forex_data",
2108
+ "description": "Get exchange rate data for currency pairs (e.g. USD/CNY, EUR/USD, USD/JPY). Returns OHLCV and current rate.",
2109
+ "parameters": {
2110
+ "type": "object",
2111
+ "properties": {
2112
+ "pair": {"type": "string", "description": "Currency pair e.g. USDCNY=X, EURUSD=X, USDJPY=X"},
2113
+ "period": {"type": "string", "description": "1d | 5d | 1mo | 3mo | 1y (default 1mo)"},
2114
+ },
2115
+ "required": ["pair"],
2116
+ },
2117
+ },
2118
+ },
2119
+ # ── get_options_chain ─────────────────────────────────────────────────────
2120
+ {
2121
+ "type": "function",
2122
+ "function": {
2123
+ "name": "get_options_chain",
2124
+ "description": "Retrieve options chain for a US stock: calls & puts with strike, expiry, IV, delta, volume, OI. Use for options strategy analysis.",
2125
+ "parameters": {
2126
+ "type": "object",
2127
+ "properties": {
2128
+ "symbol": {"type": "string", "description": "US stock ticker e.g. SPCX, AAPL, TSLA"},
2129
+ "expiry": {"type": "string", "description": "Expiration date YYYY-MM-DD or leave blank for nearest"},
2130
+ "option_type": {"type": "string", "description": "call | put | both (default both)"},
2131
+ },
2132
+ "required": ["symbol"],
2133
+ },
2134
+ },
2135
+ },
2136
+ # ── peer_comparison ───────────────────────────────────────────────────────
2137
+ {
2138
+ "type": "function",
2139
+ "function": {
2140
+ "name": "peer_comparison",
2141
+ "description": "Compare a stock against sector peers on valuation (PE/PB), profitability (ROE), market cap. Automatically selects peers if not specified.",
2142
+ "parameters": {
2143
+ "type": "object",
2144
+ "properties": {
2145
+ "symbol": {"type": "string", "description": "Target stock ticker"},
2146
+ "peers": {"type": "array", "items": {"type": "string"}, "description": "Peer tickers (optional, auto-selected if omitted)"},
2147
+ },
2148
+ "required": ["symbol"],
2149
+ },
2150
+ },
2151
+ },
2152
+ # ── piotroski_fscore ──────────────────────────────────────────────────────
2153
+ {
2154
+ "type": "function",
2155
+ "function": {
2156
+ "name": "piotroski_fscore",
2157
+ "description": "Calculate Piotroski F-Score (0-9) for financial health assessment. Score ≥7 = strong, ≤2 = weak. Use for fundamental stock screening.",
2158
+ "parameters": {
2159
+ "type": "object",
2160
+ "properties": {
2161
+ "symbol": {"type": "string", "description": "Stock ticker"},
2162
+ },
2163
+ "required": ["symbol"],
2164
+ },
2165
+ },
2166
+ },
2167
+ # ── altman_zscore ─────────────────────────────────────────────────────────
2168
+ {
2169
+ "type": "function",
2170
+ "function": {
2171
+ "name": "altman_zscore",
2172
+ "description": "Calculate Altman Z-Score for bankruptcy risk. Z>2.99 = safe, 1.81-2.99 = grey zone, <1.81 = distress. Use when user asks about company financial risk.",
2173
+ "parameters": {
2174
+ "type": "object",
2175
+ "properties": {
2176
+ "symbol": {"type": "string", "description": "Stock ticker"},
2177
+ },
2178
+ "required": ["symbol"],
2179
+ },
2180
+ },
2181
+ },
2182
+ # ── calculate_ichimoku ────────────────────────────────────────────────────
2183
+ {
2184
+ "type": "function",
2185
+ "function": {
2186
+ "name": "calculate_ichimoku",
2187
+ "description": "Calculate Ichimoku Cloud indicators (Tenkan, Kijun, Senkou A/B, Chikou). Provides cloud support/resistance levels and trend signals.",
2188
+ "parameters": {
2189
+ "type": "object",
2190
+ "properties": {
2191
+ "symbol": {"type": "string", "description": "Stock or crypto ticker"},
2192
+ "period": {"type": "string", "description": "Price history period: 3mo | 6mo | 1y (default 6mo)"},
2193
+ },
2194
+ "required": ["symbol"],
2195
+ },
2196
+ },
2197
+ },
2198
+ # ── get_fear_greed_index ──────────────────────────────────────────────────
2199
+ {
2200
+ "type": "function",
2201
+ "function": {
2202
+ "name": "get_fear_greed_index",
2203
+ "description": "Get CNN Fear & Greed Index (0-100) for overall market sentiment. Use when user asks about market sentiment, risk appetite, or broad market mood.",
2204
+ "parameters": {
2205
+ "type": "object",
2206
+ "properties": {},
2207
+ "required": [],
2208
+ },
2209
+ },
2210
+ },
2211
+ # ── get_funding_rates ─────────────────────────────────────────────────────
2212
+ {
2213
+ "type": "function",
2214
+ "function": {
2215
+ "name": "get_funding_rates",
2216
+ "description": "Get perpetual futures funding rates for crypto assets (BTC, ETH, etc.) from major exchanges. Positive rate = longs pay shorts (bullish); negative = shorts pay longs (bearish).",
2217
+ "parameters": {
2218
+ "type": "object",
2219
+ "properties": {
2220
+ "symbol": {"type": "string", "description": "Crypto symbol e.g. BTC, ETH, SOL"},
2221
+ },
2222
+ "required": ["symbol"],
2223
+ },
2224
+ },
2225
+ },
2226
+ # ── walk_forward_backtest ─────────────────────────────────────────────────
2227
+ {
2228
+ "type": "function",
2229
+ "function": {
2230
+ "name": "walk_forward_backtest",
2231
+ "description": "Run walk-forward validation backtest: train on rolling windows then test out-of-sample to avoid overfitting. More robust than simple backtest.",
2232
+ "parameters": {
2233
+ "type": "object",
2234
+ "properties": {
2235
+ "symbol": {"type": "string", "description": "Stock ticker"},
2236
+ "strategy": {"type": "string", "description": "Strategy name: sma_cross | rsi_reversal | macd_trend"},
2237
+ "train_months": {"type": "integer", "description": "Training window in months (default 12)"},
2238
+ "test_months": {"type": "integer", "description": "Test window in months (default 3)"},
2239
+ },
2240
+ "required": ["symbol", "strategy"],
2241
+ },
2242
+ },
2243
+ },
2244
+ ]
2245
+
2246
+
2247
+ # ---------------------------------------------------------------------------
2248
+ # 19. Piotroski F-Score (基本面质量评分, 0–9)
2249
+ # ---------------------------------------------------------------------------
2250
+
2251
+ def _piotroski_fscore(params: dict) -> dict:
2252
+ """
2253
+ Piotroski F-Score: 9项二元信号综合判断财务质量。
2254
+ ≥7 = 高质量(做多信号), ≤3 = 低质量(做空信号), 4-6 = 中性。
2255
+ """
2256
+ symbol = params.get("symbol", "AAPL")
2257
+ if not _HAS_YF:
2258
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2259
+
2260
+ try:
2261
+ tkr = yf.Ticker(symbol)
2262
+ info = tkr.info or {}
2263
+ bs_annual = tkr.balance_sheet if hasattr(tkr, "balance_sheet") else None
2264
+ is_annual = tkr.income_stmt if hasattr(tkr, "income_stmt") else None
2265
+ cf_annual = tkr.cashflow if hasattr(tkr, "cashflow") else None
2266
+
2267
+ def _get(df, row, col=0):
2268
+ try:
2269
+ if df is None or df.empty: return None
2270
+ matches = [r for r in df.index if row.lower() in str(r).lower()]
2271
+ if not matches: return None
2272
+ val = df.loc[matches[0]].iloc[col]
2273
+ return float(val) if val is not None and str(val) not in ("nan","None") else None
2274
+ except Exception:
2275
+ return None
2276
+
2277
+ scores: Dict[str, Any] = {}
2278
+
2279
+ # ── Profitability (4 signals) ──────────────────────────────────────
2280
+ roa = info.get("returnOnAssets") or _get(is_annual, "Net Income")
2281
+ scores["F1_ROA_positive"] = int((roa or 0) > 0)
2282
+
2283
+ cfo = _get(cf_annual, "Operating Cash Flow") or _get(cf_annual, "Total Cash From Operating")
2284
+ scores["F2_CFO_positive"] = int((cfo or 0) > 0)
2285
+
2286
+ # ROA change (current vs prior year)
2287
+ net_inc_cur = _get(is_annual, "Net Income", 0)
2288
+ net_inc_prev = _get(is_annual, "Net Income", 1)
2289
+ ta_cur = _get(bs_annual, "Total Assets", 0)
2290
+ ta_prev = _get(bs_annual, "Total Assets", 1)
2291
+ roa_cur = (net_inc_cur / ta_cur) if (ta_cur and net_inc_cur is not None) else None
2292
+ roa_prev = (net_inc_prev / ta_prev) if (ta_prev and net_inc_prev is not None) else None
2293
+ scores["F3_ROA_increasing"] = int(roa_cur > roa_prev) if (roa_cur is not None and roa_prev is not None) else 0
2294
+
2295
+ # Accruals: CFO > ROA × Total Assets
2296
+ scores["F4_CFO_gt_ROA"] = int((cfo or 0) > (roa or 0) * (ta_cur or 1))
2297
+
2298
+ # ── Leverage, Liquidity (3 signals) ───────────────────────────────
2299
+ ltd_cur = _get(bs_annual, "Long Term Debt", 0)
2300
+ ltd_prev = _get(bs_annual, "Long Term Debt", 1)
2301
+ scores["F5_Leverage_lower"] = int((ltd_cur or 0) < (ltd_prev or 0)) if ltd_prev is not None else 0
2302
+
2303
+ ca_cur = _get(bs_annual, "Current Assets", 0)
2304
+ ca_prev = _get(bs_annual, "Current Assets", 1)
2305
+ cl_cur = _get(bs_annual, "Current Liabilities", 0) or _get(bs_annual, "Total Current Liabilities", 0)
2306
+ cl_prev = _get(bs_annual, "Current Liabilities", 1) or _get(bs_annual, "Total Current Liabilities", 1)
2307
+ cr_cur = (ca_cur / cl_cur) if (cl_cur and ca_cur) else None
2308
+ cr_prev = (ca_prev / cl_prev) if (cl_prev and ca_prev) else None
2309
+ scores["F6_CurrentRatio_up"] = int(cr_cur > cr_prev) if (cr_cur and cr_prev) else 0
2310
+
2311
+ # No dilution: shares outstanding not increasing
2312
+ shares_cur = info.get("sharesOutstanding") or _get(bs_annual, "Ordinary Shares Number", 0)
2313
+ shares_prev = _get(bs_annual, "Ordinary Shares Number", 1)
2314
+ scores["F7_NoDilution"] = int((shares_cur or 1) <= (shares_prev or 1)) if shares_prev else 1
2315
+
2316
+ # ── Operating Efficiency (2 signals) ──────────────────────────────
2317
+ rev_cur = _get(is_annual, "Total Revenue", 0)
2318
+ rev_prev = _get(is_annual, "Total Revenue", 1)
2319
+ gp_cur = _get(is_annual, "Gross Profit", 0)
2320
+ gp_prev = _get(is_annual, "Gross Profit", 1)
2321
+ gm_cur = (gp_cur / rev_cur) if (rev_cur and gp_cur) else None
2322
+ gm_prev = (gp_prev / rev_prev) if (rev_prev and gp_prev) else None
2323
+ scores["F8_GrossMargin_up"] = int(gm_cur > gm_prev) if (gm_cur is not None and gm_prev is not None) else 0
2324
+
2325
+ at_cur = (rev_cur / ta_cur) if (ta_cur and rev_cur) else None
2326
+ at_prev = (rev_prev / ta_prev) if (ta_prev and rev_prev) else None
2327
+ scores["F9_AssetTurnover_up"] = int(at_cur > at_prev) if (at_cur is not None and at_prev is not None) else 0
2328
+
2329
+ fscore = sum(scores.values())
2330
+
2331
+ if fscore >= 7:
2332
+ verdict, color = "高质量 — 做多信号", "bullish"
2333
+ elif fscore <= 3:
2334
+ verdict, color = "低质量 — 做空信号", "bearish"
2335
+ else:
2336
+ verdict, color = "中性", "neutral"
2337
+
2338
+ return {
2339
+ "success": True,
2340
+ "symbol": symbol,
2341
+ "f_score": fscore,
2342
+ "verdict": verdict,
2343
+ "signal": color,
2344
+ "scores": scores,
2345
+ "provider": "yfinance",
2346
+ }
2347
+ except Exception as e:
2348
+ return {"success": False, "error": str(e)}
2349
+
2350
+
2351
+ # ---------------------------------------------------------------------------
2352
+ # 20. Altman Z-Score (破产风险预测)
2353
+ # ---------------------------------------------------------------------------
2354
+
2355
+ def _altman_zscore(params: dict) -> dict:
2356
+ """
2357
+ Altman Z''-Score(适合非制造业,使用 4 变量版)。
2358
+ Z > 2.6 = 安全区,1.1–2.6 = 灰色区,< 1.1 = 破产风险。
2359
+ """
2360
+ symbol = params.get("symbol", "AAPL")
2361
+ if not _HAS_YF:
2362
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2363
+
2364
+ try:
2365
+ tkr = yf.Ticker(symbol)
2366
+ info = tkr.info or {}
2367
+ bs = tkr.balance_sheet if hasattr(tkr, "balance_sheet") else None
2368
+ is_ = tkr.income_stmt if hasattr(tkr, "income_stmt") else None
2369
+
2370
+ def _g(df, row, col=0):
2371
+ try:
2372
+ if df is None or df.empty: return None
2373
+ m = [r for r in df.index if row.lower() in str(r).lower()]
2374
+ if not m: return None
2375
+ v = df.loc[m[0]].iloc[col]
2376
+ return float(v) if str(v) not in ("nan","None","") else None
2377
+ except Exception:
2378
+ return None
2379
+
2380
+ ta = _g(bs, "Total Assets") or info.get("totalAssets")
2381
+ tl = (_g(bs, "Total Liabilities Net Minority Interest") or
2382
+ _g(bs, "Total Liabilities"))
2383
+ ca = _g(bs, "Current Assets")
2384
+ cl = (_g(bs, "Total Current Liabilities") or _g(bs, "Current Liabilities"))
2385
+ re = _g(bs, "Retained Earnings")
2386
+ ebit = _g(is_, "EBIT") or _g(is_, "Operating Income")
2387
+ revenue = _g(is_, "Total Revenue")
2388
+ market_cap = info.get("marketCap")
2389
+ bv_equity = info.get("bookValue", 0) or 0
2390
+ shares_out = info.get("sharesOutstanding", 0) or 0
2391
+ book_equity = bv_equity * shares_out
2392
+
2393
+ if not ta or ta == 0:
2394
+ return {"success": False, "error": "无法获取总资产数据"}
2395
+
2396
+ # Working capital / Total Assets (X1)
2397
+ wc = (ca or 0) - (cl or 0)
2398
+ x1 = wc / ta
2399
+
2400
+ # Retained Earnings / Total Assets (X2)
2401
+ x2 = (re or 0) / ta
2402
+
2403
+ # EBIT / Total Assets (X3)
2404
+ x3 = (ebit or 0) / ta
2405
+
2406
+ # Book/Market Value of Equity / Total Liabilities (X4 — Z'' uses book)
2407
+ bv = book_equity or (market_cap or 0)
2408
+ x4 = bv / (tl or 1)
2409
+
2410
+ # Z'' = 6.56·X1 + 3.26·X2 + 6.72·X3 + 1.05·X4
2411
+ z = 6.56 * x1 + 3.26 * x2 + 6.72 * x3 + 1.05 * x4
2412
+ z = round(z, 3)
2413
+
2414
+ if z > 2.6:
2415
+ zone = "安全区"
2416
+ risk = "low"
2417
+ elif z > 1.1:
2418
+ zone = "灰色区(不确定)"
2419
+ risk = "medium"
2420
+ else:
2421
+ zone = "破产风险区"
2422
+ risk = "high"
2423
+
2424
+ return {
2425
+ "success": True,
2426
+ "symbol": symbol,
2427
+ "z_score": z,
2428
+ "zone": zone,
2429
+ "risk": risk,
2430
+ "components": {
2431
+ "X1_working_capital_ratio": round(x1, 4),
2432
+ "X2_retained_earnings_ratio": round(x2, 4),
2433
+ "X3_ebit_ratio": round(x3, 4),
2434
+ "X4_equity_to_debt": round(x4, 4),
2435
+ },
2436
+ "formula": "Z'' = 6.56·X1 + 3.26·X2 + 6.72·X3 + 1.05·X4",
2437
+ "provider": "yfinance",
2438
+ }
2439
+ except Exception as e:
2440
+ return {"success": False, "error": str(e)}
2441
+
2442
+
2443
+ # ---------------------------------------------------------------------------
2444
+ # 21. Options Chain (期权链)
2445
+ # ---------------------------------------------------------------------------
2446
+
2447
+ def _get_options_chain(params: dict) -> dict:
2448
+ """
2449
+ 获取股票期权链(via yfinance)。
2450
+ 返回最近到期日的 calls + puts 列表。
2451
+ """
2452
+ symbol = str(params.get("symbol", "AAPL")).strip().upper()
2453
+ expiry = params.get("expiry", "") # "YYYY-MM-DD" or "" = nearest
2454
+ opt_type = params.get("type", "both").lower() # "calls" | "puts" | "both"
2455
+ limit = int(params.get("limit", 15))
2456
+
2457
+ if not _HAS_YF:
2458
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2459
+
2460
+ try:
2461
+ tkr = yf.Ticker(symbol)
2462
+ dates = tkr.options
2463
+ if not dates:
2464
+ return {"success": False, "error": f"{symbol} 无可用期权数据"}
2465
+
2466
+ if expiry and expiry in dates:
2467
+ exp = expiry
2468
+ else:
2469
+ exp = dates[0] # nearest expiry
2470
+
2471
+ chain = tkr.option_chain(exp)
2472
+ price = (tkr.info or {}).get("regularMarketPrice") or (tkr.info or {}).get("currentPrice") or 0
2473
+
2474
+ def _fmt(df):
2475
+ if df is None or df.empty:
2476
+ return []
2477
+ cols = [c for c in ["strike","lastPrice","bid","ask","volume","openInterest",
2478
+ "impliedVolatility","inTheMoney"] if c in df.columns]
2479
+ df = df[cols].head(limit)
2480
+ rows = df.to_dict("records")
2481
+ for r in rows:
2482
+ iv = r.get("impliedVolatility")
2483
+ r["iv_pct"] = round(iv * 100, 1) if iv else None
2484
+ return rows
2485
+
2486
+ result: Dict[str, Any] = {
2487
+ "success": True,
2488
+ "symbol": symbol,
2489
+ "price": price,
2490
+ "expiry": exp,
2491
+ "all_expiries": list(dates[:6]),
2492
+ "provider": "yfinance",
2493
+ }
2494
+ if opt_type in ("calls", "both"):
2495
+ result["calls"] = _fmt(chain.calls)
2496
+ if opt_type in ("puts", "both"):
2497
+ result["puts"] = _fmt(chain.puts)
2498
+
2499
+ return result
2500
+ except Exception as e:
2501
+ return {"success": False, "error": str(e)}
2502
+
2503
+
2504
+ # ---------------------------------------------------------------------------
2505
+ # 22. Ichimoku Cloud (一目均衡表)
2506
+ # ---------------------------------------------------------------------------
2507
+
2508
+ def _calculate_ichimoku(params: dict) -> dict:
2509
+ """
2510
+ 一目均衡表指标计算。
2511
+ 返回 Tenkan-sen(转换线), Kijun-sen(基准线), Senkou Span A/B(先行带),
2512
+ Chikou(迟行线), 以及云层厚度与当前信号。
2513
+ """
2514
+ symbol = str(params.get("symbol", "AAPL")).strip().upper()
2515
+ period = params.get("period", "6mo")
2516
+
2517
+ if not _HAS_YF:
2518
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2519
+
2520
+ try:
2521
+ df = yf.Ticker(symbol).history(period=period)
2522
+ if df is None or len(df) < 52:
2523
+ return {"success": False, "error": "历史数据不足(至少需要 52 天)"}
2524
+
2525
+ high = df["High"].astype(float)
2526
+ low = df["Low"].astype(float)
2527
+ close= df["Close"].astype(float)
2528
+
2529
+ def _mid(h, l, n):
2530
+ return (h.rolling(n).max() + l.rolling(n).min()) / 2
2531
+
2532
+ tenkan = _mid(high, low, 9)
2533
+ kijun = _mid(high, low, 26)
2534
+ senkou_a = ((tenkan + kijun) / 2).shift(26)
2535
+ senkou_b = _mid(high, low, 52).shift(26)
2536
+ chikou = close.shift(-26)
2537
+
2538
+ t = round(float(tenkan.iloc[-1]), 3)
2539
+ k = round(float(kijun.iloc[-1]), 3)
2540
+ sa = round(float(senkou_a.iloc[-1]), 3) if not pd.isna(senkou_a.iloc[-1]) else None
2541
+ sb = round(float(senkou_b.iloc[-1]), 3) if not pd.isna(senkou_b.iloc[-1]) else None
2542
+ c = round(float(close.iloc[-1]), 3)
2543
+ ck = round(float(chikou.iloc[-53]) if len(chikou) > 53 else float(chikou.iloc[0]), 3)
2544
+
2545
+ # Signal
2546
+ above_cloud = sa is not None and sb is not None and c > max(sa, sb)
2547
+ below_cloud = sa is not None and sb is not None and c < min(sa, sb)
2548
+ bullish_tk = t > k
2549
+ cloud_color = "绿云(多)" if (sa and sb and sa > sb) else "红云(空)"
2550
+
2551
+ if above_cloud and bullish_tk:
2552
+ signal = "强势多头"
2553
+ elif above_cloud:
2554
+ signal = "偏多(价格在云上方)"
2555
+ elif below_cloud and not bullish_tk:
2556
+ signal = "强势空头"
2557
+ elif below_cloud:
2558
+ signal = "偏空(价格在云下方)"
2559
+ else:
2560
+ signal = "震荡(价格在云内)"
2561
+
2562
+ return {
2563
+ "success": True,
2564
+ "symbol": symbol,
2565
+ "price": c,
2566
+ "tenkan": t,
2567
+ "kijun": k,
2568
+ "senkou_a": sa,
2569
+ "senkou_b": sb,
2570
+ "chikou": ck,
2571
+ "cloud_color": cloud_color,
2572
+ "cloud_thickness": round(abs((sa or 0) - (sb or 0)), 3),
2573
+ "signal": signal,
2574
+ "above_cloud": above_cloud,
2575
+ "below_cloud": below_cloud,
2576
+ "tk_cross": "金叉(多)" if bullish_tk else "死叉(空)",
2577
+ "provider": "yfinance",
2578
+ }
2579
+ except Exception as e:
2580
+ return {"success": False, "error": str(e)}
2581
+
2582
+
2583
+ # ---------------------------------------------------------------------------
2584
+ # 23. Crypto Fear & Greed Index + Funding Rates
2585
+ # ---------------------------------------------------------------------------
2586
+
2587
+ def _get_fear_greed_index(params: dict) -> dict:
2588
+ """加密货币恐惧贪婪指数(来源: alternative.me,无需 API Key)。"""
2589
+ try:
2590
+ import urllib.request, json as _json
2591
+ with urllib.request.urlopen(
2592
+ "https://api.alternative.me/fng/?limit=7&format=json", timeout=6
2593
+ ) as resp:
2594
+ data = _json.loads(resp.read().decode())
2595
+ items = data.get("data", [])
2596
+ if not items:
2597
+ return {"success": False, "error": "No data returned"}
2598
+ latest = items[0]
2599
+ value = int(latest.get("value", 0))
2600
+ label_en = latest.get("value_classification", "")
2601
+ label_cn_map = {
2602
+ "Extreme Fear": "极度恐惧",
2603
+ "Fear": "恐惧",
2604
+ "Neutral": "中性",
2605
+ "Greed": "贪婪",
2606
+ "Extreme Greed":"极度贪婪",
2607
+ }
2608
+ label_cn = label_cn_map.get(label_en, label_en)
2609
+ history = [{"date": i.get("timestamp",""), "value": int(i.get("value",0)),
2610
+ "label": i.get("value_classification","")} for i in items]
2611
+ return {
2612
+ "success": True,
2613
+ "value": value,
2614
+ "label": label_cn,
2615
+ "label_en": label_en,
2616
+ "history": history,
2617
+ "signal": "做空" if value >= 75 else "做多" if value <= 25 else "中性",
2618
+ "provider": "alternative.me",
2619
+ }
2620
+ except Exception as e:
2621
+ return {"success": False, "error": str(e)}
2622
+
2623
+
2624
+ def _get_funding_rates(params: dict) -> dict:
2625
+ """
2626
+ 获取永续合约资金费率 (via ccxt)。
2627
+ 支持 binance, okx, bybit 等主流交易所。
2628
+ 高正费率 → 多头过多 → 看空信号;负费率 → 空头过多 → 看多信号。
2629
+ """
2630
+ exchange_id = params.get("exchange", "binance").lower()
2631
+ symbols = params.get("symbols", ["BTC/USDT", "ETH/USDT", "SOL/USDT"])
2632
+ if isinstance(symbols, str):
2633
+ symbols = [s.strip() for s in symbols.replace(",", " ").split()]
2634
+
2635
+ try:
2636
+ import ccxt as _ccxt
2637
+ except ImportError:
2638
+ return {"success": False, "error": "ccxt 未安装: pip install ccxt"}
2639
+
2640
+ # Try exchanges in order; fall back if load_markets fails (network/region issues)
2641
+ _all_exchanges = ["binance", "okx", "bybit"]
2642
+ _exchange_fallback = [exchange_id] + [e for e in _all_exchanges if e != exchange_id]
2643
+
2644
+ for _exid in _exchange_fallback:
2645
+ try:
2646
+ exchange_cls = getattr(_ccxt, _exid, None)
2647
+ if not exchange_cls:
2648
+ continue
2649
+ ex = exchange_cls({"options": {"defaultType": "future"}})
2650
+ try:
2651
+ ex.load_markets()
2652
+ except Exception as _lm_err:
2653
+ _lm_msg = str(_lm_err)
2654
+ if len(_lm_msg) > 100 or "http" in _lm_msg or "GET" in _lm_msg or "POST" in _lm_msg:
2655
+ _lm_msg = f"无法连接 {_exid} 期货市场(网络或区域限制)"
2656
+ if _exid == _exchange_fallback[-1]:
2657
+ return {"success": False, "error": f"{_exid}: {_lm_msg}"}
2658
+ continue
2659
+
2660
+ results = []
2661
+ for sym in symbols:
2662
+ try:
2663
+ fi = ex.fetch_funding_rate(sym)
2664
+ rate = fi.get("fundingRate") or fi.get("funding_rate") or 0
2665
+ next_time = fi.get("fundingDatetime") or fi.get("nextFundingDatetime") or ""
2666
+ annualized = round(float(rate) * 3 * 365 * 100, 2) # 8h intervals
2667
+ results.append({
2668
+ "symbol": sym,
2669
+ "rate": round(float(rate) * 100, 4),
2670
+ "rate_pct": f"{float(rate)*100:.4f}%",
2671
+ "annualized": f"{annualized:.1f}%",
2672
+ "next_funding": str(next_time)[:16],
2673
+ "signal": "空" if float(rate) > 0.0005 else "多" if float(rate) < -0.0001 else "中性",
2674
+ })
2675
+ except Exception:
2676
+ pass
2677
+
2678
+ if not results:
2679
+ if _exid == _exchange_fallback[-1]:
2680
+ return {"success": False, "error": f"已尝试 {', '.join(_exchange_fallback)},均未能获取资金费率数据"}
2681
+ continue
2682
+
2683
+ avg_rate = sum(r["rate"] for r in results) / len(results)
2684
+ return {
2685
+ "success": True,
2686
+ "exchange": _exid,
2687
+ "rates": results,
2688
+ "avg_rate": round(avg_rate, 4),
2689
+ "market_bias": "多头过热(偏空)" if avg_rate > 0.05 else "空头过多(偏多)" if avg_rate < -0.01 else "均衡",
2690
+ "provider": "ccxt",
2691
+ }
2692
+ except Exception as e:
2693
+ _err_msg = str(e)
2694
+ if len(_err_msg) > 100 or "http" in _err_msg or "GET" in _err_msg or "POST" in _err_msg:
2695
+ _err_msg = f"无法连接 {_exid}(网络或区域限制)"
2696
+ if _exid == _exchange_fallback[-1]:
2697
+ return {"success": False, "error": _err_msg}
2698
+
2699
+ return {"success": False, "error": "所有备用交易所均连接失败"}
2700
+
2701
+
2702
+ def _get_funding_rates_compare(params: dict) -> dict:
2703
+ """
2704
+ 并行查询 binance / okx / bybit,返回三所资金费率横向对比。
2705
+ 用于发现跨所套利机会(同一标的费率差 > 0.02% 值得关注)。
2706
+ """
2707
+ symbols = params.get("symbols", ["BTC/USDT", "ETH/USDT", "SOL/USDT"])
2708
+ if isinstance(symbols, str):
2709
+ symbols = [s.strip() for s in symbols.replace(",", " ").split()]
2710
+
2711
+ try:
2712
+ import ccxt as _ccxt # noqa: F401
2713
+ except ImportError:
2714
+ return {"success": False, "error": "ccxt 未安装: pip install ccxt"}
2715
+
2716
+ import concurrent.futures as _fut
2717
+
2718
+ _exchanges = ["binance", "okx", "bybit"]
2719
+
2720
+ def _fetch(exid):
2721
+ return exid, _get_funding_rates({"exchange": exid, "symbols": symbols})
2722
+
2723
+ ex_results: dict = {}
2724
+ with _fut.ThreadPoolExecutor(max_workers=3) as pool:
2725
+ for exid, r in pool.map(_fetch, _exchanges):
2726
+ ex_results[exid] = r
2727
+
2728
+ comparison = []
2729
+ for sym in symbols:
2730
+ row: dict = {"symbol": sym}
2731
+ for exid in _exchanges:
2732
+ r = ex_results.get(exid, {})
2733
+ if r.get("success"):
2734
+ match = next((x for x in r.get("rates", []) if x["symbol"] == sym), None)
2735
+ if match:
2736
+ row[exid] = match
2737
+ if len(row) > 1:
2738
+ comparison.append(row)
2739
+
2740
+ if not comparison:
2741
+ return {"success": False, "error": "三所均无数据,请检查网络或 VPN"}
2742
+
2743
+ # Find max cross-exchange spread per symbol
2744
+ spreads = []
2745
+ for row in comparison:
2746
+ rates = [row[e]["rate"] for e in _exchanges if e in row]
2747
+ if len(rates) >= 2:
2748
+ spreads.append(round(max(rates) - min(rates), 4))
2749
+
2750
+ max_spread = max(spreads) if spreads else 0.0
2751
+ arb_note = (
2752
+ "⚠ 套利机会:最大价差 > 0.02%" if max_spread > 0.02
2753
+ else "价差正常,无明显套利空间"
2754
+ )
2755
+
2756
+ return {
2757
+ "success": True,
2758
+ "comparison": comparison,
2759
+ "exchanges": _exchanges,
2760
+ "max_spread": max_spread,
2761
+ "arb_note": arb_note,
2762
+ "provider": "ccxt_compare",
2763
+ }
2764
+
2765
+
2766
+ # ---------------------------------------------------------------------------
2767
+ # 24. Walk-Forward Backtest (滚动验证)
2768
+ # ---------------------------------------------------------------------------
2769
+
2770
+ def _walk_forward_backtest(params: dict) -> dict:
2771
+ """
2772
+ Walk-Forward 滚动回测:将历史分成 N 个窗口,每窗口 in-sample 优化、
2773
+ out-of-sample 验证,评估策略真实泛化能力。
2774
+ """
2775
+ symbol = params.get("symbol", "AAPL")
2776
+ strategy = params.get("strategy", "sma_crossover")
2777
+ periods = int(params.get("periods", 5)) # number of WF windows
2778
+ train_r = float(params.get("train_ratio", 0.7))
2779
+ period = params.get("period", "5y")
2780
+
2781
+ if not _HAS_YF:
2782
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2783
+
2784
+ try:
2785
+ import numpy as np
2786
+ tkr = yf.Ticker(symbol)
2787
+ df = tkr.history(period=period)
2788
+ if df is None or len(df) < 200:
2789
+ return {"success": False, "error": "历史数据不足(需要至少 200 天)"}
2790
+
2791
+ close = df["Close"].astype(float).values
2792
+ n = len(close)
2793
+ window_size = n // periods
2794
+
2795
+ window_results = []
2796
+ for i in range(periods):
2797
+ start = i * window_size
2798
+ end = start + window_size if i < periods - 1 else n
2799
+ split = start + int((end - start) * train_r)
2800
+
2801
+ train = close[start:split]
2802
+ test = close[split:end]
2803
+
2804
+ if len(test) < 20:
2805
+ continue
2806
+
2807
+ # Simple parameter: SMA crossover with different windows
2808
+ best_sharpe = -np.inf
2809
+ best_fast, best_slow = 10, 30
2810
+
2811
+ if strategy in ("sma_crossover", "ma_crossover"):
2812
+ for fast in (5, 10, 15, 20):
2813
+ for slow in (20, 30, 40, 60):
2814
+ if fast >= slow or len(train) <= slow:
2815
+ continue
2816
+ sig = np.where(
2817
+ np.convolve(train, np.ones(fast)/fast, mode="valid")
2818
+ [-(len(train)-slow+1):] >
2819
+ np.convolve(train, np.ones(slow)/slow, mode="valid"),
2820
+ 1, 0
2821
+ )
2822
+ if len(sig) < 2: continue
2823
+ rets = np.diff(train[-len(sig):]) / train[-len(sig):-1]
2824
+ strat_rets = rets * sig[:-1]
2825
+ sr = (np.mean(strat_rets) / (np.std(strat_rets) + 1e-8)) * np.sqrt(252)
2826
+ if sr > best_sharpe:
2827
+ best_sharpe, best_fast, best_slow = sr, fast, slow
2828
+
2829
+ # Out-of-sample evaluation with best params
2830
+ if len(test) <= best_slow:
2831
+ continue
2832
+ fast_ma = np.convolve(test, np.ones(best_fast)/best_fast, mode="valid")
2833
+ slow_ma = np.convolve(test, np.ones(best_slow)/best_slow, mode="valid")
2834
+ n_sig = min(len(fast_ma), len(slow_ma))
2835
+ signals = np.where(fast_ma[-n_sig:] > slow_ma[-n_sig:], 1, 0)
2836
+ rets_test = np.diff(test[-n_sig:]) / test[-n_sig:-1]
2837
+ strat_rets_oos = rets_test * signals[:-1]
2838
+ bh_rets = rets_test
2839
+
2840
+ oos_total = float(np.prod(1 + strat_rets_oos) - 1)
2841
+ bh_total = float(np.prod(1 + bh_rets) - 1)
2842
+ oos_sharpe = float(np.mean(strat_rets_oos) / (np.std(strat_rets_oos) + 1e-8)) * np.sqrt(252)
2843
+ dd_vals = np.maximum.accumulate(np.cumprod(1 + strat_rets_oos)) - np.cumprod(1 + strat_rets_oos)
2844
+ max_dd = float(np.max(dd_vals) / np.maximum.accumulate(np.cumprod(1 + strat_rets_oos))[-1])
2845
+
2846
+ window_results.append({
2847
+ "window": i + 1,
2848
+ "train_bars": split - start,
2849
+ "test_bars": end - split,
2850
+ "best_fast": best_fast,
2851
+ "best_slow": best_slow,
2852
+ "oos_return": round(oos_total, 4),
2853
+ "bh_return": round(bh_total, 4),
2854
+ "oos_sharpe": round(oos_sharpe, 3),
2855
+ "max_drawdown": round(max_dd, 4),
2856
+ "alpha": round(oos_total - bh_total, 4),
2857
+ })
2858
+
2859
+ if not window_results:
2860
+ return {"success": False, "error": "回测窗口计算失败"}
2861
+
2862
+ avg_oos_ret = sum(w["oos_return"] for w in window_results) / len(window_results)
2863
+ avg_sharpe = sum(w["oos_sharpe"] for w in window_results) / len(window_results)
2864
+ avg_alpha = sum(w["alpha"] for w in window_results) / len(window_results)
2865
+ pct_win = sum(1 for w in window_results if w["oos_return"] > 0) / len(window_results)
2866
+
2867
+ verdict = (
2868
+ "策略泛化能力强" if avg_alpha > 0.02 and avg_sharpe > 0.5
2869
+ else "策略泛化能力中等" if avg_alpha > 0
2870
+ else "策略泛化能力弱(过拟合风险)"
2871
+ )
2872
+
2873
+ return {
2874
+ "success": True,
2875
+ "symbol": symbol,
2876
+ "strategy": strategy,
2877
+ "windows": window_results,
2878
+ "avg_oos_return": round(avg_oos_ret, 4),
2879
+ "avg_sharpe": round(avg_sharpe, 3),
2880
+ "avg_alpha": round(avg_alpha, 4),
2881
+ "win_rate_windows": round(pct_win, 2),
2882
+ "verdict": verdict,
2883
+ "provider": "local",
2884
+ }
2885
+ except Exception as e:
2886
+ return {"success": False, "error": str(e)}
2887
+
2888
+
2889
+ # ---------------------------------------------------------------------------
2890
+ # 25. Peer Comparison (同行对比)
2891
+ # ---------------------------------------------------------------------------
2892
+
2893
+ def _peer_comparison(params: dict) -> dict:
2894
+ """
2895
+ 同行估值与表现对比。
2896
+ 返回 PE/PB/ROE/YTD收益/股息率/市值 横向对比表。
2897
+ """
2898
+ symbol = str(params.get("symbol", "AAPL")).strip().upper()
2899
+ peers = params.get("peers", []) # list of ticker strings
2900
+ if isinstance(peers, str):
2901
+ peers = [p.strip().upper() for p in peers.replace(",", " ").split() if p.strip()]
2902
+
2903
+ # Auto-suggest peers from yfinance sector info if not provided
2904
+ if not peers and _HAS_YF:
2905
+ try:
2906
+ info = yf.Ticker(symbol).info or {}
2907
+ # yfinance doesn't give peers directly; use sector to build manual map
2908
+ _SECTOR_PEERS = {
2909
+ "Technology": ["AAPL","MSFT","GOOGL","META","NVDA","AMZN"],
2910
+ "Financials": ["JPM","BAC","GS","MS","WFC","C"],
2911
+ "Healthcare": ["JNJ","LLY","ABBV","MRK","PFE","UNH"],
2912
+ "Consumer Cyclical": ["AMZN","TSLA","HD","MCD","NKE","SBUX"],
2913
+ "Energy": ["XOM","CVX","COP","SLB","EOG","OXY"],
2914
+ }
2915
+ sector = info.get("sector", "")
2916
+ default_list = _SECTOR_PEERS.get(sector, ["SPY"])
2917
+ peers = [p for p in default_list if p != symbol][:5]
2918
+ except Exception:
2919
+ pass
2920
+
2921
+ if not peers:
2922
+ return {"success": False, "error": "请提供 peers 参数,如: peers=['MSFT','GOOGL','META']"}
2923
+
2924
+ all_symbols = [symbol] + [p for p in peers if p != symbol]
2925
+
2926
+ if not _HAS_YF:
2927
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2928
+
2929
+ rows = []
2930
+ for sym in all_symbols[:8]:
2931
+ try:
2932
+ info = yf.Ticker(sym).info or {}
2933
+ price = info.get("regularMarketPrice") or info.get("currentPrice") or 0
2934
+ prev = info.get("regularMarketPreviousClose") or price
2935
+ pe = info.get("trailingPE") or info.get("forwardPE")
2936
+ pb = info.get("priceToBook")
2937
+ roe = info.get("returnOnEquity")
2938
+ dy = info.get("dividendYield")
2939
+ mc = info.get("marketCap")
2940
+ ytd = (price / prev - 1) if prev else None
2941
+ rows.append({
2942
+ "symbol": sym,
2943
+ "name": (info.get("shortName") or sym)[:12],
2944
+ "price": round(price, 2),
2945
+ "pe": round(pe, 1) if pe else None,
2946
+ "pb": round(pb, 2) if pb else None,
2947
+ "roe_pct": round(roe * 100, 1) if roe else None,
2948
+ "div_yield": round(dy * 100, 2) if dy else None,
2949
+ "market_cap_b": round(mc / 1e9, 1) if mc else None,
2950
+ "is_target": sym == symbol,
2951
+ })
2952
+ except Exception:
2953
+ pass
2954
+
2955
+ if not rows:
2956
+ return {"success": False, "error": "无法获取对比数据"}
2957
+
2958
+ # Relative rankings
2959
+ pe_vals = [r["pe"] for r in rows if r["pe"] is not None]
2960
+ pb_vals = [r["pb"] for r in rows if r["pb"] is not None]
2961
+ roe_vals = [r["roe_pct"] for r in rows if r["roe_pct"] is not None]
2962
+
2963
+ target_row = next((r for r in rows if r["is_target"]), rows[0])
2964
+ analysis = []
2965
+ if target_row.get("pe") and pe_vals:
2966
+ med_pe = sorted(pe_vals)[len(pe_vals)//2]
2967
+ vs = "高估" if target_row["pe"] > med_pe * 1.2 else "低估" if target_row["pe"] < med_pe * 0.8 else "合理"
2968
+ analysis.append(f"PE {target_row['pe']:.1f}x vs 同行中位数 {med_pe:.1f}x → {vs}")
2969
+ if target_row.get("roe_pct") and roe_vals:
2970
+ avg_roe = sum(roe_vals) / len(roe_vals)
2971
+ vs = "优于同行" if target_row["roe_pct"] > avg_roe else "低于同行"
2972
+ analysis.append(f"ROE {target_row['roe_pct']:.1f}% vs 同行均值 {avg_roe:.1f}% → {vs}")
2973
+
2974
+ return {
2975
+ "success": True,
2976
+ "symbol": symbol,
2977
+ "peers": peers,
2978
+ "table": rows,
2979
+ "analysis": analysis,
2980
+ "provider": "yfinance",
2981
+ }
2982
+
2983
+
2984
+ def _resolve_search_key(env_var: str, provider: str) -> str:
2985
+ """Resolve a search-API key: env var first, then ~/.arthera/providers.json.
2986
+
2987
+ Keys added via `/apikey set <provider> <key>` are stored in providers.json
2988
+ (data section), not exported as env vars — so a web search must check both
2989
+ or it silently falls through to DuckDuckGo despite a configured key.
2990
+ """
2991
+ val = os.getenv(env_var, "")
2992
+ if val:
2993
+ return val
2994
+ try:
2995
+ import json as _json
2996
+ import pathlib as _pl
2997
+ for _loc in ("~/.arthera/providers.json", "~/.aria/providers.json"):
2998
+ p = _pl.Path(_loc).expanduser()
2999
+ if not p.exists():
3000
+ continue
3001
+ raw = _json.loads(p.read_text(encoding="utf-8"))
3002
+ for section in ("data", "llm"):
3003
+ entry = raw.get(section, {}).get(provider.lower(), {})
3004
+ if isinstance(entry, dict) and entry.get("api_key"):
3005
+ return entry["api_key"]
3006
+ if isinstance(entry, str) and entry:
3007
+ return entry
3008
+ except Exception:
3009
+ pass
3010
+ return ""
3011
+
3012
+
3013
+ def _web_search(params: dict) -> dict:
3014
+ """Web search: Brave → Tavily → DuckDuckGo fallback chain."""
3015
+ query = str(params.get("query", "")).strip()
3016
+ num = min(int(params.get("num_results", params.get("max_results", 5))), 10)
3017
+ if not query:
3018
+ return {"success": False, "error": "query is required"}
3019
+
3020
+ # ── 1. Brave Search API ───────────────────────────────────────────────────
3021
+ brave_key = _resolve_search_key("BRAVE_SEARCH_API_KEY", "brave")
3022
+ if brave_key:
3023
+ try:
3024
+ import urllib.request as _req
3025
+ import urllib.parse as _parse
3026
+ import gzip as _gzip
3027
+ # search_lang must be a valid Brave code. "zh" is INVALID (→ HTTP 422);
3028
+ # use "zh-hans" for Chinese queries and omit it otherwise (auto-detect).
3029
+ _q_params = {"q": query, "count": num, "safesearch": "moderate"}
3030
+ if any("一" <= _c <= "鿿" for _c in query):
3031
+ _q_params["search_lang"] = "zh-hans"
3032
+ url = "https://api.search.brave.com/res/v1/web/search?" + _parse.urlencode(_q_params)
3033
+ req = _req.Request(url, headers={
3034
+ "Accept": "application/json",
3035
+ "Accept-Encoding": "gzip",
3036
+ "X-Subscription-Token": brave_key,
3037
+ })
3038
+ with _req.urlopen(req, timeout=10) as r:
3039
+ raw = r.read()
3040
+ if r.headers.get("Content-Encoding") == "gzip":
3041
+ raw = _gzip.decompress(raw)
3042
+ data = json.loads(raw)
3043
+ results = []
3044
+ for item in data.get("web", {}).get("results", [])[:num]:
3045
+ results.append({
3046
+ "title": item.get("title", ""),
3047
+ "url": item.get("url", ""),
3048
+ "snippet": item.get("description", ""),
3049
+ })
3050
+ if results:
3051
+ return {"success": True, "query": query, "results": results, "provider": "brave"}
3052
+ except Exception as e:
3053
+ # Decompress gzip error bodies so the log is readable, not garbage
3054
+ _detail = str(e)
3055
+ try:
3056
+ import urllib.error as _uerr
3057
+ if isinstance(e, _uerr.HTTPError):
3058
+ _body = e.read()
3059
+ try:
3060
+ import gzip as _gz2
3061
+ if e.headers.get("Content-Encoding") == "gzip":
3062
+ _body = _gz2.decompress(_body)
3063
+ except Exception:
3064
+ pass
3065
+ _detail = f"HTTP {e.code}: {_body.decode('utf-8', 'ignore')[:200]}"
3066
+ except Exception:
3067
+ pass
3068
+ logger.debug("Brave search failed: %s; trying next provider", _detail)
3069
+
3070
+ # ── 2. Tavily API (designed for AI agents, generous free tier) ───────────
3071
+ tavily_key = _resolve_search_key("TAVILY_API_KEY", "tavily")
3072
+ if tavily_key:
3073
+ try:
3074
+ import urllib.request as _req2
3075
+ import urllib.parse as _parse2
3076
+ req2 = _req2.Request(
3077
+ "https://api.tavily.com/search",
3078
+ data=json.dumps({"api_key": tavily_key, "query": query, "max_results": num, "search_depth": "basic"}).encode(),
3079
+ headers={"Content-Type": "application/json"},
3080
+ )
3081
+ with _req2.urlopen(req2, timeout=10) as r2:
3082
+ data2 = json.loads(r2.read())
3083
+ results = [
3084
+ {"title": item.get("title", ""), "url": item.get("url", ""), "snippet": item.get("content", "")[:300]}
3085
+ for item in data2.get("results", [])[:num]
3086
+ ]
3087
+ if results:
3088
+ return {"success": True, "query": query, "results": results, "provider": "tavily"}
3089
+ except Exception as e:
3090
+ logger.debug("Tavily search failed: %s", e)
3091
+
3092
+ # ── 3. DuckDuckGo (free, no key, but rate-limited) ────────────────────────
3093
+ try:
3094
+ import warnings as _w
3095
+ with _w.catch_warnings():
3096
+ _w.simplefilter("ignore")
3097
+ try:
3098
+ from ddgs import DDGS
3099
+ except ImportError:
3100
+ from duckduckgo_search import DDGS
3101
+ results = []
3102
+ for item in DDGS().text(query, max_results=num):
3103
+ results.append({
3104
+ "title": item.get("title", ""),
3105
+ "url": item.get("href", ""),
3106
+ "snippet": item.get("body", ""),
3107
+ })
3108
+ if results:
3109
+ return {"success": True, "query": query, "results": results, "provider": "duckduckgo"}
3110
+ return {
3111
+ "success": False,
3112
+ "query": query,
3113
+ "results": [],
3114
+ "error": (
3115
+ "DuckDuckGo returned no results (rate-limited). "
3116
+ "推荐配置: BRAVE_SEARCH_API_KEY (免费2000次/月) 或 TAVILY_API_KEY (AI专用, 免费1000次/月)"
3117
+ ),
3118
+ }
3119
+ except ImportError:
3120
+ pass
3121
+ except Exception as e:
3122
+ logger.debug("duckduckgo_search failed: %s", e)
3123
+
3124
+ return {
3125
+ "success": False,
3126
+ "query": query,
3127
+ "results": [],
3128
+ "error": (
3129
+ "无可用搜索服务。推荐配置:\n"
3130
+ " BRAVE_SEARCH_API_KEY — https://brave.com/search/api/ (免费2000次/月)\n"
3131
+ " TAVILY_API_KEY — https://tavily.com (AI专用, 免费1000次/月)\n"
3132
+ " 或安装: pip install duckduckgo-search"
3133
+ ),
3134
+ }
3135
+
3136
+
3137
+ # ---------------------------------------------------------------------------
3138
+ # Tool registry (must be after all function definitions)
3139
+ # ---------------------------------------------------------------------------
3140
+
3141
+ LOCAL_FINANCE_TOOL_REGISTRY: Dict[str, Tuple] = {
3142
+ # ── Market data (cloud → local fallback) ─────────────────────────────
3143
+ "get_market_data": (_get_market_data, "Stock/ETF quotes and OHLCV history (A股/US/global, cloud-backed)"),
3144
+ "get_crypto_data": (_get_crypto_data, "Cryptocurrency OHLCV and ticker data via ccxt/yfinance"),
3145
+ "get_forex_data": (_get_forex_data, "Foreign exchange rates (yfinance)"),
3146
+ "get_commodities_data": (_get_commodities_data, "Commodity futures prices: gold, oil, copper, wheat, etc. (yfinance)"),
3147
+ "get_futures_data": (_get_futures_data, "Equity index futures: S&P, NASDAQ, VIX, Nikkei, etc. (yfinance)"),
3148
+ # ── Factor & signal (cloud → local fallback) ─────────────────────────
3149
+ "calculate_factors": (_calculate_factors, "Technical factors: RSI, MACD, MA gaps, volatility, momentum, trend score (cloud-enhanced)"),
3150
+ "get_ai_signal": (_get_ai_signal, "AI trading signal: BUY/SELL/HOLD with confidence and reasoning (Alibaba Cloud DeepSeek)"),
3151
+ "get_market_insights": (_get_market_insights, "AI narrative market insights for a basket of symbols (Alibaba Cloud)"),
3152
+ "get_predictions": (_get_predictions, "ML-powered 5/10-day return predictions (Alibaba Cloud LightGBM/XGBoost)"),
3153
+ # ── Backtest (cloud ML → local pandas fallback) ───────────────────────
3154
+ "backtest_strategy": (_backtest_strategy, "Run a local pandas backtest: sma_cross | rsi_mean_revert | momentum | buy_hold"),
3155
+ "cloud_backtest": (_cloud_backtest, "Full ML-powered backtest via Alibaba Cloud QuantEngine (rebalance freq, dynamic position)"),
3156
+ # ── Risk & portfolio ──────────────────────────────────────────────────
3157
+ "get_risk_metrics": (_get_risk_metrics, "VaR, CVaR, max drawdown, Sharpe, Calmar, skew, kurtosis"),
3158
+ "optimize_positions": (_optimize_positions, "Portfolio optimisation: max_sharpe | min_var | equal_weight"),
3159
+ # ── A股 data services ─────────────────────────────────────────────────
3160
+ "get_sector_performance": (_get_sector_performance, "Sector performance ranking (A股 industry / US SPDR ETFs)"),
3161
+ "get_northbound_flow": (_get_northbound_flow, "北向资金 (沪深港通) net buy flow via akshare"),
3162
+ "screen_ashare": (_screen_ashare, "A股选股筛选: PE, ROE, market cap, momentum"),
3163
+ "get_limit_up_pool": (_get_limit_up_pool, "A股涨停板池 (today's limit-up stocks via akshare)"),
3164
+ "get_market_indices": (_get_market_indices, "Global market indices: US, CN, EU, crypto, commodities"),
3165
+ # ── News & macro ──────────────────────────────────────────────────────
3166
+ "analyze_news": (_analyze_news, "News sentiment analysis (A股 via akshare)"),
3167
+ "get_bonds_data": (_get_bonds_data, "US Treasury yield curve (yfinance)"),
3168
+ # ── Quality scores ────────────────────────────────────────────────────
3169
+ "piotroski_fscore": (_piotroski_fscore, "Piotroski F-Score (0-9): 财务质量评分,≥7 高质量做多,≤3 低质量做空"),
3170
+ "altman_zscore": (_altman_zscore, "Altman Z''-Score: 企业破产风险预测,>2.6安全,<1.1高风险"),
3171
+ # ── Options ───────────────────────────────────────────────────────────
3172
+ "get_options_chain": (_get_options_chain, "期权链: 获取股票的 calls/puts,含行权价、隐含波动率、未平仓量"),
3173
+ # ── Technical indicators ──────────────────────────────────────────────
3174
+ "calculate_ichimoku": (_calculate_ichimoku, "一目均衡表 Ichimoku Cloud: 转换线/基准线/先行带/迟行线,含信号判断"),
3175
+ # ── Crypto ────────────────────────────────────────────────────────────
3176
+ "get_fear_greed_index": (_get_fear_greed_index, "加密恐惧贪婪指数 (0-100),>75极度贪婪/做空信号,<25极度恐惧/做多信号"),
3177
+ "get_funding_rates": (_get_funding_rates, "永续合约资金费率 (ccxt): 高正费率=多头过热,负费率=空头过多"),
3178
+ "get_funding_rates_compare": (_get_funding_rates_compare, "三所费率横向对比 (binance/okx/bybit): 并行查询,发现跨所套利机会"),
3179
+ # ── Portfolio / backtesting ───────────────────────────────────────────
3180
+ "walk_forward_backtest": (_walk_forward_backtest, "Walk-Forward 滚动回测:N个窗口验证策略泛化能力,避免过拟合"),
3181
+ "peer_comparison": (_peer_comparison, "同行对比: PE/PB/ROE/市值/股息率横向比较,自动识别同行业股票"),
3182
+ # ── Web search ────────────────────────────────────────────────────────
3183
+ "web_search": (_web_search, "Web search via Brave Search API or DuckDuckGo fallback; set BRAVE_SEARCH_API_KEY for higher quota"),
3184
+ }
3185
+
3186
+ # ---------------------------------------------------------------------------
3187
+ # Registration helper
3188
+ # ---------------------------------------------------------------------------
3189
+
3190
+ def register_local_finance_tools(
3191
+ tool_registry: Dict,
3192
+ schema_registry: List,
3193
+ ) -> int:
3194
+ """
3195
+ Add local finance tools to the CLI's LOCAL_TOOLS and LOCAL_TOOL_SCHEMAS.
3196
+
3197
+ Only registers tools whose names are NOT already present (never overwrites
3198
+ existing tools, so remote Aria tools take precedence when backend is up).
3199
+
3200
+ Returns number of tools newly registered.
3201
+ """
3202
+ added = 0
3203
+ for name, (handler, description) in LOCAL_FINANCE_TOOL_REGISTRY.items():
3204
+ if name not in tool_registry:
3205
+ tool_registry[name] = (
3206
+ lambda p, h=handler: _safe(h, p),
3207
+ description,
3208
+ )
3209
+ added += 1
3210
+
3211
+ existing_schema_names = {
3212
+ s.get("function", {}).get("name") for s in schema_registry
3213
+ }
3214
+ for schema in LOCAL_FINANCE_TOOL_SCHEMAS:
3215
+ sname = schema.get("function", {}).get("name", "")
3216
+ if sname and sname not in existing_schema_names:
3217
+ schema_registry.append(schema)
3218
+
3219
+ return added
3220
+
3221
+ return added