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
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Backup market-data sources, plugged into the fetchers' FallbackChains.
|
|
2
|
+
|
|
3
|
+
- Tencent (qt.gtimg.cn / web.ifzq.gtimg.cn): free, no key. Quotes and
|
|
4
|
+
daily history for China A-shares and Hong Kong - used when akshare is
|
|
5
|
+
missing or Eastmoney is throttling, before Yahoo.
|
|
6
|
+
- Finnhub (FINNHUB_API_KEY, free tier 60 calls/min): US quotes.
|
|
7
|
+
- Alpha Vantage (ALPHAVANTAGE_API_KEY, free tier 25 calls/day): US quotes
|
|
8
|
+
and ~100 days of daily history.
|
|
9
|
+
- Nasdaq (api.nasdaq.com, no key): US quotes and daily history.
|
|
10
|
+
- Twelve Data (twelvedata_api_key, free tier 800 calls/day): US and NSE / BSE
|
|
11
|
+
quotes and daily history.
|
|
12
|
+
- Tiingo (tiingo_api_key, free tier): US quotes (IEX) and daily history.
|
|
13
|
+
- Polygon (polygon_key, free tier 5 calls/min): US daily history; its quote is
|
|
14
|
+
the latest daily bar.
|
|
15
|
+
|
|
16
|
+
Keys come from the environment or from the admin Integrations page
|
|
17
|
+
(config.json), see key().
|
|
18
|
+
|
|
19
|
+
Environment variable names match daily_stock_analysis. Every function
|
|
20
|
+
returns the same shapes the fetchers already use: a quote dict
|
|
21
|
+
(price, previous_close, open, day_high, day_low, volume, change,
|
|
22
|
+
change_percent) or a DataFrame indexed by date with lower-case OHLCV.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
import pandas as pd
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
from ..core.constants import TimeFrame
|
|
36
|
+
|
|
37
|
+
_TIMEOUT = 12
|
|
38
|
+
_TENCENT_HEADERS = {"Referer": "https://finance.qq.com", "User-Agent": "Mozilla/5.0"}
|
|
39
|
+
_TENCENT_MAX_BARS = 640
|
|
40
|
+
|
|
41
|
+
_PERIOD_DAYS = {"1d": 1, "5d": 7, "1mo": 31, "3mo": 93, "6mo": 186, "1y": 366, "2y": 731, "5y": 1827, "10y": 3653}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_KEY_TTL = 60.0
|
|
45
|
+
_key_cache: dict[str, tuple[float, Optional[str]]] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def key(field: str, *env_names: str) -> Optional[str]:
|
|
49
|
+
"""API key from an environment variable, else the saved config field.
|
|
50
|
+
|
|
51
|
+
Config.load() reads and decrypts config.json, so the answer is cached
|
|
52
|
+
for a minute: a key saved on the admin page is picked up shortly after.
|
|
53
|
+
"""
|
|
54
|
+
for name in (field.upper(), *env_names):
|
|
55
|
+
value = (os.environ.get(name) or "").strip()
|
|
56
|
+
if value:
|
|
57
|
+
return value
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
hit = _key_cache.get(field)
|
|
60
|
+
if hit and now - hit[0] < _KEY_TTL:
|
|
61
|
+
return hit[1]
|
|
62
|
+
value = _config_key(field)
|
|
63
|
+
_key_cache[field] = (now, value)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _config_key(field: str) -> Optional[str]:
|
|
68
|
+
try:
|
|
69
|
+
from ..core.config import Config
|
|
70
|
+
|
|
71
|
+
return (getattr(Config.load(), field, None) or "").strip() or None
|
|
72
|
+
except Exception: # noqa: BLE001 - a broken config must not break price fetching
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _trim(df: pd.DataFrame, period: str, start: Optional[datetime], end: Optional[datetime]) -> pd.DataFrame:
|
|
77
|
+
if df.empty:
|
|
78
|
+
return df
|
|
79
|
+
df = df.sort_index()[["open", "high", "low", "close", "volume"]]
|
|
80
|
+
cutoff = start or (datetime.now() - timedelta(days=period_days(period)))
|
|
81
|
+
df = df[df.index >= pd.Timestamp(cutoff)]
|
|
82
|
+
if end is not None:
|
|
83
|
+
df = df[df.index <= pd.Timestamp(end)]
|
|
84
|
+
return df
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def quote_from_frame(symbol: str, df: pd.DataFrame) -> dict:
|
|
88
|
+
"""A quote from the last two daily bars (lower-case OHLCV columns)."""
|
|
89
|
+
if df.empty:
|
|
90
|
+
raise ValueError(f"no bars for {symbol}")
|
|
91
|
+
bar = df.iloc[-1]
|
|
92
|
+
price = float(bar["close"])
|
|
93
|
+
prev = float(df["close"].iloc[-2]) if len(df) > 1 else None
|
|
94
|
+
return {
|
|
95
|
+
"symbol": symbol, "price": price, "previous_close": prev, "open": float(bar["open"]),
|
|
96
|
+
"day_high": float(bar["high"]), "day_low": float(bar["low"]),
|
|
97
|
+
"volume": float(bar["volume"]) if pd.notna(bar["volume"]) else None,
|
|
98
|
+
"change": price - prev if prev else 0, "change_percent": (price - prev) / prev * 100 if prev else 0,
|
|
99
|
+
"as_of": df.index[-1].strftime("%Y-%m-%d"),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def period_days(period: str, start: Optional[datetime] = None) -> int:
|
|
104
|
+
if start is not None:
|
|
105
|
+
return max(1, (datetime.now() - start).days + 1)
|
|
106
|
+
if period == "ytd":
|
|
107
|
+
return datetime.now().timetuple().tm_yday
|
|
108
|
+
return _PERIOD_DAYS.get(period, 366)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ensure_daily(interval: TimeFrame) -> None:
|
|
112
|
+
if interval != TimeFrame.DAY_1:
|
|
113
|
+
raise ValueError(f"backup source only serves daily bars, not {interval.value}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------- Tencent
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def tencent_symbol(code: str, market: str) -> str:
|
|
120
|
+
code = code.strip().upper()
|
|
121
|
+
if market == "hong_kong":
|
|
122
|
+
return f"hk{int(code):05d}"
|
|
123
|
+
if code[:1] in "69" or code[:1] == "5":
|
|
124
|
+
return f"sh{code}"
|
|
125
|
+
if code[:1] in "48":
|
|
126
|
+
return f"bj{code}"
|
|
127
|
+
return f"sz{code}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def tencent_quote(code: str, market: str) -> dict:
|
|
131
|
+
symbol = tencent_symbol(code, market)
|
|
132
|
+
resp = requests.get(f"https://qt.gtimg.cn/q={symbol}", headers=_TENCENT_HEADERS, timeout=_TIMEOUT)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
text = resp.content.decode("gbk", errors="replace")
|
|
135
|
+
start, end = text.find('"'), text.rfind('"')
|
|
136
|
+
fields = text[start + 1:end].split("~") if 0 <= start < end else []
|
|
137
|
+
if len(fields) < 35 or not fields[3]:
|
|
138
|
+
raise ValueError(f"Tencent has no quote for {symbol}")
|
|
139
|
+
|
|
140
|
+
def num(i: int) -> Optional[float]:
|
|
141
|
+
try:
|
|
142
|
+
return float(fields[i])
|
|
143
|
+
except (ValueError, IndexError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
volume = num(6)
|
|
147
|
+
if volume is not None and market == "china_a":
|
|
148
|
+
volume *= 100 # A-share volume is quoted in lots of 100
|
|
149
|
+
return {
|
|
150
|
+
"symbol": code,
|
|
151
|
+
"name": fields[1],
|
|
152
|
+
"price": num(3),
|
|
153
|
+
"previous_close": num(4),
|
|
154
|
+
"open": num(5),
|
|
155
|
+
"day_high": num(33),
|
|
156
|
+
"day_low": num(34),
|
|
157
|
+
"volume": volume,
|
|
158
|
+
"change": num(31),
|
|
159
|
+
"change_percent": num(32),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def tencent_history(code: str, market: str, period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
164
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
165
|
+
_ensure_daily(interval)
|
|
166
|
+
symbol = tencent_symbol(code, market)
|
|
167
|
+
bars = min(_TENCENT_MAX_BARS, int(period_days(period, start) * 0.72) + 10)
|
|
168
|
+
resp = requests.get(
|
|
169
|
+
"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get",
|
|
170
|
+
params={"param": f"{symbol},day,,,{bars},qfq"},
|
|
171
|
+
headers=_TENCENT_HEADERS,
|
|
172
|
+
timeout=_TIMEOUT,
|
|
173
|
+
)
|
|
174
|
+
resp.raise_for_status()
|
|
175
|
+
node = ((resp.json().get("data") or {}).get(symbol) or {})
|
|
176
|
+
rows = node.get("qfqday") or node.get("day") or []
|
|
177
|
+
if not rows:
|
|
178
|
+
return pd.DataFrame()
|
|
179
|
+
# Tencent order: date, open, close, high, low, volume, [extras]
|
|
180
|
+
df = pd.DataFrame([r[:6] for r in rows], columns=["date", "open", "close", "high", "low", "volume"])
|
|
181
|
+
df["date"] = pd.to_datetime(df["date"])
|
|
182
|
+
for col in ("open", "close", "high", "low", "volume"):
|
|
183
|
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
184
|
+
if market == "china_a":
|
|
185
|
+
df["volume"] = df["volume"] * 100 # lots of 100 -> shares, matching the quote
|
|
186
|
+
df = df.set_index("date").sort_index()[["open", "high", "low", "close", "volume"]]
|
|
187
|
+
if start is not None:
|
|
188
|
+
df = df[df.index >= pd.Timestamp(start)]
|
|
189
|
+
if end is not None:
|
|
190
|
+
df = df[df.index <= pd.Timestamp(end)]
|
|
191
|
+
return df
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------- Finnhub
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def finnhub_key() -> Optional[str]:
|
|
198
|
+
return key("finnhub_api_key")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def finnhub_quote(symbol: str) -> dict:
|
|
202
|
+
key = finnhub_key()
|
|
203
|
+
if not key:
|
|
204
|
+
raise ValueError("FINNHUB_API_KEY not set")
|
|
205
|
+
resp = requests.get("https://finnhub.io/api/v1/quote", params={"symbol": symbol.upper(), "token": key},
|
|
206
|
+
timeout=_TIMEOUT)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
q = resp.json()
|
|
209
|
+
if not q or not q.get("c"):
|
|
210
|
+
raise ValueError(f"Finnhub has no quote for {symbol}")
|
|
211
|
+
return {
|
|
212
|
+
"symbol": symbol, "price": q.get("c"), "previous_close": q.get("pc"), "open": q.get("o"),
|
|
213
|
+
"day_high": q.get("h"), "day_low": q.get("l"), "volume": None,
|
|
214
|
+
"change": q.get("d"), "change_percent": q.get("dp"),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ----------------------------------------------------------- Alpha Vantage
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def alphavantage_key() -> Optional[str]:
|
|
222
|
+
return key("alpha_vantage_key", "ALPHAVANTAGE_API_KEY")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _alphavantage(params: dict) -> dict:
|
|
226
|
+
key = alphavantage_key()
|
|
227
|
+
if not key:
|
|
228
|
+
raise ValueError("ALPHAVANTAGE_API_KEY not set")
|
|
229
|
+
resp = requests.get("https://www.alphavantage.co/query", params={**params, "apikey": key}, timeout=_TIMEOUT)
|
|
230
|
+
resp.raise_for_status()
|
|
231
|
+
data = resp.json()
|
|
232
|
+
# Rate limits come back as HTTP 200 with a "Note"/"Information" message.
|
|
233
|
+
if "Note" in data or "Information" in data or "Error Message" in data:
|
|
234
|
+
raise ValueError(data.get("Note") or data.get("Information") or data.get("Error Message"))
|
|
235
|
+
return data
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def alphavantage_quote(symbol: str) -> dict:
|
|
239
|
+
q = _alphavantage({"function": "GLOBAL_QUOTE", "symbol": symbol.upper()}).get("Global Quote") or {}
|
|
240
|
+
if not q.get("05. price"):
|
|
241
|
+
raise ValueError(f"Alpha Vantage has no quote for {symbol}")
|
|
242
|
+
|
|
243
|
+
def num(key: str) -> Optional[float]:
|
|
244
|
+
try:
|
|
245
|
+
return float(str(q.get(key, "")).rstrip("%"))
|
|
246
|
+
except ValueError:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"symbol": symbol, "price": num("05. price"), "previous_close": num("08. previous close"),
|
|
251
|
+
"open": num("02. open"), "day_high": num("03. high"), "day_low": num("04. low"),
|
|
252
|
+
"volume": num("06. volume"), "change": num("09. change"), "change_percent": num("10. change percent"),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def alphavantage_history(symbol: str, period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
257
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
258
|
+
_ensure_daily(interval)
|
|
259
|
+
data = _alphavantage({"function": "TIME_SERIES_DAILY", "symbol": symbol.upper(), "outputsize": "compact"})
|
|
260
|
+
series = data.get("Time Series (Daily)") or {}
|
|
261
|
+
if not series:
|
|
262
|
+
return pd.DataFrame()
|
|
263
|
+
df = pd.DataFrame.from_dict(series, orient="index").rename(columns={
|
|
264
|
+
"1. open": "open", "2. high": "high", "3. low": "low", "4. close": "close", "5. volume": "volume",
|
|
265
|
+
})
|
|
266
|
+
df.index = pd.to_datetime(df.index)
|
|
267
|
+
df.index.name = "date"
|
|
268
|
+
df = df.astype(float).sort_index()[["open", "high", "low", "close", "volume"]]
|
|
269
|
+
cutoff = start or (datetime.now() - timedelta(days=period_days(period)))
|
|
270
|
+
return df[df.index >= pd.Timestamp(cutoff)]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ Nasdaq
|
|
274
|
+
|
|
275
|
+
_NASDAQ_HEADERS = {
|
|
276
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36",
|
|
277
|
+
"Accept": "application/json",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _money(text) -> Optional[float]:
|
|
282
|
+
"""'$1,234.50' / '+1.16' / '+0.34%' / '25,900,600' -> float."""
|
|
283
|
+
if text in (None, "", "N/A"):
|
|
284
|
+
return None
|
|
285
|
+
try:
|
|
286
|
+
return float(str(text).replace("$", "").replace(",", "").replace("%", "").strip())
|
|
287
|
+
except ValueError:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _nasdaq(path: str, params: dict) -> dict:
|
|
292
|
+
resp = requests.get(f"https://api.nasdaq.com/api/quote/{path}", params=params,
|
|
293
|
+
headers=_NASDAQ_HEADERS, timeout=_TIMEOUT)
|
|
294
|
+
resp.raise_for_status()
|
|
295
|
+
body = resp.json() or {}
|
|
296
|
+
data = body.get("data")
|
|
297
|
+
if not data:
|
|
298
|
+
raise ValueError(((body.get("status") or {}).get("bCodeMessage") or [{}])[0].get("errorMessage")
|
|
299
|
+
or "Nasdaq returned no data")
|
|
300
|
+
return data
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def nasdaq_quote(symbol: str) -> dict:
|
|
304
|
+
data = _nasdaq(f"{symbol.upper()}/info", {"assetclass": "stocks"})
|
|
305
|
+
primary = data.get("primaryData") or {}
|
|
306
|
+
price = _money(primary.get("lastSalePrice"))
|
|
307
|
+
if not price:
|
|
308
|
+
raise ValueError(f"Nasdaq has no quote for {symbol}")
|
|
309
|
+
change = _money(primary.get("netChange"))
|
|
310
|
+
return {
|
|
311
|
+
"symbol": symbol, "price": price, "previous_close": price - change if change is not None else None,
|
|
312
|
+
"open": None, "day_high": None, "day_low": None, "volume": _money(primary.get("volume")),
|
|
313
|
+
"change": change, "change_percent": _money(primary.get("percentageChange")),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def nasdaq_history(symbol: str, period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
318
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
319
|
+
_ensure_daily(interval)
|
|
320
|
+
since = start or (datetime.now() - timedelta(days=period_days(period)))
|
|
321
|
+
until = end or datetime.now()
|
|
322
|
+
data = _nasdaq(f"{symbol.upper()}/historical", {
|
|
323
|
+
"assetclass": "stocks", "fromdate": since.strftime("%Y-%m-%d"), "todate": until.strftime("%Y-%m-%d"),
|
|
324
|
+
"limit": 9999,
|
|
325
|
+
})
|
|
326
|
+
rows = ((data.get("tradesTable") or {}).get("rows")) or []
|
|
327
|
+
if not rows:
|
|
328
|
+
return pd.DataFrame()
|
|
329
|
+
df = pd.DataFrame([{
|
|
330
|
+
"date": pd.to_datetime(r.get("date"), format="%m/%d/%Y"), "open": _money(r.get("open")),
|
|
331
|
+
"high": _money(r.get("high")), "low": _money(r.get("low")), "close": _money(r.get("close")),
|
|
332
|
+
"volume": _money(r.get("volume")),
|
|
333
|
+
} for r in rows]).set_index("date")
|
|
334
|
+
return _trim(df.dropna(subset=["close"]), period, start, end)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------- Twelve Data
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def twelvedata_key() -> Optional[str]:
|
|
341
|
+
return key("twelvedata_api_key", "TWELVE_DATA_API_KEY")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _twelvedata_symbol(symbol: str, market: str) -> dict:
|
|
345
|
+
if market == "india":
|
|
346
|
+
code = symbol.upper()
|
|
347
|
+
exchange = "BSE" if code.endswith(".BO") else "NSE"
|
|
348
|
+
return {"symbol": code.removesuffix(".NS").removesuffix(".BO"), "exchange": exchange}
|
|
349
|
+
return {"symbol": symbol.upper()}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _twelvedata(path: str, params: dict) -> dict:
|
|
353
|
+
k = twelvedata_key()
|
|
354
|
+
if not k:
|
|
355
|
+
raise ValueError("Twelve Data key not set")
|
|
356
|
+
resp = requests.get(f"https://api.twelvedata.com/{path}", params={**params, "apikey": k}, timeout=_TIMEOUT)
|
|
357
|
+
resp.raise_for_status()
|
|
358
|
+
data = resp.json() or {}
|
|
359
|
+
# Errors (bad key, rate limit, symbol not on the plan) come back as HTTP 200.
|
|
360
|
+
if data.get("status") == "error":
|
|
361
|
+
raise ValueError(f"Twelve Data: {data.get('message')}")
|
|
362
|
+
return data
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def twelvedata_quote(symbol: str, market: str = "us") -> dict:
|
|
366
|
+
q = _twelvedata("quote", _twelvedata_symbol(symbol, market))
|
|
367
|
+
num = lambda k: _money(q.get(k)) # noqa: E731
|
|
368
|
+
price = num("close")
|
|
369
|
+
if not price:
|
|
370
|
+
raise ValueError(f"Twelve Data has no quote for {symbol}")
|
|
371
|
+
return {
|
|
372
|
+
"symbol": symbol, "price": price, "previous_close": num("previous_close"), "open": num("open"),
|
|
373
|
+
"day_high": num("high"), "day_low": num("low"), "volume": num("volume"),
|
|
374
|
+
"change": num("change"), "change_percent": num("percent_change"),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def twelvedata_history(symbol: str, market: str = "us", period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
379
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
380
|
+
_ensure_daily(interval)
|
|
381
|
+
bars = min(5000, int(period_days(period, start) * 0.72) + 10)
|
|
382
|
+
data = _twelvedata("time_series", {**_twelvedata_symbol(symbol, market), "interval": "1day", "outputsize": bars})
|
|
383
|
+
values = data.get("values") or []
|
|
384
|
+
if not values:
|
|
385
|
+
return pd.DataFrame()
|
|
386
|
+
df = pd.DataFrame(values)
|
|
387
|
+
df["date"] = pd.to_datetime(df["datetime"])
|
|
388
|
+
for col in ("open", "high", "low", "close", "volume"):
|
|
389
|
+
df[col] = pd.to_numeric(df.get(col), errors="coerce")
|
|
390
|
+
return _trim(df.set_index("date"), period, start, end)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------ Tiingo
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def tiingo_key() -> Optional[str]:
|
|
397
|
+
return key("tiingo_api_key")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _tiingo(path: str, params: Optional[dict] = None):
|
|
401
|
+
k = tiingo_key()
|
|
402
|
+
if not k:
|
|
403
|
+
raise ValueError("Tiingo key not set")
|
|
404
|
+
resp = requests.get(f"https://api.tiingo.com/{path}", params={**(params or {}), "token": k}, timeout=_TIMEOUT)
|
|
405
|
+
resp.raise_for_status()
|
|
406
|
+
return resp.json()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def tiingo_quote(symbol: str) -> dict:
|
|
410
|
+
rows = _tiingo(f"iex/{symbol.lower()}") or []
|
|
411
|
+
q = rows[0] if isinstance(rows, list) and rows else {}
|
|
412
|
+
price = q.get("tngoLast") or q.get("last")
|
|
413
|
+
if not price:
|
|
414
|
+
raise ValueError(f"Tiingo has no quote for {symbol}")
|
|
415
|
+
prev = q.get("prevClose")
|
|
416
|
+
return {
|
|
417
|
+
"symbol": symbol, "price": float(price), "previous_close": prev, "open": q.get("open"),
|
|
418
|
+
"day_high": q.get("high"), "day_low": q.get("low"), "volume": q.get("volume"),
|
|
419
|
+
"change": float(price) - prev if prev else None,
|
|
420
|
+
"change_percent": (float(price) - prev) / prev * 100 if prev else None,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def tiingo_history(symbol: str, period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
425
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
426
|
+
_ensure_daily(interval)
|
|
427
|
+
since = start or (datetime.now() - timedelta(days=period_days(period)))
|
|
428
|
+
params = {"startDate": since.strftime("%Y-%m-%d")}
|
|
429
|
+
if end is not None:
|
|
430
|
+
params["endDate"] = end.strftime("%Y-%m-%d")
|
|
431
|
+
rows = _tiingo(f"tiingo/daily/{symbol.lower()}/prices", params) or []
|
|
432
|
+
if not rows:
|
|
433
|
+
return pd.DataFrame()
|
|
434
|
+
df = pd.DataFrame(rows)
|
|
435
|
+
# Split- and dividend-adjusted bars, like Yahoo's default history.
|
|
436
|
+
if "adjClose" in df:
|
|
437
|
+
df = df.assign(open=df["adjOpen"], high=df["adjHigh"], low=df["adjLow"], close=df["adjClose"],
|
|
438
|
+
volume=df["adjVolume"])
|
|
439
|
+
df["date"] = pd.to_datetime(df["date"]).dt.tz_localize(None)
|
|
440
|
+
return _trim(df.set_index("date"), period, start, end)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ----------------------------------------------------------------- Polygon
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def polygon_key() -> Optional[str]:
|
|
447
|
+
return key("polygon_key", "POLYGON_API_KEY")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def polygon_history(symbol: str, period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
451
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
452
|
+
_ensure_daily(interval)
|
|
453
|
+
k = polygon_key()
|
|
454
|
+
if not k:
|
|
455
|
+
raise ValueError("Polygon key not set")
|
|
456
|
+
since = start or (datetime.now() - timedelta(days=period_days(period)))
|
|
457
|
+
until = end or datetime.now()
|
|
458
|
+
resp = requests.get(
|
|
459
|
+
f"https://api.polygon.io/v2/aggs/ticker/{symbol.upper()}/range/1/day/"
|
|
460
|
+
f"{since:%Y-%m-%d}/{until:%Y-%m-%d}",
|
|
461
|
+
params={"adjusted": "true", "sort": "asc", "limit": 50000, "apiKey": k}, timeout=_TIMEOUT,
|
|
462
|
+
)
|
|
463
|
+
resp.raise_for_status()
|
|
464
|
+
data = resp.json() or {}
|
|
465
|
+
if data.get("status") == "ERROR":
|
|
466
|
+
raise ValueError(f"Polygon: {data.get('error') or data.get('message')}")
|
|
467
|
+
rows = data.get("results") or []
|
|
468
|
+
if not rows:
|
|
469
|
+
return pd.DataFrame()
|
|
470
|
+
df = pd.DataFrame(rows).rename(columns={"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"})
|
|
471
|
+
# A point at 00:00 UTC is the previous day's close; the last point is "now".
|
|
472
|
+
df["date"] = pd.to_datetime(df["t"] - 1, unit="ms").dt.normalize()
|
|
473
|
+
return _trim(df.set_index("date"), period, start, end)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def polygon_quote(symbol: str) -> dict:
|
|
477
|
+
return quote_from_frame(symbol, polygon_history(symbol, period="1mo"))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------- CoinGecko
|
|
481
|
+
|
|
482
|
+
_COINGECKO = "https://api.coingecko.com/api/v3"
|
|
483
|
+
_coin_ids: dict[str, Optional[str]] = {}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _coingecko(path: str, params: dict):
|
|
487
|
+
headers = {"Accept": "application/json"}
|
|
488
|
+
k = key("coingecko_api_key") # optional free "demo" key: higher rate limit
|
|
489
|
+
if k:
|
|
490
|
+
headers["x-cg-demo-api-key"] = k
|
|
491
|
+
resp = requests.get(f"{_COINGECKO}/{path}", params=params, headers=headers, timeout=_TIMEOUT)
|
|
492
|
+
resp.raise_for_status()
|
|
493
|
+
return resp.json()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def coin_base(symbol: str) -> str:
|
|
497
|
+
"""BTC-USD / BTCUSDT / BTC/USDT / btc -> BTC."""
|
|
498
|
+
s = symbol.upper().replace("/", "").replace("-", "")
|
|
499
|
+
for quote in ("USDT", "USDC", "USD"):
|
|
500
|
+
if s.endswith(quote) and len(s) > len(quote):
|
|
501
|
+
return s[: -len(quote)]
|
|
502
|
+
return s
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def coingecko_id(symbol: str) -> str:
|
|
506
|
+
"""CoinGecko's id for a ticker: the best-ranked coin with exactly that symbol."""
|
|
507
|
+
base = coin_base(symbol)
|
|
508
|
+
if base not in _coin_ids:
|
|
509
|
+
coins = (_coingecko("search", {"query": base}) or {}).get("coins") or []
|
|
510
|
+
same = [c for c in coins if (c.get("symbol") or "").upper() == base]
|
|
511
|
+
same.sort(key=lambda c: c.get("market_cap_rank") or 10**9)
|
|
512
|
+
_coin_ids[base] = same[0]["id"] if same else None
|
|
513
|
+
coin = _coin_ids[base]
|
|
514
|
+
if not coin:
|
|
515
|
+
raise ValueError(f"CoinGecko has no coin {base}")
|
|
516
|
+
return coin
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def coingecko_quote(symbol: str) -> dict:
|
|
520
|
+
rows = _coingecko("coins/markets", {"vs_currency": "usd", "ids": coingecko_id(symbol)}) or []
|
|
521
|
+
if not rows or rows[0].get("current_price") is None:
|
|
522
|
+
raise ValueError(f"CoinGecko has no price for {symbol}")
|
|
523
|
+
q = rows[0]
|
|
524
|
+
price, change = q["current_price"], q.get("price_change_24h")
|
|
525
|
+
return {
|
|
526
|
+
"symbol": symbol, "price": price, "previous_close": price - change if change is not None else None,
|
|
527
|
+
"open": None, "day_high": q.get("high_24h"), "day_low": q.get("low_24h"),
|
|
528
|
+
"volume": q.get("total_volume"), "change": change, "change_percent": q.get("price_change_percentage_24h"),
|
|
529
|
+
"market_cap": q.get("market_cap"),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def coingecko_history(symbol: str, period: str = "1y", interval: TimeFrame = TimeFrame.DAY_1,
|
|
534
|
+
start: Optional[datetime] = None, end: Optional[datetime] = None, **_ignored) -> pd.DataFrame:
|
|
535
|
+
"""Daily closes and USD volume only (daily candles are a paid feature):
|
|
536
|
+
open is the previous close and high / low equal the close."""
|
|
537
|
+
_ensure_daily(interval)
|
|
538
|
+
days = min(365, period_days(period, start)) # the free API serves the last 365 days
|
|
539
|
+
data = _coingecko(f"coins/{coingecko_id(symbol)}/market_chart",
|
|
540
|
+
{"vs_currency": "usd", "days": days, "interval": "daily"}) or {}
|
|
541
|
+
prices = data.get("prices") or []
|
|
542
|
+
if not prices:
|
|
543
|
+
return pd.DataFrame()
|
|
544
|
+
df = pd.DataFrame(prices, columns=["t", "close"])
|
|
545
|
+
vol = dict((t, v) for t, v in (data.get("total_volumes") or []))
|
|
546
|
+
df["volume"] = df["t"].map(vol)
|
|
547
|
+
# A point at 00:00 UTC is the previous day's close; the last point is "now".
|
|
548
|
+
df["date"] = pd.to_datetime(df["t"] - 1, unit="ms").dt.normalize()
|
|
549
|
+
df = df.drop_duplicates("date", keep="last").set_index("date")
|
|
550
|
+
df["open"] = df["close"].shift(1).fillna(df["close"])
|
|
551
|
+
df["high"] = df[["open", "close"]].max(axis=1)
|
|
552
|
+
df["low"] = df[["open", "close"]].min(axis=1)
|
|
553
|
+
df = _trim(df, period, start, end)
|
|
554
|
+
df.attrs["close_only"] = True
|
|
555
|
+
return df
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ------------------------------------------------------- Crypto fear & greed
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def crypto_fear_greed(days: int = 30) -> list[dict]:
|
|
562
|
+
"""alternative.me's daily crypto fear & greed index (0-100), newest first."""
|
|
563
|
+
resp = requests.get("https://api.alternative.me/fng/", params={"limit": days}, timeout=_TIMEOUT)
|
|
564
|
+
resp.raise_for_status()
|
|
565
|
+
rows = (resp.json() or {}).get("data") or []
|
|
566
|
+
return [{
|
|
567
|
+
"date": datetime.fromtimestamp(int(r["timestamp"]), timezone.utc).strftime("%Y-%m-%d"),
|
|
568
|
+
"value": int(r["value"]), "label": r.get("value_classification"),
|
|
569
|
+
} for r in rows if r.get("value") is not None]
|