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
data_analysis_tools.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"""
|
|
2
|
+
data_analysis_tools.py — 数据分析增强层
|
|
3
|
+
==========================================
|
|
4
|
+
提供:
|
|
5
|
+
- Excel 导出 (多 Sheet + 内嵌图表)
|
|
6
|
+
- DuckDB SQL 接口(本地内存数据仓库)
|
|
7
|
+
- 价格预警系统 (~/.arthera/alerts.json)
|
|
8
|
+
- 多资产组合回测 (相关性矩阵 + 权重收益)
|
|
9
|
+
- 相关性热力图数据
|
|
10
|
+
- 自定义因子表达式 DSL
|
|
11
|
+
|
|
12
|
+
依赖(可选):
|
|
13
|
+
pip install openpyxl duckdb pandas numpy yfinance
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# ── Optional imports ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import pandas as pd
|
|
31
|
+
_HAS_PD = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
_HAS_PD = False
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import numpy as np
|
|
37
|
+
_HAS_NP = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
_HAS_NP = False
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import yfinance as yf
|
|
43
|
+
_HAS_YF = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
_HAS_YF = False
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import duckdb
|
|
49
|
+
_HAS_DUCK = True
|
|
50
|
+
except ImportError:
|
|
51
|
+
_HAS_DUCK = False
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import openpyxl
|
|
55
|
+
from openpyxl.styles import PatternFill, Font, Alignment, Border, Side
|
|
56
|
+
from openpyxl.chart import LineChart, Reference
|
|
57
|
+
from openpyxl.utils import get_column_letter
|
|
58
|
+
_HAS_OPENPYXL = True
|
|
59
|
+
except ImportError:
|
|
60
|
+
_HAS_OPENPYXL = False
|
|
61
|
+
|
|
62
|
+
ALERTS_PATH = Path.home() / ".arthera" / "alerts.json"
|
|
63
|
+
EXPORT_DIR = Path.home() / ".arthera" / "exports"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Excel 导出 ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def export_to_excel(params: dict) -> dict:
|
|
69
|
+
"""
|
|
70
|
+
将数据导出到格式化 Excel 文件(多 Sheet)。
|
|
71
|
+
|
|
72
|
+
参数:
|
|
73
|
+
data: dict,键=Sheet名,值=list[dict] 行数据
|
|
74
|
+
filename: 输出文件名(默认 aria_export_<timestamp>.xlsx)
|
|
75
|
+
add_chart: 是否为含 '价格'/'close'/'收盘' 列的 Sheet 添加折线图
|
|
76
|
+
"""
|
|
77
|
+
if not _HAS_OPENPYXL:
|
|
78
|
+
return {"success": False, "error": "openpyxl 未安装,请运行: pip install openpyxl"}
|
|
79
|
+
if not _HAS_PD:
|
|
80
|
+
return {"success": False, "error": "pandas 未安装"}
|
|
81
|
+
|
|
82
|
+
data = params.get("data", {})
|
|
83
|
+
if not data:
|
|
84
|
+
return {"success": False, "error": "data 参数不能为空"}
|
|
85
|
+
|
|
86
|
+
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
88
|
+
filename = params.get("filename") or f"aria_export_{ts}.xlsx"
|
|
89
|
+
if not filename.endswith(".xlsx"):
|
|
90
|
+
filename += ".xlsx"
|
|
91
|
+
out_path = EXPORT_DIR / filename
|
|
92
|
+
add_chart = bool(params.get("add_chart", True))
|
|
93
|
+
|
|
94
|
+
wb = openpyxl.Workbook()
|
|
95
|
+
wb.remove(wb.active) # remove default empty sheet
|
|
96
|
+
|
|
97
|
+
# Style constants
|
|
98
|
+
header_fill = PatternFill("solid", fgColor="1F4E79")
|
|
99
|
+
header_font = Font(color="FFFFFF", bold=True, size=10)
|
|
100
|
+
alt_fill = PatternFill("solid", fgColor="EBF5FB")
|
|
101
|
+
center_align = Alignment(horizontal="center", vertical="center")
|
|
102
|
+
thin_border = Border(
|
|
103
|
+
bottom=Side(style="thin", color="BDC3C7"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
for sheet_name, rows in data.items():
|
|
107
|
+
if not rows:
|
|
108
|
+
continue
|
|
109
|
+
ws = wb.create_sheet(title=str(sheet_name)[:31]) # Excel max 31 chars
|
|
110
|
+
df = pd.DataFrame(rows)
|
|
111
|
+
cols = list(df.columns)
|
|
112
|
+
|
|
113
|
+
# Header row
|
|
114
|
+
for ci, col in enumerate(cols, 1):
|
|
115
|
+
cell = ws.cell(row=1, column=ci, value=str(col))
|
|
116
|
+
cell.fill = header_fill
|
|
117
|
+
cell.font = header_font
|
|
118
|
+
cell.alignment = center_align
|
|
119
|
+
|
|
120
|
+
# Data rows
|
|
121
|
+
price_col_idx = None
|
|
122
|
+
for ri, row_dict in enumerate(rows, 2):
|
|
123
|
+
for ci, col in enumerate(cols, 1):
|
|
124
|
+
val = row_dict.get(col)
|
|
125
|
+
cell = ws.cell(row=ri, column=ci, value=val)
|
|
126
|
+
if ri % 2 == 0:
|
|
127
|
+
cell.fill = alt_fill
|
|
128
|
+
cell.border = thin_border
|
|
129
|
+
# Auto-detect price column
|
|
130
|
+
col_lower = col.lower()
|
|
131
|
+
if price_col_idx is None and any(k in col_lower for k in
|
|
132
|
+
["价格","close","收盘","price","last","最新"]):
|
|
133
|
+
price_col_idx = ci
|
|
134
|
+
|
|
135
|
+
# Auto-column width
|
|
136
|
+
for ci, col in enumerate(cols, 1):
|
|
137
|
+
max_len = max(len(str(col)),
|
|
138
|
+
*(len(str(r.get(col,""))) for r in rows[:50]))
|
|
139
|
+
ws.column_dimensions[get_column_letter(ci)].width = min(max_len + 3, 40)
|
|
140
|
+
|
|
141
|
+
# Freeze header
|
|
142
|
+
ws.freeze_panes = "A2"
|
|
143
|
+
|
|
144
|
+
# Add line chart if price column detected
|
|
145
|
+
if add_chart and price_col_idx and len(rows) >= 3:
|
|
146
|
+
chart = LineChart()
|
|
147
|
+
chart.title = f"{sheet_name} 价格走势"
|
|
148
|
+
chart.style = 10
|
|
149
|
+
chart.y_axis.title = "价格"
|
|
150
|
+
chart.x_axis.title = "时间"
|
|
151
|
+
data_ref = Reference(ws, min_col=price_col_idx,
|
|
152
|
+
min_row=1, max_row=len(rows) + 1)
|
|
153
|
+
chart.add_data(data_ref, titles_from_data=True)
|
|
154
|
+
chart.width = 20
|
|
155
|
+
chart.height = 12
|
|
156
|
+
ws.add_chart(chart, f"A{len(rows) + 4}")
|
|
157
|
+
|
|
158
|
+
# Summary sheet
|
|
159
|
+
ws_sum = wb.create_sheet(title="📊 汇总", index=0)
|
|
160
|
+
ws_sum["A1"] = "Aria Code 数据导出报告"
|
|
161
|
+
ws_sum["A1"].font = Font(bold=True, size=14, color="1F4E79")
|
|
162
|
+
ws_sum["A3"] = "导出时间:"
|
|
163
|
+
ws_sum["B3"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
164
|
+
ws_sum["A4"] = "Sheet 数量:"
|
|
165
|
+
ws_sum["B4"] = len(data)
|
|
166
|
+
ws_sum["A5"] = "总行数:"
|
|
167
|
+
ws_sum["B5"] = sum(len(v) for v in data.values())
|
|
168
|
+
ws_sum["A7"] = "包含 Sheet:"
|
|
169
|
+
for i, sname in enumerate(data.keys(), 8):
|
|
170
|
+
ws_sum[f"A{i}"] = f" • {sname}"
|
|
171
|
+
ws_sum[f"B{i}"] = f"{len(data[sname])} 行"
|
|
172
|
+
|
|
173
|
+
wb.save(str(out_path))
|
|
174
|
+
return {
|
|
175
|
+
"success": True,
|
|
176
|
+
"path": str(out_path),
|
|
177
|
+
"sheets": list(data.keys()),
|
|
178
|
+
"total_rows": sum(len(v) for v in data.values()),
|
|
179
|
+
"filename": filename,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── DuckDB SQL 接口 ───────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
# Persistent in-memory DuckDB connection for the session
|
|
186
|
+
_duck_conn: Optional[Any] = None
|
|
187
|
+
_duck_tables: Dict[str, bool] = {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_duck_conn():
|
|
191
|
+
global _duck_conn
|
|
192
|
+
if _duck_conn is None:
|
|
193
|
+
_duck_conn = duckdb.connect(":memory:")
|
|
194
|
+
return _duck_conn
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def sql_query(params: dict) -> dict:
|
|
198
|
+
"""
|
|
199
|
+
在内存 DuckDB 中执行 SQL 查询。
|
|
200
|
+
|
|
201
|
+
参数:
|
|
202
|
+
query: SQL 语句
|
|
203
|
+
load: 可选,dict {table_name: list[dict]} — 在查询前加载数据
|
|
204
|
+
limit: 结果行数限制(默认 500)
|
|
205
|
+
"""
|
|
206
|
+
if not _HAS_DUCK:
|
|
207
|
+
return {"success": False,
|
|
208
|
+
"error": "duckdb 未安装,请运行: pip install duckdb"}
|
|
209
|
+
if not _HAS_PD:
|
|
210
|
+
return {"success": False, "error": "pandas 未安装"}
|
|
211
|
+
|
|
212
|
+
query = str(params.get("query", "")).strip()
|
|
213
|
+
if not query:
|
|
214
|
+
return {"success": False, "error": "query 不能为空"}
|
|
215
|
+
|
|
216
|
+
conn = _get_duck_conn()
|
|
217
|
+
limit = int(params.get("limit", 500))
|
|
218
|
+
load = params.get("load", {})
|
|
219
|
+
|
|
220
|
+
# Load tables
|
|
221
|
+
for tname, rows in (load or {}).items():
|
|
222
|
+
if rows:
|
|
223
|
+
df = pd.DataFrame(rows)
|
|
224
|
+
conn.register(tname, df)
|
|
225
|
+
_duck_tables[tname] = True
|
|
226
|
+
|
|
227
|
+
# Safety check: block destructive operations
|
|
228
|
+
q_upper = query.upper().lstrip()
|
|
229
|
+
if any(q_upper.startswith(kw) for kw in ("DROP ", "DELETE ", "TRUNCATE ")):
|
|
230
|
+
return {"success": False,
|
|
231
|
+
"error": "安全限制:不允许 DROP/DELETE/TRUNCATE 操作"}
|
|
232
|
+
|
|
233
|
+
# Auto-add LIMIT if SELECT without one
|
|
234
|
+
if q_upper.startswith("SELECT") and "LIMIT" not in q_upper:
|
|
235
|
+
query = f"{query.rstrip(';')} LIMIT {limit}"
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
result = conn.execute(query).fetchdf()
|
|
239
|
+
records = result.to_dict("records")
|
|
240
|
+
cols = list(result.columns)
|
|
241
|
+
return {
|
|
242
|
+
"success": True,
|
|
243
|
+
"columns": cols,
|
|
244
|
+
"rows": records,
|
|
245
|
+
"row_count": len(records),
|
|
246
|
+
"tables_loaded": list(_duck_tables.keys()),
|
|
247
|
+
}
|
|
248
|
+
except Exception as e:
|
|
249
|
+
return {"success": False, "error": str(e), "query": query}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def sql_list_tables(params: dict = None) -> dict:
|
|
253
|
+
"""列出内存 DuckDB 中已加载的表。"""
|
|
254
|
+
if not _HAS_DUCK:
|
|
255
|
+
return {"success": False, "error": "duckdb 未安装"}
|
|
256
|
+
conn = _get_duck_conn()
|
|
257
|
+
try:
|
|
258
|
+
df = conn.execute("SHOW TABLES").fetchdf()
|
|
259
|
+
tables = df["name"].tolist() if "name" in df.columns else []
|
|
260
|
+
return {"success": True, "tables": tables}
|
|
261
|
+
except Exception as e:
|
|
262
|
+
return {"success": False, "error": str(e)}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── 价格预警系统 ──────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
def _load_alerts() -> List[dict]:
|
|
268
|
+
if ALERTS_PATH.exists():
|
|
269
|
+
try:
|
|
270
|
+
return json.loads(ALERTS_PATH.read_text(encoding="utf-8"))
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _save_alerts(alerts: list) -> None:
|
|
277
|
+
ALERTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
ALERTS_PATH.write_text(json.dumps(alerts, ensure_ascii=False, indent=2),
|
|
279
|
+
encoding="utf-8")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def add_price_alert(params: dict) -> dict:
|
|
283
|
+
"""
|
|
284
|
+
添加价格预警。
|
|
285
|
+
|
|
286
|
+
参数:
|
|
287
|
+
symbol: 标的代码(如 "AAPL" / "600519")
|
|
288
|
+
condition: 触发条件 ("gt" / "lt" / "cross_up" / "cross_down")
|
|
289
|
+
price: 触发价格
|
|
290
|
+
note: 备注(可选)
|
|
291
|
+
"""
|
|
292
|
+
symbol = str(params.get("symbol", "")).upper().strip()
|
|
293
|
+
condition = str(params.get("condition", "gt")).lower()
|
|
294
|
+
price = float(params.get("price", 0))
|
|
295
|
+
note = str(params.get("note", ""))
|
|
296
|
+
|
|
297
|
+
if not symbol:
|
|
298
|
+
return {"success": False, "error": "symbol 不能为空"}
|
|
299
|
+
if condition not in ("gt", "lt", "cross_up", "cross_down"):
|
|
300
|
+
return {"success": False,
|
|
301
|
+
"error": "condition 必须是 gt/lt/cross_up/cross_down"}
|
|
302
|
+
if price <= 0:
|
|
303
|
+
return {"success": False, "error": "price 必须 > 0"}
|
|
304
|
+
|
|
305
|
+
alerts = _load_alerts()
|
|
306
|
+
alert_id = f"{symbol}_{condition}_{price}_{int(datetime.now().timestamp())}"
|
|
307
|
+
new_alert = {
|
|
308
|
+
"id": alert_id,
|
|
309
|
+
"symbol": symbol,
|
|
310
|
+
"condition": condition,
|
|
311
|
+
"price": price,
|
|
312
|
+
"note": note,
|
|
313
|
+
"created_at": datetime.now().isoformat(),
|
|
314
|
+
"triggered": False,
|
|
315
|
+
}
|
|
316
|
+
alerts.append(new_alert)
|
|
317
|
+
_save_alerts(alerts)
|
|
318
|
+
|
|
319
|
+
cond_label = {"gt": ">", "lt": "<",
|
|
320
|
+
"cross_up": "向上突破", "cross_down": "向下跌破"}[condition]
|
|
321
|
+
return {
|
|
322
|
+
"success": True,
|
|
323
|
+
"alert_id": alert_id,
|
|
324
|
+
"message": f"已设置预警:{symbol} 价格 {cond_label} {price}",
|
|
325
|
+
"total_alerts": len(alerts),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def list_price_alerts(params: dict = None) -> dict:
|
|
330
|
+
"""列出所有价格预警。"""
|
|
331
|
+
alerts = _load_alerts()
|
|
332
|
+
active = [a for a in alerts if not a.get("triggered")]
|
|
333
|
+
done = [a for a in alerts if a.get("triggered")]
|
|
334
|
+
return {
|
|
335
|
+
"success": True,
|
|
336
|
+
"active_alerts": active,
|
|
337
|
+
"triggered_alerts": done,
|
|
338
|
+
"total": len(alerts),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def delete_price_alert(params: dict) -> dict:
|
|
343
|
+
"""删除指定 ID 的价格预警。"""
|
|
344
|
+
alert_id = str(params.get("alert_id", ""))
|
|
345
|
+
if not alert_id:
|
|
346
|
+
return {"success": False, "error": "alert_id 不能为空"}
|
|
347
|
+
alerts = _load_alerts()
|
|
348
|
+
before = len(alerts)
|
|
349
|
+
alerts = [a for a in alerts if a.get("id") != alert_id]
|
|
350
|
+
if len(alerts) == before:
|
|
351
|
+
return {"success": False, "error": f"未找到预警 {alert_id}"}
|
|
352
|
+
_save_alerts(alerts)
|
|
353
|
+
return {"success": True, "deleted_id": alert_id, "remaining": len(alerts)}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def check_alerts(params: dict = None) -> dict:
|
|
357
|
+
"""
|
|
358
|
+
检查当前所有未触发预警的状态(需联网获取实时价格)。
|
|
359
|
+
返回已触发的预警列表。
|
|
360
|
+
"""
|
|
361
|
+
if not _HAS_YF:
|
|
362
|
+
return {"success": False, "error": "yfinance 未安装,无法获取实时价格"}
|
|
363
|
+
|
|
364
|
+
alerts = _load_alerts()
|
|
365
|
+
active = [a for a in alerts if not a.get("triggered")]
|
|
366
|
+
if not active:
|
|
367
|
+
return {"success": True, "triggered": [], "message": "无活跃预警"}
|
|
368
|
+
|
|
369
|
+
# Batch price fetch
|
|
370
|
+
symbols = list(set(a.get("symbol", "") for a in active if a.get("symbol")))
|
|
371
|
+
prices: Dict[str, float] = {}
|
|
372
|
+
try:
|
|
373
|
+
tickers = yf.Tickers(" ".join(symbols))
|
|
374
|
+
for sym in symbols:
|
|
375
|
+
try:
|
|
376
|
+
info = tickers.tickers[sym].fast_info
|
|
377
|
+
px = getattr(info, "last_price", None) or \
|
|
378
|
+
getattr(info, "regularMarketPrice", None)
|
|
379
|
+
if px:
|
|
380
|
+
prices[sym] = float(px)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.debug("Alert price fetch failed: %s", e)
|
|
385
|
+
|
|
386
|
+
triggered_now = []
|
|
387
|
+
for alert in active:
|
|
388
|
+
sym = alert.get("symbol")
|
|
389
|
+
cond = alert.get("condition")
|
|
390
|
+
tgt = alert.get("price")
|
|
391
|
+
if not sym or not cond or tgt is None:
|
|
392
|
+
continue
|
|
393
|
+
cur = prices.get(sym)
|
|
394
|
+
if cur is None:
|
|
395
|
+
continue
|
|
396
|
+
hit = False
|
|
397
|
+
if cond == "gt" and cur > tgt:
|
|
398
|
+
hit = True
|
|
399
|
+
elif cond == "lt" and cur < tgt:
|
|
400
|
+
hit = True
|
|
401
|
+
elif cond == "cross_up" and cur >= tgt:
|
|
402
|
+
hit = True
|
|
403
|
+
elif cond == "cross_down" and cur <= tgt:
|
|
404
|
+
hit = True
|
|
405
|
+
if hit:
|
|
406
|
+
alert["triggered"] = True
|
|
407
|
+
alert["triggered_at"] = datetime.now().isoformat()
|
|
408
|
+
alert["triggered_price"] = cur
|
|
409
|
+
triggered_now.append(alert)
|
|
410
|
+
|
|
411
|
+
_save_alerts(alerts)
|
|
412
|
+
|
|
413
|
+
# Push notifications for newly triggered alerts
|
|
414
|
+
if triggered_now:
|
|
415
|
+
try:
|
|
416
|
+
from notification_tools import send_alert_notification
|
|
417
|
+
for _alrt in triggered_now:
|
|
418
|
+
send_alert_notification(_alrt)
|
|
419
|
+
except Exception as _ne:
|
|
420
|
+
logger.debug("Notification dispatch failed: %s", _ne)
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"success": True,
|
|
424
|
+
"triggered": triggered_now,
|
|
425
|
+
"checked": len(active),
|
|
426
|
+
"prices": prices,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── 相关性矩阵 ────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
def calc_correlation_matrix(params: dict) -> dict:
|
|
433
|
+
"""
|
|
434
|
+
计算多资产收益率相关性矩阵。
|
|
435
|
+
|
|
436
|
+
参数:
|
|
437
|
+
symbols: list[str],如 ["AAPL","MSFT","TSLA","SPY"]
|
|
438
|
+
period: 历史区间 ("1y" / "2y" / "6mo",默认 "1y")
|
|
439
|
+
interval: 频率 ("1d" / "1wk",默认 "1d")
|
|
440
|
+
"""
|
|
441
|
+
if not _HAS_YF:
|
|
442
|
+
return {"success": False, "error": "yfinance 未安装"}
|
|
443
|
+
if not _HAS_PD or not _HAS_NP:
|
|
444
|
+
return {"success": False, "error": "pandas / numpy 未安装"}
|
|
445
|
+
|
|
446
|
+
symbols = params.get("symbols", [])
|
|
447
|
+
period = str(params.get("period", "1y"))
|
|
448
|
+
interval = str(params.get("interval", "1d"))
|
|
449
|
+
|
|
450
|
+
if len(symbols) < 2:
|
|
451
|
+
return {"success": False, "error": "至少需要 2 个标的"}
|
|
452
|
+
if len(symbols) > 20:
|
|
453
|
+
symbols = symbols[:20]
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
raw = yf.download(symbols, period=period, interval=interval,
|
|
457
|
+
progress=False, auto_adjust=True)
|
|
458
|
+
if raw.empty:
|
|
459
|
+
return {"success": False, "error": "下载数据为空"}
|
|
460
|
+
|
|
461
|
+
if isinstance(raw.columns, pd.MultiIndex):
|
|
462
|
+
closes = raw["Close"]
|
|
463
|
+
else:
|
|
464
|
+
closes = raw
|
|
465
|
+
|
|
466
|
+
closes = closes.dropna(how="all", axis=1)
|
|
467
|
+
rets = closes.pct_change().dropna()
|
|
468
|
+
corr = rets.corr()
|
|
469
|
+
|
|
470
|
+
# Convert to serializable format
|
|
471
|
+
corr_dict = {}
|
|
472
|
+
for sym in corr.columns:
|
|
473
|
+
corr_dict[sym] = {
|
|
474
|
+
other: round(float(corr.loc[sym, other]), 4)
|
|
475
|
+
for other in corr.columns
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
# Summary stats
|
|
479
|
+
stats = {}
|
|
480
|
+
for sym in closes.columns:
|
|
481
|
+
s = closes[sym].dropna()
|
|
482
|
+
r = rets[sym].dropna() if sym in rets.columns else pd.Series(dtype=float)
|
|
483
|
+
if s.empty:
|
|
484
|
+
continue
|
|
485
|
+
stats[sym] = {
|
|
486
|
+
"return_total": round((s.iloc[-1]/s.iloc[0] - 1)*100, 2),
|
|
487
|
+
"volatility": round(float(r.std() * np.sqrt(252) * 100), 2) if len(r) > 1 else None,
|
|
488
|
+
"sharpe": _calc_sharpe(r),
|
|
489
|
+
"max_drawdown": round(_max_drawdown(s) * 100, 2),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
"success": True,
|
|
494
|
+
"symbols": list(closes.columns),
|
|
495
|
+
"period": period,
|
|
496
|
+
"interval": interval,
|
|
497
|
+
"corr_matrix": corr_dict,
|
|
498
|
+
"stats": stats,
|
|
499
|
+
"data_points": len(rets),
|
|
500
|
+
}
|
|
501
|
+
except Exception as e:
|
|
502
|
+
return {"success": False, "error": str(e)}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ── 多资产组合回测 ─────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
def portfolio_backtest(params: dict) -> dict:
|
|
508
|
+
"""
|
|
509
|
+
多资产组合历史回测。
|
|
510
|
+
|
|
511
|
+
参数:
|
|
512
|
+
symbols: list[str],如 ["AAPL","MSFT","GOOG"]
|
|
513
|
+
weights: list[float],权重(自动归一化);为空则等权
|
|
514
|
+
period: "1y" / "2y" / "3y" / "5y"(默认 "2y")
|
|
515
|
+
benchmark: 基准标的(默认 "SPY")
|
|
516
|
+
rebalance: 再平衡频率 ("monthly" / "quarterly" / "none",默认 "monthly")
|
|
517
|
+
"""
|
|
518
|
+
if not _HAS_YF or not _HAS_PD or not _HAS_NP:
|
|
519
|
+
return {"success": False, "error": "需要 yfinance + pandas + numpy"}
|
|
520
|
+
|
|
521
|
+
symbols = params.get("symbols", [])
|
|
522
|
+
weights_r = params.get("weights", [])
|
|
523
|
+
period = str(params.get("period", "2y"))
|
|
524
|
+
benchmark = str(params.get("benchmark", "SPY"))
|
|
525
|
+
rebalance = str(params.get("rebalance", "monthly"))
|
|
526
|
+
|
|
527
|
+
if not symbols:
|
|
528
|
+
return {"success": False, "error": "symbols 不能为空"}
|
|
529
|
+
if len(symbols) > 15:
|
|
530
|
+
symbols = symbols[:15]
|
|
531
|
+
|
|
532
|
+
# Normalize weights
|
|
533
|
+
if weights_r and len(weights_r) == len(symbols):
|
|
534
|
+
ws = [float(w) for w in weights_r]
|
|
535
|
+
total = sum(ws)
|
|
536
|
+
weights = [w / total for w in ws]
|
|
537
|
+
else:
|
|
538
|
+
weights = [1 / len(symbols)] * len(symbols)
|
|
539
|
+
|
|
540
|
+
all_syms = list(dict.fromkeys(symbols + [benchmark]))
|
|
541
|
+
try:
|
|
542
|
+
raw = yf.download(all_syms, period=period, progress=False, auto_adjust=True)
|
|
543
|
+
if raw.empty:
|
|
544
|
+
return {"success": False, "error": "数据下载失败"}
|
|
545
|
+
|
|
546
|
+
closes = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw
|
|
547
|
+
closes = closes.dropna(how="all", axis=1)
|
|
548
|
+
|
|
549
|
+
# Align symbols with available data
|
|
550
|
+
avail = [s for s in symbols if s in closes.columns]
|
|
551
|
+
if not avail:
|
|
552
|
+
return {"success": False, "error": "所有标的数据均不可用"}
|
|
553
|
+
if len(avail) < len(symbols):
|
|
554
|
+
missing = [s for s in symbols if s not in closes.columns]
|
|
555
|
+
weights = [weights[i] for i, s in enumerate(symbols) if s in closes.columns]
|
|
556
|
+
wt_total = sum(weights)
|
|
557
|
+
weights = [w / wt_total for w in weights]
|
|
558
|
+
symbols = avail
|
|
559
|
+
|
|
560
|
+
port_data = closes[symbols].ffill().dropna()
|
|
561
|
+
if benchmark in closes.columns:
|
|
562
|
+
bench_data = closes[benchmark].ffill().dropna()
|
|
563
|
+
else:
|
|
564
|
+
bench_data = None
|
|
565
|
+
|
|
566
|
+
# Rebalance
|
|
567
|
+
if rebalance == "monthly":
|
|
568
|
+
freq = "MS"
|
|
569
|
+
elif rebalance == "quarterly":
|
|
570
|
+
freq = "QS"
|
|
571
|
+
else:
|
|
572
|
+
freq = None
|
|
573
|
+
|
|
574
|
+
# Compute portfolio returns
|
|
575
|
+
if freq and len(port_data) > 0:
|
|
576
|
+
port_rets = port_data.pct_change().dropna()
|
|
577
|
+
# Monthly rebalancing: compound weighted returns per period
|
|
578
|
+
port_monthly_rets = (port_rets + 1).resample(freq).prod() - 1
|
|
579
|
+
port_rets_w = (port_monthly_rets[symbols] * weights).sum(axis=1)
|
|
580
|
+
# Expand back to daily for metrics
|
|
581
|
+
port_cum = (1 + port_rets_w).cumprod()
|
|
582
|
+
else:
|
|
583
|
+
port_rets = port_data.pct_change().dropna()
|
|
584
|
+
port_rets_w = (port_rets[symbols] * weights).sum(axis=1)
|
|
585
|
+
port_cum = (1 + port_rets_w).cumprod()
|
|
586
|
+
|
|
587
|
+
total_return = float(port_cum.iloc[-1] - 1) * 100
|
|
588
|
+
annual_vol = float(port_rets_w.std() * np.sqrt(252 if freq else 252) * 100)
|
|
589
|
+
sharpe = _calc_sharpe(port_rets_w)
|
|
590
|
+
max_dd = round(_max_drawdown(port_cum) * 100, 2)
|
|
591
|
+
|
|
592
|
+
# Benchmark
|
|
593
|
+
bench_stats = None
|
|
594
|
+
if bench_data is not None:
|
|
595
|
+
bench_rets = bench_data.pct_change().dropna()
|
|
596
|
+
b_cum = (1 + bench_rets).cumprod()
|
|
597
|
+
bench_stats = {
|
|
598
|
+
"symbol": benchmark,
|
|
599
|
+
"total_return": round((float(b_cum.iloc[-1]) - 1)*100, 2),
|
|
600
|
+
"volatility": round(float(bench_rets.std() * np.sqrt(252) * 100), 2),
|
|
601
|
+
"sharpe": _calc_sharpe(bench_rets),
|
|
602
|
+
"max_drawdown": round(_max_drawdown(b_cum) * 100, 2),
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
# Allocation breakdown
|
|
606
|
+
allocation = [{"symbol": s, "weight_pct": round(w*100, 1),
|
|
607
|
+
"weight_abs": w} for s, w in zip(symbols, weights)]
|
|
608
|
+
|
|
609
|
+
# Performance curve (monthly points)
|
|
610
|
+
if freq:
|
|
611
|
+
curve_pts = [{"date": str(d.date()), "value": round(float(v), 4)}
|
|
612
|
+
for d, v in port_cum.items()]
|
|
613
|
+
else:
|
|
614
|
+
curve_pts = [{"date": str(d.date()), "value": round(float(v), 4)}
|
|
615
|
+
for d, v in port_cum.iloc[::5].items()] # every 5 days
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
"success": True,
|
|
619
|
+
"symbols": symbols,
|
|
620
|
+
"weights": weights,
|
|
621
|
+
"allocation": allocation,
|
|
622
|
+
"period": period,
|
|
623
|
+
"rebalance": rebalance,
|
|
624
|
+
"portfolio": {
|
|
625
|
+
"total_return_pct": round(total_return, 2),
|
|
626
|
+
"annual_vol_pct": round(annual_vol, 2),
|
|
627
|
+
"sharpe_ratio": sharpe,
|
|
628
|
+
"max_drawdown_pct": max_dd,
|
|
629
|
+
"calmar_ratio": round(total_return / abs(max_dd), 2) if max_dd != 0 else None,
|
|
630
|
+
},
|
|
631
|
+
"benchmark": bench_stats,
|
|
632
|
+
"curve": curve_pts[-60:], # last 60 points for chart
|
|
633
|
+
"data_points": len(port_rets_w),
|
|
634
|
+
}
|
|
635
|
+
except Exception as e:
|
|
636
|
+
logger.exception("Portfolio backtest error")
|
|
637
|
+
return {"success": False, "error": str(e)}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ── 自定义因子 DSL ─────────────────────────────────────────────────────────────
|
|
641
|
+
|
|
642
|
+
def eval_custom_factor(params: dict) -> dict:
|
|
643
|
+
"""
|
|
644
|
+
计算自定义因子表达式。
|
|
645
|
+
|
|
646
|
+
参数:
|
|
647
|
+
symbol: 标的代码
|
|
648
|
+
period: 历史区间(默认 "1y")
|
|
649
|
+
expr: 因子表达式字符串(Python 数学语法,可使用变量)
|
|
650
|
+
可用变量:close, open, high, low, volume, returns
|
|
651
|
+
可用函数:sma(n), ema(n), std(n), rsi(n), atr(n)
|
|
652
|
+
示例:
|
|
653
|
+
"(close - sma(20)) / std(20)" → Z-Score
|
|
654
|
+
"sma(5) / sma(20) - 1" → 短期动量
|
|
655
|
+
"volume / sma(20, volume)" → 量比
|
|
656
|
+
"""
|
|
657
|
+
if not _HAS_YF or not _HAS_PD or not _HAS_NP:
|
|
658
|
+
return {"success": False, "error": "需要 yfinance + pandas + numpy"}
|
|
659
|
+
|
|
660
|
+
symbol = str(params.get("symbol", "")).upper()
|
|
661
|
+
period = str(params.get("period", "1y"))
|
|
662
|
+
expr = str(params.get("expr", "")).strip()
|
|
663
|
+
|
|
664
|
+
if not symbol or not expr:
|
|
665
|
+
return {"success": False, "error": "symbol 和 expr 均为必填"}
|
|
666
|
+
|
|
667
|
+
# Security: block imports and exec
|
|
668
|
+
forbidden = ["import", "__", "exec(", "eval(", "open(", "os.", "sys."]
|
|
669
|
+
for tok in forbidden:
|
|
670
|
+
if tok in expr:
|
|
671
|
+
return {"success": False, "error": f"表达式包含禁止关键字: {tok}"}
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
tkr = yf.Ticker(symbol)
|
|
675
|
+
hist = tkr.history(period=period, auto_adjust=True)
|
|
676
|
+
if hist.empty:
|
|
677
|
+
return {"success": False, "error": f"无法获取 {symbol} 历史数据"}
|
|
678
|
+
|
|
679
|
+
close = hist["Close"].astype(float)
|
|
680
|
+
open_ = hist["Open"].astype(float)
|
|
681
|
+
high = hist["High"].astype(float)
|
|
682
|
+
low = hist["Low"].astype(float)
|
|
683
|
+
volume = hist["Volume"].astype(float)
|
|
684
|
+
returns = close.pct_change()
|
|
685
|
+
|
|
686
|
+
def sma(n, series=None):
|
|
687
|
+
s = series if series is not None else close
|
|
688
|
+
return s.rolling(n).mean()
|
|
689
|
+
|
|
690
|
+
def ema(n, series=None):
|
|
691
|
+
s = series if series is not None else close
|
|
692
|
+
return s.ewm(span=n, adjust=False).mean()
|
|
693
|
+
|
|
694
|
+
def std(n, series=None):
|
|
695
|
+
s = series if series is not None else close
|
|
696
|
+
return s.rolling(n).std()
|
|
697
|
+
|
|
698
|
+
def rsi(n=14):
|
|
699
|
+
delta = close.diff()
|
|
700
|
+
gain = delta.clip(lower=0).rolling(n).mean()
|
|
701
|
+
loss = (-delta.clip(upper=0)).rolling(n).mean()
|
|
702
|
+
rs = gain / loss
|
|
703
|
+
return 100 - (100 / (1 + rs))
|
|
704
|
+
|
|
705
|
+
def atr(n=14):
|
|
706
|
+
tr = pd.concat([
|
|
707
|
+
high - low,
|
|
708
|
+
(high - close.shift()).abs(),
|
|
709
|
+
(low - close.shift()).abs(),
|
|
710
|
+
], axis=1).max(axis=1)
|
|
711
|
+
return tr.rolling(n).mean()
|
|
712
|
+
|
|
713
|
+
ns = dict(
|
|
714
|
+
close=close, open=open_, high=high, low=low,
|
|
715
|
+
volume=volume, returns=returns,
|
|
716
|
+
sma=sma, ema=ema, std=std, rsi=rsi, atr=atr,
|
|
717
|
+
pd=pd, np=np, abs=abs, min=min, max=max,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
result_series = eval(expr, {"__builtins__": {}}, ns) # noqa: S307
|
|
721
|
+
if hasattr(result_series, "dropna"):
|
|
722
|
+
result_series = result_series.dropna()
|
|
723
|
+
|
|
724
|
+
latest_val = float(result_series.iloc[-1]) if len(result_series) > 0 else None
|
|
725
|
+
|
|
726
|
+
# Last 20 values for chart
|
|
727
|
+
tail = result_series.tail(20)
|
|
728
|
+
series_pts = [{"date": str(d.date()), "value": round(float(v), 6)}
|
|
729
|
+
for d, v in tail.items()]
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
"success": True,
|
|
733
|
+
"symbol": symbol,
|
|
734
|
+
"expr": expr,
|
|
735
|
+
"latest": latest_val,
|
|
736
|
+
"series": series_pts,
|
|
737
|
+
"data_len": len(result_series),
|
|
738
|
+
}
|
|
739
|
+
except Exception as e:
|
|
740
|
+
return {"success": False, "error": f"表达式计算失败: {e}"}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ── 通用数据导入 ───────────────────────────────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
def load_csv_data(params: dict) -> dict:
|
|
746
|
+
"""
|
|
747
|
+
从 CSV 文件加载数据到内存 DuckDB 表。
|
|
748
|
+
|
|
749
|
+
参数:
|
|
750
|
+
path: CSV 文件路径
|
|
751
|
+
table_name: 目标表名(默认由文件名推导)
|
|
752
|
+
encoding: 编码(默认 utf-8)
|
|
753
|
+
"""
|
|
754
|
+
if not _HAS_PD:
|
|
755
|
+
return {"success": False, "error": "pandas 未安装"}
|
|
756
|
+
|
|
757
|
+
path = Path(str(params.get("path", ""))).expanduser()
|
|
758
|
+
if not path.exists():
|
|
759
|
+
return {"success": False, "error": f"文件不存在: {path}"}
|
|
760
|
+
if path.suffix.lower() not in (".csv", ".tsv", ".txt"):
|
|
761
|
+
return {"success": False, "error": "仅支持 CSV/TSV 文件"}
|
|
762
|
+
|
|
763
|
+
enc = params.get("encoding", "utf-8")
|
|
764
|
+
tname = str(params.get("table_name", path.stem))
|
|
765
|
+
|
|
766
|
+
try:
|
|
767
|
+
df = pd.read_csv(str(path), encoding=enc)
|
|
768
|
+
if _HAS_DUCK:
|
|
769
|
+
conn = _get_duck_conn()
|
|
770
|
+
conn.register(tname, df)
|
|
771
|
+
_duck_tables[tname] = True
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
"success": True,
|
|
775
|
+
"table_name": tname,
|
|
776
|
+
"rows": len(df),
|
|
777
|
+
"columns": list(df.columns),
|
|
778
|
+
"sample": df.head(3).to_dict("records"),
|
|
779
|
+
"path": str(path),
|
|
780
|
+
}
|
|
781
|
+
except Exception as e:
|
|
782
|
+
return {"success": False, "error": str(e)}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
# ── Private Helpers ───────────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
def _calc_sharpe(rets, rf_daily: float = 0.0) -> Optional[float]:
|
|
788
|
+
if not _HAS_NP or not hasattr(rets, "__len__") or len(rets) < 2:
|
|
789
|
+
return None
|
|
790
|
+
try:
|
|
791
|
+
excess = rets - rf_daily
|
|
792
|
+
if float(excess.std()) == 0:
|
|
793
|
+
return None
|
|
794
|
+
return round(float(excess.mean() / excess.std() * np.sqrt(252)), 3)
|
|
795
|
+
except Exception:
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _max_drawdown(cum_series) -> float:
|
|
800
|
+
if not _HAS_NP or not _HAS_PD:
|
|
801
|
+
return 0.0
|
|
802
|
+
try:
|
|
803
|
+
s = pd.Series(cum_series)
|
|
804
|
+
roll_max = s.cummax()
|
|
805
|
+
drawdown = (s - roll_max) / roll_max
|
|
806
|
+
return float(drawdown.min())
|
|
807
|
+
except Exception:
|
|
808
|
+
return 0.0
|