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,495 @@
1
+ """Economic calendar: the macro dates that move whole markets.
2
+
3
+ - US Fed rate decisions: federalreserve.gov FOMC calendar (decision on the last
4
+ meeting day; "*" meetings come with economic projections).
5
+ - US data releases: FRED's release calendar (CPI, PPI, jobs report, GDP, PCE,
6
+ retail sales), times in US Central time.
7
+ - RBI rate decisions: the MPC meeting schedule on rbi.org.in.
8
+ - India CPI and GDP: MoSPI's standing schedule (CPI on the 12th, GDP on the
9
+ last working day of Feb / May / Aug / Nov, both 4 pm IST), shifted off
10
+ weekends. Public holidays are not known here, so these are marked estimated.
11
+
12
+ Modeled on OpenBB's economy.calendar. Each source is cached for 12 hours and
13
+ its last good result is saved under ~/.marketmyna/econ/, served if a later
14
+ fetch fails (same pattern as india_rates).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import html as htmllib
20
+ import json
21
+ import logging
22
+ import re
23
+ import threading
24
+ import time
25
+ from datetime import date, datetime, timedelta
26
+ from pathlib import Path
27
+ from typing import Callable, Optional
28
+ from zoneinfo import ZoneInfo
29
+
30
+ from marketmyna.core.home import app_home
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ FED_URL = "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm"
35
+ RBI_URL = "https://www.rbi.org.in/Scripts/Annualpolicy.aspx"
36
+ FRED_URL = "https://fred.stlouisfed.org/releases/calendar?rid={rid}&y={year}"
37
+ UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
38
+ CACHE_SECONDS = 12 * 3600
39
+
40
+ # FRED release id -> (key, short title, kind, importance, what it tells you)
41
+ FRED_RELEASES = {
42
+ 10: ("us_cpi", "US inflation (CPI)", "inflation", "high", "Consumer prices; drives Fed rate expectations"),
43
+ 50: ("us_jobs", "US jobs report", "jobs", "high", "Payrolls and unemployment rate"),
44
+ 53: ("us_gdp", "US GDP", "growth", "high", "Quarterly growth estimate"),
45
+ 54: ("us_pce", "US PCE inflation", "inflation", "high", "The Fed's preferred inflation gauge, with spending"),
46
+ 46: ("us_ppi", "US producer prices (PPI)", "inflation", "medium", "Pipeline inflation at the wholesale level"),
47
+ 9: ("us_retail", "US retail sales", "spending", "medium", "Consumer spending momentum"),
48
+ }
49
+
50
+ # key -> how a surprise usually moves stocks (shown with the event and in the reminder)
51
+ WHY = {
52
+ "fed": "A cut or a softer tone usually lifts stocks and foreign flows into India; a hawkish hold firms the dollar "
53
+ "and weighs on tech and the rupee.",
54
+ "rbi": "A repo cut lowers loan rates, good for banks, NBFCs, autos and real estate; a hawkish hold weighs on "
55
+ "those rate-sensitive stocks.",
56
+ "us_cpi": "Hotter than last month pushes Fed cuts back, lifting bond yields and pressuring tech / growth stocks; "
57
+ "cooler supports a rally.",
58
+ "us_pce": "The Fed's own inflation gauge: a hot print means fewer rate cuts, a soft one backs them.",
59
+ "us_ppi": "Wholesale prices feed into CPI a few months later; a jump hints at stickier inflation.",
60
+ "us_jobs": "Strong hiring means fewer Fed cuts; a weak print raises recession worries but also cut hopes.",
61
+ "us_gdp": "Shows whether the US economy is slowing; a weak number hits cyclicals and IT exporters' demand outlook.",
62
+ "us_retail": "Consumer spending is ~70% of US GDP; a miss points to slowing growth.",
63
+ "in_cpi": "Sets up the next RBI decision: inside RBI's 2-6% band leaves room for cuts, above 6% rules them out.",
64
+ "in_gdp": "Confirms India's growth pace; a miss hits banks, capital goods and other domestic cyclicals.",
65
+ }
66
+
67
+ Fetch = Callable[[str], str]
68
+
69
+ _cache: dict[str, tuple[float, list]] = {}
70
+ _lock = threading.Lock()
71
+
72
+
73
+ def _http(url: str) -> str:
74
+ import requests
75
+
76
+ # FRED stalls requests with an unknown custom User-Agent; the default works there.
77
+ headers = {} if "stlouisfed" in url else {"User-Agent": UA}
78
+ r = requests.get(url, headers=headers, timeout=25)
79
+ r.raise_for_status()
80
+ return r.text
81
+
82
+
83
+ def _event(key: str, day: date, title: str, region: str, kind: str, importance: str, note: str, source: str,
84
+ time_str: Optional[str] = None, tz: Optional[str] = None, estimated: bool = False) -> dict:
85
+ return {"key": key, "date": day.isoformat(), "time": time_str, "tz": tz, "title": title, "region": region,
86
+ "kind": kind, "importance": importance, "note": note, "source": source, "estimated": estimated}
87
+
88
+
89
+ MONTHS = {m: i for i, m in enumerate(["january", "february", "march", "april", "may", "june", "july", "august",
90
+ "september", "october", "november", "december"], 1)}
91
+ MON3 = {k[:3]: v for k, v in MONTHS.items()}
92
+
93
+
94
+ def parse_fed(page: str) -> list[dict]:
95
+ """FOMC meetings per year section; decision on the last day."""
96
+ out = []
97
+ parts = re.split(r"(\d{4}) FOMC Meetings", page)
98
+ for i in range(1, len(parts) - 1, 2):
99
+ year, body = int(parts[i]), parts[i + 1]
100
+ for month, days in re.findall(r'fomc-meeting__month[^>]*>\s*<strong>([^<]+)</strong>.*?fomc-meeting__date[^>]*>([^<]+)<',
101
+ body, re.S):
102
+ month, days = month.strip(), htmllib.unescape(days).strip()
103
+ if "notation" in days.lower() or "cancel" in days.lower():
104
+ continue
105
+ sep = "*" in days
106
+ nums = re.findall(r"\d+", days)
107
+ months = [MON3.get(m.strip()[:3].lower()) for m in month.split("/")]
108
+ if not nums or not months[0]:
109
+ continue
110
+ end_month = months[-1] if len(months) > 1 else months[0]
111
+ try:
112
+ day = date(year, end_month, int(nums[-1]))
113
+ except ValueError:
114
+ continue
115
+ out.append(_event("fed", day, "Fed rate decision" + (" + projections" if sep else ""), "US", "central_bank", "high",
116
+ "FOMC statement 2:00 pm ET; press conference follows", "Federal Reserve",
117
+ "14:00", "America/New_York"))
118
+ return out
119
+
120
+
121
+ def parse_rbi(page: str) -> list[dict]:
122
+ """'October 5, 6 and 7, 2026' rows of the MPC schedule; decision on the last day."""
123
+ text = htmllib.unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " | ", page)))
124
+ out, seen = [], set()
125
+ for month, days, year in re.findall(r"(January|February|March|April|May|June|July|August|September|October|November|December)"
126
+ r"\s+((?:\d{1,2}(?:,|\s+and|\s+to|\s*-)?\s*)+),\s*(\d{4})", text):
127
+ nums = re.findall(r"\d{1,2}", days)
128
+ if len(nums) < 2: # a single date is a press conference line, not a meeting range
129
+ continue
130
+ try:
131
+ day = date(int(year), MONTHS[month.lower()], int(nums[-1]))
132
+ except ValueError:
133
+ continue
134
+ if day in seen:
135
+ continue
136
+ seen.add(day)
137
+ out.append(_event("rbi", day, "RBI rate decision", "India", "central_bank", "high",
138
+ "MPC resolution around 10:00 am IST, governor's statement", "Reserve Bank of India",
139
+ "10:00", "Asia/Kolkata"))
140
+ return out
141
+
142
+
143
+ def parse_fred(page: str, rid: int) -> list[dict]:
144
+ key, title, kind, importance, note = FRED_RELEASES[rid]
145
+ out = []
146
+ for day_s, time_s, row_rid in re.findall(
147
+ r'font-weight: bold;">[A-Za-z]+ ([A-Za-z]+ \d{1,2}, \d{4})</span>.*?<td nowrap[^>]*>\s*([^<]*?)\s*</td>\s*'
148
+ r'<td[^>]*>\s*<a href="/release\?rid=(\d+)">', page, re.S):
149
+ if int(row_rid) != rid:
150
+ continue
151
+ try:
152
+ day = datetime.strptime(day_s, "%B %d, %Y").date()
153
+ except ValueError:
154
+ continue
155
+ hhmm = None
156
+ if m := re.match(r"(\d{1,2}):(\d{2})\s*([ap]m)", time_s.lower()):
157
+ h = int(m.group(1)) % 12 + (12 if m.group(3) == "pm" else 0)
158
+ hhmm = f"{h:02d}:{m.group(2)}"
159
+ out.append(_event(key, day, title, "US", kind, importance, note, "US official statistics",
160
+ hhmm, "America/Chicago"))
161
+ return out
162
+
163
+
164
+ def _workday_on_or_after(d: date) -> date:
165
+ while d.weekday() >= 5:
166
+ d += timedelta(days=1)
167
+ return d
168
+
169
+
170
+ def _last_workday(year: int, month: int) -> date:
171
+ d = (date(year, month % 12 + 1, 1) if month < 12 else date(year + 1, 1, 1)) - timedelta(days=1)
172
+ while d.weekday() >= 5:
173
+ d -= timedelta(days=1)
174
+ return d
175
+
176
+
177
+ def india_releases(start: date, end: date) -> list[dict]:
178
+ out = []
179
+ d = date(start.year, start.month, 1)
180
+ while d <= end:
181
+ cpi = _workday_on_or_after(date(d.year, d.month, 12))
182
+ out.append(_event("in_cpi", cpi, "India inflation (CPI)", "India", "inflation", "high",
183
+ "Retail inflation for the previous month (MoSPI)", "MoSPI schedule",
184
+ "16:00", "Asia/Kolkata", estimated=True))
185
+ if d.month in (2, 5, 8, 11):
186
+ out.append(_event("in_gdp", _last_workday(d.year, d.month), "India GDP", "India", "growth", "high",
187
+ "Quarterly growth estimate (MoSPI)", "MoSPI schedule",
188
+ "16:00", "Asia/Kolkata", estimated=True))
189
+ d = date(d.year + (d.month == 12), d.month % 12 + 1, 1)
190
+ return [e for e in out if start.isoformat() <= e["date"] <= end.isoformat()]
191
+
192
+
193
+ def _dir() -> Path:
194
+ p = app_home() / "econ"
195
+ p.mkdir(parents=True, exist_ok=True)
196
+ return p
197
+
198
+
199
+ def _source(name: str, loader: Callable[[], list[dict]], ttl: float = CACHE_SECONDS) -> tuple[list[dict], Optional[str]]:
200
+ """Cached per source; last good copy on disk when a fetch fails."""
201
+ with _lock:
202
+ hit = _cache.get(name)
203
+ if hit and time.time() - hit[0] < ttl:
204
+ return hit[1], None
205
+ path = _dir() / f"{name}.json"
206
+ try:
207
+ events = loader()
208
+ if not events:
209
+ raise ValueError("no dates found (page layout changed?)")
210
+ with _lock:
211
+ _cache[name] = (time.time(), events)
212
+ try:
213
+ path.write_text(json.dumps(events), encoding="utf-8")
214
+ except OSError:
215
+ pass
216
+ return events, None
217
+ except Exception as exc:
218
+ logger.warning("Economic calendar source %s failed: %s", name, exc)
219
+ if path.exists():
220
+ return json.loads(path.read_text(encoding="utf-8")), f"{name}: live fetch failed, using the last saved schedule"
221
+ return [], f"{name}: unavailable right now"
222
+
223
+
224
+ def collect(start: date, days: int, fetch: Fetch = _http, regions: tuple[str, ...] = ("US", "India")) -> dict:
225
+ end = start + timedelta(days=days)
226
+ years = sorted({start.year, end.year})
227
+ events, warnings = [], []
228
+ if "US" in regions:
229
+ ev, w = _source("fed", lambda: parse_fed(fetch(FED_URL)))
230
+ events += ev
231
+ warnings += [w] if w else []
232
+ for rid in FRED_RELEASES:
233
+ ev, w = _source(f"fred_{rid}_{'_'.join(map(str, years))}",
234
+ lambda rid=rid: [e for y in years for e in parse_fred(fetch(FRED_URL.format(rid=rid, year=y)), rid)])
235
+ events += ev
236
+ warnings += [w] if w else []
237
+ if "India" in regions:
238
+ ev, w = _source("rbi", lambda: parse_rbi(fetch(RBI_URL)))
239
+ events += ev
240
+ warnings += [w] if w else []
241
+ events += india_releases(start, end)
242
+ picked = [dict(e, days_away=(date.fromisoformat(e["date"]) - start).days)
243
+ for e in events if start.isoformat() <= e["date"] <= end.isoformat()]
244
+ picked.sort(key=lambda e: (e["date"], e["time"] or "", e["title"]))
245
+ return {"events": picked, "warnings": warnings}
246
+
247
+
248
+ def local_time(e: dict, tz_name: str) -> Optional[str]:
249
+ """Event time converted to the viewer's timezone, 'HH:MM'."""
250
+ if not e.get("time") or not e.get("tz"):
251
+ return None
252
+ try:
253
+ dt = datetime.combine(date.fromisoformat(e["date"]), datetime.strptime(e["time"], "%H:%M").time(),
254
+ tzinfo=ZoneInfo(e["tz"]))
255
+ loc = dt.astimezone(ZoneInfo(tz_name))
256
+ day_shift = (loc.date() - dt.date()).days
257
+ return loc.strftime("%H:%M") + (" (+1d)" if day_shift > 0 else "")
258
+ except Exception:
259
+ return None
260
+
261
+
262
+ # ------------------------------------------------------------------ last readings
263
+
264
+ READINGS_SECONDS = 6 * 3600
265
+ SeriesFetch = Callable[[str, date], "object"] # macro._fred: id, start -> pandas Series
266
+
267
+
268
+ def _pct_yoy(s) -> "object":
269
+ monthly = s.resample("MS").last().dropna()
270
+ return ((monthly / monthly.shift(12) - 1) * 100).dropna()
271
+
272
+
273
+ def _mon(ts) -> str:
274
+ return ts.strftime("%b")
275
+
276
+
277
+ def _qtr(ts) -> str:
278
+ return f"Q{(ts.month - 1) // 3 + 1} {ts.year}"
279
+
280
+
281
+ def _trend(now: float, prev: float, unit: str = "%") -> str:
282
+ if round(now, 1) == round(prev, 1):
283
+ return f"same as {prev:.1f}{unit} before"
284
+ return f"{'up' if now > prev else 'down'} from {prev:.1f}{unit}"
285
+
286
+
287
+ def _point(text: str, period, value: float, prev: Optional[float]) -> dict:
288
+ return {"text": text, "period": period.date().isoformat(), "value": float(value),
289
+ "prev": None if prev is None else float(prev)}
290
+
291
+
292
+ def _yoy_point(series: dict, main: str, core: Optional[str] = None) -> Optional[dict]:
293
+ if main not in series:
294
+ return None
295
+ y = _pct_yoy(series[main])
296
+ if len(y) < 2:
297
+ return None
298
+ text = f"{y.iloc[-1]:.1f}% YoY in {_mon(y.index[-1])}, {_trend(y.iloc[-1], y.iloc[-2])}"
299
+ if core and core in series and len(c := _pct_yoy(series[core])):
300
+ text += f"; core {c.iloc[-1]:.1f}%"
301
+ return _point(text, y.index[-1], y.iloc[-1], y.iloc[-2])
302
+
303
+
304
+ def _fred_points(series: dict) -> dict[str, dict]:
305
+ """key -> {text, period (first day of the month / quarter it covers), value, prev}."""
306
+ out: dict[str, dict] = {}
307
+ for key, main, core in (("us_cpi", "CPIAUCSL", "CPILFESL"), ("us_pce", "PCEPI", "PCEPILFE"), ("us_ppi", "PPIFIS", None)):
308
+ if pt := _yoy_point(series, main, core):
309
+ out[key] = pt
310
+ if "PAYEMS" in series and len(p := series["PAYEMS"].dropna()) >= 3:
311
+ adds = p.diff().dropna()
312
+ line = f"{adds.iloc[-1]:+,.0f}k jobs in {_mon(adds.index[-1])} (before: {adds.iloc[-2]:+,.0f}k)"
313
+ if "UNRATE" in series and len(u := series["UNRATE"].dropna()):
314
+ line += f", unemployment {u.iloc[-1]:.1f}%"
315
+ out["us_jobs"] = _point(line, adds.index[-1], adds.iloc[-1], adds.iloc[-2])
316
+ if "A191RL1Q225SBEA" in series and len(g := series["A191RL1Q225SBEA"].dropna()) >= 2:
317
+ out["us_gdp"] = _point(f"{g.iloc[-1]:.1f}% annualised in {_qtr(g.index[-1])} (before: {g.iloc[-2]:.1f}%)",
318
+ g.index[-1], g.iloc[-1], g.iloc[-2])
319
+ if "RSAFS" in series and len(r := series["RSAFS"].dropna()) >= 3:
320
+ mom = (r.pct_change() * 100).dropna()
321
+ out["us_retail"] = _point(f"{mom.iloc[-1]:+.1f}% month on month in {_mon(mom.index[-1])} (before: {mom.iloc[-2]:+.1f}%)",
322
+ mom.index[-1], mom.iloc[-1], mom.iloc[-2])
323
+ if "DFEDTARL" in series and "DFEDTARU" in series and len(series["DFEDTARL"]) and len(series["DFEDTARU"]):
324
+ lo, hi = series["DFEDTARL"], series["DFEDTARU"]
325
+ out["fed"] = _point(f"Target range now {lo.iloc[-1]:.2f}-{hi.iloc[-1]:.2f}%", hi.index[-1], hi.iloc[-1], None)
326
+ return out
327
+
328
+
329
+ READING_SERIES = ["CPIAUCSL", "CPILFESL", "PCEPI", "PCEPILFE", "PPIFIS", "PAYEMS", "UNRATE",
330
+ "A191RL1Q225SBEA", "RSAFS", "DFEDTARL", "DFEDTARU"]
331
+
332
+
333
+ def _rbi_line() -> Optional[str]:
334
+ from . import india_rates
335
+
336
+ for p in india_rates.build().get("rbi", {}).get("policy", []):
337
+ if p["key"] == "repo" and p["value"] is not None:
338
+ return f"Repo rate now {p['value']:.2f}%"
339
+ return None
340
+
341
+
342
+ def points(fetch: Optional[SeriesFetch] = None, max_age: float = READINGS_SECONDS) -> dict[str, dict]:
343
+ """Latest FRED reading per event key; cached for `max_age` seconds, never raises."""
344
+ with _lock:
345
+ hit = _cache.get("points")
346
+ if max_age and hit and time.time() - hit[0] < max_age:
347
+ return hit[1]
348
+ from concurrent.futures import ThreadPoolExecutor
349
+
350
+ if fetch is None:
351
+ from .macro import _fred as fetch
352
+ start = date.today() - timedelta(days=800)
353
+
354
+ def one(sid):
355
+ try:
356
+ return sid, fetch(sid, start)
357
+ except Exception as exc:
358
+ logger.info("Reading %s unavailable: %s", sid, exc)
359
+ return sid, None
360
+
361
+ with ThreadPoolExecutor(max_workers=6) as pool:
362
+ series = {sid: s for sid, s in pool.map(one, READING_SERIES) if s is not None and len(s)}
363
+ try:
364
+ out = _fred_points(series)
365
+ except Exception as exc:
366
+ logger.warning("Economic readings failed: %s", exc)
367
+ out = {}
368
+ if max_age and out:
369
+ with _lock:
370
+ _cache["points"] = (time.time(), out)
371
+ return out
372
+
373
+
374
+ def readings(fetch: Optional[SeriesFetch] = None, rbi: Optional[Callable[[], Optional[str]]] = _rbi_line,
375
+ use_cache: bool = True) -> dict[str, str]:
376
+ """key -> the last published reading ("2.9% YoY in Aug, up from 2.7%; core 3.1%").
377
+ Cached; a missing series just leaves its key out, never raises."""
378
+ if use_cache:
379
+ with _lock:
380
+ hit = _cache.get("readings")
381
+ if hit and time.time() - hit[0] < READINGS_SECONDS:
382
+ return hit[1]
383
+ out = {k: v["text"] for k, v in points(fetch, READINGS_SECONDS if use_cache else 0).items()}
384
+ if rbi:
385
+ try:
386
+ if line := rbi():
387
+ out["rbi"] = line
388
+ except Exception as exc:
389
+ logger.info("RBI repo reading unavailable: %s", exc)
390
+ if use_cache and out:
391
+ with _lock:
392
+ _cache["readings"] = (time.time(), out)
393
+ return out
394
+
395
+
396
+ # ------------------------------------------------------------------ results after a release
397
+
398
+ # Data releases we can read back from FRED once they are out (central bank
399
+ # decisions and India's MoSPI numbers have no reliable same-day feed here).
400
+ RESULT_KEYS = {"us_cpi", "us_pce", "us_ppi", "us_jobs", "us_gdp", "us_retail"}
401
+ RESULT_GRACE = timedelta(minutes=20) # FRED usually posts within minutes of the release
402
+ RESULT_GIVE_UP = timedelta(hours=10) # stop looking if FRED has not posted by then
403
+
404
+
405
+ def _month_start(d: date, back: int = 0) -> date:
406
+ m = d.year * 12 + d.month - 1 - back
407
+ return date(m // 12, m % 12 + 1, 1)
408
+
409
+
410
+ def covers(key: str, release_day: date) -> date:
411
+ """The earliest period a release on `release_day` reports: last month for the
412
+ monthly ones, last quarter for GDP. A FRED value at or after it is the new print."""
413
+ if key == "us_gdp":
414
+ q = _month_start(release_day)
415
+ return _month_start(date(q.year, (q.month - 1) // 3 * 3 + 1, 1), 3)
416
+ return _month_start(release_day, 1)
417
+
418
+
419
+ def release_moment(e: dict) -> Optional[datetime]:
420
+ if not e.get("time") or not e.get("tz"):
421
+ return None
422
+ return datetime.combine(date.fromisoformat(e["date"]), datetime.strptime(e["time"], "%H:%M").time(),
423
+ tzinfo=ZoneInfo(e["tz"]))
424
+
425
+
426
+ def verdict(key: str, value: float, prev: Optional[float]) -> str:
427
+ """Plain words on the new print against the one before (no consensus forecast here)."""
428
+ if prev is None:
429
+ return ""
430
+ if key in ("us_cpi", "us_pce", "us_ppi"):
431
+ if round(value, 1) == round(prev, 1):
432
+ return "Same as the month before: no change to the rate-cut picture."
433
+ if value > prev:
434
+ return "Hotter than the month before: Fed cuts look less likely, usually a drag on tech / growth stocks and the rupee."
435
+ return "Cooler than the month before: supports Fed rate cuts, usually good for stocks."
436
+ if key == "us_jobs":
437
+ if value >= 150:
438
+ return "Strong hiring: the economy is holding up, but it gives the Fed less reason to cut."
439
+ if value < 50:
440
+ return "Weak hiring: raises slowdown worries, and the odds of Fed cuts."
441
+ return "Moderate hiring: neither hot nor weak."
442
+ if key in ("us_gdp", "us_retail"):
443
+ if round(value, 1) == round(prev, 1):
444
+ return "Same pace as before."
445
+ return ("Faster than before: the US economy is holding up." if value > prev
446
+ else "Slower than before: a sign the US economy is cooling.")
447
+ return ""
448
+
449
+
450
+ def results(events: list[dict], now: datetime, fetch_points: Callable[[], dict]) -> list[dict]:
451
+ """Of today's `events`, the ones whose new number is out: each gets `result`
452
+ (text) and `verdict`. FRED is only asked when a release time has passed."""
453
+ due = []
454
+ for e in events:
455
+ key = e.get("key") or TITLE_KEYS.get(e["title"], "")
456
+ at = release_moment(e)
457
+ # GDP's later estimates revise a quarter FRED already has; only the first one is news.
458
+ advance_only = key != "us_gdp" or date.fromisoformat(e["date"]).month in (1, 4, 7, 10)
459
+ if key in RESULT_KEYS and advance_only and at and at + RESULT_GRACE <= now <= at + RESULT_GIVE_UP:
460
+ due.append((key, e))
461
+ if not due:
462
+ return []
463
+ pts = fetch_points()
464
+ out = []
465
+ for key, e in due:
466
+ pt = pts.get(key)
467
+ if pt and date.fromisoformat(pt["period"]) >= covers(key, date.fromisoformat(e["date"])):
468
+ out.append(dict(e, key=key, result=pt["text"], verdict=verdict(key, pt["value"], pt["prev"])))
469
+ return out
470
+
471
+
472
+ # region -> the markets of the stocks it moves ("global" listings trade in the US)
473
+ REGION_MARKETS = {"US": ("us", "global"), "India": ("india",), "Hong Kong": ("hong_kong",), "China": ("china_a",)}
474
+ # schedules saved before events carried a key
475
+ TITLE_KEYS = {"Fed rate decision": "fed", "RBI rate decision": "rbi", "India inflation (CPI)": "in_cpi",
476
+ "India GDP": "in_gdp", **{t: k for k, t, *_ in FRED_RELEASES.values()}}
477
+
478
+
479
+ def enrich(events: list[dict], followed: Optional[dict] = None, read: Optional[Callable[[], dict]] = None) -> list[dict]:
480
+ """Add `last` (latest reading), `why` (what a surprise does) and `exposure`
481
+ (the followed stocks listed in that event's country) to each event, in place."""
482
+ try:
483
+ last = (read or readings)()
484
+ except Exception as exc:
485
+ logger.warning("Economic readings failed: %s", exc)
486
+ last = {}
487
+ mine: dict[str, list[str]] = {}
488
+ for symbol, market in followed or {}:
489
+ mine.setdefault(market, []).append(symbol)
490
+ for e in events:
491
+ e["key"] = key = e.get("key") or TITLE_KEYS.get(e["title"].replace(" + projections", ""), "")
492
+ e["last"] = last.get(key)
493
+ e["why"] = e.get("why") or WHY.get(key)
494
+ e["exposure"] = sorted({s for m in REGION_MARKETS.get(e["region"], ()) for s in mine.get(m, [])})
495
+ return events
@@ -0,0 +1,115 @@
1
+ """US oil inventories from the EIA's Weekly Petroleum Status Report (no key).
2
+
3
+ Each series is the EIA's own history file (eia.gov/dnav/pet/hist_xls/<id>w.xls,
4
+ weekly since the 1980s), updated after the report on Wednesday (Thursday in
5
+ holiday weeks), with figures for the week ending the previous Friday. For
6
+ each series: latest level, change on the week, and the gap to the 5-year
7
+ average for the same week of the year, the way the market reads the report
8
+ (stocks swing with the season, so a raw number says little on its own).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import logging
15
+ import threading
16
+ import time
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from typing import Callable, Optional
19
+
20
+ import pandas as pd
21
+ import requests
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ URL = "https://www.eia.gov/dnav/pet/hist_xls/{sid}w.xls"
26
+ CACHE_SECONDS = 6 * 3600
27
+
28
+ # EIA series id, label, unit, note
29
+ SERIES = [
30
+ ("WCESTUS1", "Crude oil", "mb", "Commercial stocks, excluding the strategic reserve"),
31
+ ("W_EPC0_SAX_YCUOK_MBBL", "Cushing, Oklahoma", "mb", "Crude at the delivery point of the WTI futures contract"),
32
+ ("WGTSTUS1", "Gasoline", "mb", "Total motor gasoline stocks"),
33
+ ("WDISTUS1", "Distillates", "mb", "Diesel and heating oil stocks"),
34
+ ("WCSSTUS1", "Strategic reserve", "mb", "Crude held by the US government (SPR)"),
35
+ ("WPULEUS3", "Refinery use", "%", "Share of operable refining capacity in use"),
36
+ ]
37
+
38
+ Fetch = Callable[[str], pd.Series]
39
+
40
+ _cache: dict[str, tuple[float, dict]] = {}
41
+ _lock = threading.Lock()
42
+
43
+
44
+ def _fetch(sid: str) -> pd.Series:
45
+ r = requests.get(URL.format(sid=sid), timeout=30, headers={"User-Agent": "Mozilla/5.0"})
46
+ r.raise_for_status()
47
+ df = pd.read_excel(io.BytesIO(r.content), sheet_name="Data 1", skiprows=2)
48
+ s = pd.Series(df.iloc[:, 1].to_numpy(dtype=float), index=pd.DatetimeIndex(df["Date"])).dropna()
49
+ return s.sort_index()
50
+
51
+
52
+ def five_year_average(s: pd.Series) -> Optional[float]:
53
+ """Average of the same week in each of the five previous years (the
54
+ reading closest to the same date, within 4 days)."""
55
+ last = s.index[-1]
56
+ values = []
57
+ for k in range(1, 6):
58
+ target = last - pd.DateOffset(years=k)
59
+ near = s[(s.index >= target - pd.Timedelta(days=4)) & (s.index <= target + pd.Timedelta(days=4))]
60
+ if not len(near):
61
+ return None
62
+ values.append(float(near.iloc[abs(near.index - target).argmin()]))
63
+ return sum(values) / len(values)
64
+
65
+
66
+ def summarise(sid: str, label: str, unit: str, note: str, s: pd.Series) -> Optional[dict]:
67
+ if len(s) < 2:
68
+ return None
69
+ scale = 1000.0 if unit == "mb" else 1.0 # thousand barrels -> million barrels
70
+ latest, prev = float(s.iloc[-1]) / scale, float(s.iloc[-2]) / scale
71
+ avg = five_year_average(s)
72
+ avg = avg / scale if avg is not None else None
73
+ vs_avg = None
74
+ if avg:
75
+ vs_avg = round(latest - avg, 1) if unit == "%" else round((latest / avg - 1) * 100, 1)
76
+ year = s[s.index >= s.index[-1] - pd.Timedelta(days=365)]
77
+ return {
78
+ "key": sid, "label": label, "unit": unit, "note": note,
79
+ "week_ending": str(s.index[-1].date()),
80
+ "value": round(latest, 1),
81
+ "change_1w": round(latest - prev, 2),
82
+ "avg_5y": round(avg, 1) if avg is not None else None,
83
+ "vs_avg_5y": vs_avg, # % for stocks, points for refinery use
84
+ "spark": [round(float(v) / scale, 2) for v in year.to_numpy()],
85
+ }
86
+
87
+
88
+ def build(fetch: Fetch = _fetch, use_cache: bool = True) -> dict:
89
+ if use_cache:
90
+ with _lock:
91
+ hit = _cache.get("all")
92
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
93
+ return hit[1]
94
+
95
+ def one(spec):
96
+ sid, label, unit, note = spec
97
+ try:
98
+ return summarise(sid, label, unit, note, fetch(sid))
99
+ except Exception as exc:
100
+ logger.info("EIA %s failed: %s", sid, exc)
101
+ return None
102
+
103
+ with ThreadPoolExecutor(max_workers=len(SERIES)) as pool:
104
+ items = [r for r in pool.map(one, SERIES) if r]
105
+
106
+ out = {
107
+ "items": items,
108
+ "week_ending": max((i["week_ending"] for i in items), default=None),
109
+ "source": "US Energy Information Administration, Weekly Petroleum Status Report (released Wednesday "
110
+ "for the week ending the previous Friday)",
111
+ }
112
+ if use_cache and items:
113
+ with _lock:
114
+ _cache["all"] = (time.time(), out)
115
+ return out