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
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
"""Stock chart analysis handlers extracted from aria_cli.py."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from apps.cli.plotly_html import plotly_script_tag
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalise_history_frame(hist):
|
|
14
|
+
"""Return a clean OHLCV frame with title-case columns, or None."""
|
|
15
|
+
if hist is None or getattr(hist, "empty", True):
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
hist = hist.copy()
|
|
19
|
+
if getattr(hist, "columns", None) is not None:
|
|
20
|
+
try:
|
|
21
|
+
import pandas as _pd
|
|
22
|
+
if isinstance(hist.columns, _pd.MultiIndex):
|
|
23
|
+
hist.columns = hist.columns.droplevel(-1)
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
hist.columns = [str(c).title() for c in hist.columns]
|
|
27
|
+
if "Close" not in hist.columns:
|
|
28
|
+
return None
|
|
29
|
+
hist = hist.dropna(subset=["Close"]).copy()
|
|
30
|
+
return hist if not hist.empty else None
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalise_chart_symbol(symbol: str) -> str:
|
|
36
|
+
"""Normalize common A-share inputs for chart providers."""
|
|
37
|
+
raw = (symbol or "").strip().upper()
|
|
38
|
+
if not raw:
|
|
39
|
+
return raw
|
|
40
|
+
if re.match(r"^\d{6}$", raw):
|
|
41
|
+
return f"{raw}.SS" if raw.startswith(("6", "9")) else f"{raw}.SZ"
|
|
42
|
+
m = re.match(r"^(?:SH|SZ)([036]\d{5}|68\d{4})$", raw)
|
|
43
|
+
if m:
|
|
44
|
+
code = m.group(1)
|
|
45
|
+
return f"{code}.SS" if raw.startswith("SH") else f"{code}.SZ"
|
|
46
|
+
if raw.endswith(".SH"):
|
|
47
|
+
return raw[:-3] + ".SS"
|
|
48
|
+
return raw
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ashare_plain_symbol(symbol: str) -> str:
|
|
52
|
+
"""Return 6-digit A-share code for akshare, or empty string."""
|
|
53
|
+
raw = (symbol or "").strip().upper()
|
|
54
|
+
m = re.match(r"^(?:SH|SZ)?([036]\d{5}|68\d{4})(?:\.(?:SS|SH|SZ))?$", raw)
|
|
55
|
+
return m.group(1) if m else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _days_for_period(period: str) -> int:
|
|
59
|
+
return {
|
|
60
|
+
"1mo": 35, "1m": 35,
|
|
61
|
+
"3mo": 100, "3m": 100,
|
|
62
|
+
"6mo": 185, "6m": 185,
|
|
63
|
+
"ytd": 370,
|
|
64
|
+
"1y": 370,
|
|
65
|
+
"2y": 740,
|
|
66
|
+
"3y": 1100,
|
|
67
|
+
"5y": 1830,
|
|
68
|
+
"max": 7300,
|
|
69
|
+
}.get((period or "1y").lower(), 370)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fetch_akshare_history_frame(symbol: str, period: str):
|
|
73
|
+
"""Fetch A-share history via akshare; returns (frame, currency, error)."""
|
|
74
|
+
code = _ashare_plain_symbol(symbol)
|
|
75
|
+
if not code:
|
|
76
|
+
return None, None, "not an A-share symbol"
|
|
77
|
+
try:
|
|
78
|
+
import pandas as _pd
|
|
79
|
+
import akshare as _ak
|
|
80
|
+
from datetime import datetime as _dt, timedelta as _td
|
|
81
|
+
|
|
82
|
+
end_date = _dt.now().strftime("%Y%m%d")
|
|
83
|
+
start_date = (_dt.now() - _td(days=_days_for_period(period))).strftime("%Y%m%d")
|
|
84
|
+
frame = _ak.stock_zh_a_hist(
|
|
85
|
+
symbol=code,
|
|
86
|
+
period="daily",
|
|
87
|
+
start_date=start_date,
|
|
88
|
+
end_date=end_date,
|
|
89
|
+
adjust="qfq",
|
|
90
|
+
)
|
|
91
|
+
if frame is None or frame.empty:
|
|
92
|
+
return None, None, "empty akshare result"
|
|
93
|
+
frame = frame.rename(columns={
|
|
94
|
+
"日期": "Date",
|
|
95
|
+
"开盘": "Open",
|
|
96
|
+
"最高": "High",
|
|
97
|
+
"最低": "Low",
|
|
98
|
+
"收盘": "Close",
|
|
99
|
+
"成交量": "Volume",
|
|
100
|
+
"成交额": "Turnover",
|
|
101
|
+
"振幅": "Amplitude",
|
|
102
|
+
"涨跌幅": "ChangePct",
|
|
103
|
+
"涨跌额": "Change",
|
|
104
|
+
"换手率": "TurnoverRate",
|
|
105
|
+
})
|
|
106
|
+
if "Date" in frame.columns:
|
|
107
|
+
frame["Date"] = _pd.to_datetime(frame["Date"])
|
|
108
|
+
frame = frame.set_index("Date")
|
|
109
|
+
frame = frame.sort_index()
|
|
110
|
+
for col in ("Open", "High", "Low", "Close", "Volume"):
|
|
111
|
+
if col in frame.columns:
|
|
112
|
+
frame[col] = _pd.to_numeric(frame[col], errors="coerce")
|
|
113
|
+
frame = _normalise_history_frame(frame)
|
|
114
|
+
return frame, "CNY", "" if frame is not None else "akshare frame missing Close"
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
return None, None, str(exc)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fetch_mdc_history_frame(symbol: str, period: str, interval: str = "1d"):
|
|
120
|
+
"""Fetch history through the unified MarketDataClient provider chain."""
|
|
121
|
+
code = _ashare_plain_symbol(symbol)
|
|
122
|
+
if not code:
|
|
123
|
+
return None, None, "", "not an A-share symbol"
|
|
124
|
+
try:
|
|
125
|
+
import pandas as _pd
|
|
126
|
+
from market_data_client import get_mdc
|
|
127
|
+
|
|
128
|
+
result = get_mdc().history(code, days=_days_for_period(period), interval=interval)
|
|
129
|
+
if not result or not result.get("success"):
|
|
130
|
+
return None, None, "", str((result or {}).get("error") or "empty MarketDataClient result")
|
|
131
|
+
records = result.get("data") or []
|
|
132
|
+
if not records:
|
|
133
|
+
return None, None, "", "MarketDataClient returned no bars"
|
|
134
|
+
frame = _pd.DataFrame(records).rename(columns={
|
|
135
|
+
"date": "Date",
|
|
136
|
+
"open": "Open",
|
|
137
|
+
"high": "High",
|
|
138
|
+
"low": "Low",
|
|
139
|
+
"close": "Close",
|
|
140
|
+
"volume": "Volume",
|
|
141
|
+
})
|
|
142
|
+
if "Date" in frame.columns:
|
|
143
|
+
frame["Date"] = _pd.to_datetime(frame["Date"])
|
|
144
|
+
frame = frame.set_index("Date")
|
|
145
|
+
for col in ("Open", "High", "Low", "Close", "Volume"):
|
|
146
|
+
if col in frame.columns:
|
|
147
|
+
frame[col] = _pd.to_numeric(frame[col], errors="coerce")
|
|
148
|
+
frame = _normalise_history_frame(frame)
|
|
149
|
+
provider_chain = result.get("provider_chain") or [result.get("provider", "market_data_client")]
|
|
150
|
+
provider = " → ".join(str(item) for item in provider_chain if item) or "market_data_client"
|
|
151
|
+
return frame, "CNY", provider, "" if frame is not None else "MarketDataClient frame missing Close"
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
return None, None, "", str(exc)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _fetch_yahoo_chart_frame(symbol: str, period: str, interval: str = "1d"):
|
|
157
|
+
"""Fetch Yahoo chart API data without yfinance; returns (frame, currency, error)."""
|
|
158
|
+
try:
|
|
159
|
+
import pandas as _pd
|
|
160
|
+
import requests as _req
|
|
161
|
+
p2 = int(time.time())
|
|
162
|
+
days_by_period = {
|
|
163
|
+
"1mo": 35, "3mo": 100, "6mo": 185, "ytd": 370,
|
|
164
|
+
"1y": 370, "2y": 740, "3y": 1100, "5y": 1830, "max": 7300,
|
|
165
|
+
}
|
|
166
|
+
p1 = p2 - days_by_period.get(period, 370) * 86400
|
|
167
|
+
url = (
|
|
168
|
+
f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
|
169
|
+
f"?period1={p1}&period2={p2}&interval={interval}&events=history"
|
|
170
|
+
f"&includeAdjustedClose=true"
|
|
171
|
+
)
|
|
172
|
+
resp = _req.get(url, timeout=20, headers={"User-Agent": "Mozilla/5.0"})
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
result = (resp.json().get("chart", {}).get("result") or [None])[0]
|
|
175
|
+
if not result:
|
|
176
|
+
return None, None, "empty Yahoo Chart result"
|
|
177
|
+
quote = ((result.get("indicators") or {}).get("quote") or [{}])[0]
|
|
178
|
+
timestamps = result.get("timestamp") or []
|
|
179
|
+
frame = _pd.DataFrame({
|
|
180
|
+
"Open": quote.get("open", []),
|
|
181
|
+
"High": quote.get("high", []),
|
|
182
|
+
"Low": quote.get("low", []),
|
|
183
|
+
"Close": quote.get("close", []),
|
|
184
|
+
"Volume": quote.get("volume", []),
|
|
185
|
+
}, index=_pd.to_datetime(timestamps, unit="s"))
|
|
186
|
+
frame = _normalise_history_frame(frame)
|
|
187
|
+
meta = result.get("meta") or {}
|
|
188
|
+
return frame, meta.get("currency"), ""
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
return None, None, str(exc)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fmt_num(value, digits: int = 2, prefix: str = "") -> str:
|
|
194
|
+
try:
|
|
195
|
+
if value is None or (hasattr(value, "__class__") and str(value) == "nan"):
|
|
196
|
+
return "N/A"
|
|
197
|
+
return f"{prefix}{float(value):,.{digits}f}"
|
|
198
|
+
except Exception:
|
|
199
|
+
return "N/A"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _fmt_int(value) -> str:
|
|
203
|
+
try:
|
|
204
|
+
return f"{int(float(value)):,}"
|
|
205
|
+
except Exception:
|
|
206
|
+
return "N/A"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_ta_png_artifact(record, hist, symbol: str, name: str, currency: str,
|
|
210
|
+
is_ashare: bool, ma20, ma60, rsi14, macd_v, macd_s_val) -> tuple[str, str]:
|
|
211
|
+
"""Write a compact PNG TA chart next to the HTML artifact when possible."""
|
|
212
|
+
try:
|
|
213
|
+
import matplotlib
|
|
214
|
+
matplotlib.use("Agg")
|
|
215
|
+
import matplotlib.pyplot as plt
|
|
216
|
+
from matplotlib.patches import Rectangle
|
|
217
|
+
import pandas as _pd
|
|
218
|
+
import math as _math
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
return "", f"PNG 依赖不可用: {exc}"
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
png_path = record.path.with_suffix(".png")
|
|
224
|
+
plot_df = hist.tail(180).copy()
|
|
225
|
+
x = list(range(len(plot_df)))
|
|
226
|
+
inc_color = "#dc2626" if is_ashare else "#16a34a"
|
|
227
|
+
dec_color = "#16a34a" if is_ashare else "#dc2626"
|
|
228
|
+
|
|
229
|
+
fig = plt.figure(figsize=(14, 9), dpi=150)
|
|
230
|
+
gs = fig.add_gridspec(4, 1, height_ratios=[4.2, 1.0, 1.2, 1.2], hspace=0.08)
|
|
231
|
+
ax_price = fig.add_subplot(gs[0])
|
|
232
|
+
ax_vol = fig.add_subplot(gs[1], sharex=ax_price)
|
|
233
|
+
ax_rsi = fig.add_subplot(gs[2], sharex=ax_price)
|
|
234
|
+
ax_macd = fig.add_subplot(gs[3], sharex=ax_price)
|
|
235
|
+
|
|
236
|
+
for i, row in enumerate(plot_df.itertuples()):
|
|
237
|
+
open_v = float(getattr(row, "Open", getattr(row, "Close")))
|
|
238
|
+
high_v = float(getattr(row, "High", getattr(row, "Close")))
|
|
239
|
+
low_v = float(getattr(row, "Low", getattr(row, "Close")))
|
|
240
|
+
close_v = float(getattr(row, "Close"))
|
|
241
|
+
color = inc_color if close_v >= open_v else dec_color
|
|
242
|
+
ax_price.vlines(i, low_v, high_v, color=color, linewidth=0.8)
|
|
243
|
+
body_low = min(open_v, close_v)
|
|
244
|
+
body_h = max(abs(close_v - open_v), max(close_v * 0.001, 0.01))
|
|
245
|
+
ax_price.add_patch(Rectangle((i - 0.32, body_low), 0.64, body_h,
|
|
246
|
+
facecolor=color, edgecolor=color, linewidth=0.6))
|
|
247
|
+
|
|
248
|
+
for col, color, label, lw in (
|
|
249
|
+
("MA20", "#f59e0b", "MA20", 1.2),
|
|
250
|
+
("MA60", "#ef4444", "MA60", 1.2),
|
|
251
|
+
("BB_UP", "#6366f1", "BB upper", 0.8),
|
|
252
|
+
("BB_LO", "#6366f1", "BB lower", 0.8),
|
|
253
|
+
):
|
|
254
|
+
if col in plot_df.columns:
|
|
255
|
+
ax_price.plot(x, plot_df[col].astype(float), color=color, linewidth=lw,
|
|
256
|
+
linestyle="--" if col.startswith("BB_") else "-", label=label)
|
|
257
|
+
|
|
258
|
+
if "Volume" in plot_df.columns:
|
|
259
|
+
closes = plot_df["Close"].astype(float).tolist()
|
|
260
|
+
vols = plot_df["Volume"].fillna(0).astype(float).tolist()
|
|
261
|
+
colors = [inc_color if i == 0 or closes[i] >= closes[i - 1] else dec_color for i in range(len(closes))]
|
|
262
|
+
ax_vol.bar(x, vols, color=colors, width=0.75, alpha=0.65)
|
|
263
|
+
|
|
264
|
+
if "RSI14" in plot_df.columns:
|
|
265
|
+
ax_rsi.plot(x, plot_df["RSI14"].astype(float), color="#8b5cf6", linewidth=1.1)
|
|
266
|
+
ax_rsi.axhline(70, color=dec_color, linestyle=":", linewidth=0.8)
|
|
267
|
+
ax_rsi.axhline(30, color=inc_color, linestyle=":", linewidth=0.8)
|
|
268
|
+
ax_rsi.set_ylim(0, 100)
|
|
269
|
+
|
|
270
|
+
if "MACD_HIS" in plot_df.columns:
|
|
271
|
+
hist_vals = plot_df["MACD_HIS"].astype(float).tolist()
|
|
272
|
+
colors = [inc_color if v >= 0 else dec_color for v in hist_vals]
|
|
273
|
+
ax_macd.bar(x, hist_vals, color=colors, width=0.75, alpha=0.65)
|
|
274
|
+
if "MACD" in plot_df.columns:
|
|
275
|
+
ax_macd.plot(x, plot_df["MACD"].astype(float), color="#2563eb", linewidth=1.0, label="MACD")
|
|
276
|
+
if "MACD_SIG" in plot_df.columns:
|
|
277
|
+
ax_macd.plot(x, plot_df["MACD_SIG"].astype(float), color="#f59e0b", linewidth=1.0, linestyle="--", label="Signal")
|
|
278
|
+
ax_macd.axhline(0, color="#94a3b8", linewidth=0.7)
|
|
279
|
+
|
|
280
|
+
tick_step = max(1, len(plot_df) // 8)
|
|
281
|
+
tick_pos = list(range(0, len(plot_df), tick_step))
|
|
282
|
+
tick_labels = []
|
|
283
|
+
for idx in tick_pos:
|
|
284
|
+
raw = plot_df.index[idx]
|
|
285
|
+
tick_labels.append(raw.strftime("%Y-%m-%d") if hasattr(raw, "strftime") else str(raw)[:10])
|
|
286
|
+
ax_macd.set_xticks(tick_pos)
|
|
287
|
+
ax_macd.set_xticklabels(tick_labels, rotation=25, ha="right", fontsize=8)
|
|
288
|
+
for ax in (ax_price, ax_vol, ax_rsi, ax_macd):
|
|
289
|
+
ax.grid(True, color="#e5e7eb", linewidth=0.6, alpha=0.7)
|
|
290
|
+
ax.spines["top"].set_visible(False)
|
|
291
|
+
ax.spines["right"].set_visible(False)
|
|
292
|
+
ax_price.legend(loc="upper left", fontsize=8, ncols=4)
|
|
293
|
+
ax_price.set_ylabel(currency)
|
|
294
|
+
ax_vol.set_ylabel("Volume")
|
|
295
|
+
ax_rsi.set_ylabel("RSI")
|
|
296
|
+
ax_macd.set_ylabel("MACD")
|
|
297
|
+
title = f"{name} ({symbol}) TA"
|
|
298
|
+
subtitle = f"MA20={ma20:.2f}" if ma20 else "MA20=—"
|
|
299
|
+
if ma60:
|
|
300
|
+
subtitle += f" MA60={ma60:.2f}"
|
|
301
|
+
if rsi14:
|
|
302
|
+
subtitle += f" RSI={rsi14:.1f}"
|
|
303
|
+
if macd_v is not None and macd_s_val is not None:
|
|
304
|
+
subtitle += f" MACD={macd_v:.3f}/{macd_s_val:.3f}"
|
|
305
|
+
fig.suptitle(f"{title}\n{subtitle}", fontsize=12)
|
|
306
|
+
fig.savefig(png_path, bbox_inches="tight", facecolor="white")
|
|
307
|
+
plt.close(fig)
|
|
308
|
+
if not png_path.exists() or png_path.stat().st_size <= 0:
|
|
309
|
+
return "", "PNG 文件未生成"
|
|
310
|
+
return str(png_path), ""
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
try:
|
|
313
|
+
plt.close("all")
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
return "", str(exc)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _review_chart(symbol: str, last_close: float, high_52w: float, low_52w: float,
|
|
320
|
+
rsi14, ma20, ma60, bb_up, bb_lo, sup3, res3, n_bars: int) -> list[str]:
|
|
321
|
+
"""
|
|
322
|
+
自审函数:检查图表数据质量,返回问题列表(空列表 = 通过)。
|
|
323
|
+
在 cmd_chart 中调用,用于发现并反馈图表异常。
|
|
324
|
+
"""
|
|
325
|
+
issues = []
|
|
326
|
+
if last_close <= 0:
|
|
327
|
+
issues.append("价格数据异常(收盘价 ≤ 0)")
|
|
328
|
+
if n_bars < 20:
|
|
329
|
+
issues.append(f"历史数据不足 20 根 K 线(仅 {n_bars} 根),指标不可靠")
|
|
330
|
+
if rsi14 is None:
|
|
331
|
+
issues.append("RSI 计算失败(数据可能不足 14 根)")
|
|
332
|
+
elif not (0 < rsi14 < 100):
|
|
333
|
+
issues.append(f"RSI 值异常: {rsi14:.1f}(应在 0-100 之间)")
|
|
334
|
+
if ma20 and last_close > 0:
|
|
335
|
+
if abs(ma20 / last_close - 1) > 0.5:
|
|
336
|
+
issues.append(f"MA20 偏离价格超 50%,数据可能存在复权误差(MA20={ma20:.2f} 价格={last_close:.2f})")
|
|
337
|
+
if bb_up and bb_lo and bb_up <= bb_lo:
|
|
338
|
+
issues.append("布林带上下轨计算倒置(BB_UP <= BB_LO)")
|
|
339
|
+
if sup3 and min(sup3) >= last_close:
|
|
340
|
+
issues.append("支撑位计算有误(支撑位不应高于现价)")
|
|
341
|
+
if res3 and max(res3) <= last_close:
|
|
342
|
+
issues.append("阻力位计算有误(阻力位不应低于现价)")
|
|
343
|
+
price_range_pct = (high_52w - low_52w) / low_52w * 100 if low_52w > 0 else 0
|
|
344
|
+
if price_range_pct > 1000:
|
|
345
|
+
issues.append(f"52周价格波动超 1000%({price_range_pct:.0f}%),可能存在股票分拆/复权问题")
|
|
346
|
+
return issues
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def handle_multi_stock_comparison_direct(symbols: list[str], period: str = "1y") -> dict:
|
|
350
|
+
"""Generate a normalized multi-symbol performance comparison chart."""
|
|
351
|
+
try:
|
|
352
|
+
import pandas as _pd
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
return {"success": False, "error": f"缺少依赖: {exc}"}
|
|
355
|
+
|
|
356
|
+
_PERIOD_MAP = {
|
|
357
|
+
"1m": "1mo", "3m": "3mo", "6m": "6mo",
|
|
358
|
+
"1y": "1y", "2y": "2y", "3y": "3y", "5y": "5y",
|
|
359
|
+
"ytd": "ytd", "max": "max",
|
|
360
|
+
}
|
|
361
|
+
period = _PERIOD_MAP.get(str(period or "1y").lower(), str(period or "1y").lower())
|
|
362
|
+
clean_symbols: list[str] = []
|
|
363
|
+
for sym in symbols or []:
|
|
364
|
+
normalized = _normalise_chart_symbol(str(sym or "").strip().upper())
|
|
365
|
+
if normalized and normalized not in clean_symbols:
|
|
366
|
+
clean_symbols.append(normalized)
|
|
367
|
+
if len(clean_symbols) < 2:
|
|
368
|
+
return {"success": False, "error": "至少需要两个标的才能生成对比图"}
|
|
369
|
+
|
|
370
|
+
series: dict[str, _pd.Series] = {}
|
|
371
|
+
raw_rows: dict[str, list[dict]] = {}
|
|
372
|
+
providers: dict[str, str] = {}
|
|
373
|
+
errors: dict[str, str] = {}
|
|
374
|
+
|
|
375
|
+
for symbol in clean_symbols:
|
|
376
|
+
hist = None
|
|
377
|
+
currency = None
|
|
378
|
+
provider = ""
|
|
379
|
+
err = ""
|
|
380
|
+
if _ashare_plain_symbol(symbol):
|
|
381
|
+
hist, currency, provider, err = _fetch_mdc_history_frame(symbol, period, "1d")
|
|
382
|
+
if hist is None or hist.empty:
|
|
383
|
+
hist, currency, err = _fetch_yahoo_chart_frame(symbol, period, "1d")
|
|
384
|
+
provider = "Yahoo Chart API" if hist is not None and not hist.empty else provider
|
|
385
|
+
hist = _normalise_history_frame(hist)
|
|
386
|
+
if hist is None or hist.empty or "Close" not in hist.columns:
|
|
387
|
+
errors[symbol] = err or "empty history"
|
|
388
|
+
continue
|
|
389
|
+
closes = _pd.to_numeric(hist["Close"], errors="coerce").dropna()
|
|
390
|
+
if len(closes) < 5:
|
|
391
|
+
errors[symbol] = "not enough close prices"
|
|
392
|
+
continue
|
|
393
|
+
series[symbol] = closes
|
|
394
|
+
providers[symbol] = provider or "market data"
|
|
395
|
+
rows = []
|
|
396
|
+
for idx, row in hist.tail(370).iterrows():
|
|
397
|
+
rows.append({
|
|
398
|
+
"date": idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx),
|
|
399
|
+
"open": None if _pd.isna(row.get("Open")) else float(row.get("Open")),
|
|
400
|
+
"high": None if _pd.isna(row.get("High")) else float(row.get("High")),
|
|
401
|
+
"low": None if _pd.isna(row.get("Low")) else float(row.get("Low")),
|
|
402
|
+
"close": None if _pd.isna(row.get("Close")) else float(row.get("Close")),
|
|
403
|
+
"volume": None if _pd.isna(row.get("Volume")) else float(row.get("Volume")),
|
|
404
|
+
"currency": currency,
|
|
405
|
+
})
|
|
406
|
+
raw_rows[symbol] = rows
|
|
407
|
+
|
|
408
|
+
if len(series) < 2:
|
|
409
|
+
return {
|
|
410
|
+
"success": False,
|
|
411
|
+
"error": "可用历史数据不足,无法生成对比图",
|
|
412
|
+
"errors": errors,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
closes_df = _pd.concat(series, axis=1).dropna(how="all").ffill().dropna()
|
|
416
|
+
normalized_df = closes_df.divide(closes_df.iloc[0]).multiply(100.0)
|
|
417
|
+
returns_df = closes_df.pct_change().dropna()
|
|
418
|
+
|
|
419
|
+
metrics = []
|
|
420
|
+
for symbol in normalized_df.columns:
|
|
421
|
+
norm = normalized_df[symbol].dropna()
|
|
422
|
+
rets = returns_df[symbol].dropna() if symbol in returns_df else _pd.Series(dtype=float)
|
|
423
|
+
drawdown = norm / norm.cummax() - 1.0
|
|
424
|
+
metrics.append({
|
|
425
|
+
"symbol": symbol,
|
|
426
|
+
"total_return_pct": round(float(norm.iloc[-1] - 100.0), 2),
|
|
427
|
+
"volatility_pct": round(float(rets.std() * (252 ** 0.5) * 100), 2) if len(rets) else 0.0,
|
|
428
|
+
"max_drawdown_pct": round(float(drawdown.min() * 100), 2) if len(drawdown) else 0.0,
|
|
429
|
+
"last_close": round(float(closes_df[symbol].dropna().iloc[-1]), 4),
|
|
430
|
+
})
|
|
431
|
+
metrics.sort(key=lambda row: row["total_return_pct"], reverse=True)
|
|
432
|
+
|
|
433
|
+
dates = [idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx) for idx in normalized_df.index]
|
|
434
|
+
traces = []
|
|
435
|
+
for symbol in normalized_df.columns:
|
|
436
|
+
traces.append({
|
|
437
|
+
"x": dates,
|
|
438
|
+
"y": [None if _pd.isna(v) else round(float(v), 4) for v in normalized_df[symbol].tolist()],
|
|
439
|
+
"type": "scatter",
|
|
440
|
+
"mode": "lines",
|
|
441
|
+
"name": symbol,
|
|
442
|
+
"line": {"width": 2},
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", "_".join(normalized_df.columns))
|
|
446
|
+
from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
|
|
447
|
+
artifact = create_user_artifact("stock-charts", safe, f"{safe}_comparison", ".html")
|
|
448
|
+
out_file = artifact.path
|
|
449
|
+
metrics_rows = "".join(
|
|
450
|
+
"<tr>"
|
|
451
|
+
f"<td>{m['symbol']}</td>"
|
|
452
|
+
f"<td>{m['total_return_pct']:+.2f}%</td>"
|
|
453
|
+
f"<td>{m['volatility_pct']:.2f}%</td>"
|
|
454
|
+
f"<td>{m['max_drawdown_pct']:.2f}%</td>"
|
|
455
|
+
f"<td>{m['last_close']}</td>"
|
|
456
|
+
"</tr>"
|
|
457
|
+
for m in metrics
|
|
458
|
+
)
|
|
459
|
+
html_doc = f"""<!doctype html>
|
|
460
|
+
<html lang="zh-CN">
|
|
461
|
+
<head>
|
|
462
|
+
<meta charset="utf-8">
|
|
463
|
+
<title>{' vs '.join(normalized_df.columns)} comparison</title>
|
|
464
|
+
{plotly_script_tag()}
|
|
465
|
+
<style>
|
|
466
|
+
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;color:#111827}}
|
|
467
|
+
h1{{font-size:20px;margin:0 0 8px}}
|
|
468
|
+
.meta{{color:#6b7280;margin-bottom:18px}}
|
|
469
|
+
#chart{{height:620px}}
|
|
470
|
+
table{{border-collapse:collapse;margin-top:18px;font-size:13px}}
|
|
471
|
+
th,td{{border-bottom:1px solid #e5e7eb;padding:8px 12px;text-align:right}}
|
|
472
|
+
th:first-child,td:first-child{{text-align:left}}
|
|
473
|
+
</style>
|
|
474
|
+
</head>
|
|
475
|
+
<body>
|
|
476
|
+
<h1>{' / '.join(normalized_df.columns)} 标准化收益对比</h1>
|
|
477
|
+
<div class="meta">Period: {period} · base=100 · providers: {', '.join(sorted(set(providers.values())))}</div>
|
|
478
|
+
<div id="chart"></div>
|
|
479
|
+
<table>
|
|
480
|
+
<thead><tr><th>Symbol</th><th>Total Return</th><th>Ann. Vol</th><th>Max Drawdown</th><th>Last Close</th></tr></thead>
|
|
481
|
+
<tbody>{metrics_rows}</tbody>
|
|
482
|
+
</table>
|
|
483
|
+
<script>
|
|
484
|
+
const traces = {json.dumps(traces, ensure_ascii=False)};
|
|
485
|
+
Plotly.newPlot("chart", traces, {{
|
|
486
|
+
margin: {{l: 48, r: 24, t: 18, b: 42}},
|
|
487
|
+
hovermode: "x unified",
|
|
488
|
+
yaxis: {{title: "Normalized value (base=100)", gridcolor: "#eef2f7"}},
|
|
489
|
+
xaxis: {{type: "date", gridcolor: "#eef2f7"}},
|
|
490
|
+
legend: {{orientation: "h", y: 1.08}},
|
|
491
|
+
paper_bgcolor: "#fff",
|
|
492
|
+
plot_bgcolor: "#fff"
|
|
493
|
+
}}, {{responsive: true, displaylogo: false}});
|
|
494
|
+
</script>
|
|
495
|
+
</body>
|
|
496
|
+
</html>"""
|
|
497
|
+
out_file.write_text(html_doc, encoding="utf-8")
|
|
498
|
+
write_artifact_metadata(artifact, {
|
|
499
|
+
"kind": "stock_comparison_chart",
|
|
500
|
+
"status": "complete",
|
|
501
|
+
"symbols": list(normalized_df.columns),
|
|
502
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
503
|
+
"data": {"period": period, "provider_chain": sorted(set(providers.values())), "errors": errors},
|
|
504
|
+
"metrics": metrics,
|
|
505
|
+
"outputs": {"html": str(out_file)},
|
|
506
|
+
})
|
|
507
|
+
write_artifact_raw_data(artifact, {
|
|
508
|
+
"symbols": list(normalized_df.columns),
|
|
509
|
+
"prices": raw_rows,
|
|
510
|
+
"normalized": {
|
|
511
|
+
symbol: [
|
|
512
|
+
{"date": date, "value": value}
|
|
513
|
+
for date, value in zip(dates, traces[idx]["y"])
|
|
514
|
+
]
|
|
515
|
+
for idx, symbol in enumerate(normalized_df.columns)
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
return {
|
|
519
|
+
"success": True,
|
|
520
|
+
"chart_path": str(out_file),
|
|
521
|
+
"symbols": list(normalized_df.columns),
|
|
522
|
+
"metrics": metrics,
|
|
523
|
+
"provider": ", ".join(sorted(set(providers.values()))),
|
|
524
|
+
"errors": errors,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def handle_stock_chart_analysis_direct(symbol: str, period: str = "1y") -> dict:
|
|
529
|
+
"""
|
|
530
|
+
生成专业股票分析图表 (HTML),并自审数据质量。
|
|
531
|
+
四面板:K线+均线+布林带 / 成交量 / RSI(14) / MACD柱状图
|
|
532
|
+
A股(.SS/.SZ)自动切换红涨绿跌配色。
|
|
533
|
+
"""
|
|
534
|
+
import html as _html
|
|
535
|
+
import math
|
|
536
|
+
try:
|
|
537
|
+
import pandas as _pd
|
|
538
|
+
except Exception as exc:
|
|
539
|
+
return {"success": False, "error": f"缺少依赖: {exc}"}
|
|
540
|
+
try:
|
|
541
|
+
import yfinance as _yf
|
|
542
|
+
except Exception:
|
|
543
|
+
_yf = None
|
|
544
|
+
|
|
545
|
+
_PERIOD_MAP = {
|
|
546
|
+
"1m": "1mo", "3m": "3mo", "6m": "6mo",
|
|
547
|
+
"1y": "1y", "2y": "2y", "3y": "3y", "5y": "5y",
|
|
548
|
+
"ytd": "ytd", "max": "max",
|
|
549
|
+
}
|
|
550
|
+
period = _PERIOD_MAP.get(period.lower(), period)
|
|
551
|
+
symbol = _normalise_chart_symbol(symbol)
|
|
552
|
+
|
|
553
|
+
# A股判断(影响K线颜色惯例)
|
|
554
|
+
is_ashare = bool(_ashare_plain_symbol(symbol))
|
|
555
|
+
|
|
556
|
+
# ── 获取历史数据 ────────────────────────────────────────────────────────────
|
|
557
|
+
hist = None
|
|
558
|
+
provider = "Yahoo Finance"
|
|
559
|
+
provider_currency = None
|
|
560
|
+
err1 = ""
|
|
561
|
+
mdc_err = ""
|
|
562
|
+
ak_err = ""
|
|
563
|
+
chart_err = ""
|
|
564
|
+
ticker = None
|
|
565
|
+
|
|
566
|
+
if is_ashare:
|
|
567
|
+
hist, provider_currency, mdc_provider, mdc_err = _fetch_mdc_history_frame(symbol, period, "1d")
|
|
568
|
+
if hist is not None and not hist.empty:
|
|
569
|
+
provider = mdc_provider or "market_data_client"
|
|
570
|
+
|
|
571
|
+
if is_ashare and (hist is None or hist.empty):
|
|
572
|
+
hist, provider_currency, ak_err = _fetch_akshare_history_frame(symbol, period)
|
|
573
|
+
if hist is not None and not hist.empty:
|
|
574
|
+
provider = "akshare"
|
|
575
|
+
|
|
576
|
+
if (hist is None or hist.empty) and _yf is not None:
|
|
577
|
+
try:
|
|
578
|
+
ticker = _yf.Ticker(symbol)
|
|
579
|
+
hist = ticker.history(period=period, interval="1d", auto_adjust=True)
|
|
580
|
+
hist = _normalise_history_frame(hist)
|
|
581
|
+
if hist is not None and not hist.empty:
|
|
582
|
+
provider = "Yahoo Finance"
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
err1 = str(exc)
|
|
585
|
+
|
|
586
|
+
if hist is None or hist.empty:
|
|
587
|
+
hist, provider_currency, chart_err = _fetch_yahoo_chart_frame(symbol, period, "1d")
|
|
588
|
+
if hist is None or hist.empty:
|
|
589
|
+
return {
|
|
590
|
+
"success": False,
|
|
591
|
+
"error": (
|
|
592
|
+
f"无法获取 {symbol} 数据: "
|
|
593
|
+
f"MarketDataClient={mdc_err or 'skipped'}; "
|
|
594
|
+
f"akshare={ak_err or 'skipped'}; "
|
|
595
|
+
f"yfinance={err1 or ('missing' if _yf is None else 'empty')}; "
|
|
596
|
+
f"YahooChart={chart_err or 'empty'}"
|
|
597
|
+
),
|
|
598
|
+
}
|
|
599
|
+
provider = "Yahoo Chart API"
|
|
600
|
+
|
|
601
|
+
if hist is None or hist.empty:
|
|
602
|
+
return {"success": False, "error": f"无法获取 {symbol} 历史数据"}
|
|
603
|
+
|
|
604
|
+
hist = hist.dropna(subset=["Close"]).copy()
|
|
605
|
+
|
|
606
|
+
# ── 指标计算 ────────────────────────────────────────────────────────────────
|
|
607
|
+
hist["MA20"] = hist["Close"].rolling(20).mean()
|
|
608
|
+
hist["MA60"] = hist["Close"].rolling(60).mean()
|
|
609
|
+
_std20 = hist["Close"].rolling(20).std()
|
|
610
|
+
hist["BB_UP"] = hist["MA20"] + 2 * _std20
|
|
611
|
+
hist["BB_LO"] = hist["MA20"] - 2 * _std20
|
|
612
|
+
_delta = hist["Close"].diff()
|
|
613
|
+
_gain = _delta.clip(lower=0).rolling(14).mean()
|
|
614
|
+
_loss = (-_delta.clip(upper=0)).rolling(14).mean()
|
|
615
|
+
hist["RSI14"] = 100 - (100 / (1 + _gain / _loss.replace(0, _pd.NA)))
|
|
616
|
+
_ema12 = hist["Close"].ewm(span=12, adjust=False).mean()
|
|
617
|
+
_ema26 = hist["Close"].ewm(span=26, adjust=False).mean()
|
|
618
|
+
hist["MACD"] = _ema12 - _ema26
|
|
619
|
+
hist["MACD_SIG"]= hist["MACD"].ewm(span=9, adjust=False).mean()
|
|
620
|
+
hist["MACD_HIS"]= hist["MACD"] - hist["MACD_SIG"]
|
|
621
|
+
|
|
622
|
+
last = hist.iloc[-1]
|
|
623
|
+
last_close = float(last["Close"])
|
|
624
|
+
high_52w = float(hist["High"].max()) if "High" in hist.columns else float(hist["Close"].max())
|
|
625
|
+
low_52w = float(hist["Low"].min()) if "Low" in hist.columns else float(hist["Close"].min())
|
|
626
|
+
ma20 = float(last["MA20"]) if _pd.notna(last["MA20"]) else None
|
|
627
|
+
ma60 = float(last["MA60"]) if _pd.notna(last["MA60"]) else None
|
|
628
|
+
bb_up = float(last["BB_UP"]) if _pd.notna(last["BB_UP"]) else None
|
|
629
|
+
bb_lo = float(last["BB_LO"]) if _pd.notna(last["BB_LO"]) else None
|
|
630
|
+
rsi14 = float(last["RSI14"]) if _pd.notna(last["RSI14"]) else None
|
|
631
|
+
macd_v = float(last["MACD"]) if _pd.notna(last["MACD"]) else None
|
|
632
|
+
macd_s_val = float(last["MACD_SIG"]) if _pd.notna(last["MACD_SIG"]) else None
|
|
633
|
+
|
|
634
|
+
# ── 支撑/阻力(10棒摆动点,去重后取最近3个)──────────────────────────────
|
|
635
|
+
_sup_lvls: list[float] = []
|
|
636
|
+
_res_lvls: list[float] = []
|
|
637
|
+
if "High" in hist.columns and "Low" in hist.columns and len(hist) >= 20:
|
|
638
|
+
_win = 10 # 10棒窗口过滤噪音,比5棒更稳健
|
|
639
|
+
_h = hist["High"].values
|
|
640
|
+
_l = hist["Low"].values
|
|
641
|
+
for i in range(_win, len(hist) - _win):
|
|
642
|
+
if float(_h[i]) == float(max(_h[i - _win:i + _win + 1])):
|
|
643
|
+
_res_lvls.append(float(_h[i]))
|
|
644
|
+
if float(_l[i]) == float(min(_l[i - _win:i + _win + 1])):
|
|
645
|
+
_sup_lvls.append(float(_l[i]))
|
|
646
|
+
# MA 作为动态支撑/阻力
|
|
647
|
+
if ma20:
|
|
648
|
+
(_sup_lvls if last_close > ma20 else _res_lvls).append(ma20)
|
|
649
|
+
if ma60:
|
|
650
|
+
(_sup_lvls if last_close > ma60 else _res_lvls).append(ma60)
|
|
651
|
+
# 布林带
|
|
652
|
+
if bb_lo:
|
|
653
|
+
_sup_lvls.append(bb_lo)
|
|
654
|
+
if bb_up:
|
|
655
|
+
_res_lvls.append(bb_up)
|
|
656
|
+
sup3 = sorted(set(round(v, 2) for v in _sup_lvls if v < last_close), reverse=True)[:3]
|
|
657
|
+
res3 = sorted(set(round(v, 2) for v in _res_lvls if v > last_close))[:3]
|
|
658
|
+
|
|
659
|
+
# ── 基本面 ──────────────────────────────────────────────────────────────────
|
|
660
|
+
info = {}
|
|
661
|
+
if ticker is None and _yf is not None:
|
|
662
|
+
try:
|
|
663
|
+
ticker = _yf.Ticker(symbol)
|
|
664
|
+
except Exception:
|
|
665
|
+
ticker = None
|
|
666
|
+
try:
|
|
667
|
+
info = ticker.get_info() or {} if ticker is not None else {}
|
|
668
|
+
except Exception:
|
|
669
|
+
pass
|
|
670
|
+
name = info.get("longName") or info.get("shortName") or symbol
|
|
671
|
+
currency = info.get("currency") or provider_currency or ("CNY" if is_ashare else "USD")
|
|
672
|
+
pe = info.get("trailingPE")
|
|
673
|
+
pb = info.get("priceToBook")
|
|
674
|
+
roe = info.get("returnOnEquity")
|
|
675
|
+
div_yield = info.get("trailingAnnualDividendYield") or info.get("dividendYield")
|
|
676
|
+
market_cap = info.get("marketCap")
|
|
677
|
+
|
|
678
|
+
def _fv(v, mult=1.0, pct=False):
|
|
679
|
+
if v is None or (isinstance(v, float) and (math.isnan(v) or v == 0)):
|
|
680
|
+
return "—"
|
|
681
|
+
x = float(v) * mult
|
|
682
|
+
return f"{x:.2f}%" if pct else f"{x:,.2f}"
|
|
683
|
+
|
|
684
|
+
def _mcap(v):
|
|
685
|
+
if not v:
|
|
686
|
+
return "—"
|
|
687
|
+
if v >= 1e12: return f"{v/1e12:.2f}T"
|
|
688
|
+
if v >= 1e9: return f"{v/1e9:.1f}B"
|
|
689
|
+
if v >= 1e8: return f"{v/1e8:.1f}亿"
|
|
690
|
+
return f"{v:,.0f}"
|
|
691
|
+
|
|
692
|
+
trend = ("偏多" if ma20 and ma60 and last_close > ma20 > ma60 else
|
|
693
|
+
"偏空" if ma20 and ma60 and last_close < ma20 < ma60 else "震荡")
|
|
694
|
+
rsi_view = ("超买" if rsi14 and rsi14 >= 70 else "超卖" if rsi14 and rsi14 <= 30 else "中性")
|
|
695
|
+
momentum = "MACD↑多" if macd_v and macd_s_val and macd_v > macd_s_val else "MACD↓弱"
|
|
696
|
+
|
|
697
|
+
# ── K线颜色惯例 ────────────────────────────────────────────────────────────
|
|
698
|
+
# 中国A股:红涨绿跌 | 美股/港股/加密:绿涨红跌
|
|
699
|
+
if is_ashare:
|
|
700
|
+
inc_color = "#dc2626" # 红 = 涨
|
|
701
|
+
dec_color = "#16a34a" # 绿 = 跌
|
|
702
|
+
vol_up_c = "rgba(220,38,38,0.75)"
|
|
703
|
+
vol_dn_c = "rgba(22,163,74,0.75)"
|
|
704
|
+
macd_pos = "rgba(220,38,38,0.75)"
|
|
705
|
+
macd_neg = "rgba(22,163,74,0.75)"
|
|
706
|
+
else:
|
|
707
|
+
inc_color = "#16a34a" # 绿 = 涨
|
|
708
|
+
dec_color = "#dc2626" # 红 = 跌
|
|
709
|
+
vol_up_c = "rgba(22,163,74,0.75)"
|
|
710
|
+
vol_dn_c = "rgba(220,38,38,0.75)"
|
|
711
|
+
macd_pos = "rgba(22,163,74,0.75)"
|
|
712
|
+
macd_neg = "rgba(220,38,38,0.75)"
|
|
713
|
+
|
|
714
|
+
# ── 序列化 ──────────────────────────────────────────────────────────────────
|
|
715
|
+
def _ser(col):
|
|
716
|
+
values = []
|
|
717
|
+
for v in hist[col]:
|
|
718
|
+
try:
|
|
719
|
+
if v is None:
|
|
720
|
+
values.append(None)
|
|
721
|
+
continue
|
|
722
|
+
if hasattr(v, "__class__") and str(v) in {"<NA>", "NaT"}:
|
|
723
|
+
values.append(None)
|
|
724
|
+
continue
|
|
725
|
+
fv = float(v)
|
|
726
|
+
if math.isnan(fv):
|
|
727
|
+
values.append(None)
|
|
728
|
+
else:
|
|
729
|
+
values.append(round(fv, 4))
|
|
730
|
+
except Exception:
|
|
731
|
+
values.append(None)
|
|
732
|
+
return json.dumps(values)
|
|
733
|
+
|
|
734
|
+
def _ser_int(col):
|
|
735
|
+
if col not in hist.columns:
|
|
736
|
+
return "[]"
|
|
737
|
+
return json.dumps([None if (v is None or (isinstance(v, float) and math.isnan(v)))
|
|
738
|
+
else int(float(v)) for v in hist[col]])
|
|
739
|
+
|
|
740
|
+
x_dates = json.dumps([idx.strftime("%Y-%m-%d") for idx in hist.index])
|
|
741
|
+
open_s = _ser("Open") if "Open" in hist.columns else _ser("Close")
|
|
742
|
+
high_s = _ser("High") if "High" in hist.columns else _ser("Close")
|
|
743
|
+
low_s = _ser("Low") if "Low" in hist.columns else _ser("Close")
|
|
744
|
+
close_s = _ser("Close")
|
|
745
|
+
vol_s = _ser_int("Volume")
|
|
746
|
+
ma20_s = _ser("MA20")
|
|
747
|
+
ma60_s = _ser("MA60")
|
|
748
|
+
bbup_s = _ser("BB_UP")
|
|
749
|
+
bblo_s = _ser("BB_LO")
|
|
750
|
+
rsi_s = _ser("RSI14")
|
|
751
|
+
macd_s2 = _ser("MACD")
|
|
752
|
+
macds_s = _ser("MACD_SIG")
|
|
753
|
+
macdh_s = _ser("MACD_HIS")
|
|
754
|
+
|
|
755
|
+
# 成交量/MACD颜色(JSON 串)
|
|
756
|
+
closes = hist["Close"].values
|
|
757
|
+
vol_colors = json.dumps([
|
|
758
|
+
vol_up_c if (i > 0 and not math.isnan(float(closes[i])) and
|
|
759
|
+
float(closes[i]) >= float(closes[i-1])) else vol_dn_c
|
|
760
|
+
for i in range(len(closes))
|
|
761
|
+
])
|
|
762
|
+
macd_colors = json.dumps([
|
|
763
|
+
macd_pos if (v is not None and not math.isnan(float(v)) and float(v) >= 0) else macd_neg
|
|
764
|
+
for v in hist["MACD_HIS"].values
|
|
765
|
+
])
|
|
766
|
+
|
|
767
|
+
# 支撑/阻力 shapes(只绘制在价格面板 y轴内)
|
|
768
|
+
sup_shapes = "".join(
|
|
769
|
+
f'{{type:"line",xref:"paper",x0:0,x1:1,yref:"y",y0:{v},y1:{v},'
|
|
770
|
+
f'line:{{color:"#22c55e",width:1.2,dash:"dot"}}}},'
|
|
771
|
+
for v in sup3
|
|
772
|
+
)
|
|
773
|
+
res_shapes = "".join(
|
|
774
|
+
f'{{type:"line",xref:"paper",x0:0,x1:1,yref:"y",y0:{v},y1:{v},'
|
|
775
|
+
f'line:{{color:"#f97316",width:1.2,dash:"dot"}}}},'
|
|
776
|
+
for v in res3
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# ── 自审 ────────────────────────────────────────────────────────────────────
|
|
780
|
+
review_issues = _review_chart(
|
|
781
|
+
symbol, last_close, high_52w, low_52w,
|
|
782
|
+
rsi14, ma20, ma60, bb_up, bb_lo, sup3, res3, len(hist)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# ── 生成 HTML ────────────────────────────────────────────────────────────────
|
|
786
|
+
safe_sym = re.sub(r"[^A-Za-z0-9_.-]+", "_", symbol)
|
|
787
|
+
from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
|
|
788
|
+
_artifact = create_user_artifact("stock-charts", symbol, f"{safe_sym}_chart", ".html")
|
|
789
|
+
out_file = _artifact.path
|
|
790
|
+
|
|
791
|
+
# 配色标注
|
|
792
|
+
color_note = "红涨绿跌(A股惯例)" if is_ashare else "绿涨红跌(国际惯例)"
|
|
793
|
+
rsi_color = "red" if rsi14 and rsi14 >= 70 else ("green" if rsi14 and rsi14 <= 30 else "")
|
|
794
|
+
cg = lambda ok: "green" if ok else "red"
|
|
795
|
+
|
|
796
|
+
cards_html = f"""
|
|
797
|
+
<div class="card"><div class="lbl">最新收盘</div><div class="val">{currency} {last_close:,.2f}</div></div>
|
|
798
|
+
<div class="card"><div class="lbl">52周区间</div><div class="val small">{low_52w:,.2f} — {high_52w:,.2f}</div></div>
|
|
799
|
+
<div class="card"><div class="lbl">MA20</div><div class="val {cg(ma20 and last_close>ma20)}">{f'{ma20:,.2f}' if ma20 else '—'}</div></div>
|
|
800
|
+
<div class="card"><div class="lbl">MA60</div><div class="val {cg(ma60 and last_close>ma60)}">{f'{ma60:,.2f}' if ma60 else '—'}</div></div>
|
|
801
|
+
<div class="card"><div class="lbl">布林上/下轨</div><div class="val small">{f'{bb_up:,.2f}' if bb_up else '—'} / {f'{bb_lo:,.2f}' if bb_lo else '—'}</div></div>
|
|
802
|
+
<div class="card"><div class="lbl">RSI(14)</div><div class="val {rsi_color}">{f'{rsi14:.1f}' if rsi14 else '—'} {rsi_view}</div></div>
|
|
803
|
+
<div class="card"><div class="lbl">趋势 / 动能</div><div class="val">{trend} · {momentum}</div></div>
|
|
804
|
+
<div class="card"><div class="lbl">P/E</div><div class="val">{_fv(pe)}</div></div>
|
|
805
|
+
<div class="card"><div class="lbl">P/B</div><div class="val">{_fv(pb)}</div></div>
|
|
806
|
+
<div class="card"><div class="lbl">ROE</div><div class="val">{_fv(roe, 100, pct=True)}</div></div>
|
|
807
|
+
<div class="card"><div class="lbl">股息率</div><div class="val">{_fv(div_yield, 100, pct=True)}</div></div>
|
|
808
|
+
<div class="card"><div class="lbl">市值</div><div class="val">{_mcap(market_cap)}</div></div>"""
|
|
809
|
+
if sup3:
|
|
810
|
+
cards_html += f'\n <div class="card sup"><div class="lbl">支撑位</div><div class="val small">{" / ".join(str(v) for v in sup3)}</div></div>'
|
|
811
|
+
if res3:
|
|
812
|
+
cards_html += f'\n <div class="card res"><div class="lbl">阻力位</div><div class="val small">{" / ".join(str(v) for v in res3)}</div></div>'
|
|
813
|
+
|
|
814
|
+
warn_html = ""
|
|
815
|
+
if review_issues:
|
|
816
|
+
warn_items = "".join(f"<li>{_html.escape(iss)}</li>" for iss in review_issues)
|
|
817
|
+
warn_html = f'<div class="warn"><strong>⚠ 图表自审发现 {len(review_issues)} 个问题:</strong><ul>{warn_items}</ul></div>'
|
|
818
|
+
|
|
819
|
+
html_doc = f"""<!doctype html>
|
|
820
|
+
<html lang="zh-CN">
|
|
821
|
+
<head>
|
|
822
|
+
<meta charset="utf-8">
|
|
823
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
824
|
+
<title>{_html.escape(name)} ({_html.escape(symbol)}) 分析图表</title>
|
|
825
|
+
{plotly_script_tag()}
|
|
826
|
+
<style>
|
|
827
|
+
*{{box-sizing:border-box}}
|
|
828
|
+
body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f0f2f5;color:#17202a}}
|
|
829
|
+
main{{max-width:1320px;margin:0 auto;padding:18px 20px}}
|
|
830
|
+
h1{{margin:0 0 2px;font-size:21px;font-weight:700}}
|
|
831
|
+
.meta{{color:#6b7280;font-size:11.5px;margin-bottom:12px}}
|
|
832
|
+
.cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:7px;margin-bottom:12px}}
|
|
833
|
+
.card{{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:9px 11px}}
|
|
834
|
+
.card.sup{{border-left:3px solid #22c55e}} .card.res{{border-left:3px solid #f97316}}
|
|
835
|
+
.lbl{{color:#6b7280;font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:.3px}}
|
|
836
|
+
.val{{font-size:14px;font-weight:700;margin-top:2px}} .val.small{{font-size:11.5px;font-weight:600}}
|
|
837
|
+
.green{{color:#16a34a}} .red{{color:#dc2626}}
|
|
838
|
+
#chart{{background:#fff;border:1px solid #e5e7eb;border-radius:10px}}
|
|
839
|
+
.footer{{color:#9ca3af;font-size:11px;margin-top:8px;text-align:center}}
|
|
840
|
+
.warn{{background:#fef3c7;border:1px solid #f59e0b;border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:12px;color:#92400e}}
|
|
841
|
+
.warn ul{{margin:4px 0 0 16px;padding:0}}
|
|
842
|
+
</style>
|
|
843
|
+
</head>
|
|
844
|
+
<body>
|
|
845
|
+
<main>
|
|
846
|
+
<h1>{_html.escape(name)} <span style="font-weight:400;color:#6b7280">({_html.escape(symbol)})</span></h1>
|
|
847
|
+
<p class="meta">生成: {datetime.now():%Y-%m-%d %H:%M} · 数据: Yahoo Finance · 周期: {period} · 配色: {color_note} · Aria Code</p>
|
|
848
|
+
{warn_html}<div class="cards">{cards_html}
|
|
849
|
+
</div>
|
|
850
|
+
<div id="chart"></div>
|
|
851
|
+
<p class="footer">⚠ 仅供参考,不构成投资建议。 绿虚线=支撑位 | 橙虚线=阻力位</p>
|
|
852
|
+
</main>
|
|
853
|
+
<script>
|
|
854
|
+
const x = {x_dates};
|
|
855
|
+
const op = {open_s};
|
|
856
|
+
const hi = {high_s};
|
|
857
|
+
const lo = {low_s};
|
|
858
|
+
const cl = {close_s};
|
|
859
|
+
const vol = {vol_s};
|
|
860
|
+
const volClr = {vol_colors};
|
|
861
|
+
const ma20 = {ma20_s};
|
|
862
|
+
const ma60 = {ma60_s};
|
|
863
|
+
const bbUp = {bbup_s};
|
|
864
|
+
const bbLo = {bblo_s};
|
|
865
|
+
const rsi = {rsi_s};
|
|
866
|
+
const macd = {macd_s2};
|
|
867
|
+
const macdSg = {macds_s};
|
|
868
|
+
const macdHi = {macdh_s};
|
|
869
|
+
const macdHiClr = {macd_colors};
|
|
870
|
+
|
|
871
|
+
const traces = [
|
|
872
|
+
/* K线 */
|
|
873
|
+
{{x,open:op,high:hi,low:lo,close:cl,type:"candlestick",name:"K线",
|
|
874
|
+
increasing:{{line:{{color:"{inc_color}"}},fillcolor:"{inc_color}"}},
|
|
875
|
+
decreasing:{{line:{{color:"{dec_color}"}},fillcolor:"{dec_color}"}},
|
|
876
|
+
yaxis:"y",whiskerwidth:0.3}},
|
|
877
|
+
/* 布林上轨 */
|
|
878
|
+
{{x,y:bbUp,type:"scatter",mode:"lines",name:"BB上轨",
|
|
879
|
+
line:{{color:"rgba(99,102,241,0.6)",width:1}},yaxis:"y"}},
|
|
880
|
+
/* 布林下轨(填充,hover隐藏避免重复) */
|
|
881
|
+
{{x,y:bbLo,type:"scatter",mode:"lines",name:"BB下轨",
|
|
882
|
+
line:{{color:"rgba(99,102,241,0.6)",width:1}},
|
|
883
|
+
fill:"tonexty",fillcolor:"rgba(99,102,241,0.07)",
|
|
884
|
+
showlegend:false,hoverinfo:"skip",yaxis:"y"}},
|
|
885
|
+
/* MA20 */
|
|
886
|
+
{{x,y:ma20,type:"scatter",mode:"lines",name:"MA20",
|
|
887
|
+
line:{{color:"#f59e0b",width:1.5}},yaxis:"y"}},
|
|
888
|
+
/* MA60 */
|
|
889
|
+
{{x,y:ma60,type:"scatter",mode:"lines",name:"MA60",
|
|
890
|
+
line:{{color:"#ef4444",width:1.5,dash:"dot"}},yaxis:"y"}},
|
|
891
|
+
/* 成交量 */
|
|
892
|
+
{{x,y:vol,type:"bar",name:"成交量",marker:{{color:volClr}},yaxis:"y2",showlegend:false}},
|
|
893
|
+
/* RSI */
|
|
894
|
+
{{x,y:rsi,type:"scatter",mode:"lines",name:"RSI(14)",
|
|
895
|
+
line:{{color:"#8b5cf6",width:1.5}},yaxis:"y3"}},
|
|
896
|
+
/* MACD 柱 */
|
|
897
|
+
{{x,y:macdHi,type:"bar",name:"MACD柱",marker:{{color:macdHiClr}},yaxis:"y4",showlegend:false}},
|
|
898
|
+
/* MACD 线 */
|
|
899
|
+
{{x,y:macd,type:"scatter",mode:"lines",name:"MACD",
|
|
900
|
+
line:{{color:"#2563eb",width:1.5}},yaxis:"y4"}},
|
|
901
|
+
/* Signal 线 */
|
|
902
|
+
{{x,y:macdSg,type:"scatter",mode:"lines",name:"Signal",
|
|
903
|
+
line:{{color:"#f59e0b",width:1.5,dash:"dot"}},yaxis:"y4"}}
|
|
904
|
+
];
|
|
905
|
+
|
|
906
|
+
const layout = {{
|
|
907
|
+
height:820,
|
|
908
|
+
/* 右边距加大:确保Y轴完整数字不被截断 */
|
|
909
|
+
margin:{{l:8,r:80,t:14,b:28}},
|
|
910
|
+
paper_bgcolor:"#fff",plot_bgcolor:"#fff",
|
|
911
|
+
hovermode:"x unified",
|
|
912
|
+
legend:{{orientation:"h",y:1.025,x:0,font:{{size:11}},bgcolor:"rgba(255,255,255,0.8)"}},
|
|
913
|
+
xaxis:{{domain:[0,1],type:"date",rangeslider:{{visible:false}},
|
|
914
|
+
gridcolor:"#f1f5f9",showgrid:true}},
|
|
915
|
+
/* 面板分配:价格60% / 成交量11% / RSI10% / MACD11% */
|
|
916
|
+
yaxis: {{domain:[0.25,1], side:"right",gridcolor:"#f1f5f9",
|
|
917
|
+
title:{{text:"价格 ({currency})",font:{{size:11}}}},tickfont:{{size:11}}}},
|
|
918
|
+
yaxis2:{{domain:[0.145,0.22],side:"right",gridcolor:"#f1f5f9",
|
|
919
|
+
showticklabels:false,title:""}},
|
|
920
|
+
yaxis3:{{domain:[0.075,0.135],side:"right",range:[0,100],gridcolor:"#f1f5f9",
|
|
921
|
+
title:{{text:"RSI",font:{{size:11}}}},tickfont:{{size:10}}}},
|
|
922
|
+
yaxis4:{{domain:[0,0.065], side:"right",gridcolor:"#f1f5f9",
|
|
923
|
+
title:{{text:"MACD",font:{{size:11}}}},tickfont:{{size:10}}}},
|
|
924
|
+
shapes:[
|
|
925
|
+
{{type:"line",xref:"paper",x0:0,x1:1,yref:"y3",y0:70,y1:70,
|
|
926
|
+
line:{{color:"rgba(220,38,38,0.6)",width:1,dash:"dot"}}}},
|
|
927
|
+
{{type:"line",xref:"paper",x0:0,x1:1,yref:"y3",y0:30,y1:30,
|
|
928
|
+
line:{{color:"rgba(22,163,74,0.6)",width:1,dash:"dot"}}}},
|
|
929
|
+
{{type:"line",xref:"paper",x0:0,x1:1,yref:"y4",y0:0,y1:0,
|
|
930
|
+
line:{{color:"#94a3b8",width:0.8}}}},
|
|
931
|
+
{sup_shapes}{res_shapes}
|
|
932
|
+
]
|
|
933
|
+
}};
|
|
934
|
+
Plotly.newPlot("chart",traces,layout,{{responsive:true,displaylogo:false,
|
|
935
|
+
modeBarButtonsToRemove:["autoScale2d","lasso2d","select2d"]}});
|
|
936
|
+
</script>
|
|
937
|
+
</body>
|
|
938
|
+
</html>"""
|
|
939
|
+
|
|
940
|
+
out_file.write_text(html_doc, encoding="utf-8")
|
|
941
|
+
|
|
942
|
+
png_path, png_error = _write_ta_png_artifact(
|
|
943
|
+
_artifact, hist, symbol, name, currency, is_ashare,
|
|
944
|
+
ma20, ma60, rsi14, macd_v, macd_s_val,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
_raw_prices = []
|
|
948
|
+
try:
|
|
949
|
+
_raw_prices = hist.reset_index().tail(370).to_dict(orient="records")
|
|
950
|
+
except Exception:
|
|
951
|
+
_raw_prices = []
|
|
952
|
+
write_artifact_metadata(_artifact, {
|
|
953
|
+
"kind": "stock_chart",
|
|
954
|
+
"status": "complete",
|
|
955
|
+
"symbol": symbol,
|
|
956
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
957
|
+
"data": {
|
|
958
|
+
"provider_chain": [provider],
|
|
959
|
+
"rows": int(len(hist)),
|
|
960
|
+
"panels": ["candlestick", "bollinger", "ma20", "ma60", "volume", "rsi14", "macd"],
|
|
961
|
+
"color_convention": "ashare_red_up" if is_ashare else "western_green_up",
|
|
962
|
+
},
|
|
963
|
+
"review": {"issues": review_issues, "passed": len(review_issues) == 0},
|
|
964
|
+
"outputs": {
|
|
965
|
+
"html": str(out_file),
|
|
966
|
+
"png": png_path,
|
|
967
|
+
"png_error": png_error,
|
|
968
|
+
},
|
|
969
|
+
"metrics": {
|
|
970
|
+
"last_close": last_close, "high_52w": high_52w, "low_52w": low_52w,
|
|
971
|
+
"trend": trend, "rsi14": rsi14, "momentum": momentum,
|
|
972
|
+
"support": sup3, "resistance": res3,
|
|
973
|
+
},
|
|
974
|
+
})
|
|
975
|
+
write_artifact_raw_data(_artifact, {
|
|
976
|
+
"symbol": symbol, "provider": provider, "info": info, "prices": _raw_prices,
|
|
977
|
+
})
|
|
978
|
+
return {
|
|
979
|
+
"success": True,
|
|
980
|
+
"chart_path": str(out_file),
|
|
981
|
+
"png_path": png_path,
|
|
982
|
+
"png_error": png_error,
|
|
983
|
+
"response": f"图表已生成:{out_file.name}",
|
|
984
|
+
"symbol": symbol,
|
|
985
|
+
"name": name,
|
|
986
|
+
"last_close": last_close,
|
|
987
|
+
"trend": trend,
|
|
988
|
+
"rsi": rsi14,
|
|
989
|
+
"momentum": momentum,
|
|
990
|
+
"support": sup3,
|
|
991
|
+
"resistance": res3,
|
|
992
|
+
"review_issues": review_issues,
|
|
993
|
+
"provider": provider,
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def handle_stock_chart_analysis(
|
|
998
|
+
message: str,
|
|
999
|
+
*,
|
|
1000
|
+
is_chart_request: Callable[[str], bool],
|
|
1001
|
+
extract_symbol: Callable[[str], str],
|
|
1002
|
+
) -> dict:
|
|
1003
|
+
"""Deterministic path for stock analysis + chart requests.
|
|
1004
|
+
|
|
1005
|
+
This avoids weak local models writing fake scripts or leaking pseudo tool
|
|
1006
|
+
calls. It fetches historical data, computes common indicators, writes a
|
|
1007
|
+
standalone HTML chart, and returns a concise Markdown analysis.
|
|
1008
|
+
"""
|
|
1009
|
+
if not is_chart_request(message):
|
|
1010
|
+
return {"success": False, "error": "not_stock_chart_analysis"}
|
|
1011
|
+
|
|
1012
|
+
symbol = extract_symbol(message) or "AAPL"
|
|
1013
|
+
period = "1y"
|
|
1014
|
+
interval = "1d"
|
|
1015
|
+
|
|
1016
|
+
try:
|
|
1017
|
+
import html as _html
|
|
1018
|
+
import pandas as _pd
|
|
1019
|
+
except Exception as exc:
|
|
1020
|
+
return {
|
|
1021
|
+
"success": False,
|
|
1022
|
+
"error": f"缺少图表分析依赖:{exc}",
|
|
1023
|
+
"response": "当前环境缺少 `pandas`,无法生成股票图表。",
|
|
1024
|
+
}
|
|
1025
|
+
try:
|
|
1026
|
+
import yfinance as _yf
|
|
1027
|
+
except Exception:
|
|
1028
|
+
_yf = None
|
|
1029
|
+
|
|
1030
|
+
symbol = _normalise_chart_symbol(symbol)
|
|
1031
|
+
provider = "Yahoo Finance"
|
|
1032
|
+
provider_currency = None
|
|
1033
|
+
ticker = None
|
|
1034
|
+
hist = None
|
|
1035
|
+
yahoo_error = ""
|
|
1036
|
+
chart_error = ""
|
|
1037
|
+
mdc_error = ""
|
|
1038
|
+
ak_error = ""
|
|
1039
|
+
|
|
1040
|
+
if _ashare_plain_symbol(symbol):
|
|
1041
|
+
hist, provider_currency, mdc_provider, mdc_error = _fetch_mdc_history_frame(symbol, period, interval)
|
|
1042
|
+
if hist is not None and not hist.empty:
|
|
1043
|
+
provider = mdc_provider or "market_data_client"
|
|
1044
|
+
|
|
1045
|
+
if _ashare_plain_symbol(symbol) and (hist is None or hist.empty):
|
|
1046
|
+
hist, provider_currency, ak_error = _fetch_akshare_history_frame(symbol, period)
|
|
1047
|
+
if hist is not None and not hist.empty:
|
|
1048
|
+
provider = "akshare"
|
|
1049
|
+
|
|
1050
|
+
if (hist is None or hist.empty) and _yf is not None:
|
|
1051
|
+
try:
|
|
1052
|
+
ticker = _yf.Ticker(symbol)
|
|
1053
|
+
hist = ticker.history(period=period, interval=interval, auto_adjust=False)
|
|
1054
|
+
hist = _normalise_history_frame(hist)
|
|
1055
|
+
if hist is not None and not hist.empty:
|
|
1056
|
+
provider = "Yahoo Finance"
|
|
1057
|
+
except Exception as exc:
|
|
1058
|
+
hist = None
|
|
1059
|
+
yahoo_error = str(exc)
|
|
1060
|
+
elif _yf is None:
|
|
1061
|
+
yahoo_error = "yfinance missing"
|
|
1062
|
+
|
|
1063
|
+
if hist is None or hist.empty:
|
|
1064
|
+
hist, provider_currency, chart_error = _fetch_yahoo_chart_frame(symbol, period, interval)
|
|
1065
|
+
if hist is not None and not hist.empty:
|
|
1066
|
+
provider = "Yahoo Chart API"
|
|
1067
|
+
|
|
1068
|
+
if hist is None or hist.empty:
|
|
1069
|
+
try:
|
|
1070
|
+
stooq_symbol = symbol.lower()
|
|
1071
|
+
if "." not in stooq_symbol:
|
|
1072
|
+
stooq_symbol = f"{stooq_symbol}.us"
|
|
1073
|
+
url = f"https://stooq.com/q/d/l/?s={stooq_symbol}&i=d"
|
|
1074
|
+
hist = _pd.read_csv(url)
|
|
1075
|
+
if hist is not None and not hist.empty:
|
|
1076
|
+
hist["Date"] = _pd.to_datetime(hist["Date"])
|
|
1077
|
+
hist = hist.set_index("Date").sort_index().tail(260)
|
|
1078
|
+
hist = _normalise_history_frame(hist)
|
|
1079
|
+
provider = "Stooq"
|
|
1080
|
+
except Exception as exc:
|
|
1081
|
+
return {
|
|
1082
|
+
"success": False,
|
|
1083
|
+
"error": (
|
|
1084
|
+
f"获取 {symbol} 历史行情失败:"
|
|
1085
|
+
f"MarketDataClient={mdc_error or 'skipped'}; "
|
|
1086
|
+
f"akshare={ak_error or 'skipped'}; "
|
|
1087
|
+
f"Yahoo={yahoo_error or 'empty'}; "
|
|
1088
|
+
f"YahooChart={chart_error or 'empty'}; "
|
|
1089
|
+
f"Stooq={exc}"
|
|
1090
|
+
),
|
|
1091
|
+
"response": f"无法获取 {symbol} 历史行情,图表未生成。请稍后重试,或检查网络/数据源访问。",
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if hist is None or hist.empty or "Close" not in hist.columns:
|
|
1095
|
+
return {
|
|
1096
|
+
"success": False,
|
|
1097
|
+
"error": (
|
|
1098
|
+
f"{symbol} 历史行情为空:"
|
|
1099
|
+
f"MarketDataClient={mdc_error or 'skipped'}; "
|
|
1100
|
+
f"akshare={ak_error or 'skipped'}; "
|
|
1101
|
+
f"Yahoo={yahoo_error or 'empty'}; "
|
|
1102
|
+
f"YahooChart={chart_error or 'empty'}"
|
|
1103
|
+
),
|
|
1104
|
+
"response": f"没有拿到 {symbol} 的可用历史行情,图表未生成。请稍后重试,或检查网络/数据源访问。",
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
hist = hist.dropna(subset=["Close"]).copy()
|
|
1108
|
+
hist["MA20"] = hist["Close"].rolling(20).mean()
|
|
1109
|
+
hist["MA50"] = hist["Close"].rolling(50).mean()
|
|
1110
|
+
hist["MA200"] = hist["Close"].rolling(200).mean()
|
|
1111
|
+
delta = hist["Close"].diff()
|
|
1112
|
+
gain = delta.clip(lower=0).rolling(14).mean()
|
|
1113
|
+
loss = (-delta.clip(upper=0)).rolling(14).mean()
|
|
1114
|
+
rs = gain / loss.replace(0, _pd.NA)
|
|
1115
|
+
hist["RSI14"] = 100 - (100 / (1 + rs))
|
|
1116
|
+
ema12 = hist["Close"].ewm(span=12, adjust=False).mean()
|
|
1117
|
+
ema26 = hist["Close"].ewm(span=26, adjust=False).mean()
|
|
1118
|
+
hist["MACD"] = ema12 - ema26
|
|
1119
|
+
hist["MACD_SIGNAL"] = hist["MACD"].ewm(span=9, adjust=False).mean()
|
|
1120
|
+
|
|
1121
|
+
last = hist.iloc[-1]
|
|
1122
|
+
first_close = hist["Close"].iloc[0]
|
|
1123
|
+
last_close = float(last["Close"])
|
|
1124
|
+
ytd_like_return = (last_close / float(first_close) - 1) * 100 if first_close else 0
|
|
1125
|
+
ma20 = float(last["MA20"]) if _pd.notna(last["MA20"]) else None
|
|
1126
|
+
ma50 = float(last["MA50"]) if _pd.notna(last["MA50"]) else None
|
|
1127
|
+
ma200 = float(last["MA200"]) if _pd.notna(last["MA200"]) else None
|
|
1128
|
+
rsi14 = float(last["RSI14"]) if _pd.notna(last["RSI14"]) else None
|
|
1129
|
+
macd = float(last["MACD"]) if _pd.notna(last["MACD"]) else None
|
|
1130
|
+
macd_sig = float(last["MACD_SIGNAL"]) if _pd.notna(last["MACD_SIGNAL"]) else None
|
|
1131
|
+
high_52w = float(hist["High"].max()) if "High" in hist else float(hist["Close"].max())
|
|
1132
|
+
low_52w = float(hist["Low"].min()) if "Low" in hist else float(hist["Close"].min())
|
|
1133
|
+
|
|
1134
|
+
info = {}
|
|
1135
|
+
try:
|
|
1136
|
+
if ticker is None and _yf is not None:
|
|
1137
|
+
ticker = _yf.Ticker(symbol)
|
|
1138
|
+
info = ticker.get_info() or {} if ticker is not None else {}
|
|
1139
|
+
except Exception:
|
|
1140
|
+
info = {}
|
|
1141
|
+
name = info.get("longName") or info.get("shortName") or symbol
|
|
1142
|
+
pe = info.get("trailingPE")
|
|
1143
|
+
market_cap = info.get("marketCap")
|
|
1144
|
+
currency = info.get("currency") or provider_currency or ("CNY" if _ashare_plain_symbol(symbol) else "USD")
|
|
1145
|
+
|
|
1146
|
+
if ma20 and ma50 and last_close > ma20 > ma50:
|
|
1147
|
+
trend = "偏多"
|
|
1148
|
+
elif ma20 and ma50 and last_close < ma20 < ma50:
|
|
1149
|
+
trend = "偏空"
|
|
1150
|
+
else:
|
|
1151
|
+
trend = "震荡/中性"
|
|
1152
|
+
momentum = "MACD偏多" if macd is not None and macd_sig is not None and macd > macd_sig else "MACD偏弱"
|
|
1153
|
+
rsi_view = "超买" if rsi14 is not None and rsi14 >= 70 else ("超卖" if rsi14 is not None and rsi14 <= 30 else "中性")
|
|
1154
|
+
|
|
1155
|
+
safe_symbol = re.sub(r"[^A-Za-z0-9_.-]+", "_", symbol)
|
|
1156
|
+
from artifacts import create_user_artifact, write_artifact_metadata, write_artifact_raw_data
|
|
1157
|
+
_artifact = create_user_artifact("stock-charts", symbol, f"{safe_symbol}_analysis_chart", ".html")
|
|
1158
|
+
out_file = _artifact.path
|
|
1159
|
+
|
|
1160
|
+
x = [idx.strftime("%Y-%m-%d") for idx in hist.index]
|
|
1161
|
+
close = [None if _pd.isna(v) else round(float(v), 4) for v in hist["Close"]]
|
|
1162
|
+
volume = [None if _pd.isna(v) else int(float(v)) for v in hist.get("Volume", _pd.Series(index=hist.index, dtype=float))]
|
|
1163
|
+
ma20_arr = [None if _pd.isna(v) else round(float(v), 4) for v in hist["MA20"]]
|
|
1164
|
+
ma50_arr = [None if _pd.isna(v) else round(float(v), 4) for v in hist["MA50"]]
|
|
1165
|
+
rsi_arr = [None if _pd.isna(v) else round(float(v), 4) for v in hist["RSI14"]]
|
|
1166
|
+
|
|
1167
|
+
html_doc = f"""<!doctype html>
|
|
1168
|
+
<html lang="zh-CN">
|
|
1169
|
+
<head>
|
|
1170
|
+
<meta charset="utf-8">
|
|
1171
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1172
|
+
<title>{_html.escape(symbol)} 股票分析图表</title>
|
|
1173
|
+
{plotly_script_tag()}
|
|
1174
|
+
<style>
|
|
1175
|
+
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f8fa; color: #17202a; }}
|
|
1176
|
+
main {{ max-width: 1180px; margin: 0 auto; padding: 28px; }}
|
|
1177
|
+
h1 {{ margin: 0 0 6px; font-size: 28px; }}
|
|
1178
|
+
.meta {{ color: #667085; margin-bottom: 18px; }}
|
|
1179
|
+
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 18px 0; }}
|
|
1180
|
+
.metric {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }}
|
|
1181
|
+
.label {{ color: #667085; font-size: 12px; }}
|
|
1182
|
+
.value {{ font-size: 18px; font-weight: 650; margin-top: 4px; }}
|
|
1183
|
+
#chart {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; }}
|
|
1184
|
+
.note {{ color: #667085; font-size: 13px; margin-top: 14px; }}
|
|
1185
|
+
</style>
|
|
1186
|
+
</head>
|
|
1187
|
+
<body>
|
|
1188
|
+
<main>
|
|
1189
|
+
<h1>{_html.escape(name)} ({_html.escape(symbol)})</h1>
|
|
1190
|
+
<div class="meta">生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} · 数据:{_html.escape(provider)} · 周期:{period}</div>
|
|
1191
|
+
<section class="grid">
|
|
1192
|
+
<div class="metric"><div class="label">最新收盘</div><div class="value">{currency} {_fmt_num(last_close)}</div></div>
|
|
1193
|
+
<div class="metric"><div class="label">近一年区间</div><div class="value">{_fmt_num(low_52w)} - {_fmt_num(high_52w)}</div></div>
|
|
1194
|
+
<div class="metric"><div class="label">MA20 / MA50</div><div class="value">{_fmt_num(ma20)} / {_fmt_num(ma50)}</div></div>
|
|
1195
|
+
<div class="metric"><div class="label">RSI14</div><div class="value">{_fmt_num(rsi14)}</div></div>
|
|
1196
|
+
<div class="metric"><div class="label">P/E</div><div class="value">{_fmt_num(pe)}</div></div>
|
|
1197
|
+
<div class="metric"><div class="label">成交量</div><div class="value">{_fmt_int(last.get("Volume"))}</div></div>
|
|
1198
|
+
</section>
|
|
1199
|
+
<div id="chart"></div>
|
|
1200
|
+
<p class="note">图表包含收盘价、MA20、MA50、成交量和 RSI14。该文件为本地 HTML,可直接在浏览器打开。</p>
|
|
1201
|
+
</main>
|
|
1202
|
+
<script>
|
|
1203
|
+
const x = {json.dumps(x)};
|
|
1204
|
+
const close = {json.dumps(close)};
|
|
1205
|
+
const volume = {json.dumps(volume)};
|
|
1206
|
+
const ma20 = {json.dumps(ma20_arr)};
|
|
1207
|
+
const ma50 = {json.dumps(ma50_arr)};
|
|
1208
|
+
const rsi = {json.dumps(rsi_arr)};
|
|
1209
|
+
const data = [
|
|
1210
|
+
{{x, y: close, type: "scatter", mode: "lines", name: "Close", line: {{color: "#2563eb", width: 2}}, yaxis: "y"}},
|
|
1211
|
+
{{x, y: ma20, type: "scatter", mode: "lines", name: "MA20", line: {{color: "#f59e0b", width: 1.5}}, yaxis: "y"}},
|
|
1212
|
+
{{x, y: ma50, type: "scatter", mode: "lines", name: "MA50", line: {{color: "#10b981", width: 1.5}}, yaxis: "y"}},
|
|
1213
|
+
{{x, y: volume, type: "bar", name: "Volume", marker: {{color: "rgba(100,116,139,0.35)"}}, yaxis: "y2"}},
|
|
1214
|
+
{{x, y: rsi, type: "scatter", mode: "lines", name: "RSI14", line: {{color: "#dc2626", width: 1.5}}, yaxis: "y3"}}
|
|
1215
|
+
];
|
|
1216
|
+
const layout = {{
|
|
1217
|
+
height: 720,
|
|
1218
|
+
margin: {{l: 62, r: 30, t: 28, b: 42}},
|
|
1219
|
+
paper_bgcolor: "#fff",
|
|
1220
|
+
plot_bgcolor: "#fff",
|
|
1221
|
+
hovermode: "x unified",
|
|
1222
|
+
legend: {{orientation: "h", y: 1.04}},
|
|
1223
|
+
xaxis: {{domain: [0, 1], rangeslider: {{visible: false}}, gridcolor: "#eef2f7"}},
|
|
1224
|
+
yaxis: {{domain: [0.36, 1], title: "Price", gridcolor: "#eef2f7"}},
|
|
1225
|
+
yaxis2: {{domain: [0.18, 0.31], title: "Volume", gridcolor: "#eef2f7"}},
|
|
1226
|
+
yaxis3: {{domain: [0, 0.13], title: "RSI", range: [0, 100], gridcolor: "#eef2f7"}},
|
|
1227
|
+
shapes: [
|
|
1228
|
+
{{type: "line", xref: "paper", x0: 0, x1: 1, yref: "y3", y0: 70, y1: 70, line: {{color: "#ef4444", dash: "dot"}}}},
|
|
1229
|
+
{{type: "line", xref: "paper", x0: 0, x1: 1, yref: "y3", y0: 30, y1: 30, line: {{color: "#22c55e", dash: "dot"}}}}
|
|
1230
|
+
]
|
|
1231
|
+
}};
|
|
1232
|
+
Plotly.newPlot("chart", data, layout, {{responsive: true, displaylogo: false}});
|
|
1233
|
+
</script>
|
|
1234
|
+
</body>
|
|
1235
|
+
</html>
|
|
1236
|
+
"""
|
|
1237
|
+
out_file.write_text(html_doc, encoding="utf-8")
|
|
1238
|
+
|
|
1239
|
+
_raw_prices = []
|
|
1240
|
+
try:
|
|
1241
|
+
_raw_prices = hist.reset_index().tail(370).to_dict(orient="records")
|
|
1242
|
+
except Exception:
|
|
1243
|
+
_raw_prices = []
|
|
1244
|
+
write_artifact_metadata(_artifact, {
|
|
1245
|
+
"kind": "stock_chart_analysis",
|
|
1246
|
+
"status": "complete",
|
|
1247
|
+
"symbol": symbol,
|
|
1248
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
1249
|
+
"data": {
|
|
1250
|
+
"provider_chain": [provider],
|
|
1251
|
+
"rows": int(len(hist)),
|
|
1252
|
+
"missing_fields": [
|
|
1253
|
+
k for k, v in {
|
|
1254
|
+
"ma20": ma20,
|
|
1255
|
+
"ma50": ma50,
|
|
1256
|
+
"ma200": ma200,
|
|
1257
|
+
"rsi14": rsi14,
|
|
1258
|
+
"macd": macd,
|
|
1259
|
+
"macd_signal": macd_sig,
|
|
1260
|
+
"pe": pe,
|
|
1261
|
+
"market_cap": market_cap,
|
|
1262
|
+
}.items()
|
|
1263
|
+
if v is None
|
|
1264
|
+
],
|
|
1265
|
+
},
|
|
1266
|
+
"metrics": {
|
|
1267
|
+
"last_close": last_close,
|
|
1268
|
+
"trend": trend,
|
|
1269
|
+
"rsi14": rsi14,
|
|
1270
|
+
"momentum": momentum,
|
|
1271
|
+
"ytd_like_return": ytd_like_return,
|
|
1272
|
+
},
|
|
1273
|
+
})
|
|
1274
|
+
write_artifact_raw_data(_artifact, {
|
|
1275
|
+
"symbol": symbol,
|
|
1276
|
+
"provider": provider,
|
|
1277
|
+
"info": info,
|
|
1278
|
+
"prices": _raw_prices,
|
|
1279
|
+
})
|
|
1280
|
+
|
|
1281
|
+
market_cap_text = "—"
|
|
1282
|
+
if market_cap:
|
|
1283
|
+
market_cap_text = f"{currency} {market_cap / 1e12:.2f}T" if market_cap >= 1e12 else f"{currency} {market_cap / 1e9:.1f}B"
|
|
1284
|
+
|
|
1285
|
+
response = (
|
|
1286
|
+
f"## {name} ({symbol}) 股票分析\n\n"
|
|
1287
|
+
f"已生成图表:[{out_file.name}]({out_file})\n\n"
|
|
1288
|
+
f"| 指标 | 数值 |\n"
|
|
1289
|
+
f"| --- | --- |\n"
|
|
1290
|
+
f"| 最新收盘 | {currency} {_fmt_num(last_close)} |\n"
|
|
1291
|
+
f"| 近一年涨跌幅 | {ytd_like_return:+.2f}% |\n"
|
|
1292
|
+
f"| 近一年高/低 | {_fmt_num(high_52w)} / {_fmt_num(low_52w)} |\n"
|
|
1293
|
+
f"| MA20 / MA50 / MA200 | {_fmt_num(ma20)} / {_fmt_num(ma50)} / {_fmt_num(ma200)} |\n"
|
|
1294
|
+
f"| RSI14 | {_fmt_num(rsi14)}({rsi_view}) |\n"
|
|
1295
|
+
f"| MACD | {_fmt_num(macd)} / signal {_fmt_num(macd_sig)}({momentum}) |\n"
|
|
1296
|
+
f"| P/E / 市值 | {_fmt_num(pe)} / {market_cap_text} |\n\n"
|
|
1297
|
+
f"**结论**:当前技术结构为 **{trend}**。"
|
|
1298
|
+
f"RSI 处于{rsi_view}区间,{momentum}。"
|
|
1299
|
+
f"若价格能稳定站上 MA20 和 MA50,短线结构会更健康;若跌破 MA50 或放量下行,需要降低仓位和预期。\n\n"
|
|
1300
|
+
f"**风险**:该分析基于 {provider} 历史行情和常用技术指标,不构成投资建议;财报、产品周期、利率和大盘风险都会影响股价。"
|
|
1301
|
+
)
|
|
1302
|
+
return {
|
|
1303
|
+
"success": True,
|
|
1304
|
+
"response": response,
|
|
1305
|
+
"provider": "deterministic",
|
|
1306
|
+
"tools_used": ["stock_chart", provider, "html_chart"],
|
|
1307
|
+
"chart_path": str(out_file),
|
|
1308
|
+
"symbol": symbol,
|
|
1309
|
+
}
|