marketmyna 0.2.0__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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
marketmyna/data/china.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""China A-share and Hong Kong market data fetchers, backed by akshare
|
|
2
|
+
(with yfinance as the fallback source, so quotes/history still work when
|
|
3
|
+
akshare isn't installed).
|
|
4
|
+
|
|
5
|
+
akshare is free and token-less (unlike tushare/baostock/pytdx, which need
|
|
6
|
+
paid tiers or registered tokens), making it the pragmatic first source for
|
|
7
|
+
a self-hosted personal tool. Two real gotchas carried forward from how
|
|
8
|
+
daily_stock_analysis's own akshare adapter uses these same endpoints:
|
|
9
|
+
|
|
10
|
+
- The realtime-quote endpoints (`stock_zh_a_spot_em` / `stock_hk_spot_em`)
|
|
11
|
+
return a FULL MARKET snapshot (thousands of rows), not a single symbol -
|
|
12
|
+
fetching it per quote request would be both slow and a good way to get
|
|
13
|
+
rate-limited/banned. Snapshots are cached in-memory with a TTL and a
|
|
14
|
+
single symbol is filtered out of the cached DataFrame.
|
|
15
|
+
- akshare's free endpoints are known to be flaky/occasionally rate-limit
|
|
16
|
+
or ban aggressive callers. A small randomized delay is added before each
|
|
17
|
+
raw network call, and both fetchers go through Phase 2a's
|
|
18
|
+
`FallbackChain`/`CircuitBreaker` so a source having a bad day gets
|
|
19
|
+
skipped for a cooldown instead of hammered on every call.
|
|
20
|
+
|
|
21
|
+
Known limitation (not implemented in this pass): the index codes listed in
|
|
22
|
+
`MARKET_INDICES` (e.g. "000001" for 上证指数) are NOT resolvable through
|
|
23
|
+
these fetchers - `stock_zh_a_hist`/`stock_zh_a_spot_em` are stock-only
|
|
24
|
+
endpoints, and a 6-digit index code can collide with an A-share stock code
|
|
25
|
+
in the same numeric range. Index history needs akshare's separate
|
|
26
|
+
`index_zh_a_hist`-family functions, out of scope for this phase (which
|
|
27
|
+
targets individual stock quotes/history).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import random
|
|
33
|
+
import time
|
|
34
|
+
from datetime import datetime, timedelta
|
|
35
|
+
from threading import RLock
|
|
36
|
+
from typing import Optional
|
|
37
|
+
|
|
38
|
+
import pandas as pd
|
|
39
|
+
|
|
40
|
+
from ..core.constants import TimeFrame
|
|
41
|
+
from .base import DataFetcher
|
|
42
|
+
from .extra_sources import tencent_history, tencent_quote
|
|
43
|
+
from .resilience import FallbackChain
|
|
44
|
+
|
|
45
|
+
# Akshare's Chinese column names -> the same lowercase-English convention
|
|
46
|
+
# StockDataFetcher.get_history already returns, so downstream analysis
|
|
47
|
+
# code (marketmyna/analysis/technical.py etc.) needs no market-specific handling.
|
|
48
|
+
_CN_COLUMN_MAP = {
|
|
49
|
+
"日期": "date",
|
|
50
|
+
"开盘": "open",
|
|
51
|
+
"收盘": "close",
|
|
52
|
+
"最高": "high",
|
|
53
|
+
"最低": "low",
|
|
54
|
+
"成交量": "volume",
|
|
55
|
+
"成交额": "amount",
|
|
56
|
+
"振幅": "amplitude",
|
|
57
|
+
"涨跌幅": "pct_chg",
|
|
58
|
+
"涨跌额": "change",
|
|
59
|
+
"换手率": "turnover_rate",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# period string -> approximate days, same convention as IndiaDataFetcher's
|
|
63
|
+
# _PERIOD_DAYS, used to compute a start date when the caller doesn't pass
|
|
64
|
+
# explicit start/end datetimes.
|
|
65
|
+
_PERIOD_DAYS = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365, "2y": 730, "5y": 1825, "10y": 3650}
|
|
66
|
+
|
|
67
|
+
# akshare's stock_zh_a_hist/stock_hk_hist only offer daily/weekly/monthly
|
|
68
|
+
# bars - there is no intraday endpoint behind these functions.
|
|
69
|
+
_TIMEFRAME_TO_AKSHARE_PERIOD = {
|
|
70
|
+
TimeFrame.DAY_1: "daily",
|
|
71
|
+
TimeFrame.WEEK_1: "weekly",
|
|
72
|
+
TimeFrame.MONTH_1: "monthly",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _yf_quote(yf_symbol: str, code: str) -> dict:
|
|
77
|
+
"""yfinance fallback for when akshare is missing or failing."""
|
|
78
|
+
from .stocks import StockDataFetcher
|
|
79
|
+
|
|
80
|
+
quote = StockDataFetcher()._fetch_quote_yfinance(yf_symbol)
|
|
81
|
+
quote["symbol"] = code
|
|
82
|
+
return quote
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _yf_history(yf_symbol: str, **kwargs) -> pd.DataFrame:
|
|
86
|
+
from .stocks import StockDataFetcher
|
|
87
|
+
|
|
88
|
+
return StockDataFetcher()._fetch_history_yfinance(yf_symbol, **kwargs)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _rate_limit_delay() -> None:
|
|
92
|
+
"""Small randomized delay before a raw akshare call, mirroring
|
|
93
|
+
daily_stock_analysis's own anti-ban strategy for these free endpoints."""
|
|
94
|
+
time.sleep(random.uniform(0.5, 1.5))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _SnapshotCache:
|
|
98
|
+
"""Caches one akshare full-market-snapshot DataFrame with a TTL, so a
|
|
99
|
+
quote for a single symbol doesn't trigger a fresh full-market pull on
|
|
100
|
+
every call. Thread-safe; a failed fetch is not cached (so the next call
|
|
101
|
+
retries rather than serving a permanently-empty snapshot)."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, ttl_seconds: float = 1200.0):
|
|
104
|
+
self.ttl_seconds = ttl_seconds
|
|
105
|
+
self._lock = RLock()
|
|
106
|
+
self._df: Optional[pd.DataFrame] = None
|
|
107
|
+
self._fetched_at: float = 0.0
|
|
108
|
+
|
|
109
|
+
def get(self, fetch_fn) -> pd.DataFrame:
|
|
110
|
+
with self._lock:
|
|
111
|
+
age = time.time() - self._fetched_at
|
|
112
|
+
if self._df is not None and age < self.ttl_seconds:
|
|
113
|
+
return self._df
|
|
114
|
+
_rate_limit_delay()
|
|
115
|
+
df = fetch_fn()
|
|
116
|
+
if df is not None and not df.empty:
|
|
117
|
+
self._df = df
|
|
118
|
+
self._fetched_at = time.time()
|
|
119
|
+
return df
|
|
120
|
+
# Failed/empty fetch: serve a stale snapshot if we have one
|
|
121
|
+
# rather than nothing, but don't reset the TTL clock on it.
|
|
122
|
+
if self._df is not None:
|
|
123
|
+
return self._df
|
|
124
|
+
return pd.DataFrame()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_date_range(
|
|
128
|
+
period: str, start: Optional[datetime], end: Optional[datetime]
|
|
129
|
+
) -> tuple[str, str]:
|
|
130
|
+
"""-> (start_date, end_date) as 'YYYYMMDD' strings, akshare's expected format."""
|
|
131
|
+
range_end = end or datetime.now()
|
|
132
|
+
range_start = start or (range_end - timedelta(days=_PERIOD_DAYS.get(period, 365)))
|
|
133
|
+
return range_start.strftime("%Y%m%d"), range_end.strftime("%Y%m%d")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resolve_akshare_period(interval: TimeFrame) -> str:
|
|
137
|
+
ak_period = _TIMEFRAME_TO_AKSHARE_PERIOD.get(interval)
|
|
138
|
+
if ak_period is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Intraday interval {interval.value!r} is not supported by akshare's "
|
|
141
|
+
"A-share/Hong Kong history endpoints (daily/weekly/monthly only)."
|
|
142
|
+
)
|
|
143
|
+
return ak_period
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _normalize_history_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
147
|
+
if df is None or df.empty:
|
|
148
|
+
return pd.DataFrame()
|
|
149
|
+
df = df.rename(columns=_CN_COLUMN_MAP)
|
|
150
|
+
if "date" in df.columns:
|
|
151
|
+
df["date"] = pd.to_datetime(df["date"])
|
|
152
|
+
df = df.set_index("date").sort_index()
|
|
153
|
+
return df
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _AkshareFetcher(DataFetcher):
|
|
157
|
+
"""Shared by the A-share and Hong Kong fetchers: eastmoney (akshare) first,
|
|
158
|
+
then Tencent, then yfinance. Subclasses set the market specifics below."""
|
|
159
|
+
|
|
160
|
+
MARKET = "" # FallbackChain / tencent market name
|
|
161
|
+
EXCHANGE = ""
|
|
162
|
+
CURRENCY = ""
|
|
163
|
+
SNAPSHOT_FN = "" # akshare spot table
|
|
164
|
+
HISTORY_FN = "" # akshare daily / weekly / monthly history
|
|
165
|
+
PE_COLUMN = ""
|
|
166
|
+
|
|
167
|
+
def __init__(self):
|
|
168
|
+
self._snapshot = _SnapshotCache()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _normalize_symbol(symbol: str) -> str:
|
|
172
|
+
raise NotImplementedError
|
|
173
|
+
|
|
174
|
+
def _yf_symbol(self, symbol: str) -> str:
|
|
175
|
+
raise NotImplementedError
|
|
176
|
+
|
|
177
|
+
def _fetch_quote_tencent(self, symbol: str) -> dict:
|
|
178
|
+
return tencent_quote(self._normalize_symbol(symbol), self.MARKET)
|
|
179
|
+
|
|
180
|
+
def _fetch_history_tencent(self, symbol: str, **kwargs) -> pd.DataFrame:
|
|
181
|
+
return tencent_history(self._normalize_symbol(symbol), self.MARKET, **kwargs)
|
|
182
|
+
|
|
183
|
+
def _fetch_quote_yfinance(self, symbol: str) -> dict:
|
|
184
|
+
return _yf_quote(self._yf_symbol(symbol), self._normalize_symbol(symbol))
|
|
185
|
+
|
|
186
|
+
def _fetch_history_yfinance(self, symbol: str, **kwargs) -> pd.DataFrame:
|
|
187
|
+
return _yf_history(self._yf_symbol(symbol), **kwargs)
|
|
188
|
+
|
|
189
|
+
def _fetch_snapshot(self) -> pd.DataFrame:
|
|
190
|
+
import akshare as ak
|
|
191
|
+
|
|
192
|
+
return self._snapshot.get(getattr(ak, self.SNAPSHOT_FN))
|
|
193
|
+
|
|
194
|
+
def _fetch_quote_akshare(self, symbol: str) -> dict:
|
|
195
|
+
code = self._normalize_symbol(symbol)
|
|
196
|
+
df = self._fetch_snapshot()
|
|
197
|
+
if df.empty:
|
|
198
|
+
return {}
|
|
199
|
+
|
|
200
|
+
row = df[df["代码"] == code]
|
|
201
|
+
if row.empty:
|
|
202
|
+
return {}
|
|
203
|
+
row = row.iloc[0]
|
|
204
|
+
|
|
205
|
+
price = row.get("最新价")
|
|
206
|
+
change = row.get("涨跌额")
|
|
207
|
+
previous_close = (price - change) if price is not None and change is not None else None
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"symbol": code,
|
|
211
|
+
"name": str(row.get("名称", "")),
|
|
212
|
+
"price": price,
|
|
213
|
+
"previous_close": previous_close,
|
|
214
|
+
"open": row.get("今开"),
|
|
215
|
+
"day_high": row.get("最高"),
|
|
216
|
+
"day_low": row.get("最低"),
|
|
217
|
+
"volume": row.get("成交量"),
|
|
218
|
+
"market_cap": row.get("总市值"),
|
|
219
|
+
"change": change,
|
|
220
|
+
"change_percent": row.get("涨跌幅"),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def get_quote(self, symbol: str) -> dict:
|
|
224
|
+
chain = FallbackChain(f"{self.MARKET}.get_quote")
|
|
225
|
+
chain.add("akshare_em", self._fetch_quote_akshare)
|
|
226
|
+
chain.add("tencent", self._fetch_quote_tencent)
|
|
227
|
+
chain.add("yfinance", self._fetch_quote_yfinance)
|
|
228
|
+
return chain.call(symbol, is_empty=lambda d: not d or d.get("price") is None)
|
|
229
|
+
|
|
230
|
+
def _fetch_history_akshare(
|
|
231
|
+
self,
|
|
232
|
+
symbol: str,
|
|
233
|
+
period: str = "1y",
|
|
234
|
+
interval: TimeFrame = TimeFrame.DAY_1,
|
|
235
|
+
start: Optional[datetime] = None,
|
|
236
|
+
end: Optional[datetime] = None,
|
|
237
|
+
) -> pd.DataFrame:
|
|
238
|
+
import akshare as ak
|
|
239
|
+
|
|
240
|
+
code = self._normalize_symbol(symbol)
|
|
241
|
+
ak_period = _resolve_akshare_period(interval)
|
|
242
|
+
start_date, end_date = _resolve_date_range(period, start, end)
|
|
243
|
+
|
|
244
|
+
_rate_limit_delay()
|
|
245
|
+
df = getattr(ak, self.HISTORY_FN)(
|
|
246
|
+
symbol=code, period=ak_period, start_date=start_date, end_date=end_date, adjust="qfq"
|
|
247
|
+
)
|
|
248
|
+
return _normalize_history_columns(df)
|
|
249
|
+
|
|
250
|
+
def get_history(
|
|
251
|
+
self,
|
|
252
|
+
symbol: str,
|
|
253
|
+
period: str = "1y",
|
|
254
|
+
interval: TimeFrame = TimeFrame.DAY_1,
|
|
255
|
+
start: Optional[datetime] = None,
|
|
256
|
+
end: Optional[datetime] = None,
|
|
257
|
+
) -> pd.DataFrame:
|
|
258
|
+
chain = FallbackChain(f"{self.MARKET}.get_history")
|
|
259
|
+
chain.add("akshare_em", self._fetch_history_akshare)
|
|
260
|
+
chain.add("tencent", self._fetch_history_tencent)
|
|
261
|
+
chain.add("yfinance", self._fetch_history_yfinance)
|
|
262
|
+
return chain.call(
|
|
263
|
+
symbol, period=period, interval=interval, start=start, end=end, is_empty=lambda df: df.empty
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def search(self, query: str) -> list[dict]:
|
|
267
|
+
"""Stocks by code prefix or name substring."""
|
|
268
|
+
df = self._fetch_snapshot()
|
|
269
|
+
if df.empty:
|
|
270
|
+
return []
|
|
271
|
+
query_lower = query.strip().lower()
|
|
272
|
+
matches = df[
|
|
273
|
+
df["代码"].astype(str).str.lower().str.startswith(query_lower)
|
|
274
|
+
| df["名称"].astype(str).str.contains(query, case=False, na=False)
|
|
275
|
+
]
|
|
276
|
+
return [
|
|
277
|
+
{"symbol": row["代码"], "name": row["名称"], "exchange": self.EXCHANGE, "type": "EQUITY"}
|
|
278
|
+
for _, row in matches.head(20).iterrows()
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
def get_info(self, symbol: str) -> dict:
|
|
282
|
+
code = self._normalize_symbol(symbol)
|
|
283
|
+
df = self._fetch_snapshot()
|
|
284
|
+
row = df[df["代码"] == code] if not df.empty else df
|
|
285
|
+
if row.empty:
|
|
286
|
+
return {"symbol": code, "name": "", "currency": self.CURRENCY}
|
|
287
|
+
row = row.iloc[0]
|
|
288
|
+
return {
|
|
289
|
+
"symbol": code,
|
|
290
|
+
"name": str(row.get("名称", "")),
|
|
291
|
+
"sector": "",
|
|
292
|
+
"industry": "",
|
|
293
|
+
"currency": self.CURRENCY,
|
|
294
|
+
"market_cap": row.get("总市值", 0),
|
|
295
|
+
"pe_ratio": row.get(self.PE_COLUMN, 0),
|
|
296
|
+
"pb_ratio": row.get("市净率", 0),
|
|
297
|
+
"52_week_high": row.get("52周最高", 0),
|
|
298
|
+
"52_week_low": row.get("52周最低", 0),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ChinaAFetcher(_AkshareFetcher):
|
|
303
|
+
"""Fetches mainland China A-share data (Shanghai/Shenzhen) via akshare."""
|
|
304
|
+
|
|
305
|
+
MARKET = "china_a"
|
|
306
|
+
EXCHANGE = "SH/SZ"
|
|
307
|
+
CURRENCY = "CNY"
|
|
308
|
+
SNAPSHOT_FN = "stock_zh_a_spot_em"
|
|
309
|
+
HISTORY_FN = "stock_zh_a_hist"
|
|
310
|
+
PE_COLUMN = "市盈率-动态"
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _normalize_symbol(symbol: str) -> str:
|
|
314
|
+
"""Accept 'sh600519', 'SZ000001', '600519.SH', or bare '600519' ->
|
|
315
|
+
the bare 6-digit code akshare's A-share functions expect."""
|
|
316
|
+
code = symbol.strip().lower()
|
|
317
|
+
for prefix in ("sh", "sz", "bj"):
|
|
318
|
+
if code.startswith(prefix):
|
|
319
|
+
code = code[len(prefix):]
|
|
320
|
+
for suffix in (".sh", ".sz", ".bj"):
|
|
321
|
+
if code.endswith(suffix):
|
|
322
|
+
code = code[: -len(suffix)]
|
|
323
|
+
code = code.strip()
|
|
324
|
+
if not code.isdigit() or len(code) != 6:
|
|
325
|
+
raise ValueError(f"'{symbol}' does not look like a 6-digit A-share code")
|
|
326
|
+
return code
|
|
327
|
+
|
|
328
|
+
def _yf_symbol(self, symbol: str) -> str:
|
|
329
|
+
code = self._normalize_symbol(symbol)
|
|
330
|
+
suffix = ".SS" if code[0] in "569" else ".BJ" if code[0] in "48" else ".SZ"
|
|
331
|
+
return code + suffix
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class HongKongFetcher(_AkshareFetcher):
|
|
335
|
+
"""Fetches Hong Kong exchange data via akshare."""
|
|
336
|
+
|
|
337
|
+
MARKET = "hong_kong"
|
|
338
|
+
EXCHANGE = "HKEX"
|
|
339
|
+
CURRENCY = "HKD"
|
|
340
|
+
SNAPSHOT_FN = "stock_hk_spot_em"
|
|
341
|
+
HISTORY_FN = "stock_hk_hist"
|
|
342
|
+
PE_COLUMN = "市盈率"
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _normalize_symbol(symbol: str) -> str:
|
|
346
|
+
"""Accept 'hk00700', '00700.HK', '700', or bare '00700' -> the
|
|
347
|
+
zero-padded 5-digit code akshare's HK functions expect."""
|
|
348
|
+
code = symbol.strip().lower()
|
|
349
|
+
if code.startswith("hk"):
|
|
350
|
+
code = code[2:]
|
|
351
|
+
if code.endswith(".hk"):
|
|
352
|
+
code = code[:-3]
|
|
353
|
+
code = code.strip()
|
|
354
|
+
if not code.isdigit() or not (1 <= len(code) <= 5):
|
|
355
|
+
raise ValueError(f"'{symbol}' does not look like a Hong Kong stock code")
|
|
356
|
+
return code.zfill(5)
|
|
357
|
+
|
|
358
|
+
def _yf_symbol(self, symbol: str) -> str:
|
|
359
|
+
# yfinance uses 4-digit HK codes: 00700 -> 0700.HK, 09988 -> 9988.HK
|
|
360
|
+
return f"{int(self._normalize_symbol(symbol)):04d}.HK"
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Cryptocurrency data fetcher: Binance (ccxt), then Yahoo, then CoinGecko."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from ..core.constants import TimeFrame
|
|
9
|
+
from .base import DataFetcher
|
|
10
|
+
from .extra_sources import coingecko_history, coingecko_quote
|
|
11
|
+
from .resilience import FallbackChain
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CryptoDataFetcher(DataFetcher):
|
|
15
|
+
"""Fetches cryptocurrency data from multiple exchanges."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, exchange: str = "binance"):
|
|
18
|
+
self._exchange_name = exchange
|
|
19
|
+
self._exchange = None
|
|
20
|
+
self._init_exchange()
|
|
21
|
+
|
|
22
|
+
def _init_exchange(self):
|
|
23
|
+
"""Initialize the exchange connection."""
|
|
24
|
+
try:
|
|
25
|
+
import ccxt
|
|
26
|
+
exchange_class = getattr(ccxt, self._exchange_name, None)
|
|
27
|
+
if exchange_class:
|
|
28
|
+
self._exchange = exchange_class()
|
|
29
|
+
except ImportError:
|
|
30
|
+
self._exchange = None
|
|
31
|
+
|
|
32
|
+
def _get_yf_symbol(self, symbol: str) -> str:
|
|
33
|
+
"""Convert to Yahoo Finance format for crypto."""
|
|
34
|
+
if "-USD" not in symbol and "USDT" not in symbol:
|
|
35
|
+
return f"{symbol}-USD"
|
|
36
|
+
return symbol
|
|
37
|
+
|
|
38
|
+
def _fetch_quote_ccxt(self, symbol: str) -> dict:
|
|
39
|
+
# Convert symbol format (BTC -> BTC/USDT)
|
|
40
|
+
ccxt_symbol = symbol.replace("-USD", "/USDT").replace("USD", "/USDT")
|
|
41
|
+
if "/" not in ccxt_symbol:
|
|
42
|
+
ccxt_symbol = f"{ccxt_symbol}/USDT"
|
|
43
|
+
|
|
44
|
+
ticker = self._exchange.fetch_ticker(ccxt_symbol)
|
|
45
|
+
return {
|
|
46
|
+
"symbol": symbol,
|
|
47
|
+
"price": ticker["last"],
|
|
48
|
+
"previous_close": ticker["previousClose"] or ticker["open"],
|
|
49
|
+
"open": ticker["open"],
|
|
50
|
+
"day_high": ticker["high"],
|
|
51
|
+
"day_low": ticker["low"],
|
|
52
|
+
"volume": ticker["baseVolume"],
|
|
53
|
+
"quote_volume": ticker["quoteVolume"],
|
|
54
|
+
"change": ticker["change"] or 0,
|
|
55
|
+
"change_percent": ticker["percentage"] or 0,
|
|
56
|
+
"bid": ticker["bid"],
|
|
57
|
+
"ask": ticker["ask"],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def _fetch_quote_yfinance(self, symbol: str) -> dict:
|
|
61
|
+
import yfinance as yf
|
|
62
|
+
ticker = yf.Ticker(self._get_yf_symbol(symbol))
|
|
63
|
+
info = ticker.fast_info
|
|
64
|
+
try:
|
|
65
|
+
price = info.last_price
|
|
66
|
+
except KeyError:
|
|
67
|
+
return {} # unknown symbol: empty, not a source failure (see india.py)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"symbol": symbol,
|
|
71
|
+
"price": price,
|
|
72
|
+
"previous_close": info.previous_close,
|
|
73
|
+
"open": info.open,
|
|
74
|
+
"day_high": info.day_high,
|
|
75
|
+
"day_low": info.day_low,
|
|
76
|
+
"volume": info.last_volume,
|
|
77
|
+
"change": info.last_price - info.previous_close if info.previous_close else 0,
|
|
78
|
+
"change_percent": ((info.last_price - info.previous_close) / info.previous_close) * 100
|
|
79
|
+
if info.previous_close
|
|
80
|
+
else 0,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def get_quote(self, symbol: str) -> dict:
|
|
84
|
+
"""Get current quote for a cryptocurrency."""
|
|
85
|
+
chain = FallbackChain("crypto.get_quote")
|
|
86
|
+
if self._exchange:
|
|
87
|
+
chain.add(self._exchange_name, self._fetch_quote_ccxt)
|
|
88
|
+
chain.add("yfinance", self._fetch_quote_yfinance)
|
|
89
|
+
chain.add("coingecko", coingecko_quote)
|
|
90
|
+
return chain.call(symbol, is_empty=lambda d: not d or d.get("price") is None)
|
|
91
|
+
|
|
92
|
+
_CCXT_TIMEFRAME_MAP = {
|
|
93
|
+
TimeFrame.MINUTE_1: "1m",
|
|
94
|
+
TimeFrame.MINUTE_5: "5m",
|
|
95
|
+
TimeFrame.MINUTE_15: "15m",
|
|
96
|
+
TimeFrame.MINUTE_30: "30m",
|
|
97
|
+
TimeFrame.HOUR_1: "1h",
|
|
98
|
+
TimeFrame.HOUR_4: "4h",
|
|
99
|
+
TimeFrame.DAY_1: "1d",
|
|
100
|
+
TimeFrame.WEEK_1: "1w",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def _fetch_history_ccxt(
|
|
104
|
+
self, symbol: str, interval: TimeFrame, start: Optional[datetime] = None, **_ignored
|
|
105
|
+
) -> pd.DataFrame:
|
|
106
|
+
ccxt_symbol = symbol.replace("-USD", "/USDT").replace("USD", "/USDT")
|
|
107
|
+
if "/" not in ccxt_symbol:
|
|
108
|
+
ccxt_symbol = f"{ccxt_symbol}/USDT"
|
|
109
|
+
|
|
110
|
+
since = int(start.timestamp() * 1000) if start else None
|
|
111
|
+
|
|
112
|
+
ohlcv = self._exchange.fetch_ohlcv(
|
|
113
|
+
ccxt_symbol, self._CCXT_TIMEFRAME_MAP[interval], since=since, limit=1000
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
|
117
|
+
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
|
118
|
+
df.set_index("timestamp", inplace=True)
|
|
119
|
+
return df
|
|
120
|
+
|
|
121
|
+
def _fetch_history_yfinance(self, symbol: str, **kwargs) -> pd.DataFrame:
|
|
122
|
+
from .stocks import StockDataFetcher
|
|
123
|
+
|
|
124
|
+
return StockDataFetcher()._fetch_history_yfinance(self._get_yf_symbol(symbol), **kwargs)
|
|
125
|
+
|
|
126
|
+
def get_history(
|
|
127
|
+
self,
|
|
128
|
+
symbol: str,
|
|
129
|
+
period: str = "1y",
|
|
130
|
+
interval: TimeFrame = TimeFrame.DAY_1,
|
|
131
|
+
start: Optional[datetime] = None,
|
|
132
|
+
end: Optional[datetime] = None,
|
|
133
|
+
) -> pd.DataFrame:
|
|
134
|
+
"""Get historical price data for a cryptocurrency."""
|
|
135
|
+
chain = FallbackChain("crypto.get_history")
|
|
136
|
+
if self._exchange and interval in self._CCXT_TIMEFRAME_MAP:
|
|
137
|
+
chain.add(self._exchange_name, self._fetch_history_ccxt)
|
|
138
|
+
chain.add("yfinance", self._fetch_history_yfinance)
|
|
139
|
+
if interval == TimeFrame.DAY_1:
|
|
140
|
+
chain.add("coingecko", coingecko_history)
|
|
141
|
+
return chain.call(
|
|
142
|
+
symbol, period=period, interval=interval, start=start, end=end, is_empty=lambda df: df.empty
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def search(self, query: str) -> list[dict]:
|
|
146
|
+
"""Search for cryptocurrencies matching query."""
|
|
147
|
+
query_upper = query.upper()
|
|
148
|
+
|
|
149
|
+
# Common crypto symbols
|
|
150
|
+
popular_cryptos = {
|
|
151
|
+
"BTC": "Bitcoin",
|
|
152
|
+
"ETH": "Ethereum",
|
|
153
|
+
"BNB": "Binance Coin",
|
|
154
|
+
"XRP": "Ripple",
|
|
155
|
+
"SOL": "Solana",
|
|
156
|
+
"ADA": "Cardano",
|
|
157
|
+
"DOGE": "Dogecoin",
|
|
158
|
+
"DOT": "Polkadot",
|
|
159
|
+
"MATIC": "Polygon",
|
|
160
|
+
"SHIB": "Shiba Inu",
|
|
161
|
+
"LTC": "Litecoin",
|
|
162
|
+
"AVAX": "Avalanche",
|
|
163
|
+
"LINK": "Chainlink",
|
|
164
|
+
"UNI": "Uniswap",
|
|
165
|
+
"ATOM": "Cosmos",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
results = []
|
|
169
|
+
for symbol, name in popular_cryptos.items():
|
|
170
|
+
if query_upper in symbol or query_upper in name.upper():
|
|
171
|
+
results.append(
|
|
172
|
+
{
|
|
173
|
+
"symbol": f"{symbol}-USD",
|
|
174
|
+
"name": name,
|
|
175
|
+
"exchange": "Crypto",
|
|
176
|
+
"type": "CRYPTOCURRENCY",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return results
|
|
181
|
+
|
|
182
|
+
def get_info(self, symbol: str) -> dict:
|
|
183
|
+
"""Get detailed information about a cryptocurrency."""
|
|
184
|
+
import yfinance as yf
|
|
185
|
+
|
|
186
|
+
ticker = yf.Ticker(self._get_yf_symbol(symbol))
|
|
187
|
+
info = ticker.info
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"symbol": symbol,
|
|
191
|
+
"name": info.get("name", info.get("shortName", "")),
|
|
192
|
+
"description": info.get("description", ""),
|
|
193
|
+
"market_cap": info.get("marketCap", 0),
|
|
194
|
+
"circulating_supply": info.get("circulatingSupply", 0),
|
|
195
|
+
"total_supply": info.get("totalSupply", 0),
|
|
196
|
+
"max_supply": info.get("maxSupply", 0),
|
|
197
|
+
"volume_24h": info.get("volume24Hr", 0),
|
|
198
|
+
"52_week_high": info.get("fiftyTwoWeekHigh", 0),
|
|
199
|
+
"52_week_low": info.get("fiftyTwoWeekLow", 0),
|
|
200
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""ECB reference rates through Frankfurter (api.frankfurter.dev, no key) as a
|
|
2
|
+
backup for Yahoo currency pairs.
|
|
3
|
+
|
|
4
|
+
The ECB publishes one rate per business day at about 16:00 CET for ~30
|
|
5
|
+
currencies (INR, CNY, HKD, JPY, GBP, AUD... but no dollar index). Rates come
|
|
6
|
+
with the dollar as base and are crossed for other pairs. Pairs are named the
|
|
7
|
+
Yahoo way: "EURUSD=X" (dollars per euro), "USDINR=X", "HKD=X" (= USDHKD).
|
|
8
|
+
A pair is filled only when Yahoo returned nothing for it or its last bar is
|
|
9
|
+
more than STALE_DAYS older than the ECB's; filled series carry
|
|
10
|
+
attrs["source"] = SOURCE so a page can say where the number came from.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from datetime import date, timedelta
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
API = "https://api.frankfurter.dev/v1"
|
|
28
|
+
SOURCE = "ECB reference rate"
|
|
29
|
+
CACHE_SECONDS = 3600
|
|
30
|
+
STALE_DAYS = 4
|
|
31
|
+
|
|
32
|
+
_PAIR = re.compile(r"^([A-Z]{3})?([A-Z]{3})=X$")
|
|
33
|
+
|
|
34
|
+
_cache: dict[tuple[int, str], tuple[float, dict[str, pd.Series]]] = {}
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pair(ticker: str) -> Optional[tuple[str, str]]:
|
|
39
|
+
"""(base, quote) for a Yahoo FX ticker, None for anything else."""
|
|
40
|
+
m = _PAIR.match(ticker)
|
|
41
|
+
if not m:
|
|
42
|
+
return None
|
|
43
|
+
return (m.group(1), m.group(2)) if m.group(1) else ("USD", m.group(2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fetch(start: date, symbols: str) -> dict:
|
|
47
|
+
r = requests.get(f"{API}/{start.isoformat()}..", params={"base": "USD", "symbols": symbols}, timeout=20)
|
|
48
|
+
r.raise_for_status()
|
|
49
|
+
return r.json()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usd_rates(currencies: set[str], days: int, fetch: Optional[Callable[[date, str], dict]] = None) -> dict[str, pd.Series]:
|
|
53
|
+
"""Currency -> units per US dollar by day, over the last `days` days."""
|
|
54
|
+
wanted = sorted(currencies - {"USD"})
|
|
55
|
+
if not wanted:
|
|
56
|
+
return {}
|
|
57
|
+
key = (days, ",".join(wanted))
|
|
58
|
+
with _lock:
|
|
59
|
+
hit = _cache.get(key)
|
|
60
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
61
|
+
return hit[1]
|
|
62
|
+
payload = (fetch or _fetch)(date.today() - timedelta(days=days), ",".join(wanted))
|
|
63
|
+
rows = payload.get("rates") or {}
|
|
64
|
+
frame = pd.DataFrame.from_dict(rows, orient="index").sort_index()
|
|
65
|
+
frame.index = pd.DatetimeIndex(frame.index)
|
|
66
|
+
out = {c: frame[c].dropna().astype(float) for c in frame.columns if frame[c].notna().any()}
|
|
67
|
+
with _lock:
|
|
68
|
+
_cache[key] = (time.time(), out)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def series(tickers: list[str], days: int = 730, fetch: Optional[Callable[[date, str], dict]] = None) -> dict[str, pd.Series]:
|
|
73
|
+
"""Yahoo-named FX ticker -> daily ECB series (quote currency per base)."""
|
|
74
|
+
pairs = {t: p for t in tickers if (p := pair(t))}
|
|
75
|
+
if not pairs:
|
|
76
|
+
return {}
|
|
77
|
+
usd = usd_rates({c for p in pairs.values() for c in p}, days, fetch)
|
|
78
|
+
out = {}
|
|
79
|
+
for t, (base, quote) in pairs.items():
|
|
80
|
+
q = usd.get(quote) if quote != "USD" else None
|
|
81
|
+
b = usd.get(base) if base != "USD" else None
|
|
82
|
+
if (quote != "USD" and q is None) or (base != "USD" and b is None):
|
|
83
|
+
continue
|
|
84
|
+
if q is None: # e.g. EURUSD = 1 / (euros per dollar)
|
|
85
|
+
s = 1 / b
|
|
86
|
+
elif b is None:
|
|
87
|
+
s = q
|
|
88
|
+
else:
|
|
89
|
+
s = (q / b).dropna()
|
|
90
|
+
s = s.rename(t)
|
|
91
|
+
s.attrs["source"] = SOURCE
|
|
92
|
+
out[t] = s
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _last(s: pd.Series) -> pd.Timestamp:
|
|
97
|
+
t = pd.Timestamp(s.index[-1])
|
|
98
|
+
return t.tz_localize(None) if t.tzinfo else t
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fill(data: dict[str, pd.Series], tickers: list[str], days: int = 730,
|
|
102
|
+
fetch: Optional[Callable[[date, str], dict]] = None, today: Optional[date] = None) -> dict[str, pd.Series]:
|
|
103
|
+
"""Fill FX pairs Yahoo missed or left stale with ECB rates (in place, returned).
|
|
104
|
+
No request is made while Yahoo's pairs are fresh."""
|
|
105
|
+
cutoff = pd.Timestamp(today or date.today()) - pd.Timedelta(days=STALE_DAYS)
|
|
106
|
+
weak = [t for t in tickers if pair(t) and (t not in data or len(data[t]) < 2 or _last(data[t]) < cutoff)]
|
|
107
|
+
if not weak:
|
|
108
|
+
return data
|
|
109
|
+
try:
|
|
110
|
+
ecb = series(weak, days, fetch)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.info("ECB FX backup unavailable: %s", exc)
|
|
113
|
+
return data
|
|
114
|
+
for t, s in ecb.items():
|
|
115
|
+
old = data.get(t)
|
|
116
|
+
if len(s) > 1 and (old is None or len(old) < 2 or s.index[-1] > _last(old)):
|
|
117
|
+
data[t] = s
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def guarded(download: Callable[[], dict[str, pd.Series]], tickers: list[str], days: int = 730) -> dict[str, pd.Series]:
|
|
122
|
+
"""Run a Yahoo download; when it fails outright and FX pairs were asked for,
|
|
123
|
+
still return those pairs from the ECB. Other failures raise as before."""
|
|
124
|
+
try:
|
|
125
|
+
out = download()
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
if not any(pair(t) for t in tickers):
|
|
128
|
+
raise
|
|
129
|
+
logger.warning("Yahoo download failed, ECB rates for currencies only: %s", exc)
|
|
130
|
+
out = {}
|
|
131
|
+
return fill(out, tickers, days)
|