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,497 @@
1
+ """Live prices for the Market insights views (Market pulse, Hot stocks,
2
+ Market mood, Sector / ETF rotation, World markets, Commodities).
3
+
4
+ The listings store is a snapshot (India: the last session's exchange file;
5
+ US: hourly; China / HK: every 4 hours), and the rotation / mood maths run on
6
+ daily closes. While a market is open this module keeps fresh quotes for its
7
+ largest names, so the views recompute gainers, losers, breadth, sector moves,
8
+ mood readings and the leading / weakening / lagging / improving quadrants on
9
+ live prices:
10
+
11
+ - `market(name)`: quotes for the market's largest names (whole market where
12
+ one call covers it), refreshed in the background while someone is looking
13
+ and the market is open, at most every REFRESH seconds. Requests never wait
14
+ on the upstream source once something is cached.
15
+ - `latest(tickers)`: last price of index / futures / FX / ETF tickers from
16
+ one batched 1-minute download, cached LATEST_SECONDS.
17
+ - `merge(rows, quotes)` and `extend(series, price, day)` fold those into
18
+ listing rows and daily close series.
19
+
20
+ Sources: the broker feed for India when it is set up (else the regular feed),
21
+ the US screener (whole market, one call), the crypto exchange's tickers (whole
22
+ market, one call), batched exchange quotes for China A / Hong Kong.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import os
29
+ import threading
30
+ import time
31
+ from datetime import date, datetime
32
+ from typing import Optional
33
+ from zoneinfo import ZoneInfo
34
+
35
+ import pandas as pd
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Seconds between upstream refreshes per market while it is open.
40
+ REFRESH = {
41
+ "india": float(os.environ.get("LIVE_MARKET_REFRESH_INDIA", 60)),
42
+ "us": float(os.environ.get("LIVE_MARKET_REFRESH_US", 60)),
43
+ "china_a": float(os.environ.get("LIVE_MARKET_REFRESH_CHINA_A", 60)),
44
+ "hong_kong": float(os.environ.get("LIVE_MARKET_REFRESH_HONG_KONG", 60)),
45
+ "crypto": float(os.environ.get("LIVE_MARKET_REFRESH_CRYPTO", 15)),
46
+ }
47
+ # The regular feed is slow for hundreds of Indian names and lags anyway.
48
+ INDIA_FEED_REFRESH = float(os.environ.get("LIVE_MARKET_REFRESH_INDIA_FEED", 120))
49
+ # How many of the largest names get live prices (US and crypto: the whole
50
+ # market comes in one call, so everything).
51
+ TOP = {
52
+ "india": int(os.environ.get("LIVE_MARKET_TOP_INDIA", 500)),
53
+ "china_a": int(os.environ.get("LIVE_MARKET_TOP_CHINA_A", 1000)),
54
+ "hong_kong": int(os.environ.get("LIVE_MARKET_TOP_HONG_KONG", 600)),
55
+ }
56
+ WATCH_SECONDS = 120 # nobody asked for two minutes: stop refreshing
57
+ MAX_AGE = 18 * 3600 # older quotes are from an earlier session: not used
58
+ LATEST_SECONDS = 60
59
+ YAHOO_CHUNK = 150
60
+ TIMEZONES = {"india": "Asia/Kolkata", "us": "America/New_York", "china_a": "Asia/Shanghai",
61
+ "hong_kong": "Asia/Hong_Kong", "crypto": "UTC"}
62
+
63
+
64
+ def _now() -> float:
65
+ return time.time()
66
+
67
+
68
+ def market_day(market: str, epoch: Optional[float] = None) -> date:
69
+ """The calendar date in the market's own time zone."""
70
+ tz = ZoneInfo(TIMEZONES.get(market, "UTC"))
71
+ return datetime.fromtimestamp(epoch if epoch is not None else _now(), tz).date()
72
+
73
+
74
+ # ------------------------------------------------------------- market quotes
75
+
76
+
77
+ class _State:
78
+ def __init__(self):
79
+ self.quotes: dict[str, dict] = {} # symbol -> {price, change, change_percent, volume, time, day}
80
+ self.updated = 0.0 # wall clock of the last successful refresh
81
+ self.asked = 0.0 # wall clock of the last request
82
+ self.error: Optional[str] = None
83
+ self.source = ""
84
+ self.busy = False
85
+ self.version = 0
86
+ self.lock = threading.Lock()
87
+
88
+
89
+ _states: dict[str, _State] = {}
90
+ _states_lock = threading.Lock()
91
+
92
+
93
+ def _state(name: str) -> _State:
94
+ with _states_lock:
95
+ return _states.setdefault(name, _State())
96
+
97
+
98
+ def _broker_on() -> bool:
99
+ from . import live_quotes
100
+
101
+ return live_quotes._broker_live()
102
+
103
+
104
+ def refresh_seconds(name: str) -> float:
105
+ if name == "india" and not _broker_on():
106
+ return INDIA_FEED_REFRESH
107
+ return REFRESH.get(name, 60)
108
+
109
+
110
+ def _source(name: str) -> str:
111
+ from . import live_quotes
112
+
113
+ return live_quotes.source(name)
114
+
115
+
116
+ def market(name: str) -> dict:
117
+ """{"quotes": {symbol: quote}, "status": {...}} for one market. Starts a
118
+ background refresh when due; returns at once with what is cached."""
119
+ from ..trading.market_hours import is_open
120
+
121
+ s = _state(name)
122
+ now = _now()
123
+ open_now = is_open(name)
124
+ with s.lock:
125
+ s.asked = now
126
+ age = now - s.updated if s.updated else None
127
+ due = age is None or age >= MAX_AGE or (open_now and age >= refresh_seconds(name))
128
+ start = due and not s.busy and name in REFRESH
129
+ if start:
130
+ s.busy = True
131
+ fresh = age is not None and age < MAX_AGE
132
+ quotes = dict(s.quotes) if fresh else {}
133
+ # The tables' per-second cache (live_quotes) is fresher for the names on
134
+ # someone's screen: take those prices too.
135
+ if quotes:
136
+ quotes.update(_table_quotes(name, quotes))
137
+ with s.lock:
138
+ status = {
139
+ "open": open_now,
140
+ "live": bool(quotes),
141
+ "updated": s.updated or None,
142
+ "age_seconds": round(age, 1) if age is not None else None,
143
+ "refresh_seconds": refresh_seconds(name),
144
+ "covered": len(quotes),
145
+ "source": s.source or _source(name),
146
+ "error": s.error,
147
+ "version": s.version,
148
+ }
149
+ if start:
150
+ threading.Thread(target=_refresh, args=(name,), name=f"live-market-{name}", daemon=True).start()
151
+ return {"quotes": quotes, "status": status}
152
+
153
+
154
+ def _table_quotes(name: str, quotes: dict[str, dict]) -> dict[str, dict]:
155
+ """Quotes from the tables' cache that are newer than ours, with the
156
+ trading day added."""
157
+ from . import live_quotes
158
+
159
+ m = live_quotes._markets.get(name)
160
+ if m is None:
161
+ return {}
162
+ with m.lock:
163
+ table = dict(m.quotes)
164
+ out = {}
165
+ for sym, q in table.items():
166
+ mine = quotes.get(sym)
167
+ if mine is not None and q.get("price") and (q.get("time") or 0) > (mine.get("time") or 0):
168
+ out[sym] = {**q, "day": market_day(name, q.get("time"))}
169
+ return out
170
+
171
+
172
+ def _universe(name: str) -> list[str]:
173
+ """Symbols of the market's largest names, biggest first."""
174
+ from ..data.listings import get_store
175
+
176
+ if name not in TOP:
177
+ return []
178
+ rows = [r for r in get_store().rows(name) if r.get("type") == "stock" and r.get("market_cap")]
179
+ rows.sort(key=lambda r: -r["market_cap"])
180
+ return [r["symbol"] for r in rows[:TOP[name]]]
181
+
182
+
183
+ def _refresh(name: str) -> None:
184
+ s = _state(name)
185
+ try:
186
+ fresh = FETCHERS[name](_universe(name))
187
+ if not fresh:
188
+ raise RuntimeError("no quotes returned")
189
+ error = None
190
+ except Exception as exc: # keep serving the last quotes
191
+ logger.warning("Live market prices for %s failed: %s", name, exc)
192
+ fresh, error = {}, "Live prices are temporarily unavailable; showing the last ones."
193
+ with s.lock:
194
+ if fresh:
195
+ s.quotes = fresh
196
+ s.updated = _now()
197
+ s.version += 1
198
+ s.error = error
199
+ s.source = _source(name)
200
+ s.busy = False
201
+
202
+
203
+ def _fetch_us(_symbols: list[str]) -> dict[str, dict]:
204
+ """The whole US market from the screener, one call."""
205
+ from ..data.listings import _float, _nasdaq_screener
206
+ from ..trading.market_hours import is_open
207
+
208
+ now = _now()
209
+ day = market_day("us", now) if is_open("us") else None
210
+ out = {}
211
+ for r in _nasdaq_screener("stocks"):
212
+ sym = (r.get("symbol") or "").strip().replace("/", "-").replace("^", "-P")
213
+ price = _float(r.get("lastsale"))
214
+ if not sym or not price:
215
+ continue
216
+ out[sym] = {"price": price, "change": _float(r.get("netchange")), "change_percent": _float(r.get("pctchange")),
217
+ "volume": _float(r.get("volume")), "time": now, "day": day}
218
+ return out
219
+
220
+
221
+ def _fetch_crypto(_symbols: list[str]) -> dict[str, dict]:
222
+ """Every USDT pair's 24-hour ticker, one call."""
223
+ import requests
224
+
225
+ r = requests.get("https://api.binance.com/api/v3/ticker/24hr", timeout=15)
226
+ r.raise_for_status()
227
+ now = _now()
228
+ day = market_day("crypto", now)
229
+ out = {}
230
+ for t in r.json():
231
+ pair = t.get("symbol", "")
232
+ if not pair.endswith("USDT"):
233
+ continue
234
+ price = _num(t.get("lastPrice"))
235
+ if price:
236
+ out[f"{pair[:-4]}-USD"] = {"price": price, "change": _num(t.get("priceChange")),
237
+ "change_percent": _num(t.get("priceChangePercent")),
238
+ "volume": _num(t.get("quoteVolume")), "time": now, "day": day}
239
+ return out
240
+
241
+
242
+ def _tencent(symbols: list[str], name: str) -> dict[str, dict]:
243
+ from ..data.extra_sources import tencent_symbol
244
+ from ..data.listings import _tencent_quote_lines
245
+
246
+ tz = ZoneInfo(TIMEZONES[name])
247
+ out = {}
248
+ for f in _tencent_quote_lines([tencent_symbol(c, name) for c in symbols]):
249
+ if len(f) < 33 or not f[2]:
250
+ continue
251
+ price = _num(f[3])
252
+ if not price:
253
+ continue
254
+ try:
255
+ stamp = datetime.strptime(f[30][:14], "%Y%m%d%H%M%S").replace(tzinfo=tz)
256
+ except (ValueError, IndexError):
257
+ stamp = datetime.now(tz)
258
+ vol = _num(f[6])
259
+ if vol is not None and name == "china_a":
260
+ vol *= 100 # lots of 100 shares
261
+ out[f[2]] = {"price": price, "change": _num(f[31]), "change_percent": _num(f[32]), "volume": vol,
262
+ "time": stamp.timestamp(), "day": stamp.date()}
263
+ return out
264
+
265
+
266
+ def _fetch_india(symbols: list[str]) -> dict[str, dict]:
267
+ """Broker feed when it is set up (live); the regular feed otherwise, or
268
+ for names the broker can't price."""
269
+ out: dict[str, dict] = {}
270
+ if _broker_on():
271
+ from . import live_quotes
272
+ from .brokers import angel
273
+
274
+ try:
275
+ for sym, q in angel.client(live_quotes._broker_config()).quotes(symbols).items():
276
+ out[sym] = {**q, "day": market_day("india", q.get("time"))}
277
+ except Exception as exc:
278
+ logger.warning("Broker quotes for the live market failed, using the regular feed: %s", exc)
279
+ rest = [s for s in symbols if s not in out]
280
+ if rest:
281
+ out.update(_yahoo_daily(rest, "india"))
282
+ return out
283
+
284
+
285
+ def _yahoo_daily(symbols: list[str], name: str) -> dict[str, dict]:
286
+ """Today's bar so far (price, change vs the previous close, volume) for
287
+ many names: daily bars are far lighter than minute bars."""
288
+ import yfinance as yf
289
+
290
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
291
+ from .analysis_run import yahoo_symbol
292
+
293
+ out: dict[str, dict] = {}
294
+ now = _now()
295
+ for i in range(0, len(symbols), YAHOO_CHUNK):
296
+ tickers = {yahoo_symbol(s, name): s for s in symbols[i:i + YAHOO_CHUNK]}
297
+ with YF_DOWNLOAD_LOCK:
298
+ df = yf.download(list(tickers), period="5d", interval="1d", group_by="ticker", progress=False,
299
+ threads=True, auto_adjust=False, timeout=20)
300
+ if df is None or df.empty:
301
+ continue
302
+ for yt, sym in tickers.items():
303
+ try:
304
+ frame = (df[yt] if isinstance(df.columns, pd.MultiIndex) else df).dropna(subset=["Close"])
305
+ except KeyError:
306
+ continue
307
+ if len(frame) < 2:
308
+ continue
309
+ price, prev = float(frame["Close"].iloc[-1]), float(frame["Close"].iloc[-2])
310
+ out[sym] = {"price": round(price, 4), "change": round(price - prev, 4),
311
+ "change_percent": round((price / prev - 1) * 100, 2) if prev else None,
312
+ "volume": _num(frame["Volume"].iloc[-1]) if "Volume" in frame else None,
313
+ "time": now, "day": pd.Timestamp(frame.index[-1]).date()}
314
+ return out
315
+
316
+
317
+ FETCHERS = {
318
+ "india": _fetch_india,
319
+ "us": _fetch_us,
320
+ "crypto": _fetch_crypto,
321
+ "china_a": lambda symbols: _tencent(symbols, "china_a"),
322
+ "hong_kong": lambda symbols: _tencent(symbols, "hong_kong"),
323
+ }
324
+
325
+
326
+ def merge(rows: list[dict], quotes: dict[str, dict], only_live: bool = False) -> list[dict]:
327
+ """Listing rows with live price, change and volume folded in; the 52-week
328
+ range widens when a live price breaks it and market cap moves with price.
329
+ `only_live` drops rows of the quoted kind that have no live quote, so
330
+ today's moves are never mixed with an earlier session's."""
331
+ if not quotes:
332
+ return rows
333
+ out = []
334
+ for r in rows:
335
+ q = quotes.get(r.get("symbol"))
336
+ if q is None:
337
+ if not (only_live and r.get("type") in ("stock", "crypto")):
338
+ out.append(r)
339
+ continue
340
+ new = dict(r)
341
+ price, old = q["price"], r.get("price")
342
+ new["price"] = price
343
+ for key in ("change", "change_percent", "volume"):
344
+ if q.get(key) is not None:
345
+ new[key] = q[key]
346
+ if r.get("market_cap") and old:
347
+ new["market_cap"] = r["market_cap"] * price / old
348
+ if r.get("high_52w"):
349
+ new["high_52w"] = max(r["high_52w"], price)
350
+ if r.get("low_52w"):
351
+ new["low_52w"] = min(r["low_52w"], price)
352
+ new["live"] = True
353
+ out.append(new)
354
+ return out
355
+
356
+
357
+ # ------------------------------------------------------------ latest tickers
358
+
359
+ _latest: dict[str, tuple[float, dict]] = {}
360
+ _latest_lock = threading.Lock()
361
+ _latest_busy: set[str] = set()
362
+
363
+
364
+ def latest(tickers: list[str], wait: bool = True) -> dict[str, dict]:
365
+ """ticker -> {price, day, time} from one batched 1-minute download.
366
+ Cached LATEST_SECONDS; stale tickers refresh in the background, and only
367
+ tickers never seen before are fetched inline (when `wait`)."""
368
+ now = _now()
369
+ tickers = list(dict.fromkeys(tickers))
370
+ with _latest_lock:
371
+ have = {t: v for t, (at, v) in _latest.items() if t in tickers}
372
+ stale = [t for t in tickers if t not in _latest or now - _latest[t][0] >= LATEST_SECONDS]
373
+ stale = [t for t in stale if t not in _latest_busy]
374
+ _latest_busy.update(stale)
375
+ missing = [t for t in stale if t not in have]
376
+ background = [t for t in stale if t in have]
377
+ if missing:
378
+ if wait:
379
+ _fill_latest(missing)
380
+ else:
381
+ background += missing
382
+ if background:
383
+ threading.Thread(target=_fill_latest, args=(background,), name="live-latest", daemon=True).start()
384
+ with _latest_lock:
385
+ return {t: _latest[t][1] for t in tickers if t in _latest}
386
+
387
+
388
+ def _fill_latest(tickers: list[str]) -> None:
389
+ try:
390
+ got = _yahoo_latest(tickers)
391
+ except Exception as exc:
392
+ logger.info("Latest prices failed: %s", exc)
393
+ got = {}
394
+ now = _now()
395
+ with _latest_lock:
396
+ for t in tickers:
397
+ _latest_busy.discard(t)
398
+ if t in got:
399
+ _latest[t] = (now, got[t])
400
+
401
+
402
+ def _yahoo_latest(tickers: list[str]) -> dict[str, dict]:
403
+ import yfinance as yf
404
+
405
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
406
+
407
+ with YF_DOWNLOAD_LOCK:
408
+ df = yf.download(tickers, period="5d", interval="1m", group_by="ticker", progress=False,
409
+ threads=True, auto_adjust=False, prepost=False, timeout=15)
410
+ out = {}
411
+ if df is None or df.empty:
412
+ return out
413
+ for t in tickers:
414
+ try:
415
+ frame = (df[t] if isinstance(df.columns, pd.MultiIndex) else df).dropna(subset=["Close"])
416
+ except KeyError:
417
+ continue
418
+ if frame.empty:
419
+ continue
420
+ stamp = pd.Timestamp(frame.index[-1])
421
+ out[t] = {"price": float(frame["Close"].iloc[-1]), "day": stamp.date(), "time": stamp.timestamp()}
422
+ return out
423
+
424
+
425
+ def extend(series: pd.Series, price: Optional[float], day: Optional[date]) -> pd.Series:
426
+ """A daily close series with `price` as the close of `day`: replaces the
427
+ last bar when it is that day, appends one when the day is newer, and
428
+ leaves the series alone for an older day (or no price)."""
429
+ if price is None or day is None or series is None or not len(series):
430
+ return series
431
+ last = pd.Timestamp(series.index[-1])
432
+ if last.date() > day:
433
+ return series
434
+ s = series.copy()
435
+ if last.date() == day:
436
+ s.iloc[-1] = price
437
+ return s
438
+ stamp = pd.Timestamp(day)
439
+ if last.tzinfo is not None:
440
+ stamp = stamp.tz_localize(last.tzinfo)
441
+ s.loc[stamp] = price
442
+ return s
443
+
444
+
445
+ def extend_all(closes: dict[str, pd.Series], prices: dict[str, dict]) -> dict[str, pd.Series]:
446
+ """`extend` for every series that has a live {price, day}."""
447
+ out = dict(closes)
448
+ for t, q in prices.items():
449
+ if t in out and q:
450
+ out[t] = extend(out[t], q.get("price"), q.get("day"))
451
+ return out
452
+
453
+
454
+ def for_tickers(name: str, quotes: dict[str, dict]) -> dict[str, dict]:
455
+ """Market quotes keyed by the app's symbols -> keyed by Yahoo tickers,
456
+ for `extend_all` on daily close series."""
457
+ from .analysis_run import yahoo_symbol
458
+
459
+ return {yahoo_symbol(sym, name): q for sym, q in quotes.items()}
460
+
461
+
462
+ def status_for(prices: dict[str, dict], source: str = "Market data feed, can lag up to 15 min") -> dict:
463
+ """The `live` block for views priced by `latest` (no market cache):
464
+ live while something in it traded in the last 15 minutes."""
465
+ times = [q["time"] for q in prices.values() if q.get("time")]
466
+ newest = max(times) if times else None
467
+ age = _now() - newest if newest else None
468
+ return {"open": age is not None and age < 900, "live": bool(prices), "updated": newest,
469
+ "age_seconds": round(age, 1) if age is not None else None, "refresh_seconds": LATEST_SECONDS,
470
+ "covered": len(prices), "source": source, "error": None, "version": None}
471
+
472
+
473
+ _memo: dict[tuple, tuple[float, object]] = {}
474
+ _memo_lock = threading.Lock()
475
+ MEMO_SECONDS = 15
476
+
477
+
478
+ def memo(key: tuple, fn, seconds: float = MEMO_SECONDS):
479
+ """fn() shared by every request for `seconds`: many people polling a
480
+ view cost one recomputation per interval."""
481
+ now = _now()
482
+ with _memo_lock:
483
+ hit = _memo.get(key)
484
+ if hit and now - hit[0] < seconds:
485
+ return hit[1]
486
+ value = fn()
487
+ with _memo_lock:
488
+ _memo[key] = (now, value)
489
+ return value
490
+
491
+
492
+ def _num(value) -> Optional[float]:
493
+ try:
494
+ v = float(value)
495
+ except (TypeError, ValueError):
496
+ return None
497
+ return v if v == v else None # NaN -> None