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.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. 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]