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,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)