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,148 @@
1
+ """Commitments of Traders: how hedge funds and producers are positioned in the
2
+ main US commodity futures, from the CFTC's disaggregated futures-only report
3
+ (publicreporting.cftc.gov, no key).
4
+
5
+ Positions are as of each Tuesday and published the following Friday. For each
6
+ market this gives managed money (hedge funds, CTAs) net long contracts, the
7
+ weekly change, net as a share of open interest, producers' / merchants' net
8
+ (commercial hedgers) and a 3-year position index: where this week's managed
9
+ money net sits between its 3-year low (0) and high (100). The reading is a
10
+ description of positioning, not a forecast. Brent is not covered: it trades on
11
+ ICE Futures Europe, outside the CFTC's report.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import threading
18
+ import time
19
+ from datetime import date, timedelta
20
+ from typing import Callable, Optional
21
+
22
+ import requests
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ API = "https://publicreporting.cftc.gov/resource/72hh-3qpy.json"
27
+ CACHE_SECONDS = 6 * 3600
28
+ YEARS = 3
29
+
30
+ # Yahoo ticker on the Commodities card, label, CFTC contract market code
31
+ MARKETS = [
32
+ ("GC=F", "Gold", "088691"),
33
+ ("SI=F", "Silver", "084691"),
34
+ ("HG=F", "Copper", "085692"),
35
+ ("CL=F", "WTI crude", "067651"),
36
+ ("NG=F", "Natural gas", "023651"),
37
+ ("ZW=F", "Wheat", "001602"),
38
+ ("ZC=F", "Corn", "002602"),
39
+ ]
40
+
41
+ Fetch = Callable[[list[str], date], list[dict]]
42
+
43
+ _cache: dict[str, tuple[float, dict]] = {}
44
+ _lock = threading.Lock()
45
+
46
+
47
+ def _fetch(codes: list[str], since: date) -> list[dict]:
48
+ where = (f"cftc_contract_market_code in({','.join(repr(c) for c in codes)}) "
49
+ f"AND report_date_as_yyyy_mm_dd >= '{since.isoformat()}T00:00:00'")
50
+ params = {
51
+ "$select": ("cftc_contract_market_code,report_date_as_yyyy_mm_dd,open_interest_all,"
52
+ "m_money_positions_long_all,m_money_positions_short_all,"
53
+ "prod_merc_positions_long,prod_merc_positions_short"),
54
+ "$where": where,
55
+ "$order": "report_date_as_yyyy_mm_dd",
56
+ "$limit": 5000,
57
+ }
58
+ r = requests.get(API, params=params, timeout=30)
59
+ r.raise_for_status()
60
+ return r.json()
61
+
62
+
63
+ def _int(row: dict, key: str) -> int:
64
+ return int(float(row.get(key) or 0))
65
+
66
+
67
+ def reading(index: Optional[float]) -> str:
68
+ if index is None:
69
+ return ""
70
+ if index >= 90:
71
+ return "Funds near their most long in 3 years"
72
+ if index <= 10:
73
+ return "Funds near their most short in 3 years"
74
+ if index >= 70:
75
+ return "Funds longer than usual"
76
+ if index <= 30:
77
+ return "Funds shorter than usual"
78
+ return "Fund positioning mid-range"
79
+
80
+
81
+ def summarise(rows: list[dict]) -> Optional[dict]:
82
+ """One market's weekly rows (oldest first) -> latest positioning."""
83
+ weeks = []
84
+ for r in rows:
85
+ weeks.append({
86
+ "date": (r.get("report_date_as_yyyy_mm_dd") or "")[:10],
87
+ "oi": _int(r, "open_interest_all"),
88
+ "mm_net": _int(r, "m_money_positions_long_all") - _int(r, "m_money_positions_short_all"),
89
+ "mm_long": _int(r, "m_money_positions_long_all"),
90
+ "mm_short": _int(r, "m_money_positions_short_all"),
91
+ "prod_net": _int(r, "prod_merc_positions_long") - _int(r, "prod_merc_positions_short"),
92
+ })
93
+ weeks = sorted({w["date"]: w for w in weeks if w["date"]}.values(), key=lambda w: w["date"])
94
+ if not weeks:
95
+ return None
96
+ last = weeks[-1]
97
+ nets = [w["mm_net"] for w in weeks]
98
+ lo, hi = min(nets), max(nets)
99
+ index = round((last["mm_net"] - lo) / (hi - lo) * 100) if hi > lo and len(weeks) >= 52 else None
100
+ return {
101
+ "as_of": last["date"],
102
+ "open_interest": last["oi"],
103
+ "managed_money_long": last["mm_long"],
104
+ "managed_money_short": last["mm_short"],
105
+ "managed_money_net": last["mm_net"],
106
+ "net_change_1w": last["mm_net"] - weeks[-2]["mm_net"] if len(weeks) > 1 else None,
107
+ "net_pct_oi": round(last["mm_net"] / last["oi"] * 100, 1) if last["oi"] else None,
108
+ "producer_net": last["prod_net"],
109
+ "index_3y": index,
110
+ "reading": reading(index),
111
+ "history": [w["mm_net"] for w in weeks[-52:]],
112
+ }
113
+
114
+
115
+ def build(fetch: Fetch = _fetch, use_cache: bool = True, today: Optional[date] = None) -> dict:
116
+ if use_cache:
117
+ with _lock:
118
+ hit = _cache.get("all")
119
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
120
+ return hit[1]
121
+
122
+ since = (today or date.today()) - timedelta(days=365 * YEARS + 7)
123
+ try:
124
+ raw = fetch([code for *_, code in MARKETS], since)
125
+ except Exception as exc:
126
+ logger.warning("CFTC commitments of traders fetch failed: %s", exc)
127
+ raw = []
128
+
129
+ by_code: dict[str, list[dict]] = {}
130
+ for r in raw:
131
+ by_code.setdefault(r.get("cftc_contract_market_code", "").strip(), []).append(r)
132
+
133
+ items = []
134
+ for ticker, label, code in MARKETS:
135
+ s = summarise(by_code.get(code, []))
136
+ if s:
137
+ items.append({"key": ticker, "label": label, **s})
138
+
139
+ out = {
140
+ "items": items,
141
+ "as_of": max((i["as_of"] for i in items), default=None),
142
+ "source": "CFTC Commitments of Traders (disaggregated, futures only); positions as of Tuesday, "
143
+ "published Friday. Managed money = hedge funds and commodity trading advisers",
144
+ }
145
+ if use_cache and items:
146
+ with _lock:
147
+ _cache["all"] = (time.time(), out)
148
+ return out
@@ -0,0 +1,135 @@
1
+ """Country macro from the IMF World Economic Outlook (DataMapper API, no key).
2
+
3
+ Real GDP growth, inflation, unemployment, government debt and fiscal balance,
4
+ current account, GDP and GDP per capita for any of about 200 economies, as
5
+ last year / this year / next year, where this year and later are IMF
6
+ projections. One request per indicator returns every country, so the whole
7
+ set is fetched at most once a day and kept on disk; a failed refresh serves
8
+ the saved copy marked stale.
9
+
10
+ India's figures are fiscal years (April to March), as the IMF reports them.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import threading
18
+ import time
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from datetime import date
21
+ from pathlib import Path
22
+ from typing import Callable, Optional
23
+
24
+ from marketmyna.core.home import app_home
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ API = "https://www.imf.org/external/datamapper/api/v1"
29
+ REFRESH_SECONDS = 24 * 3600
30
+
31
+ # IMF code, label, unit, note
32
+ INDICATORS = [
33
+ ("NGDP_RPCH", "GDP growth", "%", "Real GDP, change on the year before"),
34
+ ("PCPIPCH", "Inflation", "%", "Average consumer prices, change on the year before"),
35
+ ("LUR", "Unemployment", "%", "Share of the labour force"),
36
+ ("GGXWDG_NGDP", "Govt debt", "% of GDP", "General government gross debt"),
37
+ ("GGXCNL_NGDP", "Fiscal balance", "% of GDP", "General government net lending (+) / borrowing (-)"),
38
+ ("BCA_NGDPD", "Current account", "% of GDP", "Surplus (+) or deficit (-) with the rest of the world"),
39
+ ("NGDPD", "GDP", "US$ bn", "Nominal GDP in current US dollars"),
40
+ ("NGDPDPC", "GDP per person", "US$", "Nominal GDP per capita in current US dollars"),
41
+ ]
42
+ DEFAULT_COUNTRIES = ["IND", "USA", "CHN", "JPN", "DEU", "GBR", "FRA", "KOR", "IDN", "BRA", "SAU", "ARE", "AUS", "CAN"]
43
+ FISCAL_YEAR = {"IND": "April-March fiscal years"}
44
+
45
+ Fetch = Callable[[str], dict]
46
+
47
+ _lock = threading.Lock()
48
+ _state: dict[str, object] = {}
49
+
50
+
51
+ def _dir() -> Path:
52
+ path = app_home() / "imf"
53
+ path.mkdir(parents=True, exist_ok=True)
54
+ return path
55
+
56
+
57
+ def _get(path: str) -> dict:
58
+ # The IMF's bot filter answers 403 to some clients (browser-like user
59
+ # agents from scripts); curl_cffi (installed with yfinance) presents a
60
+ # real browser handshake and is let through.
61
+ from curl_cffi import requests as cffi
62
+
63
+ r = cffi.get(f"{API}/{path}", timeout=30, impersonate="chrome")
64
+ r.raise_for_status()
65
+ return r.json() or {}
66
+
67
+
68
+ def _download(fetch: Fetch) -> dict:
69
+ def one(code: str):
70
+ return code, ((fetch(code).get("values") or {}).get(code)) or {}
71
+
72
+ with ThreadPoolExecutor(max_workers=2) as pool:
73
+ values = dict(pool.map(one, [c for c, *_ in INDICATORS]))
74
+ if not any(values.values()):
75
+ raise ValueError("IMF returned no data")
76
+ names = {k: v.get("label") for k, v in ((fetch("countries").get("countries")) or {}).items() if v.get("label")}
77
+ return {"values": values, "countries": names, "fetched": time.time()}
78
+
79
+
80
+ def _data(fetch: Fetch, now: Optional[float] = None) -> tuple[dict, bool]:
81
+ """(data, stale). Memory, then disk, then the IMF once a day."""
82
+ now = now or time.time()
83
+ with _lock:
84
+ data = _state.get("data")
85
+ if data is None:
86
+ try:
87
+ data = json.loads((_dir() / "weo.json").read_text(encoding="utf-8"))
88
+ except (OSError, ValueError):
89
+ data = None
90
+ if data is not None and now - data.get("fetched", 0) < REFRESH_SECONDS:
91
+ with _lock:
92
+ _state["data"] = data
93
+ return data, False
94
+ try:
95
+ fresh = _download(fetch)
96
+ except Exception as exc: # noqa: BLE001 - keep serving the saved copy
97
+ logger.warning("IMF DataMapper fetch failed: %s", exc)
98
+ if data is None:
99
+ raise
100
+ return data, True
101
+ try:
102
+ (_dir() / "weo.json").write_text(json.dumps(fresh), encoding="utf-8")
103
+ except OSError as exc:
104
+ logger.warning("Could not save IMF data: %s", exc)
105
+ with _lock:
106
+ _state["data"] = fresh
107
+ return fresh, False
108
+
109
+
110
+ def build(countries: Optional[list[str]] = None, fetch: Fetch = _get, today: Optional[date] = None) -> dict:
111
+ year = (today or date.today()).year
112
+ years = [year - 1, year, year + 1]
113
+ data, stale = _data(fetch)
114
+ names: dict = data.get("countries") or {}
115
+ codes = [c.upper() for c in (countries or DEFAULT_COUNTRIES) if c.upper() in names][:30]
116
+ rows = []
117
+ for code in codes:
118
+ row = {"code": code, "name": names[code], "fiscal_year": FISCAL_YEAR.get(code)}
119
+ for ind, *_ in INDICATORS:
120
+ series = (data["values"].get(ind) or {}).get(code) or {}
121
+ row[ind] = [series.get(str(y)) for y in years]
122
+ # a 5-year look back for the growth / inflation sparkline
123
+ row[f"{ind}_trend"] = [{"year": y, "value": series.get(str(y))} for y in range(year - 6, year + 2)
124
+ if series.get(str(y)) is not None]
125
+ rows.append(row)
126
+ return {
127
+ "years": years,
128
+ "projection_from": year,
129
+ "indicators": [{"key": c, "label": label, "unit": unit, "note": note} for c, label, unit, note in INDICATORS],
130
+ "rows": rows,
131
+ "countries": sorted(({"code": k, "name": v} for k, v in names.items()), key=lambda c: c["name"]),
132
+ "stale": stale,
133
+ "as_of": time.strftime("%Y-%m-%d", time.localtime(data.get("fetched", time.time()))),
134
+ "source": "IMF World Economic Outlook (DataMapper)",
135
+ }
@@ -0,0 +1,239 @@
1
+ """How oil-linked stocks react to crude - India, US, Hong Kong, China A.
2
+
3
+ For each stock, weekly returns over two years are regressed on the local
4
+ index and on crude in local currency together (India: Nifty 50 and Brent
5
+ in rupees; US: S&P 500 and WTI; HK: Hang Seng and Brent; China: Shanghai Composite
6
+ and Brent in yuan):
7
+
8
+ r_stock = a + b_mkt * r_index + b_crude * r_crude_local + e
9
+
10
+ b_crude is the crude sensitivity net of the market: a b_crude of -0.3 means a
11
+ 10% rise in rupee Brent has gone with the stock doing about 3% worse than its
12
+ market exposure alone would suggest. Weekly bars sidestep the time-zone gap
13
+ between NSE and ICE closes. Plain numpy OLS with ordinary standard errors.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import threading
20
+ import time
21
+ from typing import Callable, Optional
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ CACHE_SECONDS = 6 * 3600
29
+ BRENT, FX, NIFTY = "BZ=F", "USDINR=X", "^NSEI"
30
+
31
+ # group, why it is linked to crude, [(symbol, name)]
32
+ GROUPS = [
33
+ ("Oil marketing (OMCs)", "Buy crude, sell fuel at prices the government leans on: costlier crude squeezes margins",
34
+ [("BPCL", "Bharat Petroleum"), ("HINDPETRO", "Hindustan Petroleum"), ("IOC", "Indian Oil")]),
35
+ ("Upstream producers", "Sell the crude they pump: higher prices lift revenue (windfall taxes cap it)",
36
+ [("ONGC", "ONGC"), ("OIL", "Oil India")]),
37
+ ("Refining & petchem", "Margins track the spread between crude and products",
38
+ [("RELIANCE", "Reliance Industries"), ("MRPL", "MRPL"), ("CHENNPETRO", "Chennai Petroleum")]),
39
+ ("Paints", "Many raw materials are crude derivatives",
40
+ [("ASIANPAINT", "Asian Paints"), ("BERGEPAINT", "Berger Paints"), ("KANSAINER", "Kansai Nerolac")]),
41
+ ("Aviation", "Jet fuel is the largest cost line",
42
+ [("INDIGO", "InterGlobe Aviation")]),
43
+ ("Tyres", "Synthetic rubber and carbon black come from crude",
44
+ [("MRF", "MRF"), ("APOLLOTYRE", "Apollo Tyres"), ("CEATLTD", "CEAT")]),
45
+ ("Gas", "City gas buys LNG priced partly off crude and sells at regulated prices; GAIL also trades it",
46
+ [("IGL", "Indraprastha Gas"), ("MGL", "Mahanagar Gas"), ("GAIL", "GAIL")]),
47
+ ]
48
+
49
+ US_GROUPS = [
50
+ ("Integrated majors", "Pump, refine and sell: revenue rides the crude price, refining softens it",
51
+ [("XOM", "Exxon Mobil"), ("CVX", "Chevron")]),
52
+ ("Producers (E&P)", "Sell the crude they pump: the purest upside to higher oil",
53
+ [("COP", "ConocoPhillips"), ("EOG", "EOG Resources"), ("OXY", "Occidental"), ("DVN", "Devon Energy")]),
54
+ ("Oil services", "Drilling budgets follow the oil price with a lag",
55
+ [("SLB", "SLB"), ("HAL", "Halliburton"), ("BKR", "Baker Hughes")]),
56
+ ("Refiners", "Margins track the spread between crude and fuel, not crude itself",
57
+ [("VLO", "Valero"), ("MPC", "Marathon Petroleum"), ("PSX", "Phillips 66")]),
58
+ ("Airlines", "Jet fuel is one of the two biggest costs",
59
+ [("DAL", "Delta"), ("UAL", "United"), ("AAL", "American"), ("LUV", "Southwest")]),
60
+ ("Cruise lines", "Bunker fuel is a big cost and higher pump prices squeeze customers",
61
+ [("CCL", "Carnival"), ("RCL", "Royal Caribbean"), ("NCLH", "Norwegian Cruise")]),
62
+ ("Chemicals", "Feedstock is oil and gas derivatives",
63
+ [("DOW", "Dow"), ("LYB", "LyondellBasell")]),
64
+ ("Parcel & trucking", "Diesel and jet fuel costs, partly passed on through surcharges",
65
+ [("UPS", "UPS"), ("FDX", "FedEx"), ("ODFL", "Old Dominion")]),
66
+ ]
67
+
68
+ HK_GROUPS = [
69
+ ("Oil majors", "China's big three: upstream profits rise with crude, refining and retail cushion it",
70
+ [("00857", "PetroChina"), ("00386", "Sinopec"), ("00883", "CNOOC")]),
71
+ ("Airlines", "Jet fuel is one of the largest costs",
72
+ [("00293", "Cathay Pacific"), ("00753", "Air China"), ("00670", "China Eastern"), ("01055", "China Southern")]),
73
+ ("Shipping", "Bunker fuel costs; freight rates often move with energy demand",
74
+ [("01919", "COSCO Shipping"), ("00316", "OOIL"), ("02866", "COSCO Shipping Dev")]),
75
+ ("City gas", "Buy gas priced partly off oil, sell at regulated tariffs",
76
+ [("00003", "HK & China Gas"), ("01193", "China Resources Gas"), ("02688", "ENN Energy")]),
77
+ ]
78
+
79
+ CHINA_GROUPS = [
80
+ ("Oil majors", "Upstream profits rise with crude; state-set fuel prices cap the refining side",
81
+ [("601857", "PetroChina"), ("600028", "Sinopec"), ("600938", "CNOOC")]),
82
+ ("Oilfield services", "Spending follows the oil price",
83
+ [("600871", "Sinopec Oilfield"), ("601808", "China Oilfield Services")]),
84
+ ("Airlines", "Jet fuel is one of the largest costs",
85
+ [("601111", "Air China"), ("600115", "China Eastern"), ("600029", "China Southern")]),
86
+ ("Chemicals & refining", "Crude is the feedstock",
87
+ [("600309", "Wanhua Chemical"), ("002493", "Rongsheng Petrochem"), ("600346", "Hengli Petrochem")]),
88
+ ]
89
+
90
+ # market -> index, crude contract, FX to local (None = priced in USD), labels.
91
+ # Group symbols are the app's own (00857, 601857); yahoo_symbol maps them.
92
+ MARKETS = {
93
+ "india": {"index": NIFTY, "index_name": "Nifty 50", "crude": BRENT, "crude_name": "Brent", "fx": FX,
94
+ "currency": "₹", "groups": GROUPS},
95
+ "us": {"index": "^GSPC", "index_name": "S&P 500", "crude": "CL=F", "crude_name": "WTI", "fx": None,
96
+ "currency": "$", "groups": US_GROUPS},
97
+ "hong_kong": {"index": "^HSI", "index_name": "Hang Seng", "crude": BRENT, "crude_name": "Brent", "fx": "HKD=X",
98
+ "currency": "HK$", "groups": HK_GROUPS},
99
+ "china_a": {"index": "000001.SS", "index_name": "Shanghai Composite", "crude": BRENT, "crude_name": "Brent", "fx": "CNY=X",
100
+ "currency": "¥", "groups": CHINA_GROUPS},
101
+ }
102
+
103
+ Fetch = Callable[[list[str]], dict[str, pd.Series]]
104
+
105
+ _cache: dict[str, tuple[float, dict]] = {}
106
+ _lock = threading.Lock()
107
+
108
+
109
+ def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
110
+ import yfinance as yf
111
+
112
+ from ..data import ecb_fx
113
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
114
+
115
+ def download() -> dict[str, pd.Series]:
116
+ with YF_DOWNLOAD_LOCK:
117
+ df = yf.download(tickers, period="2y", interval="1d", progress=False, auto_adjust=True, threads=True)
118
+ closes = df["Close"]
119
+ return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
120
+
121
+ return ecb_fx.guarded(download, tickers)
122
+
123
+
124
+ def _weekly(s: pd.Series) -> pd.Series:
125
+ s = s.copy()
126
+ s.index = pd.DatetimeIndex(s.index).tz_localize(None) if getattr(s.index, "tz", None) else pd.DatetimeIndex(s.index)
127
+ return s.resample("W-FRI").last()
128
+
129
+
130
+ def regress(stock: pd.Series, nifty: pd.Series, crude: pd.Series) -> Optional[dict]:
131
+ """Weekly two-factor OLS. Inputs are daily closes."""
132
+ df = pd.concat([_weekly(stock), _weekly(nifty), _weekly(crude)], axis=1, keys=["s", "m", "c"]).pct_change().dropna()
133
+ if len(df) < 40:
134
+ return None
135
+ y = df["s"].to_numpy()
136
+ x = np.column_stack([np.ones(len(df)), df["m"].to_numpy(), df["c"].to_numpy()])
137
+ beta, *_ = np.linalg.lstsq(x, y, rcond=None)
138
+ resid = y - x @ beta
139
+ dof = len(y) - x.shape[1]
140
+ s2 = float(resid @ resid) / dof
141
+ se = np.sqrt(np.diag(s2 * np.linalg.inv(x.T @ x)))
142
+ tss = float(((y - y.mean()) ** 2).sum())
143
+ r2 = 1 - float(resid @ resid) / tss if tss else None
144
+ # R2 of the market-only model, to show what crude adds
145
+ xm = x[:, :2]
146
+ bm, *_ = np.linalg.lstsq(xm, y, rcond=None)
147
+ rm = y - xm @ bm
148
+ r2_mkt = 1 - float(rm @ rm) / tss if tss else None
149
+ t = float(beta[2] / se[2]) if se[2] else 0.0
150
+ return {
151
+ "weeks": len(df),
152
+ "beta_market": float(beta[1]),
153
+ "beta_crude": float(beta[2]),
154
+ "t_crude": t,
155
+ "significant": abs(t) >= 2,
156
+ "corr_crude": float(np.corrcoef(df["s"], df["c"])[0, 1]),
157
+ "r2": r2,
158
+ "r2_added": (r2 - r2_mkt) if r2 is not None and r2_mkt is not None else None,
159
+ }
160
+
161
+
162
+ def _change(s: pd.Series, days: int) -> Optional[float]:
163
+ s = s.dropna()
164
+ if s.empty:
165
+ return None
166
+ idx = pd.DatetimeIndex(s.index).tz_localize(None) if getattr(s.index, "tz", None) else pd.DatetimeIndex(s.index)
167
+ s = pd.Series(s.to_numpy(), index=idx)
168
+ past = s[s.index <= s.index[-1] - pd.Timedelta(days=days)]
169
+ return float(s.iloc[-1] / past.iloc[-1] - 1) * 100 if len(past) else None
170
+
171
+
172
+ def build(fetch: Fetch = _yahoo, use_cache: bool = True, market: str = "india") -> dict:
173
+ cfg = MARKETS.get(market)
174
+ if cfg is None:
175
+ return {"groups": [], "market": market, "error": "Oil sensitivity is not available for this market"}
176
+ if use_cache:
177
+ with _lock:
178
+ hit = _cache.get(market)
179
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
180
+ return hit[1]
181
+
182
+ index, crude_t, fx = cfg["index"], cfg["crude"], cfg["fx"]
183
+ from .analysis_run import yahoo_symbol
184
+
185
+ tickers = [yahoo_symbol(sym, market) for _, _, members in cfg["groups"] for sym, _ in members]
186
+ tickers += [crude_t, index] + ([fx] if fx else [])
187
+ try:
188
+ data = fetch(tickers)
189
+ except Exception as exc:
190
+ logger.warning("Crude sensitivity fetch failed: %s", exc)
191
+ return {"groups": [], "market": market, "error": "Price data unavailable right now"}
192
+ if crude_t not in data or index not in data or (fx and fx not in data):
193
+ return {"groups": [], "market": market, "error": f"{cfg['crude_name']}, FX or {cfg['index_name']} history unavailable"}
194
+
195
+ if fx:
196
+ both = pd.concat([data[crude_t], data[fx]], axis=1, keys=["b", "fx"]).ffill().dropna()
197
+ crude_local = both["b"] * both["fx"]
198
+ else:
199
+ crude_local = data[crude_t]
200
+ crude_1m = _change(crude_local, 30)
201
+
202
+ groups = []
203
+ for name, why, members in cfg["groups"]:
204
+ rows = []
205
+ for sym, label in members:
206
+ s = data.get(yahoo_symbol(sym, market))
207
+ if s is None:
208
+ continue
209
+ r = regress(s, data[index], crude_local)
210
+ if r is None:
211
+ continue
212
+ r.update(symbol=sym, name=label,
213
+ change_1m=_change(s, 30),
214
+ # what crude's last month implies on its own, holding the market flat
215
+ crude_implied_1m=r["beta_crude"] * crude_1m if crude_1m is not None else None)
216
+ rows.append(r)
217
+ if rows:
218
+ groups.append({"name": name, "why": why,
219
+ "avg_beta_crude": float(np.mean([r["beta_crude"] for r in rows])),
220
+ "stocks": rows})
221
+ local = "" if not fx else {"india": " in rupees", "hong_kong": " in HK dollars", "china_a": " in yuan"}[market]
222
+ crude_label = f"{cfg['crude_name']}{local}"
223
+ out = {
224
+ "market": market,
225
+ "groups": groups,
226
+ "crude_label": crude_label,
227
+ "currency": cfg["currency"],
228
+ "index_name": cfg["index_name"],
229
+ "crude": float(crude_local.iloc[-1]),
230
+ "crude_change_1m": crude_1m,
231
+ "crude_change_1y": _change(crude_local, 365),
232
+ "method": f"Weekly returns over 2 years, regressed on the {cfg['index_name']} and {crude_label} together.",
233
+ }
234
+ if market == "india": # older field names, kept for existing clients
235
+ out.update(brent_inr=out["crude"], brent_inr_change_1m=crude_1m, brent_inr_change_1y=out["crude_change_1y"])
236
+ if use_cache and groups:
237
+ with _lock:
238
+ _cache[market] = (time.time(), out)
239
+ return out
@@ -0,0 +1,135 @@
1
+ """Crypto market mood: the stock gauge's idea (marketmyna/services/market_mood.py)
2
+ for coins, which have no 52-week history in our feed, no index and no VIX.
3
+
4
+ Universe: the most-traded coins of the crypto screens (stablecoins dropped),
5
+ six months of daily closes shared with them. Readings, each 0 (fear) - 100 (greed):
6
+
7
+ - Advancing vs declining today (24h change of each coin)
8
+ - Share above the 50-day average
9
+ - Share above the 100-day average
10
+ - Near 90-day highs vs lows (within 5% of either; share that are highs)
11
+ - Bitcoin vs its 125-day average (-20% -> 0, +20% -> 100; coins swing more than indices)
12
+ - Bitcoin's 30-day volatility vs its past months (calm = high score)
13
+ - Perpetual funding (BTC and ETH, annualised): about 11%/yr is the usual
14
+ baseline; crowded longs push it up (greed), crowded shorts below zero (fear)
15
+
16
+ Describes how the market behaves now; not a forecast or advice.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import math
23
+ import threading
24
+ import time
25
+ from typing import Callable, Optional
26
+
27
+ import pandas as pd
28
+
29
+ from . import crypto_screens
30
+ from .market_mood import _clip, label
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ CACHE_SECONDS = 300
35
+ FUNDING_BASELINE = 11.0 # % / yr: 0.01% per 8h, the exchanges' default
36
+
37
+ _cache: dict[str, tuple[float, Optional[float]]] = {}
38
+ _lock = threading.Lock()
39
+
40
+
41
+ def _funding() -> Optional[float]:
42
+ """Average annualised BTC / ETH perpetual funding in %, or None."""
43
+ from .rates import _funding_binance, _funding_bybit
44
+
45
+ for fn in (_funding_binance, _funding_bybit):
46
+ try:
47
+ rows = [r for r in fn() if r["coin"] in ("BTC", "ETH")]
48
+ except Exception as exc:
49
+ logger.warning("Funding for crypto mood via %s failed: %s", fn.__name__, exc)
50
+ continue
51
+ if rows:
52
+ return sum(r["rate_8h"] for r in rows) / len(rows) * 3 * 365 * 100
53
+ return None
54
+
55
+
56
+ def readings(coins: dict[str, pd.Series], changes: list[float], btc: Optional[pd.Series],
57
+ funding: Optional[float]) -> list[dict]:
58
+ out = []
59
+ adv = sum(1 for c in changes if c > 0)
60
+ dec = sum(1 for c in changes if c < 0)
61
+ if adv + dec:
62
+ out.append({"key": "breadth", "label": "Advancing vs declining (24h)", "score": _clip(adv / (adv + dec) * 100),
63
+ "value": f"{adv} up, {dec} down"})
64
+
65
+ for days in (50, 100):
66
+ ok = [s for s in coins.values() if len(s) >= days]
67
+ if len(ok) >= 20:
68
+ above = sum(1 for s in ok if s.iloc[-1] > s.iloc[-days:].mean())
69
+ out.append({"key": f"above_{days}", "label": f"Above the {days}-day average", "score": _clip(above / len(ok) * 100),
70
+ "value": f"{above} of {len(ok)} ({above / len(ok):.0%})"})
71
+
72
+ ok = [s for s in coins.values() if len(s) >= 90]
73
+ if len(ok) >= 20:
74
+ highs = sum(1 for s in ok if s.iloc[-1] >= s.iloc[-90:].max() * 0.95)
75
+ lows = sum(1 for s in ok if s.iloc[-1] <= s.iloc[-90:].min() * 1.05)
76
+ out.append({"key": "highs_lows", "label": "Near 90-day highs vs lows",
77
+ "score": _clip(highs / (highs + lows) * 100) if highs + lows else 50.0,
78
+ "value": f"{highs} near highs, {lows} near lows" if highs + lows else "none near a high or a low"})
79
+
80
+ if btc is not None and len(btc) >= 125:
81
+ gap = float(btc.iloc[-1] / btc.iloc[-125:].mean() - 1)
82
+ out.append({"key": "momentum", "label": "Bitcoin vs its 125-day average", "score": _clip(50 + gap * 250),
83
+ "value": f"{gap:+.1%}"})
84
+
85
+ if btc is not None and len(btc) >= 90:
86
+ vol = btc.pct_change().rolling(30).std().dropna() * math.sqrt(365) * 100
87
+ if len(vol) >= 30:
88
+ now = float(vol.iloc[-1])
89
+ rank = float((vol < now).mean())
90
+ out.append({"key": "volatility", "label": "Bitcoin volatility vs recent months", "score": _clip((1 - rank) * 100),
91
+ "value": f"{now:.0f}%/yr, higher than {rank:.0%} of the period"})
92
+
93
+ if funding is not None:
94
+ out.append({"key": "funding", "label": "Funding rates (BTC, ETH)", "score": _clip(50 + (funding - FUNDING_BASELINE) * 2.5),
95
+ "value": f"{funding:+.1f}% / yr"})
96
+ return out
97
+
98
+
99
+ def _funding_cached(funding: Callable[[], Optional[float]], use_cache: bool) -> Optional[float]:
100
+ if use_cache:
101
+ with _lock:
102
+ hit = _cache.get("funding")
103
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
104
+ return hit[1]
105
+ value = funding()
106
+ if use_cache and value is not None:
107
+ with _lock:
108
+ _cache["funding"] = (time.time(), value)
109
+ return value
110
+
111
+
112
+ def build(rows: Optional[list[dict]] = None, fetch: Callable = crypto_screens._fetch,
113
+ funding: Callable[[], Optional[float]] = _funding, use_cache: bool = True) -> dict:
114
+ """`rows` = the crypto listing, ideally with live prices merged in: today's
115
+ breadth follows them on every call; closes and funding are cached."""
116
+ if rows is None:
117
+ from ..data.listings import get_store
118
+
119
+ rows = get_store().rows("crypto")
120
+ coins = crypto_screens.universe(rows)
121
+ closes = crypto_screens.history(coins, fetch, use_cache)
122
+ series = {c["symbol"]: closes[c["symbol"]] for c in coins if c["symbol"] in closes}
123
+ changes = [float(c["change_percent"]) for c in coins if c.get("change_percent") is not None]
124
+ parts = readings(series, changes, closes.get("BTC-USD"), _funding_cached(funding, use_cache))
125
+ score = round(sum(p["score"] for p in parts) / len(parts), 1) if parts else None
126
+ for p in parts:
127
+ p["score"] = round(p["score"], 1)
128
+ p["label_text"] = label(p["score"])
129
+ out = {
130
+ "market": "crypto", "score": score, "label": label(score), "readings": parts,
131
+ "universe": f"the {len(series)} most-traded coins (stablecoins excluded)",
132
+ "index": "Bitcoin",
133
+ "source": "Daily closes from crypto exchange data; today's moves and funding from live exchange data",
134
+ }
135
+ return out