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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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
|
+
}
|