aria-code 4.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
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
|