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,335 @@
1
+ """Big trades outside India (India: NSE bulk/block deals, marketmyna/data/nse_deals.py).
2
+
3
+ - us: insider open-market buys and sells from the latest SEC Form 4
4
+ filings (EDGAR). Needs SEC_USER_AGENT (name + contact email),
5
+ as SEC's fair-access policy requires.
6
+ - china_a: Shanghai / Shenzhen block trades (大宗交易) with buyer and seller
7
+ branches, via Eastmoney's data centre (same host the listings use).
8
+ - hong_kong: Southbound Stock Connect - the HK stocks mainland investors
9
+ bought and sold most each day (Shanghai and Shenzhen links).
10
+ - crypto: no public trade-level whale feed, so the closest free signal:
11
+ Binance futures positioning of the largest accounts (top-trader
12
+ long/short), open interest change and taker buy/sell balance.
13
+
14
+ Each market is cached for CACHE_SECONDS.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import re
21
+ import threading
22
+ import time
23
+ import xml.etree.ElementTree as ET
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from datetime import date, datetime, timedelta
26
+ from typing import Callable, Optional
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ CACHE_SECONDS = 3600
31
+ EASTMONEY = "https://datacenter-web.eastmoney.com/api/data/v1/get"
32
+ SEC_FEED = ("https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=4&company=&dateb=&owner=include"
33
+ "&start={start}&count=100&output=atom")
34
+ POSITION_COINS = ["BTC", "ETH", "SOL", "XRP", "BNB", "DOGE", "ADA", "AVAX", "LINK", "SUI"]
35
+
36
+ _cache: dict[tuple, tuple[float, dict]] = {}
37
+ _lock = threading.Lock()
38
+
39
+
40
+ def _json(url: str, params: Optional[dict] = None, headers: Optional[dict] = None):
41
+ import requests
42
+
43
+ r = requests.get(url, params=params, timeout=20, headers=headers or {"User-Agent": "Mozilla/5.0 MarketMyna"})
44
+ r.raise_for_status()
45
+ return r.json()
46
+
47
+
48
+ def _num(v) -> Optional[float]:
49
+ try:
50
+ return float(v)
51
+ except (TypeError, ValueError):
52
+ return None
53
+
54
+
55
+ # ------------------------------------------------------------- US: Form 4
56
+
57
+ _ATOM = {"a": "http://www.w3.org/2005/Atom"}
58
+
59
+
60
+ def parse_feed(xml_text: str) -> list[tuple[str, str]]:
61
+ """Atom feed -> unique (cik, accession) of Form 4 filings, issuer entries first."""
62
+ out: dict[str, str] = {}
63
+ for entry in ET.fromstring(xml_text).findall("a:entry", _ATOM):
64
+ link = entry.find("a:link", _ATOM)
65
+ href = link.get("href") if link is not None else ""
66
+ m = re.search(r"/data/(\d+)/(\d{18})/", href or "")
67
+ if m and m.group(2) not in out:
68
+ out[m.group(2)] = m.group(1)
69
+ return [(cik, acc) for acc, cik in out.items()]
70
+
71
+
72
+ def _t(node, path: str) -> str:
73
+ el = node.find(path)
74
+ return (el.text or "").strip() if el is not None and el.text else ""
75
+
76
+
77
+ def parse_form4(xml_text: str) -> list[dict]:
78
+ """Open-market purchases (P) and sales (S) in one Form 4, one row per
79
+ side, shares summed and price volume-weighted."""
80
+ root = ET.fromstring(xml_text)
81
+ symbol = _t(root, "issuer/issuerTradingSymbol").upper()
82
+ issuer = _t(root, "issuer/issuerName")
83
+ owner = _t(root, "reportingOwner/reportingOwnerId/rptOwnerName")
84
+ rel = root.find("reportingOwner/reportingOwnerRelationship")
85
+ roles = []
86
+ if rel is not None:
87
+ if _t(rel, "isDirector") in ("1", "true"):
88
+ roles.append("Director")
89
+ if _t(rel, "isOfficer") in ("1", "true"):
90
+ roles.append(_t(rel, "officerTitle") or "Officer")
91
+ if _t(rel, "isTenPercentOwner") in ("1", "true"):
92
+ roles.append("10% owner")
93
+ sides: dict[str, dict] = {}
94
+ for tx in root.findall("nonDerivativeTable/nonDerivativeTransaction"):
95
+ code = _t(tx, "transactionCoding/transactionCode")
96
+ if code not in ("P", "S"):
97
+ continue # grants, option exercises, tax withholding... are not market trades
98
+ shares = _num(_t(tx, "transactionAmounts/transactionShares/value")) or 0
99
+ price = _num(_t(tx, "transactionAmounts/transactionPricePerShare/value")) or 0
100
+ side = sides.setdefault(code, {"qty": 0.0, "value": 0.0, "date": _t(tx, "transactionDate/value")})
101
+ side["qty"] += shares
102
+ side["value"] += shares * price
103
+ rows = []
104
+ for code, s in sides.items():
105
+ if not s["qty"]:
106
+ continue
107
+ rows.append({
108
+ "date": s["date"][:10], "kind": "insider", "symbol": symbol, "name": issuer,
109
+ "client": owner.title() if owner.isupper() else owner, "side": "buy" if code == "P" else "sell",
110
+ "qty": s["qty"], "price": s["value"] / s["qty"] if s["qty"] else None, "value": s["value"] or None,
111
+ "remarks": ", ".join(roles) or None,
112
+ })
113
+ return rows
114
+
115
+
116
+ def _us(fetch_text: Optional[Callable[[str], str]] = None, max_filings: int = 120) -> dict:
117
+ from .sec_mdna import user_agent
118
+
119
+ ua = user_agent() # raises SecNotConfigured with the fix
120
+ if fetch_text is None:
121
+ import requests
122
+
123
+ session = requests.Session()
124
+ session.headers.update({"User-Agent": ua, "Accept-Encoding": "gzip, deflate"})
125
+ gate = threading.Semaphore(6) # SEC allows 10 requests/second
126
+
127
+ def fetch_text(url: str) -> str:
128
+ with gate:
129
+ r = session.get(url, timeout=20)
130
+ time.sleep(0.15)
131
+ r.raise_for_status()
132
+ return r.text
133
+
134
+ filings: list[tuple[str, str]] = []
135
+ for start in (0, 100):
136
+ filings += parse_feed(fetch_text(SEC_FEED.format(start=start)))
137
+ seen, unique = set(), []
138
+ for cik, acc in filings:
139
+ if acc not in seen:
140
+ seen.add(acc)
141
+ unique.append((cik, acc))
142
+ unique = unique[:max_filings]
143
+
144
+ def one(item):
145
+ cik, acc = item
146
+ try:
147
+ import json
148
+
149
+ index = json.loads(fetch_text(f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/index.json"))
150
+ names = [f["name"] for f in index.get("directory", {}).get("item", []) if f["name"].endswith(".xml")]
151
+ if not names:
152
+ return []
153
+ return parse_form4(fetch_text(f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/{names[0]}"))
154
+ except Exception as exc:
155
+ logger.debug("Form 4 %s skipped: %s", acc, exc)
156
+ return []
157
+
158
+ with ThreadPoolExecutor(max_workers=6) as pool:
159
+ rows = [r for found in pool.map(one, unique) for r in found]
160
+ rows.sort(key=lambda r: -(r["value"] or 0))
161
+ return {
162
+ "title": "Insider buys & sells (SEC Form 4)",
163
+ "subtitle": "Open-market purchases and sales by executives, directors and 10% owners in the latest filings",
164
+ "currency": "$", "deals": rows,
165
+ "note": f"Latest {len(unique)} insider filings (Form 4) with the US regulator; open-market trades only (code P / S), "
166
+ "so grants, option exercises and tax withholding are left out. Insiders sell for many reasons; "
167
+ "buys are the rarer, stronger signal.",
168
+ }
169
+
170
+
171
+ # -------------------------------------------------- China A: block trades
172
+
173
+
174
+ def parse_blocks(rows: list[dict]) -> list[dict]:
175
+ out = []
176
+ for r in rows:
177
+ if str(r.get("SECURITY_TYPE_WEB")) != "1": # 1 = A-share stock (skip ETFs, bonds)
178
+ continue
179
+ premium = _num(r.get("PREMIUM_RATIO"))
180
+ out.append({
181
+ "date": str(r.get("TRADE_DATE", ""))[:10], "kind": "block", "symbol": r.get("SECURITY_CODE"),
182
+ "name": r.get("SECURITY_NAME_ABBR"),
183
+ "client": f"{r.get('BUYER_NAME') or '?'} ← {r.get('SELLER_NAME') or '?'}",
184
+ "side": "premium" if (premium or 0) > 0 else "discount" if (premium or 0) < 0 else "at market",
185
+ "qty": _num(r.get("DEAL_VOLUME")), "price": _num(r.get("DEAL_PRICE")), "value": _num(r.get("DEAL_AMT")),
186
+ "remarks": f"{premium * 100:+.1f}% vs close" if premium is not None else None,
187
+ })
188
+ return out
189
+
190
+
191
+ def _china(days: int, fetch=_json) -> dict:
192
+ since = (date.today() - timedelta(days=days + 16)).isoformat() # holidays (Golden Week, CNY) can run 9 days
193
+ data = fetch(EASTMONEY, {
194
+ "reportName": "RPT_DATA_BLOCKTRADE", "columns": "ALL", "sortColumns": "TRADE_DATE,DEAL_AMT", "sortTypes": "-1,-1",
195
+ "pageSize": 500, "pageNumber": 1, "source": "WEB", "client": "WEB", "filter": f"(TRADE_DATE>='{since}')",
196
+ })
197
+ rows = parse_blocks(((data or {}).get("result") or {}).get("data") or [])
198
+ if rows:
199
+ latest = max(r["date"] for r in rows)
200
+ cutoff = (date.fromisoformat(latest) - timedelta(days=days - 1)).isoformat()
201
+ rows = [r for r in rows if r["date"] >= cutoff]
202
+ rows.sort(key=lambda r: -(r["value"] or 0))
203
+ return {
204
+ "title": "Block trades (Shanghai & Shenzhen)",
205
+ "subtitle": "Large negotiated trades between brokerage branches, with the price against the day's close",
206
+ "currency": "¥", "deals": rows,
207
+ "note": "Buyer ← seller are brokerage branches; 机构专用 means an institution's dedicated seat. A deep discount "
208
+ "often means a large holder exiting; a premium, a buyer keen to get size. Source: Eastmoney.",
209
+ }
210
+
211
+
212
+ # ------------------------------------------ Hong Kong: southbound connect
213
+
214
+
215
+ def parse_southbound(rows: list[dict], link: str) -> list[dict]:
216
+ out = []
217
+ for r in rows:
218
+ net = _num(r.get("NET_BUY_AMT"))
219
+ out.append({
220
+ "date": str(r.get("TRADE_DATE", ""))[:10], "kind": f"southbound ({link})", "symbol": r.get("SECURITY_CODE"),
221
+ "name": r.get("SECURITY_NAME"), "client": f"Mainland investors via {link}",
222
+ "side": "net buy" if (net or 0) >= 0 else "net sell", "qty": None, "price": _num(r.get("CLOSE_PRICE")),
223
+ "value": abs(net) if net is not None else _num(r.get("DEAL_AMT")),
224
+ "remarks": (f"bought HK${(_num(r.get('BUY_AMT')) or 0) / 1e6:,.0f}m · sold HK${(_num(r.get('SELL_AMT')) or 0) / 1e6:,.0f}m"
225
+ if r.get("BUY_AMT") is not None else None),
226
+ })
227
+ return out
228
+
229
+
230
+ def _hk_english(rows: list[dict], names: Optional[dict[str, str]] = None) -> None:
231
+ """Eastmoney sends Chinese names; our HK listing has the English ones."""
232
+ if names is None:
233
+ try:
234
+ from ..data.listings import get_store
235
+
236
+ names = {r["symbol"]: r["name"] for r in get_store().rows("hong_kong")}
237
+ except Exception:
238
+ names = {}
239
+ for r in rows:
240
+ en = names.get(str(r.get("symbol")))
241
+ if en:
242
+ r["name_local"], r["name"] = r["name"], en
243
+
244
+
245
+ def _hong_kong(days: int, fetch=_json, names: Optional[dict[str, str]] = None) -> dict:
246
+ since = (date.today() - timedelta(days=days + 16)).isoformat() # holidays (Golden Week, CNY) can run 9 days
247
+ rows: list[dict] = []
248
+ for code, link in (("002", "Shanghai link"), ("004", "Shenzhen link")):
249
+ data = fetch(EASTMONEY, {
250
+ "reportName": "RPT_MUTUAL_TOP10DEAL", "columns": "ALL", "sortColumns": "TRADE_DATE,RANK", "sortTypes": "-1,1",
251
+ "pageSize": 200, "pageNumber": 1, "source": "WEB", "client": "WEB",
252
+ "filter": f'(MUTUAL_TYPE="{code}")(TRADE_DATE>=\'{since}\')',
253
+ })
254
+ rows += parse_southbound(((data or {}).get("result") or {}).get("data") or [], link)
255
+ if rows:
256
+ latest = max(r["date"] for r in rows)
257
+ cutoff = (date.fromisoformat(latest) - timedelta(days=days - 1)).isoformat()
258
+ rows = [r for r in rows if r["date"] >= cutoff]
259
+ rows.sort(key=lambda r: -(r["value"] or 0))
260
+ _hk_english(rows, names)
261
+ return {
262
+ "title": "Southbound Stock Connect",
263
+ "subtitle": "The Hong Kong stocks mainland investors traded most, and whether they were net buyers or sellers",
264
+ "currency": "HK$", "deals": rows,
265
+ "note": "Daily top-10 traded stocks on each southbound link (Shanghai and Shenzhen), sorted by net amount. "
266
+ "Mainland flows have become a big driver of HK prices. Source: Eastmoney / HKEX.",
267
+ }
268
+
269
+
270
+ # ------------------------------------------------ crypto: big-trader positioning
271
+
272
+
273
+ def _coin_positioning(coin: str, fetch=_json) -> Optional[dict]:
274
+ base = "https://fapi.binance.com/futures/data"
275
+ sym = f"{coin}USDT"
276
+ top = fetch(f"{base}/topLongShortPositionRatio", {"symbol": sym, "period": "1d", "limit": 8})
277
+ oi = fetch(f"{base}/openInterestHist", {"symbol": sym, "period": "1d", "limit": 8})
278
+ taker = fetch(f"{base}/takerlongshortRatio", {"symbol": sym, "period": "1d", "limit": 2})
279
+ if not top or not oi:
280
+ return None
281
+ last_top, prev_top = top[-1], top[-2] if len(top) > 1 else top[-1]
282
+ oi_now = _num(oi[-1]["sumOpenInterestValue"])
283
+ oi_1d = _num(oi[-2]["sumOpenInterestValue"]) if len(oi) > 1 else None
284
+ oi_7d = _num(oi[0]["sumOpenInterestValue"]) if len(oi) > 1 else None
285
+ return {
286
+ "coin": coin,
287
+ "top_long_pct": round(_num(last_top["longAccount"]) * 100, 1),
288
+ "top_ratio": _num(last_top["longShortRatio"]),
289
+ "top_ratio_change": (_num(last_top["longShortRatio"]) or 0) - (_num(prev_top["longShortRatio"]) or 0),
290
+ "open_interest": oi_now,
291
+ "oi_change_1d": (oi_now / oi_1d - 1) * 100 if oi_now and oi_1d else None,
292
+ "oi_change_7d": (oi_now / oi_7d - 1) * 100 if oi_now and oi_7d else None,
293
+ "taker_ratio": _num(taker[-1]["buySellRatio"]) if taker else None,
294
+ }
295
+
296
+
297
+ def _crypto(fetch=_json) -> dict:
298
+ with ThreadPoolExecutor(max_workers=5) as pool:
299
+ rows = [r for r in pool.map(lambda c: _safe(_coin_positioning, c, fetch), POSITION_COINS) if r]
300
+ return {
301
+ "title": "Whale positioning (crypto futures)",
302
+ "subtitle": "How the largest futures accounts are positioned, and whether leverage is building or unwinding",
303
+ "currency": "$", "positions": rows,
304
+ "note": "Top traders = the 20% of accounts with the biggest margin balances; their long % is by position size. "
305
+ "Open interest = total value of open futures; rising OI with rising price = new money, falling OI = "
306
+ "positions closing. Taker ratio > 1 = more aggressive buying than selling yesterday. Source: a major crypto exchange's futures data.",
307
+ }
308
+
309
+
310
+ def _safe(fn, *args):
311
+ try:
312
+ return fn(*args)
313
+ except Exception as exc:
314
+ logger.info("Positioning %s skipped: %s", args[0], exc)
315
+ return None
316
+
317
+
318
+ def build(market: str, days: int = 1, use_cache: bool = True) -> dict:
319
+ market = market.lower()
320
+ builders: dict[str, Callable[[], dict]] = {
321
+ "us": _us, "china_a": lambda: _china(days), "hong_kong": lambda: _hong_kong(days), "crypto": _crypto,
322
+ }
323
+ if market not in builders:
324
+ raise ValueError("Big trades are available for India, US, China A, Hong Kong and crypto")
325
+ key = (market, days if market in ("china_a", "hong_kong") else 0)
326
+ if use_cache:
327
+ with _lock:
328
+ hit = _cache.get(key)
329
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
330
+ return hit[1]
331
+ out = {"market": market, "days": days, "as_of": datetime.now().isoformat(timespec="minutes"), **builders[market]()}
332
+ if use_cache and (out.get("deals") or out.get("positions")):
333
+ with _lock:
334
+ _cache[key] = (time.time(), out)
335
+ return out
File without changes
@@ -0,0 +1,271 @@
1
+ """Angel One SmartAPI: live NSE prices for the Markets / Watchlist tables.
2
+
3
+ Free with an Angel One account (smartapi.angelone.in -> create an app for
4
+ the API key). The server logs itself in every day with the client code,
5
+ PIN and the TOTP secret shown when enabling TOTP, so prices keep flowing
6
+ without a manual morning login.
7
+
8
+ Only the Market Quote REST endpoint is used (up to 50 symbols per call).
9
+ NSE symbols are mapped to Angel's instrument tokens with their public
10
+ instrument master, cached on disk for a day.
11
+
12
+ Note: showing exchange prices to the public needs the exchange's data
13
+ licence; check NSE's terms before launch.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import hmac
21
+ import json
22
+ import logging
23
+ import os
24
+ import struct
25
+ import threading
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from marketmyna.core.home import app_home
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ API = os.environ.get("ANGEL_API_ROOT", "https://apiconnect.angelone.in")
35
+ SCRIP_MASTER = os.environ.get(
36
+ "ANGEL_SCRIP_MASTER", "https://margincalculator.angelone.in/OpenAPI_File/files/OpenAPIScripMaster.json")
37
+ BATCH = 50 # symbols per quote call (API limit)
38
+ # Market Quote limits per Angel account (docs: RateLimit): 10/s, 500/min,
39
+ # 5000/hour. Every app user shares the one account, so stay under the
40
+ # hourly cap: about 1.1 calls a second for the tables (~4,000 an hour), which
41
+ # leaves room for the Market insights' live prices (services/live_market.py:
42
+ # 10 calls a minute for the 500 largest names, ~600 an hour).
43
+ CALLS_PER_SECOND = float(os.environ.get("ANGEL_QUOTE_CALLS_PER_SECOND", 1.1))
44
+ MASTER_MAX_AGE = 20 * 3600
45
+
46
+
47
+ class AngelError(Exception):
48
+ def __init__(self, message: str, code: str = "", http: int = 0):
49
+ super().__init__(message)
50
+ self.code, self.http = code, http
51
+
52
+ @property
53
+ def session_expired(self) -> bool:
54
+ # AG8001 invalid / AG8002 expired / AG8003 missing token (docs: Error Codes).
55
+ return self.http == 401 or self.code in ("AG8001", "AG8002", "AG8003")
56
+
57
+
58
+ def totp(secret: str, now: Optional[float] = None, digits: int = 6, step: int = 30) -> str:
59
+ """RFC 6238 time-based code from a base32 secret (what authenticator apps show)."""
60
+ key = base64.b32decode(secret.replace(" ", "").upper() + "=" * (-len(secret.replace(" ", "")) % 8))
61
+ counter = int((now if now is not None else time.time()) // step)
62
+ digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
63
+ offset = digest[-1] & 0x0F
64
+ code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % (10 ** digits)
65
+ return str(code).zfill(digits)
66
+
67
+
68
+ def configured(config) -> bool:
69
+ return all(getattr(config, f, None) for f in ("angel_api_key", "angel_client_code", "angel_pin", "angel_totp_secret"))
70
+
71
+
72
+ def _headers(config, token: Optional[str] = None) -> dict:
73
+ h = {
74
+ "Content-Type": "application/json",
75
+ "Accept": "application/json",
76
+ "X-UserType": "USER",
77
+ "X-SourceID": "WEB",
78
+ "X-ClientLocalIP": "127.0.0.1",
79
+ "X-ClientPublicIP": "127.0.0.1",
80
+ "X-MACAddress": "00:00:00:00:00:00",
81
+ "X-PrivateKey": config.angel_api_key,
82
+ }
83
+ if token:
84
+ h["Authorization"] = f"Bearer {token}"
85
+ return h
86
+
87
+
88
+ def _post(path: str, config, body: dict, token: Optional[str] = None) -> dict:
89
+ import requests
90
+
91
+ r = requests.post(API + path, headers=_headers(config, token), data=json.dumps(body), timeout=10)
92
+ try:
93
+ data = r.json()
94
+ except ValueError:
95
+ detail = "rate limit exceeded" if r.status_code == 403 else "no JSON"
96
+ raise AngelError(f"Angel One answered HTTP {r.status_code} ({detail})", http=r.status_code)
97
+ # The docs use both status/errorcode and success/errorCode.
98
+ ok = data.get("status", data.get("success", False))
99
+ code = data.get("errorcode") or data.get("errorCode") or ""
100
+ if r.status_code >= 400 or not ok:
101
+ raise AngelError(data.get("message") or f"Angel One error (HTTP {r.status_code})", code=code, http=r.status_code)
102
+ return data
103
+
104
+
105
+ class AngelClient:
106
+ """One logged-in session, shared; logs in again when it expires."""
107
+
108
+ def __init__(self, config):
109
+ self.config = config
110
+ self._token: Optional[str] = None
111
+ self._logged_at = 0.0
112
+ self._lock = threading.Lock()
113
+ self._tokens: dict[str, str] = {} # NSE symbol -> instrument token
114
+ self._tokens_at = 0.0
115
+
116
+ # -------------------------------------------------------------- session
117
+ def login(self) -> str:
118
+ cfg = self.config
119
+ data = _post("/rest/auth/angelbroking/user/v1/loginByPassword", cfg, {
120
+ "clientcode": cfg.angel_client_code, "password": cfg.angel_pin, "totp": totp(cfg.angel_totp_secret),
121
+ })
122
+ token = (data.get("data") or {}).get("jwtToken")
123
+ if not token:
124
+ raise AngelError("Angel One login returned no session token")
125
+ self._token, self._logged_at = token, time.time()
126
+ return token
127
+
128
+ def _session(self) -> str:
129
+ with self._lock:
130
+ # A SmartAPI session lasts until 12 midnight (IST): log in again on a new day.
131
+ if not self._token or _ist_date(self._logged_at) != _ist_date(time.time()):
132
+ self.login()
133
+ return self._token
134
+
135
+ # -------------------------------------------------------------- symbols
136
+ def _symbol_tokens(self) -> dict[str, str]:
137
+ if self._tokens and time.time() - self._tokens_at < MASTER_MAX_AGE:
138
+ return self._tokens
139
+ path = Path(getattr(self.config, "app_dir", app_home())) / "cache" / "angel_nse_tokens.json"
140
+ data: Optional[dict] = None
141
+ if path.exists() and time.time() - path.stat().st_mtime < MASTER_MAX_AGE:
142
+ try:
143
+ data = json.loads(path.read_text(encoding="utf-8"))
144
+ except ValueError:
145
+ data = None
146
+ if data is None:
147
+ import requests
148
+
149
+ r = requests.get(SCRIP_MASTER, timeout=60)
150
+ r.raise_for_status()
151
+ data = parse_master(r.json())
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ path.write_text(json.dumps(data), encoding="utf-8")
154
+ self._tokens, self._tokens_at = data, time.time()
155
+ return data
156
+
157
+ # -------------------------------------------------------------- quotes
158
+ def quotes(self, symbols: list[str]) -> dict[str, dict]:
159
+ """NSE symbol -> {price, change, change_percent, volume, time}."""
160
+ tokens = self._symbol_tokens()
161
+ wanted = {tokens[s.upper()]: s.upper() for s in symbols if s.upper() in tokens}
162
+ out: dict[str, dict] = {}
163
+ ids = list(wanted)
164
+ for i in range(0, len(ids), BATCH):
165
+ chunk = ids[i:i + BATCH]
166
+ body = {"mode": "FULL", "exchangeTokens": {"NSE": chunk}}
167
+ try:
168
+ data = _post("/rest/secure/angelbroking/market/v1/quote/", self.config, body, self._session())
169
+ except AngelError as exc:
170
+ if not exc.session_expired:
171
+ raise # e.g. rate limit: logging in again would only add calls
172
+ with self._lock:
173
+ self._token = None
174
+ data = _post("/rest/secure/angelbroking/market/v1/quote/", self.config, body, self._session())
175
+ for q in (data.get("data") or {}).get("fetched") or []:
176
+ sym = wanted.get(str(q.get("symbolToken")))
177
+ ltp = _num(q.get("ltp"))
178
+ if sym and ltp:
179
+ out[sym] = {
180
+ "price": ltp,
181
+ "change": _num(q.get("netChange")),
182
+ "change_percent": _num(q.get("percentChange")),
183
+ "volume": _num(q.get("tradeVolume")),
184
+ "time": _feed_time(q.get("exchFeedTime")) or time.time(),
185
+ **order_book(q),
186
+ }
187
+ return out
188
+
189
+
190
+ def order_book(q: dict) -> dict:
191
+ """FULL-mode extras: pending buy / sell quantity, the best five bids and
192
+ offers, average traded price and circuit limits. Missing fields are left out."""
193
+ out = {
194
+ "buy_qty": _num(q.get("totBuyQuan")),
195
+ "sell_qty": _num(q.get("totSellQuan")),
196
+ "avg_price": _num(q.get("avgPrice")),
197
+ "last_qty": _num(q.get("lastTradeQty")),
198
+ "upper_circuit": _num(q.get("upperCircuit")),
199
+ "lower_circuit": _num(q.get("lowerCircuit")),
200
+ }
201
+ out = {k: v for k, v in out.items() if v is not None}
202
+ depth = q.get("depth") or {}
203
+ sides = {}
204
+ for side in ("buy", "sell"):
205
+ levels = [{"price": _num(lv.get("price")), "quantity": _num(lv.get("quantity")), "orders": _num(lv.get("orders"))}
206
+ for lv in depth.get(side) or [] if isinstance(lv, dict)]
207
+ # Empty levels come back as price 0 (no order at that rank).
208
+ sides[side] = [lv for lv in levels if lv["price"] and lv["quantity"]][:5]
209
+ if sides["buy"] or sides["sell"]:
210
+ out["depth"] = sides
211
+ return out
212
+
213
+
214
+ def parse_master(rows: list[dict]) -> dict[str, str]:
215
+ """NSE cash-market symbols (RELIANCE-EQ, NIFTYBEES-EQ, ...) -> token,
216
+ keyed by the plain symbol MarketMyna uses (RELIANCE)."""
217
+ out: dict[str, str] = {}
218
+ for r in rows:
219
+ # The live file says "NSE"; the docs' sample says "nse_cm".
220
+ if r.get("exch_seg") not in ("NSE", "nse_cm") or r.get("instrumenttype"):
221
+ continue
222
+ sym = str(r.get("symbol", ""))
223
+ for series in ("-EQ", "-BE", "-BZ", "-SM", "-ST"):
224
+ if sym.endswith(series):
225
+ base = sym[: -len(series)]
226
+ # Prefer the normal -EQ series when a symbol trades in several.
227
+ if base not in out or series == "-EQ":
228
+ out[base] = str(r.get("token"))
229
+ break
230
+ return out
231
+
232
+
233
+ def _ist_date(epoch: float):
234
+ from datetime import datetime
235
+ from zoneinfo import ZoneInfo
236
+
237
+ return datetime.fromtimestamp(epoch, ZoneInfo("Asia/Kolkata")).date()
238
+
239
+
240
+ def _feed_time(text: Optional[str]) -> Optional[float]:
241
+ from datetime import datetime
242
+ from zoneinfo import ZoneInfo
243
+
244
+ if not text:
245
+ return None
246
+ try:
247
+ return datetime.strptime(text, "%d-%b-%Y %H:%M:%S").replace(tzinfo=ZoneInfo("Asia/Kolkata")).timestamp()
248
+ except ValueError:
249
+ return None
250
+
251
+
252
+ def _num(value) -> Optional[float]:
253
+ try:
254
+ return float(value)
255
+ except (TypeError, ValueError):
256
+ return None
257
+
258
+
259
+ _client: Optional[AngelClient] = None
260
+ _client_lock = threading.Lock()
261
+
262
+
263
+ def client(config) -> AngelClient:
264
+ """Shared client; rebuilt when the credentials change."""
265
+ global _client
266
+ with _client_lock:
267
+ key = (config.angel_api_key, config.angel_client_code, config.angel_pin, config.angel_totp_secret)
268
+ if _client is None or getattr(_client, "_key", None) != key:
269
+ _client = AngelClient(config)
270
+ _client._key = key # type: ignore[attr-defined]
271
+ return _client