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
market_data_client.py
ADDED
|
@@ -0,0 +1,1899 @@
|
|
|
1
|
+
"""
|
|
2
|
+
market_data_client.py — Arthera unified real-time market data client.
|
|
3
|
+
|
|
4
|
+
Design principles
|
|
5
|
+
─────────────────
|
|
6
|
+
1. Proxy-bypassed : uses requests.Session(trust_env=False) so Chinese data
|
|
7
|
+
sources work even when HTTP_PROXY / HTTPS_PROXY is set (VPN / Clash).
|
|
8
|
+
2. Multi-source fallback chain:
|
|
9
|
+
US/Global → yfinance (primary) → Alpha Vantage (if key set)
|
|
10
|
+
A-shares → Eastmoney push2 API → AKShare (historical)
|
|
11
|
+
Crypto → ccxt (binance/okx) → yfinance fallback
|
|
12
|
+
3. Unified output schema — every function returns a consistent dict so callers
|
|
13
|
+
don't care which data source actually served the data.
|
|
14
|
+
4. No blocking calls inside async context — use run_in_executor where needed.
|
|
15
|
+
|
|
16
|
+
Quick usage
|
|
17
|
+
───────────
|
|
18
|
+
from market_data_client import MarketDataClient
|
|
19
|
+
mdc = MarketDataClient()
|
|
20
|
+
print(mdc.quote("NVDA"))
|
|
21
|
+
print(mdc.quote("000001")) # A-share
|
|
22
|
+
print(mdc.quote("BTC/USDT")) # crypto
|
|
23
|
+
print(mdc.history("AAPL", days=30))
|
|
24
|
+
print(mdc.indices()) # major global indices
|
|
25
|
+
print(mdc.northbound_flow()) # 北向资金
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from datetime import datetime, timedelta, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, List, Optional
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
import pandas as pd
|
|
41
|
+
import requests
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
logging.getLogger("yfinance").setLevel(logging.CRITICAL)
|
|
45
|
+
logging.getLogger("curl_cffi").setLevel(logging.CRITICAL)
|
|
46
|
+
|
|
47
|
+
_UNSET = object() # sentinel: "not yet resolved" (distinct from a resolved None)
|
|
48
|
+
|
|
49
|
+
# 东方财富 API 公开默认令牌(非个人凭证,各开源项目通用值)
|
|
50
|
+
# 可通过环境变量覆盖:export EASTMONEY_UT=your_token
|
|
51
|
+
_EM_UT = os.environ.get("EASTMONEY_UT", "bd1d9ddb04089700cf9c27f6f7426281")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _friendly_market_error(symbol: str, providers: List[str], detail: Any = "") -> str:
|
|
55
|
+
"""Return a user-facing market data error without leaking vendor internals."""
|
|
56
|
+
tried = " -> ".join(providers) if providers else "market data providers"
|
|
57
|
+
detail_text = str(detail).lower()
|
|
58
|
+
if any(token in detail_text for token in ("timeout", "timed out", "curl: (28)", "read timed out")):
|
|
59
|
+
reason = "连接超时"
|
|
60
|
+
elif any(token in detail_text for token in ("connection", "network", "remote", "refused")):
|
|
61
|
+
reason = "网络连接不可用"
|
|
62
|
+
elif any(token in detail_text for token in ("rate", "429", "too many")):
|
|
63
|
+
reason = "数据源限流"
|
|
64
|
+
else:
|
|
65
|
+
reason = "数据源暂时不可用"
|
|
66
|
+
return f"{reason},已尝试 {tried},暂时无法获取 {symbol} 行情。请稍后重试或切换数据源。"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_valid_price(value: Any) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
return float(value) > 0
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# ── Simple in-process cache (TTL-based) ─────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
class _Cache:
|
|
78
|
+
def __init__(self):
|
|
79
|
+
self._store: Dict[str, tuple] = {} # key → (value, expire_ts)
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def get(self, key: str):
|
|
83
|
+
with self._lock:
|
|
84
|
+
entry = self._store.get(key)
|
|
85
|
+
if entry and time.time() < entry[1]:
|
|
86
|
+
return entry[0]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def set(self, key: str, value, ttl: int = 60):
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._store[key] = (value, time.time() + ttl)
|
|
92
|
+
|
|
93
|
+
_cache = _Cache()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _session() -> requests.Session:
|
|
97
|
+
"""Return a requests Session for Chinese financial APIs.
|
|
98
|
+
|
|
99
|
+
Uses the system proxy (HTTP_PROXY / HTTPS_PROXY) when set — users outside
|
|
100
|
+
China need a proxy/VPN to reach Eastmoney servers. Previously trust_env=False
|
|
101
|
+
was set here, which bypassed the proxy and caused connection failures for
|
|
102
|
+
non-China IPs.
|
|
103
|
+
"""
|
|
104
|
+
s = requests.Session()
|
|
105
|
+
s.headers.update({
|
|
106
|
+
"User-Agent": (
|
|
107
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
108
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
109
|
+
"Chrome/124.0 Safari/537.36"
|
|
110
|
+
),
|
|
111
|
+
"Referer": "https://finance.eastmoney.com/",
|
|
112
|
+
})
|
|
113
|
+
return s
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _session_no_proxy() -> requests.Session:
|
|
117
|
+
"""Return a requests Session that explicitly bypasses any system proxy.
|
|
118
|
+
|
|
119
|
+
Use for globally-accessible endpoints (Yahoo Finance, Alpha Vantage) that
|
|
120
|
+
should NOT go through a China-routing VPN.
|
|
121
|
+
"""
|
|
122
|
+
s = requests.Session()
|
|
123
|
+
s.trust_env = False
|
|
124
|
+
s.headers.update({
|
|
125
|
+
"User-Agent": (
|
|
126
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
127
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
128
|
+
"Chrome/124.0 Safari/537.36"
|
|
129
|
+
),
|
|
130
|
+
})
|
|
131
|
+
return s
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Symbol classification ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def _is_ashare(symbol: str) -> bool:
|
|
137
|
+
s = symbol.strip().upper()
|
|
138
|
+
if s.endswith((".SZ", ".SS", ".SH")):
|
|
139
|
+
s = s.rsplit(".", 1)[0]
|
|
140
|
+
digits = s.lstrip("SZ").lstrip("SH")
|
|
141
|
+
return (
|
|
142
|
+
(s.startswith(("60","00","30","68","83","87")) and s.isdigit() and len(s) == 6)
|
|
143
|
+
or (s.startswith(("SH", "SZ")) and s[2:].isdigit() and len(s[2:]) == 6)
|
|
144
|
+
or digits.isdigit() and len(digits) == 6
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _is_crypto(symbol: str) -> bool:
|
|
148
|
+
s = symbol.upper()
|
|
149
|
+
return "/" in s or s.endswith(("USDT","BTC","ETH","BNB","-USD","-USDT"))
|
|
150
|
+
|
|
151
|
+
def _norm_crypto(symbol: str, quote: str = "USDT") -> str:
|
|
152
|
+
"""Normalise a crypto symbol to ccxt 'BASE/QUOTE' form.
|
|
153
|
+
|
|
154
|
+
Fixes the rstrip bug: 'DOTUSDT'.rstrip('USDT') → 'DO' (strips chars, not
|
|
155
|
+
the suffix). Here we strip the quote suffix exactly.
|
|
156
|
+
"""
|
|
157
|
+
s = symbol.upper().strip()
|
|
158
|
+
if "/" in s:
|
|
159
|
+
return s
|
|
160
|
+
if "-" in s:
|
|
161
|
+
base, quote_part = s.split("-", 1)
|
|
162
|
+
quote_norm = "USDT" if quote_part == "USD" else quote_part
|
|
163
|
+
return f"{base}/{quote_norm}"
|
|
164
|
+
for q in ("USDT", "USDC", "BUSD", "USD", "BTC", "ETH", "BNB"):
|
|
165
|
+
if s.endswith(q) and len(s) > len(q):
|
|
166
|
+
return f"{s[:-len(q)]}/{q if q != 'USD' else 'USDT'}"
|
|
167
|
+
return f"{s}/{quote}"
|
|
168
|
+
|
|
169
|
+
def _normalise_ashare(symbol: str) -> str:
|
|
170
|
+
s = symbol.strip().upper()
|
|
171
|
+
if s.endswith((".SZ", ".SS", ".SH")):
|
|
172
|
+
s = s.rsplit(".", 1)[0]
|
|
173
|
+
s = s.lstrip("SH").lstrip("SZ")
|
|
174
|
+
s = s.lstrip("0") if s.startswith(("60","00","30","68","83","87")) else s
|
|
175
|
+
return s.zfill(6)
|
|
176
|
+
|
|
177
|
+
def _ashare_secid(code: str) -> str:
|
|
178
|
+
"""Convert 6-digit code → Eastmoney secid (1.XXXXXX or 0.XXXXXX)."""
|
|
179
|
+
code = code.zfill(6)
|
|
180
|
+
if code.startswith(("60", "68", "83", "87")):
|
|
181
|
+
return f"1.{code}" # 上交所
|
|
182
|
+
return f"0.{code}" # 深交所
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
186
|
+
# MarketDataClient
|
|
187
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
188
|
+
|
|
189
|
+
class MarketDataClient:
|
|
190
|
+
"""Unified market data access with proxy bypass and multi-source fallback."""
|
|
191
|
+
|
|
192
|
+
EM_QUOTE_URL = "https://push2.eastmoney.com/api/qt/stock/get"
|
|
193
|
+
EM_ULIST_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
|
194
|
+
EM_NORTHBOUND = "https://push2.eastmoney.com/api/qt/kamt/get"
|
|
195
|
+
EM_HIST_URL = "https://push2.eastmoney.com/api/qt/stock/kline/get"
|
|
196
|
+
EM_HOT_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
|
197
|
+
EM_LIMIT_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
|
198
|
+
|
|
199
|
+
# Eastmoney field map for stock quote
|
|
200
|
+
_EM_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f169,f170,f171,f116,f117,f162,f167,f168"
|
|
201
|
+
|
|
202
|
+
def __init__(self, alpha_vantage_key: str = ""):
|
|
203
|
+
self._sess = _session()
|
|
204
|
+
self._sess_np = None # lazy no-proxy session for proxy-bypass fallback
|
|
205
|
+
self._av_key = alpha_vantage_key or os.getenv("ALPHA_VANTAGE_KEY", "")
|
|
206
|
+
self._fh_key = self._load_finnhub_key()
|
|
207
|
+
self._ts_source = _UNSET # lazy Tushare source (None once resolved & unconfigured)
|
|
208
|
+
|
|
209
|
+
def _tushare(self):
|
|
210
|
+
"""Lazily resolve the user's configured Tushare source.
|
|
211
|
+
|
|
212
|
+
Returns a ``TushareSource`` only when TUSHARE_TOKEN is set (env or
|
|
213
|
+
~/.aria|.arthera/.env); otherwise ``None``. Users who never configured
|
|
214
|
+
Tushare pay nothing — the A-share chain falls straight through to the
|
|
215
|
+
free HTTP sources. When a token *is* present we honour it as the
|
|
216
|
+
preferred A-share source (it is the user's explicit, reliable choice,
|
|
217
|
+
especially behind the GFW where Eastmoney/AKShare can be flaky).
|
|
218
|
+
"""
|
|
219
|
+
if self._ts_source is _UNSET:
|
|
220
|
+
try:
|
|
221
|
+
from datasources.sources.tushare_source import TushareSource
|
|
222
|
+
src = TushareSource()
|
|
223
|
+
self._ts_source = src if src.is_configured() else None
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.debug("Tushare source unavailable: %s", e)
|
|
226
|
+
self._ts_source = None
|
|
227
|
+
return self._ts_source
|
|
228
|
+
|
|
229
|
+
def _em_get_json(self, url: str, params: dict, timeout: int = 8):
|
|
230
|
+
"""GET JSON from an eastmoney endpoint, resilient to flaky hosts/proxy.
|
|
231
|
+
|
|
232
|
+
Two failure modes are handled together:
|
|
233
|
+
* A broken HTTP(S)_PROXY returns empty bodies / ProxyError → retry
|
|
234
|
+
with a trust_env=False (no-proxy) session.
|
|
235
|
+
* eastmoney's push2 cluster has many numbered hosts
|
|
236
|
+
(N.push2.eastmoney.com) that go down individually → rotate hosts
|
|
237
|
+
until one responds with valid JSON.
|
|
238
|
+
"""
|
|
239
|
+
import re as _re
|
|
240
|
+
# Build a small candidate host list (original + a couple numbered hosts).
|
|
241
|
+
# Kept short so we fail fast and don't hammer eastmoney's rate limiter.
|
|
242
|
+
m = _re.search(r"https?://([^/]+)(/.*)", url)
|
|
243
|
+
if m and "push2.eastmoney.com" in m.group(1):
|
|
244
|
+
path = m.group(2)
|
|
245
|
+
_hosts = list(dict.fromkeys([m.group(1), "1.push2.eastmoney.com", "7.push2.eastmoney.com"]))
|
|
246
|
+
_urls = [f"https://{h}{path}" for h in _hosts]
|
|
247
|
+
else:
|
|
248
|
+
_urls = [url]
|
|
249
|
+
|
|
250
|
+
if self._sess_np is None:
|
|
251
|
+
self._sess_np = _session_no_proxy()
|
|
252
|
+
for candidate in _urls:
|
|
253
|
+
for sess in (self._sess, self._sess_np):
|
|
254
|
+
try:
|
|
255
|
+
r = sess.get(candidate, params=params, timeout=timeout)
|
|
256
|
+
r.raise_for_status()
|
|
257
|
+
data = r.json()
|
|
258
|
+
if isinstance(data, dict) and data.get("data") is not None:
|
|
259
|
+
return data
|
|
260
|
+
except Exception:
|
|
261
|
+
continue
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _load_finnhub_key() -> str:
|
|
266
|
+
"""Read Finnhub API key from env var or ~/.arthera/providers.json."""
|
|
267
|
+
key = os.getenv("FINNHUB_API_KEY", "") or os.getenv("FINNHUB_KEY", "")
|
|
268
|
+
if key:
|
|
269
|
+
return key
|
|
270
|
+
try:
|
|
271
|
+
p = Path.home() / ".arthera" / "providers.json"
|
|
272
|
+
if p.exists():
|
|
273
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
274
|
+
key = raw.get("data", {}).get("finnhub", {}).get("api_key", "")
|
|
275
|
+
if key:
|
|
276
|
+
return key
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
return ""
|
|
280
|
+
|
|
281
|
+
def _quote_finnhub(self, symbol: str) -> Dict[str, Any]:
|
|
282
|
+
"""Finnhub quote fallback — uses configured API key."""
|
|
283
|
+
if not self._fh_key:
|
|
284
|
+
return {"success": False, "error": "no finnhub key", "symbol": symbol}
|
|
285
|
+
try:
|
|
286
|
+
url = f"https://finnhub.io/api/v1/quote?symbol={symbol.upper()}&token={self._fh_key}"
|
|
287
|
+
r = self._sess.get(url, timeout=6)
|
|
288
|
+
if r.status_code != 200:
|
|
289
|
+
return {"success": False, "error": f"HTTP {r.status_code}", "symbol": symbol}
|
|
290
|
+
d = r.json()
|
|
291
|
+
price = float(d.get("c") or 0)
|
|
292
|
+
if price <= 0:
|
|
293
|
+
return {"success": False, "error": "price=0 from finnhub", "symbol": symbol}
|
|
294
|
+
prev = float(d.get("pc") or price)
|
|
295
|
+
chg_p = round(float(d.get("dp") or 0), 2)
|
|
296
|
+
name = symbol
|
|
297
|
+
mktcap = None
|
|
298
|
+
currency = "USD"
|
|
299
|
+
try:
|
|
300
|
+
prof_url = f"https://finnhub.io/api/v1/stock/profile2?symbol={symbol.upper()}&token={self._fh_key}"
|
|
301
|
+
pr = self._sess.get(prof_url, timeout=5).json()
|
|
302
|
+
name = pr.get("name") or symbol
|
|
303
|
+
mktcap = (float(pr.get("marketCapitalization") or 0) * 1e6) or None
|
|
304
|
+
currency = pr.get("currency") or "USD"
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
# Finnhub /quote has no volume field — enrich from yfinance fast_info
|
|
308
|
+
# so 成交量 isn't always 0 for US stocks (finnhub is primary here).
|
|
309
|
+
_vol = int(d.get("v") or 0)
|
|
310
|
+
if _vol == 0:
|
|
311
|
+
try:
|
|
312
|
+
import yfinance as _yf
|
|
313
|
+
_fi = _yf.Ticker(symbol).fast_info
|
|
314
|
+
_vol = int(getattr(_fi, "last_volume", 0)
|
|
315
|
+
or getattr(_fi, "ten_day_average_volume", 0) or 0)
|
|
316
|
+
except Exception:
|
|
317
|
+
_vol = 0
|
|
318
|
+
return {
|
|
319
|
+
"success": True,
|
|
320
|
+
"symbol": symbol.upper(),
|
|
321
|
+
"name": name,
|
|
322
|
+
"price": price,
|
|
323
|
+
"change": round(price - prev, 4),
|
|
324
|
+
"change_pct": chg_p,
|
|
325
|
+
"volume": _vol,
|
|
326
|
+
"market_cap": mktcap,
|
|
327
|
+
"high": round(float(d.get("h") or 0), 2),
|
|
328
|
+
"low": round(float(d.get("l") or 0), 2),
|
|
329
|
+
"open": round(float(d.get("o") or 0), 4),
|
|
330
|
+
"prev_close": round(prev, 4),
|
|
331
|
+
"currency": currency,
|
|
332
|
+
"market": "US",
|
|
333
|
+
"provider": "finnhub",
|
|
334
|
+
"timestamp": datetime.now().isoformat(),
|
|
335
|
+
}
|
|
336
|
+
except Exception as e:
|
|
337
|
+
return {"success": False, "error": str(e), "symbol": symbol}
|
|
338
|
+
|
|
339
|
+
def _history_finnhub(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
|
|
340
|
+
"""Finnhub candle history fallback."""
|
|
341
|
+
if not self._fh_key:
|
|
342
|
+
return {"success": False, "error": "no finnhub key", "symbol": symbol}
|
|
343
|
+
resolution = "D" if interval in ("1d", "day", "daily") else "60"
|
|
344
|
+
_end = int(time.time())
|
|
345
|
+
_start = int((datetime.now() - timedelta(days=days + 5)).timestamp())
|
|
346
|
+
try:
|
|
347
|
+
url = (f"https://finnhub.io/api/v1/stock/candle?symbol={symbol.upper()}"
|
|
348
|
+
f"&resolution={resolution}&from={_start}&to={_end}&token={self._fh_key}")
|
|
349
|
+
r = self._sess.get(url, timeout=10)
|
|
350
|
+
if r.status_code != 200:
|
|
351
|
+
return {"success": False, "error": f"HTTP {r.status_code}", "symbol": symbol}
|
|
352
|
+
d = r.json()
|
|
353
|
+
if d.get("s") != "ok" or not d.get("c"):
|
|
354
|
+
return {"success": False, "error": "no candle data", "symbol": symbol}
|
|
355
|
+
records = [
|
|
356
|
+
{
|
|
357
|
+
"date": datetime.fromtimestamp(t, tz=timezone.utc).strftime("%Y-%m-%d"),
|
|
358
|
+
"open": round(float(o), 4),
|
|
359
|
+
"high": round(float(h), 4),
|
|
360
|
+
"low": round(float(l), 4),
|
|
361
|
+
"close": round(float(c), 4),
|
|
362
|
+
"volume": int(v),
|
|
363
|
+
}
|
|
364
|
+
for t, o, h, l, c, v in zip(
|
|
365
|
+
d["t"], d["o"], d["h"], d["l"], d["c"], d.get("v", [0]*len(d["c"]))
|
|
366
|
+
)
|
|
367
|
+
]
|
|
368
|
+
return {
|
|
369
|
+
"success": True, "symbol": symbol.upper(),
|
|
370
|
+
"data": records, "provider": "finnhub",
|
|
371
|
+
"interval": interval, "count": len(records),
|
|
372
|
+
}
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return {"success": False, "error": str(e), "symbol": symbol}
|
|
375
|
+
|
|
376
|
+
# ── Public API ───────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
def quote(self, symbol: str) -> Dict[str, Any]:
|
|
379
|
+
"""Real-time quote for US stock / A-share / crypto / index.
|
|
380
|
+
|
|
381
|
+
Returns unified dict:
|
|
382
|
+
symbol, name, price, change, change_pct, volume, market_cap,
|
|
383
|
+
high, low, open, prev_close, provider, timestamp
|
|
384
|
+
"""
|
|
385
|
+
ckey = f"quote:{symbol}"
|
|
386
|
+
cached = _cache.get(ckey)
|
|
387
|
+
if cached:
|
|
388
|
+
return cached
|
|
389
|
+
|
|
390
|
+
if _is_ashare(symbol):
|
|
391
|
+
result = self._quote_ashare(symbol)
|
|
392
|
+
elif _is_crypto(symbol):
|
|
393
|
+
result = self._quote_crypto(symbol)
|
|
394
|
+
elif self._fh_key:
|
|
395
|
+
# Finnhub is primary for US/global stocks — faster, no rate limits
|
|
396
|
+
result = self._quote_finnhub(symbol)
|
|
397
|
+
if not result.get("success"):
|
|
398
|
+
result = self._quote_yfinance(symbol)
|
|
399
|
+
else:
|
|
400
|
+
result = self._quote_yfinance(symbol)
|
|
401
|
+
|
|
402
|
+
if result.get("success"):
|
|
403
|
+
_cache.set(ckey, result, ttl=30) # 30s cache for quotes
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
def history(self, symbol: str, days: int = 252,
|
|
407
|
+
interval: str = "1d") -> Dict[str, Any]:
|
|
408
|
+
"""OHLCV history as a list of dicts (sorted ascending by date).
|
|
409
|
+
|
|
410
|
+
Returns: {success, symbol, data: [{date,open,high,low,close,volume},...],
|
|
411
|
+
provider}
|
|
412
|
+
"""
|
|
413
|
+
ckey = f"hist:{symbol}:{days}:{interval}"
|
|
414
|
+
cached = _cache.get(ckey)
|
|
415
|
+
if cached:
|
|
416
|
+
return cached
|
|
417
|
+
|
|
418
|
+
if _is_ashare(symbol):
|
|
419
|
+
result = self._history_ashare(symbol, days, interval)
|
|
420
|
+
elif _is_crypto(symbol):
|
|
421
|
+
result = self._history_crypto(symbol, days, interval)
|
|
422
|
+
else:
|
|
423
|
+
result = self._history_yfinance(symbol, days, interval)
|
|
424
|
+
|
|
425
|
+
if result.get("success"):
|
|
426
|
+
_cache.set(ckey, result, ttl=300) # 5min cache for history
|
|
427
|
+
elif "rate" in str(result.get("error", "")).lower():
|
|
428
|
+
# Brief negative cache: stops every agent in a /team run from
|
|
429
|
+
# re-hammering the same rate-limited symbol with its own backoff.
|
|
430
|
+
_cache.set(ckey, result, ttl=20)
|
|
431
|
+
return result
|
|
432
|
+
|
|
433
|
+
def indices(self) -> Dict[str, Any]:
|
|
434
|
+
"""Real-time major global and Chinese indices."""
|
|
435
|
+
ckey = "indices:global"
|
|
436
|
+
cached = _cache.get(ckey)
|
|
437
|
+
if cached:
|
|
438
|
+
return cached
|
|
439
|
+
result = self._fetch_indices()
|
|
440
|
+
if result.get("success"):
|
|
441
|
+
_cache.set(ckey, result, ttl=60)
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
def northbound_flow(self) -> Dict[str, Any]:
|
|
445
|
+
"""北向资金 (沪股通+深股通) net buy/sell today."""
|
|
446
|
+
ckey = "northbound"
|
|
447
|
+
cached = _cache.get(ckey)
|
|
448
|
+
if cached:
|
|
449
|
+
return cached
|
|
450
|
+
result = self._fetch_northbound()
|
|
451
|
+
if result.get("success"):
|
|
452
|
+
_cache.set(ckey, result, ttl=120)
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
def hot_stocks(self, market: str = "cn", top_n: int = 20) -> Dict[str, Any]:
|
|
456
|
+
"""热门/活跃股票榜单."""
|
|
457
|
+
ckey = f"hot:{market}:{top_n}"
|
|
458
|
+
cached = _cache.get(ckey)
|
|
459
|
+
if cached:
|
|
460
|
+
return cached
|
|
461
|
+
if market == "cn":
|
|
462
|
+
result = self._fetch_hot_ashare(top_n)
|
|
463
|
+
else:
|
|
464
|
+
result = self._fetch_hot_us(top_n)
|
|
465
|
+
if result.get("success"):
|
|
466
|
+
_cache.set(ckey, result, ttl=120)
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
def multi_quote(self, symbols: List[str]) -> Dict[str, Any]:
|
|
470
|
+
"""Batch quotes for multiple symbols."""
|
|
471
|
+
results = {}
|
|
472
|
+
for sym in symbols:
|
|
473
|
+
r = self.quote(sym)
|
|
474
|
+
results[sym] = r
|
|
475
|
+
return {"success": True, "quotes": results}
|
|
476
|
+
|
|
477
|
+
def technical_indicators(self, symbol: str, days: int = 120) -> Dict[str, Any]:
|
|
478
|
+
"""Compute RSI, MACD, Bollinger Bands, MA from history data."""
|
|
479
|
+
hist = self.history(symbol, days=days)
|
|
480
|
+
if not hist.get("success"):
|
|
481
|
+
return hist
|
|
482
|
+
try:
|
|
483
|
+
df = pd.DataFrame(hist["data"])
|
|
484
|
+
if df.empty:
|
|
485
|
+
return {"success": False, "error": "empty history dataframe", "symbol": symbol}
|
|
486
|
+
df["close"] = pd.to_numeric(df["close"], errors="coerce")
|
|
487
|
+
df["high"] = pd.to_numeric(df.get("high", df["close"]), errors="coerce")
|
|
488
|
+
df["low"] = pd.to_numeric(df.get("low", df["close"]), errors="coerce")
|
|
489
|
+
df.dropna(subset=["close"], inplace=True)
|
|
490
|
+
close = df["close"]
|
|
491
|
+
n = len(close)
|
|
492
|
+
if n < 2:
|
|
493
|
+
return {"success": False, "error": f"insufficient data: {n} bars", "symbol": symbol}
|
|
494
|
+
|
|
495
|
+
result: Dict[str, Any] = {
|
|
496
|
+
"success": True,
|
|
497
|
+
"symbol": symbol,
|
|
498
|
+
"provider": "local_pandas",
|
|
499
|
+
"data_provider": hist.get("provider"),
|
|
500
|
+
"provider_chain": hist.get("provider_chain") or [hist.get("provider", "history")],
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# Current price (always available if n >= 1)
|
|
504
|
+
result["price"] = round(float(close.iloc[-1]), 4)
|
|
505
|
+
|
|
506
|
+
# RSI(14) — needs at least 15 bars
|
|
507
|
+
if n >= 15:
|
|
508
|
+
delta = close.diff()
|
|
509
|
+
gain = delta.clip(lower=0).rolling(14).mean()
|
|
510
|
+
loss = (-delta.clip(upper=0)).rolling(14).mean()
|
|
511
|
+
rs = gain / loss.replace(0, np.nan)
|
|
512
|
+
rsi_s = 100 - 100 / (1 + rs)
|
|
513
|
+
rsi_v = rsi_s.iloc[-1]
|
|
514
|
+
result["rsi"] = round(float(rsi_v), 2) if not np.isnan(rsi_v) else None
|
|
515
|
+
|
|
516
|
+
# MACD(12,26,9) — needs at least 27 bars
|
|
517
|
+
if n >= 27:
|
|
518
|
+
ema12 = close.ewm(span=12).mean()
|
|
519
|
+
ema26 = close.ewm(span=26).mean()
|
|
520
|
+
macd_l = ema12 - ema26
|
|
521
|
+
sig_l = macd_l.ewm(span=9).mean()
|
|
522
|
+
hist_m = macd_l - sig_l
|
|
523
|
+
result["macd"] = round(float(macd_l.iloc[-1]), 4)
|
|
524
|
+
result["macd_signal"]= round(float(sig_l.iloc[-1]), 4)
|
|
525
|
+
result["macd_hist"] = round(float(hist_m.iloc[-1]), 4)
|
|
526
|
+
|
|
527
|
+
# Bollinger Bands(20) — needs at least 20 bars
|
|
528
|
+
if n >= 20:
|
|
529
|
+
ma20 = close.rolling(20).mean()
|
|
530
|
+
std20 = close.rolling(20).std()
|
|
531
|
+
bb_u = (ma20 + 2 * std20).iloc[-1]
|
|
532
|
+
bb_l = (ma20 - 2 * std20).iloc[-1]
|
|
533
|
+
bb_m = ma20.iloc[-1]
|
|
534
|
+
if not any(np.isnan(v) for v in (bb_u, bb_l, bb_m)):
|
|
535
|
+
result["bb_upper"] = round(float(bb_u), 4)
|
|
536
|
+
result["bb_mid"] = round(float(bb_m), 4)
|
|
537
|
+
result["bb_lower"] = round(float(bb_l), 4)
|
|
538
|
+
result["bb_position"] = round(
|
|
539
|
+
(result["price"] - float(bb_l)) /
|
|
540
|
+
max(float(bb_u - bb_l), 1e-9), 4
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Moving averages
|
|
544
|
+
for ma_n in [5, 10, 20, 60, 120]:
|
|
545
|
+
if n >= ma_n:
|
|
546
|
+
v = close.rolling(ma_n).mean().iloc[-1]
|
|
547
|
+
if not np.isnan(v):
|
|
548
|
+
result[f"ma{ma_n}"] = round(float(v), 4)
|
|
549
|
+
|
|
550
|
+
return result
|
|
551
|
+
except Exception as e:
|
|
552
|
+
return {"success": False, "error": str(e), "symbol": symbol}
|
|
553
|
+
|
|
554
|
+
def fundamentals(self, symbol: str) -> Dict[str, Any]:
|
|
555
|
+
"""US stock fundamentals via yfinance."""
|
|
556
|
+
if _is_ashare(symbol):
|
|
557
|
+
return self._fundamentals_ashare(symbol)
|
|
558
|
+
try:
|
|
559
|
+
import yfinance as yf
|
|
560
|
+
t = yf.Ticker(symbol)
|
|
561
|
+
info = t.info or {}
|
|
562
|
+
return {
|
|
563
|
+
"success": True,
|
|
564
|
+
"symbol": symbol,
|
|
565
|
+
"name": info.get("longName",""),
|
|
566
|
+
"sector": info.get("sector",""),
|
|
567
|
+
"industry": info.get("industry",""),
|
|
568
|
+
"market_cap": info.get("marketCap"),
|
|
569
|
+
"pe_ratio": info.get("trailingPE"),
|
|
570
|
+
"fwd_pe": info.get("forwardPE"),
|
|
571
|
+
"pb_ratio": info.get("priceToBook"),
|
|
572
|
+
"ps_ratio": info.get("priceToSalesTrailing12Months"),
|
|
573
|
+
"ev_ebitda": info.get("enterpriseToEbitda"),
|
|
574
|
+
# ROE / revenue growth — yfinance returns ratios (0.12), the
|
|
575
|
+
# agent expects percent (12), so ×100. Fixes 基本面 数据不足.
|
|
576
|
+
"roe": (info["returnOnEquity"] * 100
|
|
577
|
+
if info.get("returnOnEquity") is not None else None),
|
|
578
|
+
"revenue_growth": (info["revenueGrowth"] * 100
|
|
579
|
+
if info.get("revenueGrowth") is not None else None),
|
|
580
|
+
"revenue": info.get("totalRevenue"),
|
|
581
|
+
"net_income": info.get("netIncomeToCommon"),
|
|
582
|
+
"eps": info.get("trailingEps"),
|
|
583
|
+
"fwd_eps": info.get("forwardEps"),
|
|
584
|
+
"dividend_yield": info.get("dividendYield"),
|
|
585
|
+
"beta": info.get("beta"),
|
|
586
|
+
"52w_high": info.get("fiftyTwoWeekHigh"),
|
|
587
|
+
"52w_low": info.get("fiftyTwoWeekLow"),
|
|
588
|
+
"analyst_target": info.get("targetMeanPrice"),
|
|
589
|
+
"recommendation": info.get("recommendationKey"),
|
|
590
|
+
"employees": info.get("fullTimeEmployees"),
|
|
591
|
+
"description": (info.get("longBusinessSummary","")[:300]
|
|
592
|
+
if info.get("longBusinessSummary") else ""),
|
|
593
|
+
"provider": "yfinance",
|
|
594
|
+
}
|
|
595
|
+
except Exception as e:
|
|
596
|
+
# Finnhub fundamentals fallback
|
|
597
|
+
if self._fh_key:
|
|
598
|
+
try:
|
|
599
|
+
m_url = (f"https://finnhub.io/api/v1/stock/metric?symbol={symbol.upper()}"
|
|
600
|
+
f"&metric=all&token={self._fh_key}")
|
|
601
|
+
m_r = self._sess.get(m_url, timeout=8)
|
|
602
|
+
if m_r.status_code == 200:
|
|
603
|
+
m = m_r.json().get("metric") or {}
|
|
604
|
+
p_url = (f"https://finnhub.io/api/v1/stock/profile2?symbol={symbol.upper()}"
|
|
605
|
+
f"&token={self._fh_key}")
|
|
606
|
+
p_r = self._sess.get(p_url, timeout=5)
|
|
607
|
+
prof = p_r.json() if p_r.status_code == 200 else {}
|
|
608
|
+
return {
|
|
609
|
+
"success": True,
|
|
610
|
+
"symbol": symbol,
|
|
611
|
+
"name": prof.get("name", symbol),
|
|
612
|
+
"sector": prof.get("gsector", ""),
|
|
613
|
+
"industry": prof.get("gind", ""),
|
|
614
|
+
"market_cap": (float(prof.get("marketCapitalization") or 0) * 1e6) or None,
|
|
615
|
+
"pe_ratio": m.get("peTTM"),
|
|
616
|
+
"fwd_pe": m.get("peExclExtraTTM"),
|
|
617
|
+
"pb_ratio": m.get("pbAnnual") or m.get("pbQuarterly"),
|
|
618
|
+
"ev_ebitda": m.get("currentEv/freeCashFlowAnnual"),
|
|
619
|
+
"dividend_yield": m.get("dividendYieldIndicatedAnnual"),
|
|
620
|
+
# ROE + revenue growth (finnhub metric=all has these) —
|
|
621
|
+
# fixes 基本面 ROE/营收增速 showing 数据不足.
|
|
622
|
+
"roe": m.get("roeTTM") or m.get("roeRfy") or m.get("roeAnnual"),
|
|
623
|
+
"revenue_growth": (m.get("revenueGrowthTTMYoy")
|
|
624
|
+
or m.get("revenueGrowthQuarterlyYoy")
|
|
625
|
+
or m.get("revenueGrowth5Y")),
|
|
626
|
+
"52w_high": m.get("52WeekHigh"),
|
|
627
|
+
"52w_low": m.get("52WeekLow"),
|
|
628
|
+
"beta": m.get("beta"),
|
|
629
|
+
"eps": m.get("epsInclExtraItemsTTM"),
|
|
630
|
+
"provider": "finnhub",
|
|
631
|
+
}
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
return {"success": False, "error": str(e), "symbol": symbol}
|
|
635
|
+
|
|
636
|
+
# ── US / Global (yfinance) ───────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
def _quote_yfinance(self, symbol: str) -> Dict[str, Any]:
|
|
639
|
+
try:
|
|
640
|
+
import yfinance as yf
|
|
641
|
+
except Exception:
|
|
642
|
+
yc = self._quote_yahoo_chart(symbol)
|
|
643
|
+
if yc.get("success"):
|
|
644
|
+
return yc
|
|
645
|
+
stooq = self._quote_stooq(symbol)
|
|
646
|
+
if stooq.get("success"):
|
|
647
|
+
return stooq
|
|
648
|
+
return {"success": False, "error": "yfinance unavailable", "symbol": symbol}
|
|
649
|
+
|
|
650
|
+
def _attempt_fast_info():
|
|
651
|
+
t = yf.Ticker(symbol)
|
|
652
|
+
fi = t.fast_info
|
|
653
|
+
info = {}
|
|
654
|
+
try:
|
|
655
|
+
info = t.info or {}
|
|
656
|
+
except Exception as _e:
|
|
657
|
+
logger.debug("yfinance t.info slow/failed for %s: %s", symbol, _e)
|
|
658
|
+
price = float(fi.last_price or 0)
|
|
659
|
+
prev = float(fi.previous_close or price)
|
|
660
|
+
chg = price - prev
|
|
661
|
+
chg_p = chg / prev * 100 if prev else 0
|
|
662
|
+
return {
|
|
663
|
+
"success": True,
|
|
664
|
+
"symbol": symbol.upper(),
|
|
665
|
+
"name": info.get("longName","") or info.get("shortName",""),
|
|
666
|
+
"price": round(price, 4),
|
|
667
|
+
"change": round(chg, 4),
|
|
668
|
+
"change_pct": round(chg_p, 2),
|
|
669
|
+
"volume": int(fi.three_month_average_volume or 0),
|
|
670
|
+
"market_cap": fi.market_cap,
|
|
671
|
+
"high": round(float(fi.day_high or 0), 2),
|
|
672
|
+
"low": round(float(fi.day_low or 0), 2),
|
|
673
|
+
"open": round(float(fi.open or 0), 4),
|
|
674
|
+
"prev_close": round(prev, 4),
|
|
675
|
+
"currency": fi.currency or "USD",
|
|
676
|
+
"market": "US",
|
|
677
|
+
"provider": "yfinance",
|
|
678
|
+
"timestamp": datetime.now().isoformat(),
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
# Primary attempt
|
|
682
|
+
try:
|
|
683
|
+
return _attempt_fast_info()
|
|
684
|
+
except Exception as e:
|
|
685
|
+
err_text = str(e).lower()
|
|
686
|
+
is_rate_limit = any(t in err_text for t in ("too many", "rate", "429", "429"))
|
|
687
|
+
if not is_rate_limit:
|
|
688
|
+
yc = self._quote_yahoo_chart(symbol)
|
|
689
|
+
if yc.get("success"):
|
|
690
|
+
return yc
|
|
691
|
+
stooq = self._quote_stooq(symbol)
|
|
692
|
+
if stooq.get("success"):
|
|
693
|
+
return stooq
|
|
694
|
+
return {"success": False, "error": str(e), "symbol": symbol}
|
|
695
|
+
|
|
696
|
+
# Rate-limited: wait 3s then retry once
|
|
697
|
+
logger.debug("yfinance rate-limited for %s, retrying in 3s…", symbol)
|
|
698
|
+
time.sleep(3)
|
|
699
|
+
try:
|
|
700
|
+
return _attempt_fast_info()
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
|
|
704
|
+
# Final fallback: yf.download (different API endpoint, avoids rate limit)
|
|
705
|
+
try:
|
|
706
|
+
from datetime import date as _date, timedelta as _td
|
|
707
|
+
df = yf.download(
|
|
708
|
+
symbol,
|
|
709
|
+
start=(_date.today() - _td(days=5)).isoformat(),
|
|
710
|
+
end=_date.today().isoformat(),
|
|
711
|
+
interval="1d", auto_adjust=True, progress=False, timeout=15,
|
|
712
|
+
)
|
|
713
|
+
if not df.empty:
|
|
714
|
+
if hasattr(df.columns, "levels"):
|
|
715
|
+
df.columns = df.columns.droplevel(1) if len(df.columns.levels) > 1 else df.columns
|
|
716
|
+
last = df.iloc[-1]
|
|
717
|
+
price = round(float(last.get("Close", 0)), 4)
|
|
718
|
+
prev_row = df.iloc[-2] if len(df) >= 2 else last
|
|
719
|
+
prev = round(float(prev_row.get("Close", price)), 4)
|
|
720
|
+
chg = round(price - prev, 4)
|
|
721
|
+
chg_p = round(chg / prev * 100 if prev else 0, 2)
|
|
722
|
+
return {
|
|
723
|
+
"success": True,
|
|
724
|
+
"symbol": symbol.upper(),
|
|
725
|
+
"name": "",
|
|
726
|
+
"price": price,
|
|
727
|
+
"change": chg,
|
|
728
|
+
"change_pct": chg_p,
|
|
729
|
+
"volume": int(last.get("Volume", 0)),
|
|
730
|
+
"market_cap": None,
|
|
731
|
+
"high": round(float(last.get("High", 0)), 2),
|
|
732
|
+
"low": round(float(last.get("Low", 0)), 2),
|
|
733
|
+
"open": round(float(last.get("Open", 0)), 4),
|
|
734
|
+
"prev_close": prev,
|
|
735
|
+
"currency": "USD",
|
|
736
|
+
"market": "US",
|
|
737
|
+
"provider": "yfinance_download",
|
|
738
|
+
"timestamp": datetime.now().isoformat(),
|
|
739
|
+
}
|
|
740
|
+
except Exception as _dl_err:
|
|
741
|
+
logger.debug("yfinance download fallback also failed for %s: %s", symbol, _dl_err)
|
|
742
|
+
|
|
743
|
+
# Finnhub fallback when yfinance is completely exhausted
|
|
744
|
+
if self._fh_key:
|
|
745
|
+
fh = self._quote_finnhub(symbol)
|
|
746
|
+
if fh.get("success"):
|
|
747
|
+
return fh
|
|
748
|
+
|
|
749
|
+
yc = self._quote_yahoo_chart(symbol)
|
|
750
|
+
if yc.get("success"):
|
|
751
|
+
return yc
|
|
752
|
+
|
|
753
|
+
stooq = self._quote_stooq(symbol)
|
|
754
|
+
if stooq.get("success"):
|
|
755
|
+
return stooq
|
|
756
|
+
|
|
757
|
+
return {"success": False, "error": "yfinance rate-limited or no data", "symbol": symbol}
|
|
758
|
+
|
|
759
|
+
@staticmethod
|
|
760
|
+
def _stooq_symbol(symbol: str) -> str:
|
|
761
|
+
"""Best-effort conversion from Yahoo-style tickers to Stooq tickers."""
|
|
762
|
+
s = (symbol or "").strip().lower()
|
|
763
|
+
if not s:
|
|
764
|
+
return s
|
|
765
|
+
if s.startswith("^") or "=" in s:
|
|
766
|
+
return ""
|
|
767
|
+
if "." not in s:
|
|
768
|
+
return f"{s}.us"
|
|
769
|
+
suffix_map = {
|
|
770
|
+
"de": "de",
|
|
771
|
+
"pa": "fr",
|
|
772
|
+
"as": "nl",
|
|
773
|
+
"mi": "it",
|
|
774
|
+
"mc": "es",
|
|
775
|
+
"ls": "pt",
|
|
776
|
+
"sw": "ch",
|
|
777
|
+
"l": "uk",
|
|
778
|
+
"hk": "hk",
|
|
779
|
+
}
|
|
780
|
+
base, suffix = s.rsplit(".", 1)
|
|
781
|
+
return f"{base}.{suffix_map.get(suffix, suffix)}"
|
|
782
|
+
|
|
783
|
+
def _history_stooq(self, symbol: str, days: int, interval: str = "1d") -> Dict[str, Any]:
|
|
784
|
+
if interval not in ("1d", "day", "daily"):
|
|
785
|
+
return {"success": False, "error": "stooq only supports daily history", "symbol": symbol}
|
|
786
|
+
stooq_symbol = self._stooq_symbol(symbol)
|
|
787
|
+
if not stooq_symbol:
|
|
788
|
+
return {"success": False, "error": "unsupported stooq symbol", "symbol": symbol}
|
|
789
|
+
try:
|
|
790
|
+
r = self._sess.get(
|
|
791
|
+
"https://stooq.com/q/d/l/",
|
|
792
|
+
params={"s": stooq_symbol, "i": "d"},
|
|
793
|
+
headers={"User-Agent": "Mozilla/5.0"},
|
|
794
|
+
timeout=10,
|
|
795
|
+
)
|
|
796
|
+
text = getattr(r, "text", "")
|
|
797
|
+
if not text and hasattr(r, "content"):
|
|
798
|
+
text = r.content.decode("utf-8", errors="ignore")
|
|
799
|
+
if not text or "No data" in text:
|
|
800
|
+
raise ValueError("empty Stooq response")
|
|
801
|
+
from io import StringIO
|
|
802
|
+
df = pd.read_csv(StringIO(text))
|
|
803
|
+
if df.empty or "Close" not in df.columns:
|
|
804
|
+
raise ValueError("empty Stooq dataframe")
|
|
805
|
+
df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
|
|
806
|
+
df = df.dropna(subset=["Date", "Close"]).sort_values("Date").tail(days + 5)
|
|
807
|
+
records = []
|
|
808
|
+
for _, row in df.iterrows():
|
|
809
|
+
records.append({
|
|
810
|
+
"date": str(row["Date"].date()),
|
|
811
|
+
"open": round(float(row.get("Open", row.get("Close", 0))), 4),
|
|
812
|
+
"high": round(float(row.get("High", row.get("Close", 0))), 4),
|
|
813
|
+
"low": round(float(row.get("Low", row.get("Close", 0))), 4),
|
|
814
|
+
"close": round(float(row.get("Close", 0)), 4),
|
|
815
|
+
"volume": int(float(row.get("Volume", 0) or 0)),
|
|
816
|
+
})
|
|
817
|
+
if not records:
|
|
818
|
+
raise ValueError("empty Stooq records")
|
|
819
|
+
return {
|
|
820
|
+
"success": True,
|
|
821
|
+
"symbol": symbol.upper(),
|
|
822
|
+
"data": records,
|
|
823
|
+
"provider": "stooq",
|
|
824
|
+
"provider_chain": ["yfinance", "finnhub", "stooq"],
|
|
825
|
+
"count": len(records),
|
|
826
|
+
}
|
|
827
|
+
except Exception as exc:
|
|
828
|
+
return {"success": False, "error": str(exc), "symbol": symbol}
|
|
829
|
+
|
|
830
|
+
def _history_yahoo_chart(self, symbol: str, days: int, interval: str = "1d") -> Dict[str, Any]:
|
|
831
|
+
"""Direct Yahoo chart endpoint fallback independent of yfinance objects."""
|
|
832
|
+
iv_map = {"1d": "1d", "1h": "1h", "15m": "15m", "5m": "5m"}
|
|
833
|
+
iv = iv_map.get(interval, "1d")
|
|
834
|
+
p2 = int(time.time())
|
|
835
|
+
p1 = p2 - max(days + 5, 30) * 86400
|
|
836
|
+
try:
|
|
837
|
+
r = self._sess.get(
|
|
838
|
+
f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}",
|
|
839
|
+
params={
|
|
840
|
+
"period1": p1,
|
|
841
|
+
"period2": p2,
|
|
842
|
+
"interval": iv,
|
|
843
|
+
"events": "history",
|
|
844
|
+
"includeAdjustedClose": "true",
|
|
845
|
+
},
|
|
846
|
+
headers={"User-Agent": "Mozilla/5.0", "Referer": "https://finance.yahoo.com/"},
|
|
847
|
+
timeout=12,
|
|
848
|
+
)
|
|
849
|
+
data = r.json()
|
|
850
|
+
result = (data.get("chart", {}).get("result") or [None])[0]
|
|
851
|
+
if not result:
|
|
852
|
+
raise ValueError("empty Yahoo chart result")
|
|
853
|
+
quote = ((result.get("indicators") or {}).get("quote") or [{}])[0]
|
|
854
|
+
timestamps = result.get("timestamp") or []
|
|
855
|
+
closes = quote.get("close") or []
|
|
856
|
+
def _q_at(name: str, idx: int, fallback=0):
|
|
857
|
+
values = quote.get(name) or []
|
|
858
|
+
try:
|
|
859
|
+
value = values[idx]
|
|
860
|
+
return fallback if value is None else value
|
|
861
|
+
except Exception:
|
|
862
|
+
return fallback
|
|
863
|
+
records = []
|
|
864
|
+
for idx, ts in enumerate(timestamps):
|
|
865
|
+
try:
|
|
866
|
+
close = closes[idx]
|
|
867
|
+
if close is None:
|
|
868
|
+
continue
|
|
869
|
+
records.append({
|
|
870
|
+
"date": datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d"),
|
|
871
|
+
"open": round(float(_q_at("open", idx, close) or close), 4),
|
|
872
|
+
"high": round(float(_q_at("high", idx, close) or close), 4),
|
|
873
|
+
"low": round(float(_q_at("low", idx, close) or close), 4),
|
|
874
|
+
"close": round(float(close), 4),
|
|
875
|
+
"volume": int(float(_q_at("volume", idx, 0) or 0)),
|
|
876
|
+
})
|
|
877
|
+
except Exception:
|
|
878
|
+
continue
|
|
879
|
+
if not records:
|
|
880
|
+
raise ValueError("empty Yahoo chart records")
|
|
881
|
+
return {
|
|
882
|
+
"success": True,
|
|
883
|
+
"symbol": symbol.upper(),
|
|
884
|
+
"data": records,
|
|
885
|
+
"provider": "yahoo_chart",
|
|
886
|
+
"provider_chain": ["yfinance", "yahoo_chart"],
|
|
887
|
+
"count": len(records),
|
|
888
|
+
}
|
|
889
|
+
except Exception as exc:
|
|
890
|
+
return {"success": False, "error": str(exc), "symbol": symbol}
|
|
891
|
+
|
|
892
|
+
def _quote_stooq(self, symbol: str) -> Dict[str, Any]:
|
|
893
|
+
hist = self._history_stooq(symbol, days=7, interval="1d")
|
|
894
|
+
if not hist.get("success"):
|
|
895
|
+
return hist
|
|
896
|
+
records = hist.get("data") or []
|
|
897
|
+
if not records:
|
|
898
|
+
return {"success": False, "error": "empty Stooq quote records", "symbol": symbol}
|
|
899
|
+
last = records[-1]
|
|
900
|
+
prev = records[-2] if len(records) >= 2 else last
|
|
901
|
+
price = float(last.get("close") or 0)
|
|
902
|
+
prev_close = float(prev.get("close") or price)
|
|
903
|
+
change = price - prev_close
|
|
904
|
+
change_pct = change / prev_close * 100 if prev_close else 0
|
|
905
|
+
return {
|
|
906
|
+
"success": True,
|
|
907
|
+
"symbol": symbol.upper(),
|
|
908
|
+
"name": symbol.upper(),
|
|
909
|
+
"price": round(price, 4),
|
|
910
|
+
"change": round(change, 4),
|
|
911
|
+
"change_pct": round(change_pct, 2),
|
|
912
|
+
"volume": int(last.get("volume") or 0),
|
|
913
|
+
"market_cap": None,
|
|
914
|
+
"high": round(float(last.get("high") or price), 2),
|
|
915
|
+
"low": round(float(last.get("low") or price), 2),
|
|
916
|
+
"open": round(float(last.get("open") or price), 4),
|
|
917
|
+
"prev_close": round(prev_close, 4),
|
|
918
|
+
"currency": "USD",
|
|
919
|
+
"market": "GLOBAL",
|
|
920
|
+
"provider": "stooq",
|
|
921
|
+
"provider_chain": ["yfinance", "finnhub", "stooq"],
|
|
922
|
+
"timestamp": datetime.now().isoformat(),
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
def _quote_yahoo_chart(self, symbol: str) -> Dict[str, Any]:
|
|
926
|
+
hist = self._history_yahoo_chart(symbol, days=7, interval="1d")
|
|
927
|
+
if not hist.get("success"):
|
|
928
|
+
return hist
|
|
929
|
+
records = hist.get("data") or []
|
|
930
|
+
if not records:
|
|
931
|
+
return {"success": False, "error": "empty Yahoo chart quote records", "symbol": symbol}
|
|
932
|
+
last = records[-1]
|
|
933
|
+
prev = records[-2] if len(records) >= 2 else last
|
|
934
|
+
price = float(last.get("close") or 0)
|
|
935
|
+
prev_close = float(prev.get("close") or price)
|
|
936
|
+
if price <= 0:
|
|
937
|
+
return {"success": False, "error": "price=0 from Yahoo chart", "symbol": symbol}
|
|
938
|
+
change = price - prev_close
|
|
939
|
+
change_pct = change / prev_close * 100 if prev_close else 0
|
|
940
|
+
meta_currency = ""
|
|
941
|
+
return {
|
|
942
|
+
"success": True,
|
|
943
|
+
"symbol": symbol.upper(),
|
|
944
|
+
"name": symbol.upper(),
|
|
945
|
+
"price": round(price, 4),
|
|
946
|
+
"change": round(change, 4),
|
|
947
|
+
"change_pct": round(change_pct, 2),
|
|
948
|
+
"volume": int(last.get("volume") or 0),
|
|
949
|
+
"market_cap": None,
|
|
950
|
+
"high": round(float(last.get("high") or price), 2),
|
|
951
|
+
"low": round(float(last.get("low") or price), 2),
|
|
952
|
+
"open": round(float(last.get("open") or price), 4),
|
|
953
|
+
"prev_close": round(prev_close, 4),
|
|
954
|
+
"currency": meta_currency or "USD",
|
|
955
|
+
"market": "GLOBAL",
|
|
956
|
+
"provider": "yahoo_chart",
|
|
957
|
+
"provider_chain": ["yfinance", "yahoo_chart"],
|
|
958
|
+
"timestamp": datetime.now().isoformat(),
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
def _history_yfinance(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
|
|
962
|
+
try:
|
|
963
|
+
import yfinance as yf
|
|
964
|
+
except Exception:
|
|
965
|
+
yc = self._history_yahoo_chart(symbol, days, interval)
|
|
966
|
+
if yc.get("success"):
|
|
967
|
+
return yc
|
|
968
|
+
stooq = self._history_stooq(symbol, days, interval)
|
|
969
|
+
if stooq.get("success"):
|
|
970
|
+
return stooq
|
|
971
|
+
return {"success": False, "error": "yfinance unavailable", "symbol": symbol}
|
|
972
|
+
period_map = {1: "5d", 5: "5d", 30: "1mo", 60: "3mo",
|
|
973
|
+
90: "3mo", 120: "6mo", 180: "6mo",
|
|
974
|
+
252: "1y", 365: "1y", 730: "2y", 1260: "5y"}
|
|
975
|
+
period = period_map.get(days) or f"{days}d"
|
|
976
|
+
iv_map = {"1d": "1d", "1h": "1h", "15m": "15m", "5m": "5m"}
|
|
977
|
+
iv = iv_map.get(interval, "1d")
|
|
978
|
+
|
|
979
|
+
def _df_to_records(df) -> list:
|
|
980
|
+
records = []
|
|
981
|
+
for ts, row in df.iterrows():
|
|
982
|
+
records.append({
|
|
983
|
+
"date": str(ts.date()) if hasattr(ts, "date") else str(ts)[:10],
|
|
984
|
+
"open": round(float(row.get("Open", row.get("open", 0))), 4),
|
|
985
|
+
"high": round(float(row.get("High", row.get("high", 0))), 4),
|
|
986
|
+
"low": round(float(row.get("Low", row.get("low", 0))), 4),
|
|
987
|
+
"close": round(float(row.get("Close", row.get("close", 0))), 4),
|
|
988
|
+
"volume": int(row.get("Volume", row.get("volume", 0))),
|
|
989
|
+
})
|
|
990
|
+
return records
|
|
991
|
+
|
|
992
|
+
# Primary: Ticker.history() with bounded exponential backoff on rate
|
|
993
|
+
# limits (1s, 2s) — gives the provider time to recover before falling
|
|
994
|
+
# through to the download/finnhub fallbacks. Total ≤3s so it stays
|
|
995
|
+
# well within per-agent timeouts.
|
|
996
|
+
for _attempt in range(3):
|
|
997
|
+
try:
|
|
998
|
+
df = yf.Ticker(symbol).history(period=period, interval=iv, auto_adjust=True)
|
|
999
|
+
if not df.empty:
|
|
1000
|
+
records = _df_to_records(df)
|
|
1001
|
+
return {"success": True, "symbol": symbol, "data": records,
|
|
1002
|
+
"provider": "yfinance", "count": len(records)}
|
|
1003
|
+
break # empty (not a rate limit) → go straight to fallback
|
|
1004
|
+
except Exception as _e:
|
|
1005
|
+
_err = str(_e).lower()
|
|
1006
|
+
_is_rl = any(t in _err for t in ("too many", "rate", "429"))
|
|
1007
|
+
if _is_rl and _attempt < 2:
|
|
1008
|
+
logger.debug("yfinance history rate-limited for %s, backoff %ss",
|
|
1009
|
+
symbol, 2 ** _attempt)
|
|
1010
|
+
time.sleep(1.0 * (2 ** _attempt)) # 1s, then 2s
|
|
1011
|
+
continue
|
|
1012
|
+
logger.debug("yfinance history primary failed for %s: %s — trying download fallback", symbol, _e)
|
|
1013
|
+
break
|
|
1014
|
+
|
|
1015
|
+
# Fallback: yf.download() uses a different API endpoint, more resilient to rate limits
|
|
1016
|
+
try:
|
|
1017
|
+
from datetime import date, timedelta as _td
|
|
1018
|
+
end_dt = date.today()
|
|
1019
|
+
start_dt = end_dt - _td(days=days + 5)
|
|
1020
|
+
df2 = yf.download(symbol, start=start_dt.isoformat(), end=end_dt.isoformat(),
|
|
1021
|
+
interval=iv, auto_adjust=True, progress=False, timeout=15)
|
|
1022
|
+
if not df2.empty:
|
|
1023
|
+
# yf.download may return MultiIndex columns when single ticker
|
|
1024
|
+
if hasattr(df2.columns, "levels"):
|
|
1025
|
+
df2.columns = df2.columns.droplevel(1) if len(df2.columns.levels) > 1 else df2.columns
|
|
1026
|
+
records = _df_to_records(df2)
|
|
1027
|
+
return {"success": True, "symbol": symbol, "data": records,
|
|
1028
|
+
"provider": "yfinance_download", "count": len(records)}
|
|
1029
|
+
except Exception as _e:
|
|
1030
|
+
logger.debug("yfinance download fallback also failed for %s: %s", symbol, _e)
|
|
1031
|
+
|
|
1032
|
+
# Finnhub candle fallback
|
|
1033
|
+
yc = self._history_yahoo_chart(symbol, days, interval)
|
|
1034
|
+
if yc.get("success"):
|
|
1035
|
+
return yc
|
|
1036
|
+
|
|
1037
|
+
if self._fh_key:
|
|
1038
|
+
fh = self._history_finnhub(symbol, days, interval)
|
|
1039
|
+
if fh.get("success"):
|
|
1040
|
+
return fh
|
|
1041
|
+
|
|
1042
|
+
stooq = self._history_stooq(symbol, days, interval)
|
|
1043
|
+
if stooq.get("success"):
|
|
1044
|
+
return stooq
|
|
1045
|
+
|
|
1046
|
+
return {
|
|
1047
|
+
"success": False,
|
|
1048
|
+
"error": (
|
|
1049
|
+
"global history unavailable: "
|
|
1050
|
+
f"yfinance/yahoo_chart/finnhub/stooq failed; "
|
|
1051
|
+
f"yahoo_chart={yc.get('error')}; stooq={stooq.get('error')}"
|
|
1052
|
+
),
|
|
1053
|
+
"symbol": symbol,
|
|
1054
|
+
"provider_chain": ["yfinance", "yahoo_chart", "finnhub", "stooq"],
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
# ── A-share (Eastmoney push2 API) ────────────────────────────────────────
|
|
1058
|
+
|
|
1059
|
+
def _quote_ashare(self, symbol: str) -> Dict[str, Any]:
|
|
1060
|
+
"""A股报价: 用户配置的 Tushare 优先(若有),再东方财富,yfinance 末级 fallback."""
|
|
1061
|
+
code = _normalise_ashare(symbol)
|
|
1062
|
+
errors: List[str] = []
|
|
1063
|
+
|
|
1064
|
+
# ── 优先路径: 用户配置的 Tushare(仅当 TUSHARE_TOKEN 已设置)──────────
|
|
1065
|
+
_ts = self._tushare()
|
|
1066
|
+
if _ts is not None:
|
|
1067
|
+
try:
|
|
1068
|
+
q = _ts.quote(code)
|
|
1069
|
+
if q is not None and _is_valid_price(float(q.price or 0)):
|
|
1070
|
+
return {
|
|
1071
|
+
"success": True,
|
|
1072
|
+
"symbol": code,
|
|
1073
|
+
"name": q.name or code,
|
|
1074
|
+
"price": round(float(q.price), 4),
|
|
1075
|
+
"change": round(float(q.change or 0), 4),
|
|
1076
|
+
"change_pct": round(float(q.change_pct or 0), 2),
|
|
1077
|
+
"volume": int(float(q.volume or 0)),
|
|
1078
|
+
"currency": "CNY",
|
|
1079
|
+
"market": "CN",
|
|
1080
|
+
"provider": "tushare",
|
|
1081
|
+
"provider_chain": ["tushare"],
|
|
1082
|
+
"timestamp": q.timestamp or datetime.now().isoformat(),
|
|
1083
|
+
}
|
|
1084
|
+
except Exception as ts_err:
|
|
1085
|
+
errors.append(f"tushare: {ts_err}")
|
|
1086
|
+
logger.debug("Tushare A-share quote failed %s: %s", code, ts_err)
|
|
1087
|
+
|
|
1088
|
+
# ── 主路径: 东方财富 push2 API ─────────────────────────────────
|
|
1089
|
+
secid = _ashare_secid(code)
|
|
1090
|
+
try:
|
|
1091
|
+
_resp = self._em_get_json(self.EM_QUOTE_URL, {
|
|
1092
|
+
"secid": secid,
|
|
1093
|
+
"fields": self._EM_FIELDS,
|
|
1094
|
+
"ut": _EM_UT,
|
|
1095
|
+
"fltt": 2, "invt": 2,
|
|
1096
|
+
}, timeout=6)
|
|
1097
|
+
d = (_resp or {}).get("data", {}) or {}
|
|
1098
|
+
price = float(d.get("f43", 0))
|
|
1099
|
+
if not _is_valid_price(price):
|
|
1100
|
+
raise ValueError("empty Eastmoney quote")
|
|
1101
|
+
chg = float(d.get("f169", 0))
|
|
1102
|
+
chg_pct = float(d.get("f170", 0))
|
|
1103
|
+
prev = round(price - chg, 4) # f46=今开(open), not 昨收; derive from change
|
|
1104
|
+
return {
|
|
1105
|
+
"success": True,
|
|
1106
|
+
"symbol": code,
|
|
1107
|
+
"name": d.get("f58", code),
|
|
1108
|
+
"price": price,
|
|
1109
|
+
"change": chg,
|
|
1110
|
+
"change_pct": chg_pct,
|
|
1111
|
+
"volume": int(d.get("f47", 0)),
|
|
1112
|
+
"turnover": float(d.get("f48", 0)),
|
|
1113
|
+
"market_cap": float(d.get("f116", 0)) * 1e4,
|
|
1114
|
+
"high": float(d.get("f44", 0)),
|
|
1115
|
+
"low": float(d.get("f45", 0)),
|
|
1116
|
+
"open": float(d.get("f46", 0)),
|
|
1117
|
+
"prev_close": prev,
|
|
1118
|
+
"currency": "CNY",
|
|
1119
|
+
"market": "CN",
|
|
1120
|
+
"provider": "eastmoney",
|
|
1121
|
+
"provider_chain": ["eastmoney"],
|
|
1122
|
+
"timestamp": datetime.now().isoformat(),
|
|
1123
|
+
}
|
|
1124
|
+
except Exception as em_err:
|
|
1125
|
+
errors.append(f"eastmoney: {em_err}")
|
|
1126
|
+
logger.debug("Eastmoney A-share failed %s: %s", code, em_err)
|
|
1127
|
+
|
|
1128
|
+
# ── 备用路径 1: 腾讯行情 qt.gtimg.cn ─────────────────────────────────
|
|
1129
|
+
try:
|
|
1130
|
+
prefix = "sz" if code.startswith(("0", "3")) else "sh"
|
|
1131
|
+
r = self._sess.get(f"https://qt.gtimg.cn/q={prefix}{code}", timeout=6)
|
|
1132
|
+
raw = r.text.strip()
|
|
1133
|
+
if raw and "=" in raw:
|
|
1134
|
+
val = raw.split("=", 1)[1].strip().strip('"').strip("'")
|
|
1135
|
+
flds = val.split("~")
|
|
1136
|
+
if len(flds) > 10 and flds[3]:
|
|
1137
|
+
price = float(flds[3])
|
|
1138
|
+
prev = float(flds[4] or price)
|
|
1139
|
+
chg = price - prev
|
|
1140
|
+
chg_p = chg / prev * 100 if prev else 0
|
|
1141
|
+
if _is_valid_price(price):
|
|
1142
|
+
return {
|
|
1143
|
+
"success": True,
|
|
1144
|
+
"symbol": code,
|
|
1145
|
+
"name": flds[1] or code,
|
|
1146
|
+
"price": round(price, 4),
|
|
1147
|
+
"change": round(chg, 4),
|
|
1148
|
+
"change_pct": round(chg_p, 2),
|
|
1149
|
+
"volume": int(float(flds[6] or 0)) * 100,
|
|
1150
|
+
"high": round(float(flds[33] if len(flds) > 33 and flds[33] else price), 2),
|
|
1151
|
+
"low": round(float(flds[34] if len(flds) > 34 and flds[34] else price), 2),
|
|
1152
|
+
"open": round(float(flds[5] or price), 4),
|
|
1153
|
+
"prev_close": round(prev, 4),
|
|
1154
|
+
"currency": "CNY",
|
|
1155
|
+
"market": "CN",
|
|
1156
|
+
"provider": "tencent",
|
|
1157
|
+
"provider_chain": ["eastmoney", "tencent"],
|
|
1158
|
+
"timestamp": datetime.now().isoformat(),
|
|
1159
|
+
}
|
|
1160
|
+
raise ValueError(f"invalid tencent price: {flds[3] if len(flds) > 3 else 'N/A'}")
|
|
1161
|
+
except Exception as tx_err:
|
|
1162
|
+
errors.append(f"tencent: {tx_err}")
|
|
1163
|
+
logger.debug("Tencent A-share failed %s: %s", code, tx_err)
|
|
1164
|
+
|
|
1165
|
+
# ── 备用路径 2: 新浪行情 hq.sinajs.cn ────────────────────────────────
|
|
1166
|
+
try:
|
|
1167
|
+
prefix = "sz" if code.startswith(("0", "3")) else "sh"
|
|
1168
|
+
r = self._sess.get(f"https://hq.sinajs.cn/list={prefix}{code}",
|
|
1169
|
+
headers={"Referer": "https://finance.sina.com.cn/"},
|
|
1170
|
+
timeout=6)
|
|
1171
|
+
raw = r.text.strip()
|
|
1172
|
+
if raw and "=" in raw:
|
|
1173
|
+
val = raw.split("=", 1)[1].strip().strip('"').strip("'")
|
|
1174
|
+
flds = val.split(",")
|
|
1175
|
+
if len(flds) > 9 and flds[3]:
|
|
1176
|
+
price = float(flds[3])
|
|
1177
|
+
prev = float(flds[2] or price)
|
|
1178
|
+
chg = price - prev
|
|
1179
|
+
chg_p = chg / prev * 100 if prev else 0
|
|
1180
|
+
if _is_valid_price(price):
|
|
1181
|
+
return {
|
|
1182
|
+
"success": True,
|
|
1183
|
+
"symbol": code,
|
|
1184
|
+
"name": flds[0] or code,
|
|
1185
|
+
"price": round(price, 4),
|
|
1186
|
+
"change": round(chg, 4),
|
|
1187
|
+
"change_pct": round(chg_p, 2),
|
|
1188
|
+
"volume": int(float(flds[8] or 0)),
|
|
1189
|
+
"turnover": float(flds[9] or 0),
|
|
1190
|
+
"high": round(float(flds[4] or price), 2),
|
|
1191
|
+
"low": round(float(flds[5] or price), 2),
|
|
1192
|
+
"open": round(float(flds[1] or price), 4),
|
|
1193
|
+
"prev_close": round(prev, 4),
|
|
1194
|
+
"currency": "CNY",
|
|
1195
|
+
"market": "CN",
|
|
1196
|
+
"provider": "sina",
|
|
1197
|
+
"provider_chain": ["eastmoney", "tencent", "sina"],
|
|
1198
|
+
"timestamp": datetime.now().isoformat(),
|
|
1199
|
+
}
|
|
1200
|
+
raise ValueError(f"invalid sina price: {flds[3] if len(flds) > 3 else 'N/A'}")
|
|
1201
|
+
except Exception as sina_err:
|
|
1202
|
+
errors.append(f"sina: {sina_err}")
|
|
1203
|
+
logger.debug("Sina A-share failed %s: %s", code, sina_err)
|
|
1204
|
+
|
|
1205
|
+
# ── 备用路径 3: AKShare snapshot(如果本地安装)──────────────────────
|
|
1206
|
+
# AKShare uses its own requests sessions; clear proxy env vars so it
|
|
1207
|
+
# connects directly instead of routing through the China VPN (which
|
|
1208
|
+
# rejects AKShare's Eastmoney endpoints with ProxyError).
|
|
1209
|
+
try:
|
|
1210
|
+
import akshare as ak
|
|
1211
|
+
import os as _ak_os
|
|
1212
|
+
_ak_proxy_bk = {k: _ak_os.environ.pop(k, None)
|
|
1213
|
+
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
|
|
1214
|
+
try:
|
|
1215
|
+
df = ak.stock_zh_a_spot_em()
|
|
1216
|
+
finally:
|
|
1217
|
+
for _k, _v in _ak_proxy_bk.items():
|
|
1218
|
+
if _v is not None: _ak_os.environ[_k] = _v
|
|
1219
|
+
row = df[df["代码"].astype(str) == code]
|
|
1220
|
+
if row.empty:
|
|
1221
|
+
raise ValueError("empty AKShare quote")
|
|
1222
|
+
item = row.iloc[0]
|
|
1223
|
+
price = float(item.get("最新价", 0))
|
|
1224
|
+
if not _is_valid_price(price):
|
|
1225
|
+
raise ValueError("empty AKShare price")
|
|
1226
|
+
return {
|
|
1227
|
+
"success": True,
|
|
1228
|
+
"symbol": code,
|
|
1229
|
+
"name": str(item.get("名称", code)),
|
|
1230
|
+
"price": price,
|
|
1231
|
+
"change": float(item.get("涨跌额", 0) or 0),
|
|
1232
|
+
"change_pct": float(item.get("涨跌幅", 0) or 0),
|
|
1233
|
+
"volume": int(float(item.get("成交量", 0) or 0)),
|
|
1234
|
+
"turnover": float(item.get("成交额", 0) or 0),
|
|
1235
|
+
"market_cap": float(item.get("总市值", 0) or 0),
|
|
1236
|
+
"high": float(item.get("最高", 0) or 0),
|
|
1237
|
+
"low": float(item.get("最低", 0) or 0),
|
|
1238
|
+
"open": float(item.get("今开", 0) or 0),
|
|
1239
|
+
"prev_close": float(item.get("昨收", 0) or 0),
|
|
1240
|
+
"currency": "CNY",
|
|
1241
|
+
"market": "CN",
|
|
1242
|
+
"provider": "akshare",
|
|
1243
|
+
"provider_chain": ["eastmoney", "tencent", "sina", "akshare"],
|
|
1244
|
+
"timestamp": datetime.now().isoformat(),
|
|
1245
|
+
}
|
|
1246
|
+
except Exception as ak_err:
|
|
1247
|
+
errors.append(f"akshare: {ak_err}")
|
|
1248
|
+
logger.debug("AKShare A-share failed %s: %s", code, ak_err)
|
|
1249
|
+
|
|
1250
|
+
# ── 末级 fallback: yfinance via Yahoo Finance(全球可访问,明确绕过代理)──
|
|
1251
|
+
# Yahoo Finance is accessible globally; bypass any China-routing proxy so
|
|
1252
|
+
# this fallback works even when the VPN/proxy is down.
|
|
1253
|
+
try:
|
|
1254
|
+
import yfinance as yf
|
|
1255
|
+
import os as _os
|
|
1256
|
+
suffix = ".SS" if code.startswith(("6", "688", "83", "87")) else ".SZ"
|
|
1257
|
+
yf_sym = code + suffix
|
|
1258
|
+
# Temporarily clear proxy env vars so yfinance connects directly to Yahoo
|
|
1259
|
+
_proxy_backup = {k: _os.environ.pop(k, None)
|
|
1260
|
+
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
|
|
1261
|
+
try:
|
|
1262
|
+
t = yf.Ticker(yf_sym)
|
|
1263
|
+
fi = t.fast_info
|
|
1264
|
+
price = float(fi.last_price or 0)
|
|
1265
|
+
if not _is_valid_price(price):
|
|
1266
|
+
# fast_info may return None outside trading hours; use history
|
|
1267
|
+
h = t.history(period="2d", auto_adjust=True)
|
|
1268
|
+
if h.empty:
|
|
1269
|
+
raise ValueError("empty yfinance history")
|
|
1270
|
+
price = float(h["Close"].iloc[-1])
|
|
1271
|
+
prev = float(h["Close"].iloc[-2]) if len(h) >= 2 else price
|
|
1272
|
+
else:
|
|
1273
|
+
prev = float(fi.previous_close or price)
|
|
1274
|
+
finally:
|
|
1275
|
+
for k, v in _proxy_backup.items():
|
|
1276
|
+
if v is not None:
|
|
1277
|
+
_os.environ[k] = v
|
|
1278
|
+
if not _is_valid_price(price):
|
|
1279
|
+
raise ValueError("empty yfinance quote")
|
|
1280
|
+
chg = price - prev
|
|
1281
|
+
chg_p = chg / prev * 100 if prev else 0
|
|
1282
|
+
return {
|
|
1283
|
+
"success": True,
|
|
1284
|
+
"symbol": code,
|
|
1285
|
+
"name": code,
|
|
1286
|
+
"price": round(price, 4),
|
|
1287
|
+
"change": round(chg, 4),
|
|
1288
|
+
"change_pct": round(chg_p, 2),
|
|
1289
|
+
"volume": int(getattr(fi, "three_month_average_volume", None) or 0),
|
|
1290
|
+
"market_cap": getattr(fi, "market_cap", None),
|
|
1291
|
+
"high": round(float(getattr(fi, "day_high", None) or 0), 2),
|
|
1292
|
+
"low": round(float(getattr(fi, "day_low", None) or 0), 2),
|
|
1293
|
+
"open": round(float(getattr(fi, "open", None) or 0), 4),
|
|
1294
|
+
"prev_close": round(prev, 4),
|
|
1295
|
+
"currency": "CNY",
|
|
1296
|
+
"market": "CN",
|
|
1297
|
+
"provider": "yfinance",
|
|
1298
|
+
"provider_chain": ["eastmoney", "tencent", "sina", "akshare", "yfinance"],
|
|
1299
|
+
"timestamp": datetime.now().isoformat(),
|
|
1300
|
+
}
|
|
1301
|
+
except Exception as yf_err:
|
|
1302
|
+
errors.append(f"yfinance: {yf_err}")
|
|
1303
|
+
logger.debug("yfinance A-share failed %s: %s", code, yf_err)
|
|
1304
|
+
|
|
1305
|
+
return {
|
|
1306
|
+
"success": False,
|
|
1307
|
+
"symbol": code,
|
|
1308
|
+
"market": "CN",
|
|
1309
|
+
"provider_chain": ["eastmoney", "tencent", "sina", "akshare", "yfinance"],
|
|
1310
|
+
"error": _friendly_market_error(
|
|
1311
|
+
code, ["Eastmoney", "腾讯", "新浪", "AKShare", "Yahoo Finance"],
|
|
1312
|
+
"; ".join(errors)
|
|
1313
|
+
),
|
|
1314
|
+
"debug_error": "; ".join(errors),
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
def _history_ashare(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
|
|
1318
|
+
code = _normalise_ashare(symbol)
|
|
1319
|
+
secid = _ashare_secid(code)
|
|
1320
|
+
errors: List[str] = []
|
|
1321
|
+
klt_map = {"1d": 101, "1w": 102, "1mo": 103, "1h": 60, "30m": 30}
|
|
1322
|
+
klt = klt_map.get(interval, 101)
|
|
1323
|
+
end_date = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
1324
|
+
|
|
1325
|
+
# ── 优先路径: 用户配置的 Tushare(仅日线,仅当 TUSHARE_TOKEN 已设置)──
|
|
1326
|
+
_ts = self._tushare()
|
|
1327
|
+
if _ts is not None and interval == "1d":
|
|
1328
|
+
try:
|
|
1329
|
+
h = _ts.history(code, days=days)
|
|
1330
|
+
if h is not None and h.data is not None and not h.data.empty:
|
|
1331
|
+
records = []
|
|
1332
|
+
for idx, row in h.data.iterrows():
|
|
1333
|
+
records.append({
|
|
1334
|
+
"date": idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx)[:10],
|
|
1335
|
+
"open": float(row.get("open", 0) or 0),
|
|
1336
|
+
"high": float(row.get("high", 0) or 0),
|
|
1337
|
+
"low": float(row.get("low", 0) or 0),
|
|
1338
|
+
"close": float(row.get("close", 0) or 0),
|
|
1339
|
+
"volume": int(float(row.get("volume", 0) or 0)),
|
|
1340
|
+
})
|
|
1341
|
+
if records:
|
|
1342
|
+
return {"success": True, "symbol": code, "name": code,
|
|
1343
|
+
"data": records, "provider": "tushare",
|
|
1344
|
+
"provider_chain": ["tushare"], "count": len(records)}
|
|
1345
|
+
except Exception as ts_err:
|
|
1346
|
+
errors.append(f"tushare: {ts_err}")
|
|
1347
|
+
logger.debug("Tushare history failed %s: %s", code, ts_err)
|
|
1348
|
+
|
|
1349
|
+
# ── 主路径: 东方财富历史 K线(通过系统代理)──────────────────────────
|
|
1350
|
+
try:
|
|
1351
|
+
_resp = self._em_get_json(self.EM_HIST_URL, {
|
|
1352
|
+
"secid": secid,
|
|
1353
|
+
"klt": klt,
|
|
1354
|
+
"fqt": 1, # 前复权
|
|
1355
|
+
"lmt": days + 50,
|
|
1356
|
+
"end": end_date,
|
|
1357
|
+
"fields1": "f1,f2,f3,f4,f5,f6",
|
|
1358
|
+
"fields2": "f51,f52,f53,f54,f55,f56",
|
|
1359
|
+
"ut": _EM_UT,
|
|
1360
|
+
}, timeout=10)
|
|
1361
|
+
raw = (_resp or {}).get("data", {}) or {}
|
|
1362
|
+
name = raw.get("name", code)
|
|
1363
|
+
klines = raw.get("klines", [])
|
|
1364
|
+
records = []
|
|
1365
|
+
for k in klines:
|
|
1366
|
+
parts = k.split(",")
|
|
1367
|
+
if len(parts) >= 6:
|
|
1368
|
+
records.append({
|
|
1369
|
+
"date": parts[0],
|
|
1370
|
+
"open": float(parts[1]),
|
|
1371
|
+
"close": float(parts[2]),
|
|
1372
|
+
"high": float(parts[3]),
|
|
1373
|
+
"low": float(parts[4]),
|
|
1374
|
+
"volume": int(float(parts[5])),
|
|
1375
|
+
})
|
|
1376
|
+
if records:
|
|
1377
|
+
return {"success": True, "symbol": code, "name": name,
|
|
1378
|
+
"data": records, "provider": "eastmoney",
|
|
1379
|
+
"provider_chain": ["eastmoney"], "count": len(records)}
|
|
1380
|
+
raise ValueError("empty Eastmoney kline response")
|
|
1381
|
+
except Exception as em_err:
|
|
1382
|
+
errors.append(f"eastmoney: {em_err}")
|
|
1383
|
+
logger.debug("Eastmoney history failed %s: %s", code, em_err)
|
|
1384
|
+
|
|
1385
|
+
# ── 备用 1: 新浪 K线(scale=240 = 日线,datalen ≈ days)────────────────
|
|
1386
|
+
try:
|
|
1387
|
+
import json as _json
|
|
1388
|
+
prefix = "sz" if code.startswith(("0", "3")) else "sh"
|
|
1389
|
+
datalen = min(max(days, 60), 1023)
|
|
1390
|
+
r = self._sess.get(
|
|
1391
|
+
"https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData",
|
|
1392
|
+
params={"symbol": f"{prefix}{code}", "scale": 240, "ma": "no", "datalen": datalen},
|
|
1393
|
+
headers={"Referer": "https://finance.sina.com.cn/"},
|
|
1394
|
+
timeout=10,
|
|
1395
|
+
)
|
|
1396
|
+
raw_list = _json.loads(r.text) if r.status_code == 200 else []
|
|
1397
|
+
records = []
|
|
1398
|
+
for item in raw_list:
|
|
1399
|
+
records.append({
|
|
1400
|
+
"date": item["day"],
|
|
1401
|
+
"open": float(item.get("open", 0)),
|
|
1402
|
+
"high": float(item.get("high", 0)),
|
|
1403
|
+
"low": float(item.get("low", 0)),
|
|
1404
|
+
"close": float(item.get("close", 0)),
|
|
1405
|
+
"volume": int(float(item.get("volume", 0))),
|
|
1406
|
+
})
|
|
1407
|
+
if records:
|
|
1408
|
+
return {"success": True, "symbol": code, "name": code,
|
|
1409
|
+
"data": records, "provider": "sina",
|
|
1410
|
+
"provider_chain": ["eastmoney", "sina"], "count": len(records)}
|
|
1411
|
+
raise ValueError("empty Sina kline response")
|
|
1412
|
+
except Exception as sina_err:
|
|
1413
|
+
errors.append(f"sina: {sina_err}")
|
|
1414
|
+
logger.debug("Sina history failed %s: %s", code, sina_err)
|
|
1415
|
+
|
|
1416
|
+
# ── 备用 2: AKShare 历史数据(如果本地安装)──────────────────────────
|
|
1417
|
+
if interval == "1d":
|
|
1418
|
+
try:
|
|
1419
|
+
import akshare as ak
|
|
1420
|
+
import os as _ak_os
|
|
1421
|
+
start_date = (datetime.now() - timedelta(days=days + 30)).strftime("%Y%m%d")
|
|
1422
|
+
end_day = datetime.now().strftime("%Y%m%d")
|
|
1423
|
+
_ak_proxy_bk = {k: _ak_os.environ.pop(k, None)
|
|
1424
|
+
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
|
|
1425
|
+
try:
|
|
1426
|
+
df = ak.stock_zh_a_hist(
|
|
1427
|
+
symbol=code,
|
|
1428
|
+
period="daily",
|
|
1429
|
+
start_date=start_date,
|
|
1430
|
+
end_date=end_day,
|
|
1431
|
+
adjust="qfq",
|
|
1432
|
+
)
|
|
1433
|
+
finally:
|
|
1434
|
+
for _k, _v in _ak_proxy_bk.items():
|
|
1435
|
+
if _v is not None:
|
|
1436
|
+
_ak_os.environ[_k] = _v
|
|
1437
|
+
if df.empty:
|
|
1438
|
+
raise ValueError("empty AKShare history")
|
|
1439
|
+
records = []
|
|
1440
|
+
for _, row in df.tail(days + 5).iterrows():
|
|
1441
|
+
records.append({
|
|
1442
|
+
"date": str(row.get("日期", ""))[:10],
|
|
1443
|
+
"open": float(row.get("开盘", 0) or 0),
|
|
1444
|
+
"high": float(row.get("最高", 0) or 0),
|
|
1445
|
+
"low": float(row.get("最低", 0) or 0),
|
|
1446
|
+
"close": float(row.get("收盘", 0) or 0),
|
|
1447
|
+
"volume": int(float(row.get("成交量", 0) or 0)),
|
|
1448
|
+
})
|
|
1449
|
+
if records:
|
|
1450
|
+
return {"success": True, "symbol": code, "name": code,
|
|
1451
|
+
"data": records, "provider": "akshare",
|
|
1452
|
+
"provider_chain": ["eastmoney", "sina", "akshare"],
|
|
1453
|
+
"count": len(records)}
|
|
1454
|
+
raise ValueError("empty AKShare records")
|
|
1455
|
+
except Exception as ak_err:
|
|
1456
|
+
errors.append(f"akshare: {ak_err}")
|
|
1457
|
+
logger.debug("AKShare history failed %s: %s", code, ak_err)
|
|
1458
|
+
else:
|
|
1459
|
+
errors.append(f"akshare: unsupported interval {interval}")
|
|
1460
|
+
|
|
1461
|
+
# ── 备用 3: yfinance Yahoo Finance(绕过代理,全球可访问)────────────────
|
|
1462
|
+
try:
|
|
1463
|
+
import yfinance as yf
|
|
1464
|
+
import os as _os
|
|
1465
|
+
suffix = ".SS" if code.startswith(("6", "688", "83", "87")) else ".SZ"
|
|
1466
|
+
yf_sym = code + suffix
|
|
1467
|
+
period_map = {30: "1mo", 60: "3mo", 90: "3mo", 120: "6mo",
|
|
1468
|
+
180: "6mo", 252: "1y", 365: "1y", 730: "2y"}
|
|
1469
|
+
period = period_map.get(days) or f"{days}d"
|
|
1470
|
+
iv_map = {"1d": "1d", "1h": "1h", "15m": "15m"}
|
|
1471
|
+
iv = iv_map.get(interval, "1d")
|
|
1472
|
+
|
|
1473
|
+
_proxy_backup = {k: _os.environ.pop(k, None)
|
|
1474
|
+
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
|
|
1475
|
+
try:
|
|
1476
|
+
df = yf.Ticker(yf_sym).history(period=period, interval=iv, auto_adjust=True)
|
|
1477
|
+
if df.empty:
|
|
1478
|
+
df = yf.download(yf_sym, period=period, interval=iv,
|
|
1479
|
+
auto_adjust=True, progress=False, timeout=15)
|
|
1480
|
+
if hasattr(df.columns, "levels") and len(df.columns.levels) > 1:
|
|
1481
|
+
df.columns = df.columns.droplevel(1)
|
|
1482
|
+
finally:
|
|
1483
|
+
for k, v in _proxy_backup.items():
|
|
1484
|
+
if v is not None:
|
|
1485
|
+
_os.environ[k] = v
|
|
1486
|
+
|
|
1487
|
+
if df.empty:
|
|
1488
|
+
raise ValueError("empty yfinance dataframe")
|
|
1489
|
+
records = []
|
|
1490
|
+
for ts, row in df.iterrows():
|
|
1491
|
+
records.append({
|
|
1492
|
+
"date": str(ts.date()) if hasattr(ts, "date") else str(ts)[:10],
|
|
1493
|
+
"open": round(float(row.get("Open", row.get("open", 0))), 4),
|
|
1494
|
+
"high": round(float(row.get("High", row.get("high", 0))), 4),
|
|
1495
|
+
"low": round(float(row.get("Low", row.get("low", 0))), 4),
|
|
1496
|
+
"close": round(float(row.get("Close", row.get("close", 0))), 4),
|
|
1497
|
+
"volume": int(row.get("Volume", row.get("volume", 0))),
|
|
1498
|
+
})
|
|
1499
|
+
return {"success": True, "symbol": code, "name": code,
|
|
1500
|
+
"data": records, "provider": "yfinance",
|
|
1501
|
+
"provider_chain": ["eastmoney", "sina", "akshare", "yfinance"],
|
|
1502
|
+
"count": len(records)}
|
|
1503
|
+
except Exception as yf_err:
|
|
1504
|
+
errors.append(f"yfinance: {yf_err}")
|
|
1505
|
+
logger.debug("yfinance history fallback failed %s: %s", code, yf_err)
|
|
1506
|
+
return {
|
|
1507
|
+
"success": False,
|
|
1508
|
+
"symbol": code,
|
|
1509
|
+
"market": "CN",
|
|
1510
|
+
"provider_chain": ["eastmoney", "sina", "akshare", "yfinance"],
|
|
1511
|
+
"error": _friendly_market_error(
|
|
1512
|
+
code, ["Eastmoney", "新浪", "AKShare", "Yahoo Finance"],
|
|
1513
|
+
"; ".join(errors),
|
|
1514
|
+
),
|
|
1515
|
+
"debug_error": "; ".join(errors),
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
def _fundamentals_ashare(self, symbol: str) -> Dict[str, Any]:
|
|
1519
|
+
"""A股基本面:东方财富个股资金流."""
|
|
1520
|
+
code = _normalise_ashare(symbol)
|
|
1521
|
+
# 通过 yfinance 尝试 (港股 / ADR)
|
|
1522
|
+
try:
|
|
1523
|
+
import yfinance as yf
|
|
1524
|
+
yf_sym = code + ".SS" if code.startswith("6") else code + ".SZ"
|
|
1525
|
+
return self._quote_yfinance(yf_sym)
|
|
1526
|
+
except Exception as e:
|
|
1527
|
+
return {"success": False, "error": str(e), "symbol": symbol}
|
|
1528
|
+
|
|
1529
|
+
# ── Crypto (ccxt) ────────────────────────────────────────────────────────
|
|
1530
|
+
|
|
1531
|
+
def _quote_crypto(self, symbol: str) -> Dict[str, Any]:
|
|
1532
|
+
try:
|
|
1533
|
+
import ccxt
|
|
1534
|
+
sym = _norm_crypto(symbol)
|
|
1535
|
+
ex = ccxt.binance({"enableRateLimit": True,
|
|
1536
|
+
"proxies": {"http": "", "https": ""}})
|
|
1537
|
+
ticker = ex.fetch_ticker(sym)
|
|
1538
|
+
price = float(ticker["last"] or 0)
|
|
1539
|
+
prev = float(ticker.get("previousClose") or ticker.get("open") or price)
|
|
1540
|
+
chg = price - prev
|
|
1541
|
+
chg_p = chg / prev * 100 if prev else 0
|
|
1542
|
+
return {
|
|
1543
|
+
"success": True,
|
|
1544
|
+
"symbol": sym,
|
|
1545
|
+
"price": price,
|
|
1546
|
+
"change": round(chg, 6),
|
|
1547
|
+
"change_pct": round(chg_p, 2),
|
|
1548
|
+
"volume": float(ticker.get("baseVolume", 0)),
|
|
1549
|
+
"high": float(ticker.get("high", 0) or 0),
|
|
1550
|
+
"low": float(ticker.get("low", 0) or 0),
|
|
1551
|
+
"market_cap": None,
|
|
1552
|
+
"currency": "USDT",
|
|
1553
|
+
"market": "CRYPTO",
|
|
1554
|
+
"provider": "ccxt/binance",
|
|
1555
|
+
"timestamp": datetime.now().isoformat(),
|
|
1556
|
+
}
|
|
1557
|
+
except Exception as e:
|
|
1558
|
+
# fallback to yfinance
|
|
1559
|
+
yf_sym = symbol.replace("/","").replace("USDT","-USD")
|
|
1560
|
+
return self._quote_yfinance(yf_sym)
|
|
1561
|
+
|
|
1562
|
+
def _history_crypto(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
|
|
1563
|
+
try:
|
|
1564
|
+
import ccxt
|
|
1565
|
+
sym = _norm_crypto(symbol)
|
|
1566
|
+
iv_map = {"1d":"1d","1h":"1h","15m":"15m","4h":"4h"}
|
|
1567
|
+
tf = iv_map.get(interval, "1d")
|
|
1568
|
+
limit = min(days, 1000)
|
|
1569
|
+
ex = ccxt.binance({"enableRateLimit": True,
|
|
1570
|
+
"proxies": {"http": "", "https": ""}})
|
|
1571
|
+
ohlcv = ex.fetch_ohlcv(sym, timeframe=tf, limit=limit)
|
|
1572
|
+
records = [{"date": datetime.fromtimestamp(c[0] / 1000, tz=timezone.utc).strftime("%Y-%m-%d"),
|
|
1573
|
+
"open": c[1], "high": c[2], "low": c[3],
|
|
1574
|
+
"close": c[4], "volume": c[5]}
|
|
1575
|
+
for c in ohlcv]
|
|
1576
|
+
return {"success": True, "symbol": sym, "data": records,
|
|
1577
|
+
"provider": "ccxt/binance", "count": len(records)}
|
|
1578
|
+
except Exception as e:
|
|
1579
|
+
yf_sym = symbol.replace("/","").replace("USDT","-USD")
|
|
1580
|
+
return self._history_yfinance(yf_sym, days, interval)
|
|
1581
|
+
|
|
1582
|
+
# ── Binance: funding rate + read-only account ──────────────────────────────
|
|
1583
|
+
|
|
1584
|
+
def crypto_funding_rate(self, symbol: str, exchange: str = "binance") -> Dict[str, Any]:
|
|
1585
|
+
"""Perpetual funding rate (read-only, no key). Falls back across
|
|
1586
|
+
exchanges so a geo-blocked Binance doesn't kill the lookup."""
|
|
1587
|
+
import ccxt
|
|
1588
|
+
sym = _norm_crypto(symbol) + ":USDT" # ccxt linear-perp notation
|
|
1589
|
+
_last_err = ""
|
|
1590
|
+
for exch in [exchange] + [e for e in ("okx", "bybit", "gate") if e != exchange]:
|
|
1591
|
+
try:
|
|
1592
|
+
ex = getattr(ccxt, exch)({"enableRateLimit": True,
|
|
1593
|
+
"options": {"defaultType": "swap"},
|
|
1594
|
+
"proxies": {"http": "", "https": ""}})
|
|
1595
|
+
fr = ex.fetch_funding_rate(sym)
|
|
1596
|
+
return {
|
|
1597
|
+
"success": True, "symbol": sym, "exchange": exch,
|
|
1598
|
+
"funding_rate": fr.get("fundingRate"),
|
|
1599
|
+
"funding_rate_pct": round((fr.get("fundingRate") or 0) * 100, 4),
|
|
1600
|
+
"next_funding": fr.get("fundingDatetime"),
|
|
1601
|
+
"mark_price": fr.get("markPrice"),
|
|
1602
|
+
"provider": f"ccxt/{exch}",
|
|
1603
|
+
}
|
|
1604
|
+
except Exception as e:
|
|
1605
|
+
_last_err = str(e)
|
|
1606
|
+
continue
|
|
1607
|
+
return {"success": False, "error": _last_err, "symbol": symbol}
|
|
1608
|
+
|
|
1609
|
+
def crypto_account(self, exchange: str = "binance") -> Dict[str, Any]:
|
|
1610
|
+
"""READ-ONLY account balance via API key (no trading).
|
|
1611
|
+
|
|
1612
|
+
Keys from env: BINANCE_API_KEY / BINANCE_SECRET (or <EXCHANGE>_API_KEY).
|
|
1613
|
+
This only reads balances — Aria never places crypto orders.
|
|
1614
|
+
"""
|
|
1615
|
+
import os as _os
|
|
1616
|
+
key = _os.getenv(f"{exchange.upper()}_API_KEY") or _os.getenv("BINANCE_API_KEY")
|
|
1617
|
+
secret = _os.getenv(f"{exchange.upper()}_SECRET") or _os.getenv("BINANCE_SECRET")
|
|
1618
|
+
if not key or not secret:
|
|
1619
|
+
return {"success": False, "error": "no_api_key",
|
|
1620
|
+
"hint": f"set {exchange.upper()}_API_KEY and {exchange.upper()}_SECRET (read-only key)"}
|
|
1621
|
+
try:
|
|
1622
|
+
import ccxt
|
|
1623
|
+
ex = getattr(ccxt, exchange)({
|
|
1624
|
+
"apiKey": key, "secret": secret,
|
|
1625
|
+
"enableRateLimit": True, "proxies": {"http": "", "https": ""},
|
|
1626
|
+
})
|
|
1627
|
+
bal = ex.fetch_balance()
|
|
1628
|
+
holdings = []
|
|
1629
|
+
for asset, amt in (bal.get("total") or {}).items():
|
|
1630
|
+
if amt and float(amt) > 0:
|
|
1631
|
+
holdings.append({"asset": asset, "amount": float(amt),
|
|
1632
|
+
"free": float((bal.get("free") or {}).get(asset, 0)),
|
|
1633
|
+
"used": float((bal.get("used") or {}).get(asset, 0))})
|
|
1634
|
+
holdings.sort(key=lambda h: -h["amount"])
|
|
1635
|
+
return {"success": True, "exchange": exchange,
|
|
1636
|
+
"holdings": holdings, "asset_count": len(holdings),
|
|
1637
|
+
"provider": f"ccxt/{exchange}", "read_only": True}
|
|
1638
|
+
except Exception as e:
|
|
1639
|
+
return {"success": False, "error": str(e), "exchange": exchange}
|
|
1640
|
+
|
|
1641
|
+
# ── Global indices ────────────────────────────────────────────────────────
|
|
1642
|
+
|
|
1643
|
+
def _fetch_indices(self) -> Dict[str, Any]:
|
|
1644
|
+
indices = {}
|
|
1645
|
+
# A股指数 (东方财富)
|
|
1646
|
+
cn_secids = "1.000001,0.399001,0.399006,1.000016,1.000688"
|
|
1647
|
+
cn_names = {"000001":"上证指数","399001":"深证成指",
|
|
1648
|
+
"399006":"创业板指","000016":"上证50","000688":"科创50"}
|
|
1649
|
+
try:
|
|
1650
|
+
_resp = self._em_get_json(self.EM_ULIST_URL, {
|
|
1651
|
+
"fltt": 2, "invt": 2,
|
|
1652
|
+
"fields": "f1,f2,f3,f4,f12,f14",
|
|
1653
|
+
"secids": cn_secids,
|
|
1654
|
+
"ut": _EM_UT,
|
|
1655
|
+
}, timeout=8)
|
|
1656
|
+
_diff = (_resp or {}).get("data", {}).get("diff", []) or []
|
|
1657
|
+
if isinstance(_diff, dict):
|
|
1658
|
+
_diff = list(_diff.values())
|
|
1659
|
+
for item in _diff:
|
|
1660
|
+
code = item.get("f12","")
|
|
1661
|
+
indices[cn_names.get(code, code)] = {
|
|
1662
|
+
"price": round(float(item.get("f2",0)), 2),
|
|
1663
|
+
"change_pct": round(float(item.get("f3",0)), 2),
|
|
1664
|
+
"change": round(float(item.get("f4",0)), 2),
|
|
1665
|
+
"market": "CN",
|
|
1666
|
+
}
|
|
1667
|
+
except Exception as e:
|
|
1668
|
+
logger.debug("CN indices error: %s", e)
|
|
1669
|
+
|
|
1670
|
+
# Global indices (yfinance)
|
|
1671
|
+
global_map = {
|
|
1672
|
+
"^GSPC": "S&P 500",
|
|
1673
|
+
"^IXIC": "纳斯达克",
|
|
1674
|
+
"^DJI": "道琼斯",
|
|
1675
|
+
"^HSI": "恒生指数",
|
|
1676
|
+
"^N225": "日经225",
|
|
1677
|
+
"^FTSE": "富时100",
|
|
1678
|
+
"GC=F": "黄金",
|
|
1679
|
+
"CL=F": "原油WTI",
|
|
1680
|
+
"BTC-USD":"比特币",
|
|
1681
|
+
}
|
|
1682
|
+
try:
|
|
1683
|
+
import yfinance as yf
|
|
1684
|
+
tickers = yf.Tickers(" ".join(global_map.keys()))
|
|
1685
|
+
for sym, name in global_map.items():
|
|
1686
|
+
try:
|
|
1687
|
+
fi = tickers.tickers[sym].fast_info
|
|
1688
|
+
price = float(fi.last_price or 0)
|
|
1689
|
+
prev = float(fi.previous_close or price)
|
|
1690
|
+
chg_p = (price - prev) / prev * 100 if prev else 0
|
|
1691
|
+
indices[name] = {
|
|
1692
|
+
"price": round(price, 2),
|
|
1693
|
+
"change_pct": round(chg_p, 2),
|
|
1694
|
+
"change": round(price - prev, 4),
|
|
1695
|
+
"market": "US" if sym.startswith("^") else "COMMOD",
|
|
1696
|
+
}
|
|
1697
|
+
except Exception as _e:
|
|
1698
|
+
logger.debug("Global index fetch failed for %s: %s", sym, _e)
|
|
1699
|
+
except Exception as e:
|
|
1700
|
+
logger.debug("Global indices yfinance error: %s", e)
|
|
1701
|
+
|
|
1702
|
+
return {"success": True, "indices": indices,
|
|
1703
|
+
"timestamp": datetime.now().isoformat()}
|
|
1704
|
+
|
|
1705
|
+
# ── 北向资金 ────────────────────────────────────────────────────────────
|
|
1706
|
+
|
|
1707
|
+
def _fetch_northbound(self) -> Dict[str, Any]:
|
|
1708
|
+
try:
|
|
1709
|
+
_resp = self._em_get_json(self.EM_NORTHBOUND, {
|
|
1710
|
+
"fields1": "f1,f2,f3,f4",
|
|
1711
|
+
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
|
|
1712
|
+
"klt": 101, "lmt": 5,
|
|
1713
|
+
"ut": _EM_UT,
|
|
1714
|
+
}, timeout=8)
|
|
1715
|
+
data = (_resp or {}).get("data", {}) or {}
|
|
1716
|
+
sh = data.get("s2n", {}) or {} # 沪股通
|
|
1717
|
+
sz = data.get("s3n", {}) or {} # 深股通
|
|
1718
|
+
def _val(obj, key):
|
|
1719
|
+
try: return float(obj.get(key, 0)) / 1e8 # 元 → 亿
|
|
1720
|
+
except (KeyError, ValueError, TypeError): return 0.0
|
|
1721
|
+
sh_net = _val(sh, "f2")
|
|
1722
|
+
sz_net = _val(sz, "f2")
|
|
1723
|
+
total = sh_net + sz_net
|
|
1724
|
+
return {
|
|
1725
|
+
"success": True,
|
|
1726
|
+
"total_net": round(total, 2),
|
|
1727
|
+
"sh_net": round(sh_net, 2),
|
|
1728
|
+
"sz_net": round(sz_net, 2),
|
|
1729
|
+
"unit": "亿元",
|
|
1730
|
+
"direction": "净流入" if total > 0 else "净流出",
|
|
1731
|
+
"provider": "eastmoney",
|
|
1732
|
+
"timestamp": datetime.now().isoformat(),
|
|
1733
|
+
}
|
|
1734
|
+
except Exception as e:
|
|
1735
|
+
return {"success": False, "error": str(e)}
|
|
1736
|
+
|
|
1737
|
+
# ── 热门股榜单 ────────────────────────────────────────────────────────────
|
|
1738
|
+
|
|
1739
|
+
def _fetch_hot_ashare(self, top_n: int = 20) -> Dict[str, Any]:
|
|
1740
|
+
try:
|
|
1741
|
+
data = self._em_get_json(self.EM_HOT_URL, {
|
|
1742
|
+
"pn": 1, "pz": top_n, "po": 1, "np": 1,
|
|
1743
|
+
"ut": _EM_UT,
|
|
1744
|
+
"fltt": 2, "invt": 2, "fid": "f6",
|
|
1745
|
+
"fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23",
|
|
1746
|
+
"fields": "f2,f3,f4,f5,f6,f7,f12,f14,f62",
|
|
1747
|
+
})
|
|
1748
|
+
if not data:
|
|
1749
|
+
return {"success": False, "error": "eastmoney 无响应(网络或代理)"}
|
|
1750
|
+
items = (data.get("data") or {}).get("diff", []) or []
|
|
1751
|
+
if isinstance(items, dict):
|
|
1752
|
+
items = list(items.values())
|
|
1753
|
+
stocks = []
|
|
1754
|
+
for d in items[:top_n]:
|
|
1755
|
+
stocks.append({
|
|
1756
|
+
"code": d.get("f12",""),
|
|
1757
|
+
"name": d.get("f14",""),
|
|
1758
|
+
"price": round(float(d.get("f2",0)), 2), # fltt=2 → already in ¥
|
|
1759
|
+
"change_pct": round(float(d.get("f3",0)), 2), # already in %
|
|
1760
|
+
"volume": int(d.get("f5",0)),
|
|
1761
|
+
"turnover": float(d.get("f6",0)),
|
|
1762
|
+
"amplitude": round(float(d.get("f7",0)), 2), # already in %
|
|
1763
|
+
})
|
|
1764
|
+
return {"success": True, "market": "CN", "stocks": stocks,
|
|
1765
|
+
"count": len(stocks), "provider": "eastmoney"}
|
|
1766
|
+
except Exception as e:
|
|
1767
|
+
return {"success": False, "error": str(e)}
|
|
1768
|
+
|
|
1769
|
+
def screen_ashare(self, *, max_pe: float = 50, min_market_cap_yi: float = 0,
|
|
1770
|
+
limit: int = 20, exclude_st: bool = True) -> Dict[str, Any]:
|
|
1771
|
+
"""Screen A-shares via the eastmoney clist endpoint (host-rotating,
|
|
1772
|
+
proxy-resilient). Sorted by change% desc, then filtered by PE/market cap.
|
|
1773
|
+
|
|
1774
|
+
Fetches a few pages (not all ~5000 stocks) so the request stays small
|
|
1775
|
+
and reliable. Fields: f2 price, f3 chg%, f8 turnover, f9 PE(dynamic),
|
|
1776
|
+
f12 code, f14 name, f20 total mktcap, f23 PB.
|
|
1777
|
+
"""
|
|
1778
|
+
rows: List[Dict[str, Any]] = []
|
|
1779
|
+
for pn in range(1, 4): # up to 3 pages × 100 = 300 movers
|
|
1780
|
+
data = self._em_get_json(self.EM_HOT_URL, {
|
|
1781
|
+
"pn": pn, "pz": 100, "po": 1, "np": 1, "ut": _EM_UT,
|
|
1782
|
+
"fltt": 2, "invt": 2, "fid": "f3", # sort by change% desc
|
|
1783
|
+
"fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048",
|
|
1784
|
+
"fields": "f2,f3,f8,f9,f12,f14,f20,f23",
|
|
1785
|
+
})
|
|
1786
|
+
if not data:
|
|
1787
|
+
break
|
|
1788
|
+
diff = (data.get("data") or {}).get("diff", []) or []
|
|
1789
|
+
if isinstance(diff, dict):
|
|
1790
|
+
diff = list(diff.values())
|
|
1791
|
+
if not diff:
|
|
1792
|
+
break
|
|
1793
|
+
rows.extend(diff)
|
|
1794
|
+
if len(diff) < 100:
|
|
1795
|
+
break
|
|
1796
|
+
|
|
1797
|
+
if not rows:
|
|
1798
|
+
return {"success": False, "error": "eastmoney 无响应(网络或代理)"}
|
|
1799
|
+
|
|
1800
|
+
def _num(v):
|
|
1801
|
+
try:
|
|
1802
|
+
return float(v)
|
|
1803
|
+
except (ValueError, TypeError):
|
|
1804
|
+
return None
|
|
1805
|
+
|
|
1806
|
+
out: List[Dict[str, Any]] = []
|
|
1807
|
+
for d in rows:
|
|
1808
|
+
name = str(d.get("f14", ""))
|
|
1809
|
+
if exclude_st and ("ST" in name or "退" in name):
|
|
1810
|
+
continue
|
|
1811
|
+
price = _num(d.get("f2"))
|
|
1812
|
+
pe = _num(d.get("f9"))
|
|
1813
|
+
mktcap = _num(d.get("f20")) # 元
|
|
1814
|
+
if price is None or price <= 0:
|
|
1815
|
+
continue
|
|
1816
|
+
if pe is not None and not (0.1 <= pe <= max_pe):
|
|
1817
|
+
continue
|
|
1818
|
+
if min_market_cap_yi > 0 and (mktcap is None or mktcap < min_market_cap_yi * 1e8):
|
|
1819
|
+
continue
|
|
1820
|
+
out.append({
|
|
1821
|
+
"code": str(d.get("f12", "")),
|
|
1822
|
+
"name": name,
|
|
1823
|
+
"price": round(price, 2),
|
|
1824
|
+
"change_pct": round(_num(d.get("f3")) or 0, 2),
|
|
1825
|
+
"pe_dynamic": round(pe, 1) if pe is not None else None,
|
|
1826
|
+
"pb": round(_num(d.get("f23")) or 0, 2),
|
|
1827
|
+
"turnover_rate": round(_num(d.get("f8")) or 0, 2),
|
|
1828
|
+
"market_cap_yi": round(mktcap / 1e8, 1) if mktcap else None,
|
|
1829
|
+
})
|
|
1830
|
+
if len(out) >= limit:
|
|
1831
|
+
break
|
|
1832
|
+
|
|
1833
|
+
return {"success": True, "count": len(out), "stocks": out,
|
|
1834
|
+
"provider": "eastmoney"}
|
|
1835
|
+
|
|
1836
|
+
def _fetch_hot_us(self, top_n: int = 10) -> Dict[str, Any]:
|
|
1837
|
+
"""US most active stocks via yfinance screener."""
|
|
1838
|
+
watchlist = ["NVDA","AAPL","TSLA","MSFT","AMZN","META","GOOGL","AMD","INTC","PLTR"]
|
|
1839
|
+
results = []
|
|
1840
|
+
try:
|
|
1841
|
+
import yfinance as yf
|
|
1842
|
+
for sym in watchlist[:top_n]:
|
|
1843
|
+
try:
|
|
1844
|
+
fi = yf.Ticker(sym).fast_info
|
|
1845
|
+
p = float(fi.last_price or 0)
|
|
1846
|
+
prev = float(fi.previous_close or p)
|
|
1847
|
+
chg_p = (p-prev)/prev*100 if prev else 0
|
|
1848
|
+
results.append({"symbol": sym, "price": round(p,2),
|
|
1849
|
+
"change_pct": round(chg_p,2)})
|
|
1850
|
+
except Exception as _e:
|
|
1851
|
+
logger.debug("Screener quote failed for %s: %s", sym, _e)
|
|
1852
|
+
return {"success": True, "market": "US", "stocks": results,
|
|
1853
|
+
"count": len(results), "provider": "yfinance"}
|
|
1854
|
+
except Exception as e:
|
|
1855
|
+
return {"success": False, "error": str(e)}
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
# ── Module-level singleton ───────────────────────────────────────────────────
|
|
1859
|
+
|
|
1860
|
+
_mdc: Optional[MarketDataClient] = None
|
|
1861
|
+
|
|
1862
|
+
def get_mdc() -> MarketDataClient:
|
|
1863
|
+
global _mdc
|
|
1864
|
+
if _mdc is None:
|
|
1865
|
+
_mdc = MarketDataClient()
|
|
1866
|
+
return _mdc
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
# ── Convenience functions (module-level API) ─────────────────────────────────
|
|
1870
|
+
|
|
1871
|
+
def quote(symbol: str) -> Dict[str, Any]:
|
|
1872
|
+
return get_mdc().quote(symbol)
|
|
1873
|
+
|
|
1874
|
+
def history(symbol: str, days: int = 252, interval: str = "1d") -> Dict[str, Any]:
|
|
1875
|
+
return get_mdc().history(symbol, days=days, interval=interval)
|
|
1876
|
+
|
|
1877
|
+
def indices() -> Dict[str, Any]:
|
|
1878
|
+
return get_mdc().indices()
|
|
1879
|
+
|
|
1880
|
+
def northbound_flow() -> Dict[str, Any]:
|
|
1881
|
+
return get_mdc().northbound_flow()
|
|
1882
|
+
|
|
1883
|
+
def technical_indicators(symbol: str, days: int = 120) -> Dict[str, Any]:
|
|
1884
|
+
return get_mdc().technical_indicators(symbol, days=days)
|
|
1885
|
+
|
|
1886
|
+
def fundamentals(symbol: str) -> Dict[str, Any]:
|
|
1887
|
+
return get_mdc().fundamentals(symbol)
|
|
1888
|
+
|
|
1889
|
+
def hot_stocks(market: str = "cn", top_n: int = 20) -> Dict[str, Any]:
|
|
1890
|
+
return get_mdc().hot_stocks(market=market, top_n=top_n)
|
|
1891
|
+
|
|
1892
|
+
def screen_ashare(**kwargs) -> Dict[str, Any]:
|
|
1893
|
+
return get_mdc().screen_ashare(**kwargs)
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
if __name__ == "__main__":
|
|
1897
|
+
import json, sys
|
|
1898
|
+
sym = sys.argv[1] if len(sys.argv) > 1 else "NVDA"
|
|
1899
|
+
print(json.dumps(quote(sym), indent=2, ensure_ascii=False, default=str))
|