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,66 @@
1
+ """Regular trading sessions, for the alert monitor (no point polling a
2
+ closed market). Exchange holidays aren't modelled: on a holiday the price
3
+ just doesn't move, so alerts stay quiet anyway."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, time, timedelta
8
+ from typing import Optional
9
+ from zoneinfo import ZoneInfo
10
+
11
+ # market -> (timezone, open, close). China/HK lunch breaks are ignored.
12
+ SESSIONS = {
13
+ "india": ("Asia/Kolkata", time(9, 15), time(15, 30)),
14
+ "us": ("America/New_York", time(9, 30), time(16, 0)),
15
+ "global": ("America/New_York", time(9, 30), time(16, 0)),
16
+ "china_a": ("Asia/Shanghai", time(9, 30), time(15, 0)),
17
+ "hong_kong": ("Asia/Hong_Kong", time(9, 30), time(16, 0)),
18
+ }
19
+
20
+
21
+ def is_open(market: str, now: Optional[datetime] = None) -> bool:
22
+ if market == "crypto" or market not in SESSIONS:
23
+ return True
24
+ tz_name, start, end = SESSIONS[market]
25
+ tz = ZoneInfo(tz_name)
26
+ local = now.astimezone(tz) if now else datetime.now(tz)
27
+ return local.weekday() < 5 and start <= local.time() <= end
28
+
29
+
30
+ def timezone_of(market: str) -> str:
31
+ return SESSIONS.get(market, ("UTC",))[0]
32
+
33
+
34
+ EXCHANGE_NAMES = {"india": "NSE", "us": "US market", "global": "US market", "china_a": "Shanghai/Shenzhen",
35
+ "hong_kong": "HKEX", "crypto": "Crypto"}
36
+ # How late free quotes are, for the chart's status line.
37
+ QUOTE_DELAY = {"crypto": "live", "us": "usually real-time", "global": "up to 15 min delayed",
38
+ "india": "can be up to 15 min delayed", "china_a": "up to 15 min delayed", "hong_kong": "about 15 min delayed"}
39
+
40
+
41
+ def status(market: str, now: Optional[datetime] = None) -> dict:
42
+ """Open/closed now, plus the next open or close in the exchange's time."""
43
+ name = EXCHANGE_NAMES.get(market, market)
44
+ if market == "crypto" or market not in SESSIONS:
45
+ return {"market": market, "name": name, "open": True, "always_open": True,
46
+ "delay": QUOTE_DELAY.get(market, "")}
47
+ tz_name, start, end = SESSIONS[market]
48
+ tz = ZoneInfo(tz_name)
49
+ local = now.astimezone(tz) if now else datetime.now(tz)
50
+ open_now = is_open(market, local)
51
+ if open_now:
52
+ nxt = local.replace(hour=end.hour, minute=end.minute, second=0, microsecond=0)
53
+ else:
54
+ day = local
55
+ if local.weekday() < 5 and local.time() < start:
56
+ nxt = day.replace(hour=start.hour, minute=start.minute, second=0, microsecond=0)
57
+ else:
58
+ day = local + timedelta(days=1)
59
+ while day.weekday() >= 5:
60
+ day += timedelta(days=1)
61
+ nxt = day.replace(hour=start.hour, minute=start.minute, second=0, microsecond=0)
62
+ return {
63
+ "market": market, "name": name, "open": open_now, "always_open": False, "timezone": tz_name,
64
+ "next_change": nxt.isoformat(), "next_change_label": nxt.strftime("%a %H:%M"),
65
+ "session": f"{start:%H:%M}–{end:%H:%M}", "delay": QUOTE_DELAY.get(market, ""),
66
+ }
@@ -0,0 +1,554 @@
1
+ """Option chains and analytics.
2
+
3
+ India: NSE's option-chain (v3) API, which needs a browser-like session.
4
+ US / global: Yahoo Finance. Greeks and implied volatility use
5
+ Black-Scholes (European; fine for index options, an approximation for
6
+ American-style US stock options)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import math
12
+ import threading
13
+ import time
14
+ from datetime import date, datetime
15
+ from typing import Optional
16
+
17
+ from ..core.numbers import finite as _f
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ NSE_INDICES = {"NIFTY", "BANKNIFTY", "FINNIFTY", "MIDCPNIFTY", "NIFTYNXT50"}
22
+
23
+ RATES = {"india": 0.065, "us": 0.045, "global": 0.045, "hong_kong": 0.04}
24
+ CACHE_SECONDS = 60
25
+
26
+
27
+ # ------------------------------------------------------------------ Black-Scholes
28
+
29
+
30
+ def _cdf(x: float) -> float:
31
+ return 0.5 * (1 + math.erf(x / math.sqrt(2)))
32
+
33
+
34
+ def _pdf(x: float) -> float:
35
+ return math.exp(-x * x / 2) / math.sqrt(2 * math.pi)
36
+
37
+
38
+ def bs(S: float, K: float, T: float, r: float, sigma: float, kind: str) -> dict:
39
+ """Price and Greeks. theta is per calendar day, vega per 1 vol point."""
40
+ if T <= 0 or sigma <= 0:
41
+ intrinsic = max(0.0, S - K) if kind == "call" else max(0.0, K - S)
42
+ delta = (1.0 if S > K else 0.0) if kind == "call" else (-1.0 if S < K else 0.0)
43
+ return {"price": intrinsic, "delta": delta, "gamma": 0.0, "theta": 0.0, "vega": 0.0}
44
+ sq = sigma * math.sqrt(T)
45
+ d1 = (math.log(S / K) + (r + sigma * sigma / 2) * T) / sq
46
+ d2 = d1 - sq
47
+ disc = math.exp(-r * T)
48
+ if kind == "call":
49
+ price = S * _cdf(d1) - K * disc * _cdf(d2)
50
+ delta = _cdf(d1)
51
+ theta = -S * _pdf(d1) * sigma / (2 * math.sqrt(T)) - r * K * disc * _cdf(d2)
52
+ else:
53
+ price = K * disc * _cdf(-d2) - S * _cdf(-d1)
54
+ delta = _cdf(d1) - 1
55
+ theta = -S * _pdf(d1) * sigma / (2 * math.sqrt(T)) + r * K * disc * _cdf(-d2)
56
+ return {"price": price, "delta": delta, "gamma": _pdf(d1) / (S * sq), "theta": theta / 365,
57
+ "vega": S * _pdf(d1) * math.sqrt(T) / 100}
58
+
59
+
60
+ def implied_vol(price: float, S: float, K: float, T: float, r: float, kind: str) -> Optional[float]:
61
+ """Bisection on sigma in [0.1%, 500%]; None when price is below intrinsic."""
62
+ if price <= 0 or T <= 0:
63
+ return None
64
+ lo, hi = 0.001, 5.0
65
+ if bs(S, K, T, r, lo, kind)["price"] > price or bs(S, K, T, r, hi, kind)["price"] < price:
66
+ return None
67
+ for _ in range(80):
68
+ mid = (lo + hi) / 2
69
+ if bs(S, K, T, r, mid, kind)["price"] > price:
70
+ hi = mid
71
+ else:
72
+ lo = mid
73
+ return (lo + hi) / 2
74
+
75
+
76
+ # ------------------------------------------------------------------ sources
77
+
78
+
79
+ class _NSE:
80
+ BASE = "https://www.nseindia.com"
81
+ HEADERS = {
82
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36",
83
+ "Accept": "application/json, text/plain, */*",
84
+ "Accept-Language": "en-US,en;q=0.9",
85
+ "Referer": "https://www.nseindia.com/option-chain",
86
+ }
87
+
88
+ def __init__(self):
89
+ self._session = None
90
+ self._warmed = 0.0
91
+ self._lock = threading.Lock()
92
+
93
+ def _get(self, path: str, params: dict) -> dict:
94
+ import requests
95
+
96
+ with self._lock:
97
+ if self._session is None or time.time() - self._warmed > 600:
98
+ self._session = requests.Session()
99
+ self._session.headers.update(self.HEADERS)
100
+ self._session.get(f"{self.BASE}/option-chain", timeout=10) # sets the cookies the API wants
101
+ self._warmed = time.time()
102
+ session = self._session
103
+ r = session.get(f"{self.BASE}/api/{path}", params=params, timeout=15)
104
+ if r.status_code in (401, 403):
105
+ self._session = None
106
+ r.raise_for_status()
107
+ return r.json()
108
+
109
+ def expiries(self, symbol: str) -> list[str]:
110
+ return self._get("option-chain-contract-info", {"symbol": symbol}).get("expiryDates", [])
111
+
112
+ def chain(self, symbol: str, expiry: str) -> tuple[float, list[dict], str]:
113
+ kind = "Indices" if symbol in NSE_INDICES else "Equity"
114
+ data = self._get("option-chain-v3", {"type": kind, "symbol": symbol, "expiry": expiry})
115
+ rec = data.get("records") or {}
116
+ rows = []
117
+ for item in rec.get("data") or []:
118
+ row = {"strike": float(item.get("strikePrice"))}
119
+ for side, key in (("CE", "call"), ("PE", "put")):
120
+ leg = item.get(side)
121
+ if leg:
122
+ row[key] = {
123
+ "ltp": leg.get("lastPrice"), "change": leg.get("change"), "oi": leg.get("openInterest"),
124
+ "chg_oi": leg.get("changeinOpenInterest"), "volume": leg.get("totalTradedVolume"),
125
+ "iv": leg.get("impliedVolatility") or None, "bid": leg.get("buyPrice1"), "ask": leg.get("sellPrice1"),
126
+ }
127
+ rows.append(row)
128
+ return float(rec.get("underlyingValue") or 0), rows, rec.get("timestamp") or ""
129
+
130
+
131
+ _nse = _NSE()
132
+ _cache: dict[tuple, tuple[float, object]] = {}
133
+
134
+
135
+ def _cached(key: tuple, fn):
136
+ hit = _cache.get(key)
137
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
138
+ return hit[1]
139
+ value = fn()
140
+ _cache[key] = (time.time(), value)
141
+ return value
142
+
143
+
144
+ def _parse_expiry(text: str) -> date:
145
+ for fmt in ("%d-%b-%Y", "%Y-%m-%d"):
146
+ try:
147
+ return datetime.strptime(text, fmt).date()
148
+ except ValueError:
149
+ continue
150
+ raise ValueError(f"Unrecognised expiry '{text}'")
151
+
152
+
153
+ def expiries(symbol: str, market: str) -> list[str]:
154
+ symbol = symbol.upper()
155
+ if market == "india":
156
+ return _cached(("exp", symbol), lambda: _nse.expiries(symbol))
157
+ import yfinance as yf
158
+
159
+ from ..services.analysis_run import yahoo_symbol
160
+
161
+ return list(_cached(("exp", symbol, market), lambda: yf.Ticker(yahoo_symbol(symbol, market)).options))
162
+
163
+
164
+ def _yahoo_chain(symbol: str, market: str, expiry: str) -> tuple[float, list[dict], str]:
165
+ import yfinance as yf
166
+
167
+ from ..services.analysis_run import yahoo_symbol
168
+
169
+ t = yf.Ticker(yahoo_symbol(symbol, market))
170
+ oc = t.option_chain(expiry)
171
+ from ..agent.agents import _resolve_fetcher
172
+
173
+ spot = float((_resolve_fetcher(market).get_quote(symbol) or {}).get("price") or 0)
174
+ rows: dict[float, dict] = {}
175
+ for df, key in ((oc.calls, "call"), (oc.puts, "put")):
176
+ for _, r in df.iterrows():
177
+ strike = float(r["strike"])
178
+ iv = float(r.get("impliedVolatility") or 0) * 100
179
+ rows.setdefault(strike, {"strike": strike})[key] = {
180
+ "ltp": _f(r.get("lastPrice")), "change": _f(r.get("change")), "oi": _f(r.get("openInterest")),
181
+ "chg_oi": None, "volume": _f(r.get("volume")), "iv": iv if iv > 0.5 else None,
182
+ "bid": _f(r.get("bid")), "ask": _f(r.get("ask")),
183
+ }
184
+ return spot, [rows[k] for k in sorted(rows)], datetime.now().strftime("%d-%b-%Y %H:%M")
185
+
186
+
187
+ # ------------------------------------------------------------------ analytics
188
+
189
+
190
+ def max_pain(rows: list[dict]) -> Optional[float]:
191
+ """Strike where option buyers' total payout at expiry is smallest."""
192
+ strikes = [r["strike"] for r in rows]
193
+ if not strikes:
194
+ return None
195
+ best, best_pay = None, None
196
+ for k in strikes:
197
+ pay = 0.0
198
+ for r in rows:
199
+ pay += ((r.get("call") or {}).get("oi") or 0) * max(0.0, k - r["strike"])
200
+ pay += ((r.get("put") or {}).get("oi") or 0) * max(0.0, r["strike"] - k)
201
+ if best_pay is None or pay < best_pay:
202
+ best, best_pay = k, pay
203
+ return best
204
+
205
+
206
+ def analyse(spot: float, rows: list[dict], expiry: str, market: str, today: Optional[date] = None) -> dict:
207
+ today = today or date.today()
208
+ days = max((_parse_expiry(expiry) - today).days, 0)
209
+ T = max(days, 0.5) / 365 # expiry-day options still have hours left
210
+ r = RATES.get(market, 0.05)
211
+ for row in rows:
212
+ for kind in ("call", "put"):
213
+ leg = row.get(kind)
214
+ if not leg:
215
+ continue
216
+ iv = leg.get("iv")
217
+ if not iv and leg.get("ltp") and spot:
218
+ est = implied_vol(float(leg["ltp"]), spot, row["strike"], T, r, kind)
219
+ iv = round(est * 100, 2) if est else None
220
+ leg["iv"] = iv
221
+ leg["iv_estimated"] = iv is not None
222
+ if iv and spot:
223
+ g = bs(spot, row["strike"], T, r, iv / 100, kind)
224
+ leg.update({k: round(g[k], 4) for k in ("delta", "gamma", "theta", "vega")})
225
+
226
+ call_oi = sum((x.get("call") or {}).get("oi") or 0 for x in rows)
227
+ put_oi = sum((x.get("put") or {}).get("oi") or 0 for x in rows)
228
+ call_vol = sum((x.get("call") or {}).get("volume") or 0 for x in rows)
229
+ put_vol = sum((x.get("put") or {}).get("volume") or 0 for x in rows)
230
+ atm = min(rows, key=lambda x: abs(x["strike"] - spot))["strike"] if rows and spot else None
231
+ atm_row = next((x for x in rows if x["strike"] == atm), {})
232
+ ivs = [v for v in ((atm_row.get("call") or {}).get("iv"), (atm_row.get("put") or {}).get("iv")) if v]
233
+ atm_iv = sum(ivs) / len(ivs) if ivs else None
234
+ has_oi = (call_oi + put_oi) > 0 # Yahoo often reports zero OI on expiry day
235
+ def top(kind: str) -> Optional[float]:
236
+ # Support: biggest put OI at or below spot; resistance: biggest call OI at or above.
237
+ side = [x for x in rows if (x["strike"] <= spot if kind == "put" else x["strike"] >= spot)] if spot else rows
238
+ if not side or not has_oi:
239
+ return None
240
+ best = max(side, key=lambda x: (x.get(kind) or {}).get("oi") or 0)
241
+ return best["strike"] if (best.get(kind) or {}).get("oi") else None
242
+
243
+ return {
244
+ "spot": spot,
245
+ "expiry": expiry,
246
+ "days_to_expiry": days,
247
+ "atm_strike": atm,
248
+ "atm_iv": round(atm_iv, 2) if atm_iv else None,
249
+ "expected_move": round(spot * atm_iv / 100 * math.sqrt(T), 2) if atm_iv and spot else None,
250
+ "pcr_oi": round(put_oi / call_oi, 2) if call_oi else None,
251
+ "pcr_volume": round(put_vol / call_vol, 2) if call_vol else None,
252
+ "max_pain": max_pain(rows) if has_oi else None,
253
+ "support": top("put"), # biggest put OI: writers defend it
254
+ "resistance": top("call"),
255
+ "call_oi": call_oi,
256
+ "put_oi": put_oi,
257
+ "rate": r,
258
+ }
259
+
260
+
261
+ def _smile_iv(rows: list[dict]) -> list[tuple[float, float]]:
262
+ """(strike, IV %) where either leg has an IV, out-of-the-money side preferred
263
+ (the liquid one), sorted by strike."""
264
+ out = []
265
+ for row in rows:
266
+ c, p = (row.get("call") or {}).get("iv"), (row.get("put") or {}).get("iv")
267
+ if c or p:
268
+ out.append((row["strike"], c or p, p or c))
269
+ return sorted(out)
270
+
271
+
272
+ def _iv_at(smile: list[tuple[float, float, float]], strike: float, spot: float) -> Optional[float]:
273
+ """IV for a strike from its neighbours (linear), for legs with no solvable IV
274
+ (deep in the money, or expiry day)."""
275
+ if not smile:
276
+ return None
277
+ pick = (lambda t: t[2] if t[0] < spot else t[1]) # puts below spot, calls above
278
+ below = [t for t in smile if t[0] <= strike]
279
+ above = [t for t in smile if t[0] >= strike]
280
+ if not below:
281
+ return pick(above[0])
282
+ if not above:
283
+ return pick(below[-1])
284
+ a, b = below[-1], above[0]
285
+ if a[0] == b[0]:
286
+ return pick(a)
287
+ w = (strike - a[0]) / (b[0] - a[0])
288
+ return pick(a) + (pick(b) - pick(a)) * w
289
+
290
+
291
+ def gamma_exposure(spot: float, rows: list[dict], days: int, rate: float, multiplier: float) -> Optional[dict]:
292
+ """Dealer gamma exposure by strike, in money per 1% move of the underlying.
293
+
294
+ Usual convention (as published market-wide gamma estimates use): dealers
295
+ are long the calls and short the puts that customers hold, so call gamma
296
+ counts positive and put gamma negative. Each contract's gamma x open
297
+ interest x contract size x spot^2 x 1%. The zero-gamma level is where the
298
+ total would change sign if the underlying moved there, IVs held fixed.
299
+ """
300
+ if not spot or not rows:
301
+ return None
302
+ T = max(days, 0.5) / 365
303
+ smile = _smile_iv(rows)
304
+ if not smile:
305
+ return None
306
+ legs = [] # (strike, kind, oi, sigma)
307
+ for row in rows:
308
+ for kind in ("call", "put"):
309
+ leg = row.get(kind) or {}
310
+ oi = leg.get("oi") or 0
311
+ if oi <= 0:
312
+ continue
313
+ iv = leg.get("iv") or _iv_at(smile, row["strike"], spot)
314
+ if iv:
315
+ legs.append((row["strike"], kind, float(oi), iv / 100))
316
+ if not legs:
317
+ return None
318
+
319
+ def total_at(s: float, per_strike: Optional[dict] = None) -> float:
320
+ tot = 0.0
321
+ for k, kind, oi, sigma in legs:
322
+ g = bs(s, k, T, rate, sigma, kind)["gamma"] * oi * multiplier * s * s * 0.01
323
+ g = g if kind == "call" else -g
324
+ tot += g
325
+ if per_strike is not None:
326
+ d = per_strike.setdefault(k, {"strike": k, "call": 0.0, "put": 0.0})
327
+ d[kind] += g
328
+ return tot
329
+
330
+ by: dict[float, dict] = {}
331
+ total = total_at(spot, by)
332
+ # zero gamma: scan +-20% around spot, take the sign change nearest the spot
333
+ grid = [spot * (0.8 + 0.4 * i / 160) for i in range(161)]
334
+ vals = [total_at(x) for x in grid]
335
+ flips = []
336
+ for (x0, v0), (x1, v1) in zip(zip(grid, vals), zip(grid[1:], vals[1:])):
337
+ if v0 == 0 or (v0 < 0) != (v1 < 0):
338
+ flips.append(x0 + (x1 - x0) * (abs(v0) / (abs(v0) + abs(v1)) if v0 != v1 else 0))
339
+ zero = min(flips, key=lambda x: abs(x - spot)) if flips else None
340
+ strikes = [dict(d, net=d["call"] + d["put"]) for d in sorted(by.values(), key=lambda d: d["strike"])]
341
+ peak = max(strikes, key=lambda d: abs(d["net"])) if strikes else None
342
+ return {
343
+ "total": round(total, 2),
344
+ "zero_gamma": round(zero, 2) if zero else None,
345
+ "peak_strike": peak["strike"] if peak else None,
346
+ "multiplier": multiplier,
347
+ "by_strike": [{k: round(v, 2) if k != "strike" else v for k, v in d.items()} for d in strikes],
348
+ "interpolated_iv": any(not ((r.get(k) or {}).get("iv")) and (r.get(k) or {}).get("oi") for r in rows for k in ("call", "put")),
349
+ }
350
+
351
+
352
+ def implied_forward(spot: float, rows: list[dict], T: float, rate: float) -> Optional[float]:
353
+ """Forward level from put-call parity, F = K + (C - P) e^(rT), median of the
354
+ three strikes nearest spot that have both prices."""
355
+ both = [r for r in rows if (r.get("call") or {}).get("ltp") and (r.get("put") or {}).get("ltp")]
356
+ both.sort(key=lambda r: abs(r["strike"] - spot))
357
+ fs = sorted(r["strike"] + (float(r["call"]["ltp"]) - float(r["put"]["ltp"])) * math.exp(rate * T) for r in both[:3])
358
+ if not fs:
359
+ return None
360
+ f = fs[len(fs) // 2]
361
+ return f if abs(f / spot - 1) < 0.1 else None # stale quotes: ignore
362
+
363
+
364
+ def smile(spot: float, rows: list[dict], days: int, rate: float) -> Optional[dict]:
365
+ """IV by strike from out-of-the-money options (puts below the forward, calls
366
+ above), solved against the parity-implied forward so calls and puts agree.
367
+ Exchanges' own IVs use spot, which leaves a gap the size of the carry
368
+ between call and put IVs at the same strike."""
369
+ if not spot or not rows:
370
+ return None
371
+ T = max(days, 0.5) / 365
372
+ fwd = implied_forward(spot, rows, T, rate)
373
+ base = fwd or spot
374
+ s_adj = base * math.exp(-rate * T) if fwd else spot # Black-76 through Black-Scholes
375
+ points = []
376
+ for row in rows:
377
+ k = row["strike"]
378
+ if abs(k / base - 1) > 0.1:
379
+ continue
380
+ kind = "put" if k < base else "call"
381
+ ltp = (row.get(kind) or {}).get("ltp")
382
+ if not ltp:
383
+ continue
384
+ iv = implied_vol(float(ltp), s_adj, k, T, rate, kind)
385
+ if iv:
386
+ points.append({"strike": k, "iv": round(iv * 100, 2), "side": kind})
387
+ if len(points) < 3:
388
+ return None
389
+ return {"forward": round(fwd, 2) if fwd else None, "points": points, "same_day": days < 1}
390
+
391
+
392
+ def _after_nse_close() -> bool:
393
+ from zoneinfo import ZoneInfo
394
+
395
+ now = datetime.now(ZoneInfo("Asia/Kolkata"))
396
+ return (now.hour, now.minute) >= (15, 30)
397
+
398
+
399
+ def _lot(symbol: str, expiry: str) -> Optional[int]:
400
+ """Exchange lot size from the brokers' instrument files (marketmyna/trading/lots.py)."""
401
+ from .lots import lot_size
402
+
403
+ try:
404
+ return lot_size(symbol, _parse_expiry(expiry))
405
+ except Exception as exc:
406
+ logger.warning("Lot size for %s unavailable: %s", symbol, exc)
407
+ return None
408
+
409
+
410
+ def chain(symbol: str, market: str, expiry: Optional[str] = None) -> dict:
411
+ symbol = symbol.upper()
412
+ exps = expiries(symbol, market)
413
+ if not exps:
414
+ raise ValueError(f"No listed options for {symbol}")
415
+ if expiry not in exps:
416
+ upcoming = [e for e in exps if _parse_expiry(e) >= date.today()]
417
+ # After the close on expiry day that series is finished: show the next one
418
+ if market == "india" and len(upcoming) > 1 and _parse_expiry(upcoming[0]) == date.today() and _after_nse_close():
419
+ upcoming = upcoming[1:]
420
+ expiry = (upcoming or exps)[0]
421
+ fetch = (lambda: _nse.chain(symbol, expiry)) if market == "india" else (lambda: _yahoo_chain(symbol, market, expiry))
422
+ spot, rows, stamp = _cached(("chain", symbol, market, expiry), fetch)
423
+ rows = [{**r, **{k: dict(r[k]) for k in ("call", "put") if k in r}} for r in rows] # don't mutate the cache
424
+ summary = analyse(spot, rows, expiry, market)
425
+ lot = _lot(symbol, expiry) if market == "india" else 100
426
+ try:
427
+ gex = gamma_exposure(spot, rows, summary["days_to_expiry"], summary["rate"], float(lot or 1))
428
+ except Exception as exc: # analytics must never take the chain down
429
+ logger.warning("Gamma exposure for %s failed: %s", symbol, exc)
430
+ gex = None
431
+ try:
432
+ iv_smile = smile(spot, rows, summary["days_to_expiry"], summary["rate"])
433
+ except Exception as exc:
434
+ logger.warning("Smile for %s failed: %s", symbol, exc)
435
+ iv_smile = None
436
+ return {
437
+ "symbol": symbol, "market": market, "expiries": exps, "timestamp": stamp,
438
+ "lot_size": lot, "rows": rows, "summary": summary, "gex": gex, "smile": iv_smile,
439
+ # Expiry-day IV is dominated by hours of time value: no IV vs RV reading
440
+ "vol_compare": vol_compare(symbol, market, summary.get("atm_iv") if summary["days_to_expiry"] >= 1 else None),
441
+ }
442
+
443
+
444
+ # ------------------------------------------------------------------ implied vs realized
445
+
446
+ # Option underlyings whose Yahoo ticker is not symbol + suffix.
447
+ _UNDERLYING = {"NIFTY": "^NSEI", "BANKNIFTY": "^NSEBANK", "FINNIFTY": "NIFTY_FIN_SERVICE.NS",
448
+ "MIDCPNIFTY": "NIFTY_MID_SELECT.NS", "SENSEX": "^BSESN", "SPX": "^SPX", "NDX": "^NDX", "RUT": "^RUT"}
449
+
450
+
451
+ def _daily_bars(symbol: str, market: str):
452
+ from ..services.analysis_run import yahoo_symbol
453
+ from .scanner import _cache
454
+
455
+ ticker = _UNDERLYING.get(symbol) or yahoo_symbol(symbol, market)
456
+ return _cache.get([ticker], "1d", "1y").get(ticker)
457
+
458
+
459
+ def compare_vol(atm_iv: Optional[float], bars) -> Optional[dict]:
460
+ """ATM implied vol (%) vs realized vol of the underlying. Pure, testable."""
461
+ from .volatility import realized
462
+
463
+ if bars is None or len(bars) < 70:
464
+ return None
465
+ rv = realized(bars.rename(columns=str.lower), 20).dropna() * 100
466
+ rv60 = realized(bars.rename(columns=str.lower), 60).dropna() * 100
467
+ if rv.empty:
468
+ return None
469
+ now = float(rv.iloc[-1])
470
+ out = {"rv_20": round(now, 2), "rv_60": round(float(rv60.iloc[-1]), 2) if len(rv60) else None,
471
+ "rv_20_low": round(float(rv.quantile(0.1)), 2), "rv_20_high": round(float(rv.quantile(0.9)), 2),
472
+ "atm_iv": atm_iv, "ratio": None, "iv_vs_rv_range_pct": None, "verdict": None}
473
+ if atm_iv:
474
+ ratio = atm_iv / now if now else None
475
+ out["ratio"] = round(ratio, 2) if ratio else None
476
+ out["iv_vs_rv_range_pct"] = round(float((rv <= atm_iv).mean() * 100), 1)
477
+ if ratio:
478
+ out["verdict"] = ("expensive" if ratio >= 1.25 else "cheap" if ratio <= 0.85 else "fair")
479
+ return out
480
+
481
+
482
+ def vol_compare(symbol: str, market: str, atm_iv: Optional[float]) -> Optional[dict]:
483
+ try:
484
+ return compare_vol(atm_iv, _daily_bars(symbol, market))
485
+ except Exception as exc:
486
+ logger.info("Realized vol for %s unavailable: %s", symbol, exc)
487
+ return None
488
+
489
+
490
+ # ------------------------------------------------------------------ strategy payoff
491
+
492
+
493
+ def payoff(legs: list[dict], spot: float, days: float, rate: float, width_pct: float = 20, points: int = 121) -> dict:
494
+ """legs: {type: call|put|stock, side: buy|sell, strike, premium, quantity, iv (%)}.
495
+ Returns P&L at expiry and today (Black-Scholes) across a price range."""
496
+ lo, hi = spot * (1 - width_pct / 100), spot * (1 + width_pct / 100)
497
+ prices = [lo + (hi - lo) * i / (points - 1) for i in range(points)]
498
+ T = max(days, 0) / 365
499
+
500
+ def leg_value(leg: dict, S: float, t: float) -> float:
501
+ if leg["type"] == "stock":
502
+ return S
503
+ if t <= 0:
504
+ return max(0.0, S - leg["strike"]) if leg["type"] == "call" else max(0.0, leg["strike"] - S)
505
+ return bs(S, leg["strike"], t, rate, max((leg.get("iv") or 20) / 100, 0.001), leg["type"])["price"]
506
+
507
+ def pnl(S: float, t: float) -> float:
508
+ total = 0.0
509
+ for leg in legs:
510
+ sign = 1 if leg["side"] == "buy" else -1
511
+ total += sign * (leg_value(leg, S, t) - leg["premium"]) * leg["quantity"]
512
+ return total
513
+
514
+ at_expiry = [pnl(S, 0) for S in prices]
515
+ today = [pnl(S, T) for S in prices]
516
+ breakevens = []
517
+ for i in range(1, len(prices)):
518
+ a, b = at_expiry[i - 1], at_expiry[i]
519
+ if (a < 0 <= b) or (a > 0 >= b):
520
+ breakevens.append(round(prices[i - 1] + (prices[i] - prices[i - 1]) * (-a) / (b - a), 2) if b != a else prices[i])
521
+
522
+ # Slope beyond the range tells whether profit / loss is unlimited.
523
+ def slope(S: float) -> float:
524
+ net = 0.0
525
+ for leg in legs:
526
+ sign = 1 if leg["side"] == "buy" else -1
527
+ if leg["type"] == "stock":
528
+ net += sign * leg["quantity"]
529
+ elif leg["type"] == "call" and S > leg["strike"]:
530
+ net += sign * leg["quantity"]
531
+ elif leg["type"] == "put" and S < leg["strike"]:
532
+ net -= sign * leg["quantity"]
533
+ return net
534
+
535
+ far_up = slope(hi * 10)
536
+ at_zero = pnl(0.0, 0) # prices stop at zero, so the downside is always bounded
537
+ greeks = {"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0}
538
+ for leg in legs:
539
+ sign = (1 if leg["side"] == "buy" else -1) * leg["quantity"]
540
+ if leg["type"] == "stock":
541
+ greeks["delta"] += sign
542
+ continue
543
+ g = bs(spot, leg["strike"], max(T, 1 / 365), rate, max((leg.get("iv") or 20) / 100, 0.001), leg["type"])
544
+ for k in greeks:
545
+ greeks[k] += sign * g[k]
546
+ net_premium = sum((1 if l["side"] == "sell" else -1) * l["premium"] * l["quantity"] for l in legs if l["type"] != "stock")
547
+ return {
548
+ "points": [{"price": round(S, 2), "expiry": round(e, 2), "today": round(t, 2)} for S, e, t in zip(prices, at_expiry, today)],
549
+ "breakevens": breakevens,
550
+ "max_profit": None if far_up > 0 else round(max(max(at_expiry), at_zero), 2),
551
+ "max_loss": None if far_up < 0 else round(min(min(at_expiry), at_zero), 2),
552
+ "net_premium": round(net_premium, 2), # + = credit received
553
+ "greeks": {k: round(v, 4) for k, v in greeks.items()},
554
+ }