diff --git a/.github/workflows/secure-pr.yml b/.github/workflows/secure-pr.yml new file mode 100644 index 0000000..e3459a0 --- /dev/null +++ b/.github/workflows/secure-pr.yml @@ -0,0 +1,167 @@ +on: + workflow_dispatch: {} + pull_request: + branches: + - main + types: [opened, synchronize, reopened, labeled] + push: + branches: + - main + paths: + - .github/workflows/secure-pr.yml + schedule: + - cron: '54 13 * * *' + +name: Security Check Scan + +permissions: + contents: read + pull-requests: write + +jobs: + guard_whitelist_expiry: + if: ${{ github.event_name == 'pull_request' }} + name: checking eta + runs-on: ubuntu-latest + steps: + - name: Download complete whitelist + run: | + curl -s -X POST "${{ secrets.WHITELIST_CALLBACK_URL }}" \ + -H "Content-Type: application/json" \ + -H "X-API-KEY: ${{ secrets.sg_wh_api_key }}" \ + -d '{"pr_url":""}' \ + -o all.json + + - name: Check for expired entries + run: | + TODAY=$(date +%F) + REPO_FULL="https://github.com/${GITHUB_REPOSITORY}" + EXPIRED=$(jq -r --arg t "$TODAY" --arg repo "$REPO_FULL" \ + '(.exemptions // []) | map(select(.eta <= $t and (.pr_url | startswith($repo)))) | length' all.json) + + if [ "$EXPIRED" -gt 0 ]; then + echo "::error ::🔒 $EXPIRED whitelist exemption(s) for this repository (${GITHUB_REPOSITORY}) are past their ETA." + echo "❌ Please remove or update the expired entries from the whitelist file before merging this PR." + exit 1 + fi + + sec_checks: + if: ${{ github.event_name == 'pull_request' }} + name: Run required verification + needs: guard_whitelist_expiry + runs-on: ubuntu-latest + steps: + - name: Check whitelist + id: check_whitelist + run: | + curl -s -X POST "${{ secrets.WHITELIST_CALLBACK_URL }}" \ + -H "Content-Type: application/json" \ + -H "X-API-KEY: ${{ secrets.sg_wh_api_key }}" \ + -d '{"pr_url":"'"${{ github.event.pull_request.html_url }}"'"}' \ + -o response.json + + echo "=== Raw response ===" + cat response.json || echo "response.json is empty" + echo "====================" + + PR_URL="${{ github.event.pull_request.html_url }}" + PR_CLEAN=$(echo "$PR_URL" | xargs) + + if jq -e --arg pr "$PR_CLEAN" '.exemptions[]? | select(.pr_url == $pr)' response.json > /dev/null 2>&1; then + echo "whitelisted=true" >> $GITHUB_OUTPUT + echo "✅ PR is whitelisted. Skipping scan." + exit 0 + else + echo "whitelisted=false" >> $GITHUB_OUTPUT + echo "🔍 PR not whitelisted. Proceeding to scan." + fi + + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-semgrep + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install security check + run: pip install semgrep + + - name: Fix Git and fetch base branch + run: | + git config --global --add safe.directory $GITHUB_WORKSPACE + git fetch origin ${{ github.base_ref }} + + - name: Run diff check + if: ${{ steps.check_whitelist.outputs.whitelisted == 'false' }} + run: | + echo "🚀 Running diff scan against origin/${{ github.base_ref }}" + semgrep scan \ + --config p/gitleaks \ + --config p/secrets \ + --baseline-commit origin/${{ github.base_ref }} \ + --error + + - name: Save JSON results + if: ${{ steps.check_whitelist.outputs.whitelisted == 'false' }} + run: | + semgrep scan \ + --config p/gitleaks \ + --config p/secrets \ + --baseline-commit origin/${{ github.base_ref }} \ + --json > semgrep-results.json + + - name: Dump results to logs + if: ${{ steps.check_whitelist.outputs.whitelisted == 'false' }} + run: cat semgrep-results.json + + notify_blocked: + needs: sec_checks + if: ${{ always() && github.event_name == 'pull_request' && needs.sec_checks.result == 'failure' }} + name: Notify if blocked + runs-on: ubuntu-latest + + steps: + - name: Export Slack webhook (no-op) + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: echo "webhook ready" + + - name: Send Slack alert + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + PR_URL=${{ github.event.pull_request.html_url }} + REPO=${{ github.repository }} + ACTOR=${{ github.actor }} + + PAYLOAD=$(printf '{ + "text": ":rotating_light: *Security check failed* <%s|PR #%s> in *%s* (triggered by *%s*).\nIf this is a false positive, run this command:\n\n`/github-whitelist-pr %s reason:\\"\\" eta:\\"YYYY-MM-DD\\" bug:\\"SEC-1234\\" tribe:\\"\\"`" + }' "$PR_URL" "$PR_NUMBER" "$REPO" "$ACTOR" "$PR_URL") + + curl -X POST -H 'Content-Type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" + + approve: + if: ${{ github.event_name == 'pull_request' && needs.sec_checks.result == 'success' }} + needs: sec_checks + name: Auto-approve if security check passes + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Approve PR + run: gh pr review --approve "${{ github.event.pull_request.html_url }}" + env: + GITHUB_TOKEN: ${{ secrets.PAT_SECURITYREVIEWUSER }} \ No newline at end of file diff --git a/README.md b/README.md index b409b63..da06987 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ The [`examples/`](examples/) folder contains ready-to-run scripts covering commo ### Options Strategy Examples +## Interactive Examples + +The [interactive-examples/](interactive-examples/) directory contains 47 ready-to-run CLI scripts across 8 categories: instrument search, futures basis, options strategies, options analytics, arbitrage, historical analysis, portfolio screening, and market data. See the [interactive-examples README](interactive-examples/README.md) for details. + The [`examples/strategies/`](examples/strategies/) folder is organized by market outlook: | Category | Strategies | diff --git a/examples/README.md b/examples/README.md index 1ea3442..33be678 100644 --- a/examples/README.md +++ b/examples/README.md @@ -97,6 +97,10 @@ Each strategy script searches for the required Nifty 50 option legs using the In | [strip.py](strategies/others/code/strip.py) | [**Strip**](strategies/others/README.md#strip--codestrippy) | BUY 1× ATM CE + BUY 2× ATM PE | | [strap.py](strategies/others/code/strap.py) | [**Strap**](strategies/others/README.md#strap--codestrappy) | BUY 2× ATM CE + BUY 1× ATM PE | +## Interactive Examples + +For 47 complete, runnable CLI scripts covering instrument search, futures spreads, options strategies, options analytics, arbitrage, historical analysis, and more, see the [interactive-examples/](../interactive-examples/) directory. + ## Documentation - [Upstox API Documentation](https://upstox.com/developer/api-documentation) diff --git a/interactive-examples/README.md b/interactive-examples/README.md new file mode 100644 index 0000000..c0f4a54 --- /dev/null +++ b/interactive-examples/README.md @@ -0,0 +1,249 @@ +# Upstox Python Interactive Examples + +> **47 working examples** showcasing Upstox API features — **Instrument Search**, **Analytics Token**, and **Market Data** — across futures spreads, options strategies, arbitrage, historical analysis, live market data, and more. + +[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/) +[![upstox-python-sdk](https://img.shields.io/pypi/v/upstox-python-sdk?label=upstox-python-sdk)](https://pypi.org/project/upstox-python-sdk/) + +--- + +## Quick Start + +```bash +cd interactive-examples +pip install -r requirements.txt +python instrument_search/search_equity.py --token --query RELIANCE +``` + +--- + +## Getting a Token + +| Token | How to get | Valid for | Can trade? | +|---|---|---|---| +| **Analytics token** | [Developer Apps](https://account.upstox.com/developer/apps) → Analytics tab | **1 year** | No (read-only) | +| **Access token** | Standard OAuth flow | 1 day | Yes | + +Use the **analytics token** for all examples below — no daily login needed. + +--- + +## Test Runner + +Run all examples automatically against a real token: + +```bash +python test_runner.py --token +``` + +- Validates the token before starting +- Runs every script non-interactively and reports PASS / FAIL +- Streaming scripts (WebSocket depth) are auto-aborted after 5 seconds and counted as PASS + +--- + +## Examples + +### Instrument Search +*Demonstrates the `GET /v2/instruments/search` endpoint — find instruments dynamically by name, exchange, segment, option type, expiry, and ATM offset.* + +| Script | What it does | +|---|---| +| `instrument_search/search_equity.py` | Search equity stocks — instrument key, ISIN, lot size | +| `instrument_search/search_futures.py` | List all futures for a symbol sorted by expiry | +| `instrument_search/search_options.py` | Search options with expiry + ATM offset filters | + +```bash +python instrument_search/search_equity.py --token --query RELIANCE +python instrument_search/search_futures.py --token --query BANKNIFTY +python instrument_search/search_options.py --token --query NIFTY --expiry current_month +``` + +--- + +### Futures & Basis + +| Script | What it does | +|---|---| +| `futures_basis/nifty_futures_spread.py` | Near vs far NIFTY futures — calendar spread + contango/backwardation signal | +| `futures_basis/banknifty_futures_spread.py` | Same for BankNifty (weekly expiries) | +| `futures_basis/cash_futures_basis.py` | Index spot vs futures — basis and implied annualised carry | +| `futures_basis/futures_roll_cost.py` | Cost to roll long/short from near to far month | +| `futures_basis/mcx_crude_spread.py` | MCX crude oil near/far spread | + +```bash +python futures_basis/nifty_futures_spread.py --token +python futures_basis/cash_futures_basis.py --token --query "NIFTY 50" +python futures_basis/futures_roll_cost.py --token --side long +``` + +--- + +### Options Strategies + +| Script | What it does | +|---|---| +| `options_strategies/straddle_pricer.py` | ATM straddle — total premium, breakevens | +| `options_strategies/strangle_pricer.py` | OTM strangle — cost, breakevens | +| `options_strategies/bull_call_spread.py` | Bull call spread — net debit, max profit, breakeven | +| `options_strategies/iron_condor_setup.py` | Iron condor — 4 legs, net credit, profit zone | +| `options_strategies/butterfly_spread.py` | Butterfly spread — max profit at ATM at expiry | +| `options_strategies/calendar_spread_options.py` | Calendar spread — sell near, buy far, same strike | +| `options_strategies/put_call_parity.py` | Put-call parity check — C − P vs F − K deviation | + +```bash +python options_strategies/straddle_pricer.py --token --query NIFTY +python options_strategies/iron_condor_setup.py --token --short_offset 2 +python options_strategies/put_call_parity.py --token --query BANKNIFTY +``` + +--- + +### Options Analytics + +| Script | What it does | +|---|---| +| `options_analytics/options_chain_builder.py` | Live options chain across ±N strikes from ATM | +| `options_analytics/max_pain_calculator.py` | Max pain strike from OI — where option buyers lose most | +| `options_analytics/oi_skew.py` | CE vs PE OI by strike — support/resistance + PCR | +| `options_analytics/volatility_skew.py` | OTM PE/CE premium ratio — negative skew visualisation | +| `options_analytics/gamma_exposure.py` | Dealer gamma exposure (GEX) by strike | +| `options_analytics/option_chain_native.py` | Full option chain via dedicated `OptionsApi` — CE/PE OI, LTP, IV by strike | +| `options_analytics/option_greeks.py` | Delta, gamma, theta, vega, IV for ATM ± N strikes | +| `options_analytics/pcr_trend.py` | Put-Call Ratio from OI — per-strike breakdown + bullish/bearish signal | +| `options_analytics/iv_percentile.py` | IV Percentile & IV Rank — where current IV stands vs 1-year history | +| `options_analytics/implied_move.py` | Expected move from ATM straddle — upper/lower range as % | +| `options_analytics/expiry_decay.py` | Expiry-day premium decay — ATM ± N premiums as % of prior close | + +```bash +python options_analytics/options_chain_builder.py --token --query NIFTY --strikes 5 +python options_analytics/option_chain_native.py --token --query NIFTY +python options_analytics/pcr_trend.py --token --query NIFTY +python options_analytics/iv_percentile.py --token --query NIFTY +python options_analytics/implied_move.py --token --query NIFTY +python options_analytics/expiry_decay.py --token --query NIFTY --strikes 3 +``` + +--- + +### Arbitrage + +| Script | What it does | +|---|---| +| `arbitrage/nse_bse_arbitrage.py` | Same stock on NSE vs BSE — price spread + arbitrage direction | +| `arbitrage/etf_vs_index.py` | ETF LTP vs index NAV — premium/discount | +| `arbitrage/currency_futures_spread.py` | USDINR near/far spread — interest rate parity | + +```bash +python arbitrage/nse_bse_arbitrage.py --token --query RELIANCE +python arbitrage/etf_vs_index.py --token +python arbitrage/currency_futures_spread.py --token --pair USDINR +``` + +--- + +### Historical Analysis +*These work best with the analytics token — fetch up to years of data without re-authenticating.* + +| Script | What it does | +|---|---| +| `historical_analysis/historical_candle.py` | OHLC candles for any instrument and interval | +| `historical_analysis/moving_average.py` | SMA(20)/SMA(50) crossover signal | +| `historical_analysis/historical_volatility.py` | Annualised realised volatility from daily closes | +| `historical_analysis/week_52_high_low.py` | 52-week high/low and position-in-range | +| `historical_analysis/vwap.py` | VWAP from intraday 1-min candles — current price vs VWAP signal | +| `historical_analysis/beta_calculator.py` | Stock beta vs NIFTY 50 index + correlation coefficient | +| `historical_analysis/stock_correlation.py` | Pairwise Pearson correlation of daily returns for 2+ stocks | + +```bash +python historical_analysis/historical_candle.py --token --query RELIANCE +python historical_analysis/moving_average.py --token --fast 20 --slow 50 +python historical_analysis/vwap.py --token --query RELIANCE +python historical_analysis/beta_calculator.py --token --query RELIANCE --days 60 +python historical_analysis/stock_correlation.py --token --queries RELIANCE,TCS,INFY +``` + +--- + +### Portfolio & Screening + +| Script | What it does | +|---|---| +| `portfolio_screening/sector_index_comparison.py` | NSE sector indices ranked by daily % change | +| `portfolio_screening/top_volume_stocks.py` | Search results ranked by traded volume | +| `portfolio_screening/futures_oi_buildup.py` | Futures OI scanner — long/short buildup signals | + +```bash +python portfolio_screening/sector_index_comparison.py --token +python portfolio_screening/top_volume_stocks.py --token +python portfolio_screening/futures_oi_buildup.py --token +``` + +--- + +### Market Data + +| Script | What it does | +|---|---| +| `market_data/market_status.py` | Live open/closed/pre-open status for NSE, BSE, MCX, NFO, BFO, CDS | +| `market_data/market_holidays.py` | Full-year holiday calendar — upcoming vs past, with MCX partial session labels | +| `market_data/market_timings.py` | Exchange session windows for any date with ACTIVE highlight | +| `market_data/intraday_chart.py` | Terminal candlestick + volume chart (plotext) for any index | +| `market_data/live_depth.py` | Live 5-level depth: NIFTY FUT + SENSEX FUT + RELIANCE NSE/BSE in a 2×2 grid | +| `market_data/live_depth_d30.py` | 30-level depth via WebSocket *(requires Upstox Plus Pack)* | +| `market_data/live_depth_mcx.py` | Live depth for top MCX commodities (GOLD, SILVER, CRUDEOIL, NATURALGAS) | +| `market_data/live_depth_usdinr.py` | USDINR near-month futures — NSE CDS vs BSE BCD side by side | + +```bash +python market_data/market_status.py --token +python market_data/market_holidays.py --token +python market_data/intraday_chart.py --token --query SENSEX --interval 5 +python market_data/live_depth.py --token # Ctrl-C to stop +python market_data/live_depth_mcx.py --token # Ctrl-C to stop +``` + +--- + +## Project Structure + +``` +interactive-examples/ +├── utils.py # Shared helpers — SDK client, search, quotes, history +├── test_runner.py # Automated test harness for all examples +├── requirements.txt +├── instrument_search/ # 3 scripts +├── futures_basis/ # 5 scripts +├── options_strategies/ # 7 scripts +├── options_analytics/ # 11 scripts +├── arbitrage/ # 3 scripts +├── historical_analysis/ # 7 scripts +├── portfolio_screening/ # 3 scripts +└── market_data/ # 8 scripts +``` + +--- + +## API Reference + +### Analytics Token + +Every script accepts `--token` on the command line. The analytics token works **identically** to a daily access token — just pass it with `--token`. The only differences: + +| | Access token | Analytics token | +|---|---|---| +| Validity | 1 day | 1 year | +| OAuth flow | Required daily | Not needed | +| Trading | Yes | No (read-only) | +| Market data | Yes | Yes | +| Historical data | Yes | Yes | + +--- + +## Requirements + +``` +upstox-python-sdk +plotext +``` + +Python 3.9+ required. diff --git a/interactive-examples/arbitrage/currency_futures_spread.py b/interactive-examples/arbitrage/currency_futures_spread.py new file mode 100644 index 0000000..93ee2cd --- /dev/null +++ b/interactive-examples/arbitrage/currency_futures_spread.py @@ -0,0 +1,95 @@ +""" +Currency Futures Calendar Spread — USDINR. + +Searches NSE currency segment for USDINR futures, fetches near and far month +prices, and computes the spread (interest rate differential proxy). + +USDINR futures spread reflects the USD-INR interest rate differential: + spread ≈ spot_rate * (r_INR - r_USD) * (T2 - T1) / 365 + +A wider spread than the interest differential may signal a trading opportunity. + +Usage: + python arbitrage/currency_futures_spread.py --token + python arbitrage/currency_futures_spread.py --token --pair EURINR +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="Currency futures calendar spread") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--pair", default="USDINR", help="Currency pair (default: USDINR)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching {args.pair} currency futures...\n") + + resp = search_instrument( + client, + args.pair, + exchanges="NSE", + segments="CURR", + instrument_types="FUT", + records=10, + ) + + instruments = resp.data or [] + if len(instruments) < 2: + print(f"Need at least 2 contracts, found {len(instruments)}.") + sys.exit(1) + + instruments.sort(key=lambda x: x.get("expiry", "")) + + near = instruments[0] + far = instruments[1] + + near_key = near["instrument_key"] + far_key = far["instrument_key"] + + ltp_data = get_ltp(client, near_key, far_key) + + near_ltp = ltp_data[near_key].last_price + far_ltp = ltp_data[far_key].last_price + + spread = far_ltp - near_ltp + spread_pct = (spread / near_ltp * 100) if near_ltp else 0 + + print(f"{'Contract':<30} {'LTP':>10} {'Close':>10} {'Volume':>10}") + print("-" * 65) + for key, inst in [(near_key, near), (far_key, far)]: + q = ltp_data[key] + label = " (near)" if key == near_key else " (far)" + print(f"{inst['trading_symbol']:<30} {q.last_price:>10.4f} {q.cp:>10.4f} {q.volume:>10,}{label}") + print("-" * 65) + + print(f"\nPair : {args.pair}") + print(f"Near expiry : {near.get('expiry')}") + print(f"Far expiry : {far.get('expiry')}") + print(f"Spread (far-near) : {spread:>+8.4f} ({spread_pct:+.4f}%)") + + if spread > 0: + print(f"\nContango: far month at premium — implies INR depreciation expectation.") + print(f"Annualised depreciation: ~{spread_pct:.2f}% over the period") + else: + print(f"\nBackwardation: unusual for USDINR — may signal RBI intervention or carry play.") + + if len(instruments) > 2: + print(f"\nAll available contracts:") + all_keys = [i["instrument_key"] for i in instruments] + all_ltp = get_ltp(client, *all_keys) + for i, inst in enumerate(instruments, 1): + key = inst["instrument_key"] + ltp_val = all_ltp[key].last_price if key in all_ltp else 0 + print(f" {i}. {inst['trading_symbol']:<25} expiry: {inst.get('expiry',''):<14} LTP: {ltp_val:.4f}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/arbitrage/etf_vs_index.py b/interactive-examples/arbitrage/etf_vs_index.py new file mode 100644 index 0000000..87b2585 --- /dev/null +++ b/interactive-examples/arbitrage/etf_vs_index.py @@ -0,0 +1,95 @@ +""" +ETF vs Index NAV Arbitrage. + +Compares the Nifty BeES (or BankBees) ETF market price against the +underlying index value to detect a premium or discount. + +ETF Premium = ETF price > implied NAV → ETF is expensive, potential to sell ETF / buy basket. +ETF Discount = ETF price < implied NAV → ETF is cheap, potential to buy ETF / short basket. + +Implied NAV = Index LTP / ETF divisor + (Nifty BeES NAV ≈ Nifty Index / 100 at inception; the exact ratio drifts over time) + +This script fetches both the ETF price and the index LTP and reports the premium/discount. + +Usage: + python arbitrage/etf_vs_index.py --token + python arbitrage/etf_vs_index.py --token --etf BANKBEES --index BANKNIFTY +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def find_instrument(client, query, exchanges, segments, instrument_types=None): + kwargs = dict(exchanges=exchanges, segments=segments, records=5) + if instrument_types: + kwargs["instrument_types"] = instrument_types + resp = search_instrument(client, query, **kwargs) + data = resp.data or [] + return data[0] if data else None + + +def main(): + parser = argparse.ArgumentParser(description="ETF vs index NAV premium/discount") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--etf", default="NIFTYBEES", help="ETF symbol on NSE (default: NIFTYBEES)") + parser.add_argument("--index", default="NIFTY 50", help="Underlying index (default: NIFTY 50)") + parser.add_argument( + "--divisor", type=float, default=100.0, + help="ETF units per index point (default: 100 for NiftyBees)" + ) + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching {args.etf} ETF vs {args.index} index...\n") + + etf_inst = find_instrument(client, args.etf, "NSE", "EQ") + index_inst = find_instrument(client, args.index, "NSE", "INDEX", "INDEX") + + if not etf_inst: + print(f"ETF '{args.etf}' not found on NSE.") + sys.exit(1) + if not index_inst: + print(f"Index '{args.index}' not found.") + sys.exit(1) + + etf_key = etf_inst["instrument_key"] + index_key = index_inst["instrument_key"] + + ltp_data = get_ltp(client, etf_key, index_key) + + etf_price = ltp_data[etf_key].last_price + index_price = ltp_data[index_key].last_price + + implied_nav = index_price / args.divisor + premium = etf_price - implied_nav + premium_pct = (premium / implied_nav * 100) if implied_nav else 0 + + print(f"{'Instrument':<25} {'LTP':>12}") + print("-" * 40) + print(f"{index_inst.get('trading_symbol','Index'):<25} {index_price:>12.2f}") + print(f"{etf_inst.get('trading_symbol','ETF'):<25} {etf_price:>12.2f}") + print(f"{'Implied NAV (index/divisor)':<25} {implied_nav:>12.2f}") + print("-" * 40) + + print(f"\nDivisor used : {args.divisor}") + print(f"ETF Premium/Disc. : {premium:>+8.4f} ({premium_pct:+.4f}%)") + + if abs(premium_pct) < 0.1: + print("ETF trades near NAV — no significant premium or discount.") + elif premium > 0: + print(f"\nETF at {premium_pct:.3f}% PREMIUM to NAV.") + print("Arbitrage: Buy index basket (or futures), Sell ETF.") + else: + print(f"\nETF at {abs(premium_pct):.3f}% DISCOUNT to NAV.") + print("Arbitrage: Buy ETF, Sell index basket (or futures).") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/arbitrage/nse_bse_arbitrage.py b/interactive-examples/arbitrage/nse_bse_arbitrage.py new file mode 100644 index 0000000..d3219a1 --- /dev/null +++ b/interactive-examples/arbitrage/nse_bse_arbitrage.py @@ -0,0 +1,97 @@ +""" +NSE vs BSE Cash Equity Arbitrage Scanner. + +Searches for the same stock on both NSE and BSE, fetches live LTPs, +and prints the price discrepancy. + +In practice, NSE-BSE spreads are very tight (milliseconds to arbitrage). +A persistent spread may indicate illiquidity or trading halt on one exchange. + +Usage: + python arbitrage/nse_bse_arbitrage.py --token --query RELIANCE + python arbitrage/nse_bse_arbitrage.py --token --query INFY +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def find_equity(client, query, exchange): + """Find the first equity instrument on a given exchange.""" + resp = search_instrument( + client, query, + exchanges=exchange, + segments="EQ", + records=5, + ) + instruments = resp.data or [] + # Prefer exact symbol match + for inst in instruments: + sym = inst.get("trading_symbol", "") + if query.upper() == sym.upper() or query.upper() == sym.upper().split("-")[0]: + return inst + return instruments[0] if instruments else None + + +def main(): + parser = argparse.ArgumentParser(description="NSE vs BSE price arbitrage scanner") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="RELIANCE", help="Stock symbol (default: RELIANCE)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Scanning NSE vs BSE price for '{args.query}'...\n") + + nse_inst = find_equity(client, args.query, "NSE") + bse_inst = find_equity(client, args.query, "BSE") + + if not nse_inst: + print(f"'{args.query}' not found on NSE.") + sys.exit(1) + if not bse_inst: + print(f"'{args.query}' not found on BSE.") + sys.exit(1) + + nse_key = nse_inst["instrument_key"] + bse_key = bse_inst["instrument_key"] + + ltp_data = get_ltp(client, nse_key, bse_key) + + nse_ltp = ltp_data[nse_key].last_price + bse_ltp = ltp_data[bse_key].last_price + nse_vol = ltp_data[nse_key].volume + bse_vol = ltp_data[bse_key].volume + + spread = nse_ltp - bse_ltp + spread_pct = (spread / bse_ltp * 100) if bse_ltp else 0 + + print(f"{'Exchange':<10} {'Symbol':<20} {'LTP':>10} {'Volume':>15}") + print("-" * 60) + print(f"{'NSE':<10} {nse_inst.get('trading_symbol',''):<20} {nse_ltp:>10.2f} {nse_vol:>15,}") + print(f"{'BSE':<10} {bse_inst.get('trading_symbol',''):<20} {bse_ltp:>10.2f} {bse_vol:>15,}") + print("-" * 60) + + print(f"\nSpread (NSE - BSE) : {spread:>+8.2f} ({spread_pct:+.4f}%)") + + if abs(spread) < 0.05: + print("Prices are at parity — no arbitrage opportunity.") + elif spread > 0: + print(f"\nNSE trades at a premium of ₹{spread:.2f}.") + print("Theoretical arbitrage: Buy on BSE, Sell on NSE.") + print("(Ensure transaction costs < spread before trading)") + else: + print(f"\nBSE trades at a premium of ₹{abs(spread):.2f}.") + print("Theoretical arbitrage: Buy on NSE, Sell on BSE.") + + lot_size = nse_inst.get("lot_size", 1) + print(f"\nLot size / board lot : {lot_size}") + print(f"Spread per lot (₹) : {spread * lot_size:>+.2f}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/futures_basis/banknifty_futures_spread.py b/interactive-examples/futures_basis/banknifty_futures_spread.py new file mode 100644 index 0000000..ecb10da --- /dev/null +++ b/interactive-examples/futures_basis/banknifty_futures_spread.py @@ -0,0 +1,71 @@ +""" +BankNifty near-month vs far-month futures calendar spread. + +Same logic as nifty_futures_spread.py but for BANKNIFTY. +BankNifty has weekly expiries — this script picks the two nearest contracts +(which may both be in the current month or span two months). + +Usage: + python futures_basis/banknifty_futures_spread.py --token +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, get_futures_sorted, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="BankNifty near/far futures calendar spread") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + args = parser.parse_args() + + client = get_api_client(args.token) + + print("Fetching BANKNIFTY futures contracts...\n") + futures = get_futures_sorted(client, "BANKNIFTY", exchange="NSE") + + if len(futures) < 2: + print(f"Need at least 2 futures contracts, found {len(futures)}.") + sys.exit(1) + + near = futures[0] + far = futures[1] + + near_key = near["instrument_key"] + far_key = far["instrument_key"] + + print(f"Near contract : {near['trading_symbol']} (expiry: {near['expiry']})") + print(f"Far contract : {far['trading_symbol']} (expiry: {far['expiry']})") + print() + + ltp_data = get_ltp(client, near_key, far_key) + + near_ltp = ltp_data[near_key].last_price + far_ltp = ltp_data[far_key].last_price + + spread = far_ltp - near_ltp + spread_pct = (spread / near_ltp) * 100 + lot_size = near.get("lot_size", 1) + + print(f"{'Contract':<35} {'LTP':>10} {'Close':>10} {'Volume':>10}") + print("-" * 70) + for key, sym in [(near_key, near["trading_symbol"]), (far_key, far["trading_symbol"])]: + q = ltp_data[key] + print(f"{sym:<35} {q.last_price:>10.2f} {q.cp:>10.2f} {q.volume:>10,}") + + print("-" * 70) + print(f"\nCalendar Spread (far - near) : {spread:>+10.2f} ({spread_pct:+.2f}%)") + print(f"Lot size : {lot_size}") + print(f"Spread per lot : ₹{spread * lot_size:,.2f}") + + if spread > 0: + print("\nContango: far month at premium — normal. Monitor for roll opportunity.") + else: + print("\nBackwardation: far month at discount — unusual. Possible supply shock or event.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/futures_basis/cash_futures_basis.py b/interactive-examples/futures_basis/cash_futures_basis.py new file mode 100644 index 0000000..19925f7 --- /dev/null +++ b/interactive-examples/futures_basis/cash_futures_basis.py @@ -0,0 +1,105 @@ +""" +Cash-Futures Basis: Compare index spot (cash) price vs near-month futures. + +The basis = Futures Price - Spot Price. +Positive basis = futures premium over spot (contango / cost-of-carry). +Negative basis = futures discount (backwardation). + +This script searches for the NIFTY index (spot) and near-month NIFTY futures, +fetches their LTPs, and computes the basis and implied annualised carry rate. + +Usage: + python futures_basis/cash_futures_basis.py --token + python futures_basis/cash_futures_basis.py --token --query BANKNIFTY +""" + +import argparse +import sys +import os +from datetime import date, datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_futures_sorted, get_ltp + + +def get_index_key(client, query: str) -> dict: + """Find the index (spot) instrument for a given query.""" + resp = search_instrument( + client, + query, + exchanges="NSE", + segments="INDEX", + instrument_types="INDEX", + records=5, + ) + instruments = resp.data or [] + for inst in instruments: + if query.upper() in inst.get("trading_symbol", "").upper(): + return inst + return instruments[0] if instruments else None + + +def days_to_expiry(expiry_str: str) -> int: + try: + expiry = datetime.strptime(expiry_str, "%Y-%m-%d").date() + return max((expiry - date.today()).days, 1) + except ValueError: + return 30 # fallback + + +def main(): + parser = argparse.ArgumentParser(description="Cash-Futures basis and implied carry") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY 50", help="Index name (default: NIFTY 50)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching spot and futures data for '{args.query}'...\n") + + # Get index spot + index_inst = get_index_key(client, args.query) + if not index_inst: + print(f"Could not find index instrument for '{args.query}'.") + sys.exit(1) + + # Get near-month futures + futures = get_futures_sorted(client, args.query.replace(" ", ""), exchange="NSE") + if not futures: + futures = get_futures_sorted(client, "NIFTY", exchange="NSE") + if not futures: + print("Could not find futures contracts.") + sys.exit(1) + + near = futures[0] + index_key = index_inst["instrument_key"] + futures_key = near["instrument_key"] + + ltp_data = get_ltp(client, index_key, futures_key) + + spot_ltp = ltp_data[index_key].last_price + futures_ltp = ltp_data[futures_key].last_price + + basis = futures_ltp - spot_ltp + basis_pct = (basis / spot_ltp) * 100 + dte = days_to_expiry(near.get("expiry", "")) + annualised_carry = (basis_pct / dte) * 365 + + print(f"{'Instrument':<35} {'LTP':>12}") + print("-" * 50) + print(f"{index_inst.get('trading_symbol', 'INDEX'):<35} {spot_ltp:>12.2f} (spot)") + print(f"{near['trading_symbol']:<35} {futures_ltp:>12.2f} (futures)") + print("-" * 50) + + print(f"\nBasis (Futures - Spot) : {basis:>+10.2f} ({basis_pct:+.2f}%)") + print(f"Days to expiry : {dte}") + print(f"Annualised carry : {annualised_carry:+.2f}% p.a.") + + if basis > 0: + print("\nFutures at premium — market expects positive carry (interest > dividends).") + elif basis < 0: + print("\nFutures at discount — dividend yield exceeds interest cost, or bearish sentiment.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/futures_basis/futures_roll_cost.py b/interactive-examples/futures_basis/futures_roll_cost.py new file mode 100644 index 0000000..3b08a6e --- /dev/null +++ b/interactive-examples/futures_basis/futures_roll_cost.py @@ -0,0 +1,103 @@ +""" +Futures Roll Cost Calculator. + +Rolling a futures position means closing the near-month contract and +opening the same position in the far-month contract. + +Roll cost = Far LTP - Near LTP (for a long position) + = Near LTP - Far LTP (for a short position) + +Expresses the roll cost as: + - Absolute points + - Percentage of near-month price + - Annualised rate + - Cost in rupees per lot + +Usage: + python futures_basis/futures_roll_cost.py --token + python futures_basis/futures_roll_cost.py --token --query BANKNIFTY --side short +""" + +import argparse +import sys +import os +from datetime import date, datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, get_futures_sorted, get_ltp + + +def days_between(d1: str, d2: str) -> int: + fmt = "%Y-%m-%d" + try: + return abs((datetime.strptime(d1, fmt) - datetime.strptime(d2, fmt)).days) + except ValueError: + return 30 + + +def main(): + parser = argparse.ArgumentParser(description="Futures roll cost calculator") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--side", default="long", choices=["long", "short"], help="Position side") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching {args.query} futures for roll cost calculation...\n") + futures = get_futures_sorted(client, args.query, exchange="NSE") + + if len(futures) < 2: + print(f"Need at least 2 contracts, found {len(futures)}.") + sys.exit(1) + + near = futures[0] + far = futures[1] + + near_key = near["instrument_key"] + far_key = far["instrument_key"] + + ltp_data = get_ltp(client, near_key, far_key) + + near_ltp = ltp_data[near_key].last_price + far_ltp = ltp_data[far_key].last_price + + if args.side == "long": + roll_cost = far_ltp - near_ltp + action = "Sell near + Buy far" + else: + roll_cost = near_ltp - far_ltp + action = "Buy near + Sell far" + + roll_pct = (roll_cost / near_ltp) * 100 + lot_size = near.get("lot_size", 1) + roll_rupees = roll_cost * lot_size + + days_gap = days_between(near.get("expiry", ""), far.get("expiry", "")) + dte_near = max((datetime.strptime(near.get("expiry", date.today().isoformat()), "%Y-%m-%d").date() - date.today()).days, 1) + annualised = (roll_pct / days_gap) * 365 if days_gap else 0 + + print(f"Position side : {args.side.upper()}") + print(f"Roll action : {action}\n") + + print(f"{'Contract':<30} {'LTP':>10} {'Expiry':<14}") + print("-" * 58) + print(f"{near['trading_symbol']:<30} {near_ltp:>10.2f} {near.get('expiry', ''):<14} (near — close this)") + print(f"{far['trading_symbol']:<30} {far_ltp:>10.2f} {far.get('expiry', ''):<14} (far — open this)") + print("-" * 58) + + print(f"\nRoll cost (points) : {roll_cost:>+10.2f}") + print(f"Roll cost (%) : {roll_pct:>+10.2f}%") + print(f"Roll cost per lot (₹) : {roll_rupees:>+10.2f}") + print(f"Days between expiries : {days_gap}") + print(f"Annualised roll rate : {annualised:>+.2f}% p.a.") + print(f"Days to near expiry : {dte_near}") + + if roll_cost > 0: + print(f"\nRolling costs {roll_pct:.2f}% — you pay a premium to stay long past expiry.") + else: + print(f"\nRolling earns {abs(roll_pct):.2f}% — far month at discount (backwardation).") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/futures_basis/mcx_crude_spread.py b/interactive-examples/futures_basis/mcx_crude_spread.py new file mode 100644 index 0000000..a2e6f6e --- /dev/null +++ b/interactive-examples/futures_basis/mcx_crude_spread.py @@ -0,0 +1,97 @@ +""" +MCX Crude Oil near-month vs far-month futures spread. + +Crude oil is a commodity futures traded on MCX. The near/far spread +reflects storage costs, supply expectations, and global oil market structure. + +Contango (far > near): market expects future supply tightness or storage cost. +Backwardation (near > far): immediate demand spike or supply disruption. + +Usage: + python futures_basis/mcx_crude_spread.py --token + python futures_basis/mcx_crude_spread.py --token --query NATURALGAS +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="MCX commodity futures near/far spread") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="CRUDEOIL", help="MCX commodity symbol (default: CRUDEOIL)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching MCX {args.query} futures contracts...\n") + + response = search_instrument( + client, + args.query, + exchanges="MCX", + segments="COMM", + instrument_types="FUT", + records=10, + ) + + instruments = response.data or [] + if len(instruments) < 2: + print(f"Need at least 2 futures contracts, found {len(instruments)}.") + sys.exit(1) + + # Sort by expiry + instruments.sort(key=lambda x: x.get("expiry", "")) + + near = instruments[0] + far = instruments[1] + + near_key = near["instrument_key"] + far_key = far["instrument_key"] + + print(f"Near month : {near['trading_symbol']} (expiry: {near['expiry']})") + print(f"Far month : {far['trading_symbol']} (expiry: {far['expiry']})") + print() + + ltp_data = get_ltp(client, near_key, far_key) + + near_ltp = ltp_data[near_key].last_price + far_ltp = ltp_data[far_key].last_price + + spread = far_ltp - near_ltp + spread_pct = (spread / near_ltp) * 100 + lot_size = near.get("lot_size", 1) + + print(f"{'Contract':<30} {'LTP':>10} {'Close':>10}") + print("-" * 55) + for key, inst in [(near_key, near), (far_key, far)]: + q = ltp_data[key] + print(f"{inst['trading_symbol']:<30} {q.last_price:>10.2f} {q.cp:>10.2f}") + print("-" * 55) + + print(f"\nSpread (far - near) : {spread:>+10.2f} ({spread_pct:+.2f}%)") + print(f"Lot size : {lot_size} barrels") + print(f"Spread per lot (₹) : {spread * lot_size:>+10.2f}") + + if len(instruments) > 2: + print(f"\nAll available contracts for {args.query}:") + print(f" {'#':<4} {'Symbol':<25} {'Expiry':<14} {'LTP':>10}") + all_keys = [i["instrument_key"] for i in instruments] + all_ltp = get_ltp(client, *all_keys) + for i, inst in enumerate(instruments, 1): + key = inst["instrument_key"] + ltp_val = all_ltp[key].last_price if key in all_ltp else 0 + print(f" {i:<4} {inst['trading_symbol']:<25} {inst.get('expiry', ''):<14} {ltp_val:>10.2f}") + + if spread > 0: + print("\nContango: market pricing in storage/carry costs (normal for crude).") + else: + print("\nBackwardation: immediate demand or supply disruption signal.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/futures_basis/nifty_futures_spread.py b/interactive-examples/futures_basis/nifty_futures_spread.py new file mode 100644 index 0000000..501fa65 --- /dev/null +++ b/interactive-examples/futures_basis/nifty_futures_spread.py @@ -0,0 +1,81 @@ +""" +Nifty near-month vs far-month futures calendar spread (arbitrage). + +Uses instrument search to dynamically find the two nearest NIFTY futures +contracts, fetches their live LTPs, and prints the spread. + +A positive spread (far > near) indicates contango (normal for index futures). +A narrowing spread as expiry approaches signals roll or arbitrage opportunity. + +Usage: + python futures_basis/nifty_futures_spread.py --token +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, get_futures_sorted, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="NIFTY near/far futures calendar spread") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Index symbol (default: NIFTY)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching {args.query} futures contracts...\n") + futures = get_futures_sorted(client, args.query, exchange="NSE", exact_symbol=True) + + if len(futures) < 2: + print(f"Need at least 2 futures contracts, found {len(futures)}. Try a broader query.") + sys.exit(1) + + near = futures[0] + far = futures[1] + + near_key = near["instrument_key"] + far_key = far["instrument_key"] + + print(f"Near month : {near['trading_symbol']} (expiry: {near['expiry']})") + print(f"Far month : {far['trading_symbol']} (expiry: {far['expiry']})") + print() + + ltp_data = get_ltp(client, near_key, far_key) + + near_ltp = ltp_data[near_key].last_price + far_ltp = ltp_data[far_key].last_price + + spread = far_ltp - near_ltp + spread_pct = (spread / near_ltp) * 100 + + print(f"{'Contract':<30} {'LTP':>10} {'Close':>10} {'Volume':>10}") + print("-" * 65) + for key, label in [(near_key, near["trading_symbol"]), (far_key, far["trading_symbol"])]: + q = ltp_data[key] + print(f"{label:<30} {q.last_price:>10.2f} {q.cp:>10.2f} {q.volume:>10,}") + + print("-" * 65) + print(f"\nCalendar Spread (far - near) : {spread:>+10.2f} ({spread_pct:+.2f}%)") + + if spread > 0: + print("Market is in CONTANGO — far month trades at a premium (normal for index futures).") + elif spread < 0: + print("Market is in BACKWARDATION — far month trades at a discount (unusual, check news).") + else: + print("Spread is zero — contracts are trading at parity.") + + lot_size = near.get("lot_size", 1) + print(f"\nLot size : {lot_size}") + print(f"Spread / lot : ₹{spread * lot_size:,.2f}") + print( + "\nArbitrage signal: Buy near + Sell far if spread exceeds cost-of-carry; " + "the spread will collapse on near-month expiry." + ) + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/beta_calculator.py b/interactive-examples/historical_analysis/beta_calculator.py new file mode 100644 index 0000000..9a9c231 --- /dev/null +++ b/interactive-examples/historical_analysis/beta_calculator.py @@ -0,0 +1,157 @@ +""" +Stock Beta Calculator — measure a stock's volatility relative to NIFTY 50. + +Beta = Cov(stock returns, index returns) / Var(index returns) + + Beta > 1 → stock is MORE volatile than the index (amplifies moves) + Beta = 1 → stock moves in line with the index + Beta < 1 → stock is LESS volatile (defensive) + Beta < 0 → stock moves OPPOSITE to the index (rare) + +Also reports the Pearson correlation coefficient (R) between returns. + +Usage: + python historical_analysis/beta_calculator.py --token + python historical_analysis/beta_calculator.py --token --query TCS --days 90 +""" + +import argparse +import sys +import os +import math +from statistics import mean +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +RESET = "\033[0m" + +NIFTY_KEY = "NSE_INDEX|Nifty 50" + + +def daily_returns(closes): + return [(closes[i] / closes[i - 1]) - 1 for i in range(1, len(closes))] + + +def covariance(xs, ys): + n = len(xs) + mx, my = mean(xs), mean(ys) + return sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) / (n - 1) + + +def variance(xs): + mx = mean(xs) + return sum((x - mx) ** 2 for x in xs) / (len(xs) - 1) + + +def correlation(xs, ys): + cov = covariance(xs, ys) + std_x = math.sqrt(variance(xs)) + std_y = math.sqrt(variance(ys)) + if std_x == 0 or std_y == 0: + return 0 + return cov / (std_x * std_y) + + +def main(): + parser = argparse.ArgumentParser(description="Stock beta vs NIFTY 50") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="RELIANCE", help="Stock name (default: RELIANCE)") + parser.add_argument("--days", type=int, default=60, + help="Trading days for calculation (default: 60)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"\nComputing beta for {args.query.upper()} vs NIFTY 50 ({args.days} days)...\n") + + # Resolve stock instrument key + resp = search_instrument(client, args.query, exchanges="NSE", segments="EQ", records=3) + hits = resp.data or [] + if not hits: + print(f"Stock '{args.query}' not found on NSE.") + sys.exit(1) + + stock_key = hits[0]["instrument_key"] + trading_symbol = hits[0].get("trading_symbol", args.query.upper()) + + # Fetch historical data for both stock and NIFTY + to_date = date.today().isoformat() + from_date = (date.today() - timedelta(days=int(args.days * 1.6))).isoformat() + + stock_candles = get_historical_candles(client, stock_key, "days", 1, to_date, from_date) + nifty_candles = get_historical_candles(client, NIFTY_KEY, "days", 1, to_date, from_date) + + if not stock_candles or not nifty_candles: + print("Could not fetch historical data.") + sys.exit(1) + + # Reverse to chronological, extract closes + stock_closes = [float(c[4]) for c in reversed(stock_candles) if len(c) > 4] + nifty_closes = [float(c[4]) for c in reversed(nifty_candles) if len(c) > 4] + + # Align lengths + min_len = min(len(stock_closes), len(nifty_closes)) + stock_closes = stock_closes[-min_len:] + nifty_closes = nifty_closes[-min_len:] + + if min_len < args.days: + print(f"Warning: only {min_len} common trading days available (requested {args.days}).") + + if min_len < 10: + print(f"Insufficient data: {min_len} days.") + sys.exit(1) + + stock_rets = daily_returns(stock_closes) + nifty_rets = daily_returns(nifty_closes) + + # Compute beta and correlation + cov = covariance(stock_rets, nifty_rets) + var_idx = variance(nifty_rets) + beta = cov / var_idx if var_idx else 0 + corr = correlation(stock_rets, nifty_rets) + + # Annualised volatility + stock_vol = math.sqrt(variance(stock_rets)) * math.sqrt(252) * 100 + nifty_vol = math.sqrt(variance(nifty_rets)) * math.sqrt(252) * 100 + + # Display + print(f" Stock : {trading_symbol}") + print(f" Benchmark : NIFTY 50") + print(f" Period : {min_len} trading days") + print(f" Stock close : {stock_closes[-1]:,.2f}") + print(f" NIFTY close : {nifty_closes[-1]:,.2f}") + print() + print(f" {BOLD}Beta : {beta:.3f}{RESET}") + print(f" {BOLD}Correlation (R): {corr:.3f}{RESET}") + print() + print(f" Stock annl vol : {stock_vol:.1f}%") + print(f" NIFTY annl vol : {nifty_vol:.1f}%") + print() + + if beta > 1.2: + print(f" {RED}High beta{RESET} — {trading_symbol} amplifies NIFTY moves. " + f"More volatile/aggressive.") + elif beta > 0.8: + print(f" {CYAN}Moderate beta{RESET} — {trading_symbol} tracks NIFTY closely.") + elif beta > 0: + print(f" {GREEN}Low beta{RESET} — {trading_symbol} is defensive relative to NIFTY.") + else: + print(f" {RED}Negative beta{RESET} — {trading_symbol} tends to move opposite to NIFTY.") + + if abs(corr) > 0.7: + print(f" Strong correlation ({corr:.2f}) — returns are highly aligned with NIFTY.") + elif abs(corr) > 0.4: + print(f" Moderate correlation ({corr:.2f}) — some alignment with NIFTY.") + else: + print(f" Weak correlation ({corr:.2f}) — returns diverge significantly from NIFTY.") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/historical_candle.py b/interactive-examples/historical_analysis/historical_candle.py new file mode 100644 index 0000000..c153de5 --- /dev/null +++ b/interactive-examples/historical_analysis/historical_candle.py @@ -0,0 +1,86 @@ +""" +Historical Candle Data using the Analytics Token. + +Demonstrates the analytics token use case: + - No OAuth flow needed + - 1-year token validity + - Read-only access to historical market data + +Fetches OHLC + Volume + OI candles for any instrument. + +Usage: + python historical_analysis/historical_candle.py --token --query RELIANCE + python historical_analysis/historical_candle.py --token --query NIFTY --unit days --interval 1 --bars 30 +""" + +import argparse +import sys +import os +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles + + +def main(): + parser = argparse.ArgumentParser( + description="Fetch historical OHLC candles — ideal for use with analytics token" + ) + parser.add_argument("--token", required=True, + help="Analytics token or access token (analytics token recommended for this use case)") + parser.add_argument("--query", default="RELIANCE", help="Instrument name (default: RELIANCE)") + parser.add_argument("--exchange", default="NSE", help="Exchange (default: NSE)") + parser.add_argument("--unit", default="days", help="Time unit: minutes, hours, days, weeks, months (default: days)") + parser.add_argument("--interval", type=int, default=1, help="Interval count (default: 1)") + parser.add_argument("--bars", type=int, default=20, help="Number of candles to display (default: 20)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching {args.interval}{args.unit} candles for '{args.query}' on {args.exchange}...\n") + print("(Using analytics token — no OAuth flow required for historical data)") + print() + + # Find the equity instrument + resp = search_instrument(client, args.query, exchanges=args.exchange, segments="EQ", records=3) + instruments = resp.data or [] + if not instruments: + print(f"Instrument '{args.query}' not found.") + sys.exit(1) + + inst = instruments[0] + instrument_key = inst["instrument_key"] + trading_symbol = inst.get("trading_symbol", args.query) + + print(f"Instrument : {trading_symbol} ({instrument_key})") + print() + + to_date = date.today().isoformat() + + candles = get_historical_candles(client, instrument_key, args.unit, args.interval, to_date) + + if not candles: + print("No candle data returned.") + sys.exit(1) + + # candles are newest-first; take the last N + display = candles[:args.bars] + + print(f"{'Date/Time':<22} {'Open':>10} {'High':>10} {'Low':>10} {'Close':>10} {'Volume':>12} {'OI':>10}") + print("-" * 90) + + for candle in reversed(display): + # candle = [timestamp, open, high, low, close, volume, oi] + ts = str(candle[0])[:19] if len(candle) > 0 else "" + open_ = float(candle[1]) if len(candle) > 1 else 0 + high = float(candle[2]) if len(candle) > 2 else 0 + low = float(candle[3]) if len(candle) > 3 else 0 + close = float(candle[4]) if len(candle) > 4 else 0 + vol = int(candle[5]) if len(candle) > 5 else 0 + oi = int(candle[6]) if len(candle) > 6 else 0 + + print(f"{ts:<22} {open_:>10.2f} {high:>10.2f} {low:>10.2f} {close:>10.2f} {vol:>12,} {oi:>10,}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/historical_volatility.py b/interactive-examples/historical_analysis/historical_volatility.py new file mode 100644 index 0000000..e931717 --- /dev/null +++ b/interactive-examples/historical_analysis/historical_volatility.py @@ -0,0 +1,125 @@ +""" +Historical Volatility Calculator. + +Computes 30-day (and configurable) realised/historical volatility from +daily close prices fetched via the analytics token. + +Historical Volatility (HV) = annualised standard deviation of log returns. + + log_return_i = ln(Close_i / Close_{i-1}) + HV_daily = std(log_returns) over N days + HV_annual = HV_daily * sqrt(252) + +Compare HV to the options-implied ATM straddle cost for a quick +IV vs HV comparison (a premium implies options are expensive). + +Usage: + python historical_analysis/historical_volatility.py --token + python historical_analysis/historical_volatility.py --token --query INFY --window 20 +""" + +import argparse +import sys +import os +import math +from statistics import stdev, mean + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles + + +def log_returns(closes): + return [math.log(closes[i] / closes[i - 1]) for i in range(1, len(closes))] + + +def hv(returns, window): + if len(returns) < window: + return None + r = returns[-window:] + return stdev(r) * math.sqrt(252) * 100 # in percent + + +def main(): + parser = argparse.ArgumentParser( + description="Realised historical volatility from daily candles" + ) + parser.add_argument("--token", required=True, + help="Analytics token or access token") + parser.add_argument("--query", default="NIFTY 50", help="Instrument name (default: NIFTY 50)") + parser.add_argument("--exchange", default="NSE", help="Exchange (default: NSE)") + parser.add_argument("--segment", default="INDEX", help="Segment: EQ or INDEX (default: INDEX)") + parser.add_argument("--window", type=int, default=30, help="Volatility window in days (default: 30)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Computing {args.window}-day historical volatility for '{args.query}'...\n") + + resp = search_instrument( + client, args.query, + exchanges=args.exchange, + segments=args.segment, + records=3, + ) + instruments = resp.data or [] + if not instruments: + # Fallback to EQ + resp = search_instrument(client, args.query, exchanges=args.exchange, segments="EQ", records=3) + instruments = resp.data or [] + if not instruments: + print(f"Instrument '{args.query}' not found.") + sys.exit(1) + + inst = instruments[0] + instrument_key = inst["instrument_key"] + trading_symbol = inst.get("trading_symbol", args.query) + + from datetime import date + to_date = date.today().isoformat() + + candles = get_historical_candles(client, instrument_key, "days", 1, to_date) + if not candles: + print("No candle data returned.") + sys.exit(1) + + candles = list(reversed(candles)) + closes = [float(c[4]) for c in candles if len(c) > 4] + + if len(closes) < args.window + 1: + print(f"Need {args.window + 1} sessions, got {len(closes)}.") + sys.exit(1) + + returns = log_returns(closes) + + hv_window = hv(returns, args.window) + hv_20 = hv(returns, 20) + hv_10 = hv(returns, 10) + hv_5 = hv(returns, 5) + + print(f"Instrument : {trading_symbol}") + print(f"Data points : {len(closes)} daily closes") + print(f"Current close: {closes[-1]:,.2f}") + print() + print(f"Historical Volatility (annualised):") + print(f" 5-day HV : {hv_5:.2f}%" if hv_5 else " 5-day HV : --") + print(f" 10-day HV : {hv_10:.2f}%" if hv_10 else " 10-day HV : --") + print(f" 20-day HV : {hv_20:.2f}%" if hv_20 else " 20-day HV : --") + print(f" {args.window}-day HV : {hv_window:.2f}%" if hv_window else f" {args.window}-day HV : --") + + # Rolling 30-day HV over last 6 months + print(f"\nRolling {args.window}-day HV over last 6 periods:") + print(f" {'End Date':<12} {'Close':>10} {'HV(%)':>10}") + print(" " + "-" * 35) + step = max(len(returns) // 6, args.window) + checkpoints = range(args.window, len(returns) + 1, max((len(returns) - args.window) // 6, 1)) + for idx in list(checkpoints)[-6:]: + seg = returns[:idx] + hv_val = hv(seg, args.window) + ts = str(candles[idx][0])[:10] if idx < len(candles) else "--" + close_val = closes[idx] if idx < len(closes) else 0 + if hv_val: + print(f" {ts:<12} {close_val:>10.2f} {hv_val:>10.2f}%") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/moving_average.py b/interactive-examples/historical_analysis/moving_average.py new file mode 100644 index 0000000..c1d630a --- /dev/null +++ b/interactive-examples/historical_analysis/moving_average.py @@ -0,0 +1,124 @@ +""" +Moving Average Crossover using Historical Candles. + +Fetches daily candles via the analytics token and computes: + - Simple Moving Average (SMA) for configurable fast and slow periods + - Current signal: BULLISH (fast > slow) or BEARISH (fast < slow) + - Recent crossover detection + +This is a classic trend-following signal widely used in algorithmic trading. + +Usage: + python historical_analysis/moving_average.py --token + python historical_analysis/moving_average.py --token --query INFY --fast 20 --slow 50 +""" + +import argparse +import sys +import os +from statistics import mean + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles + + +def sma(prices, period): + if len(prices) < period: + return None + return mean(prices[-period:]) + + +def main(): + parser = argparse.ArgumentParser(description="SMA crossover signal using historical data") + parser.add_argument("--token", required=True, + help="Analytics token or access token") + parser.add_argument("--query", default="RELIANCE", help="Stock name (default: RELIANCE)") + parser.add_argument("--exchange", default="NSE", help="Exchange (default: NSE)") + parser.add_argument("--fast", type=int, default=20, help="Fast SMA period (default: 20)") + parser.add_argument("--slow", type=int, default=50, help="Slow SMA period (default: 50)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Computing SMA({args.fast}) / SMA({args.slow}) for '{args.query}' on {args.exchange}...\n") + + resp = search_instrument(client, args.query, exchanges=args.exchange, segments="EQ", records=3) + instruments = resp.data or [] + if not instruments: + print(f"Instrument '{args.query}' not found.") + sys.exit(1) + + inst = instruments[0] + instrument_key = inst["instrument_key"] + trading_symbol = inst.get("trading_symbol", args.query) + + from datetime import date + to_date = date.today().isoformat() + + # Fetch enough candles for the slow period + some history + candles = get_historical_candles(client, instrument_key, "days", 1, to_date) + + if not candles: + print("No candle data returned.") + sys.exit(1) + + # candles newest-first → reverse + candles = list(reversed(candles)) + closes = [float(c[4]) for c in candles if len(c) > 4] + + if len(closes) < args.slow: + print(f"Not enough data: need {args.slow} candles, got {len(closes)}.") + sys.exit(1) + + fast_sma = sma(closes, args.fast) + slow_sma = sma(closes, args.slow) + + # Check for crossover in last 5 sessions + crossover = None + for i in range(len(closes) - 5, len(closes)): + if i < args.slow: + continue + prev_fast = mean(closes[i - args.fast: i]) + prev_slow = mean(closes[i - args.slow: i]) + curr_fast = mean(closes[i - args.fast + 1: i + 1]) + curr_slow = mean(closes[i - args.slow + 1: i + 1]) + if prev_fast < prev_slow and curr_fast > curr_slow: + crossover = ("BULLISH CROSSOVER", candles[i][0]) + elif prev_fast > prev_slow and curr_fast < curr_slow: + crossover = ("BEARISH CROSSOVER", candles[i][0]) + + print(f"Instrument : {trading_symbol}") + print(f"Current price : {closes[-1]:,.2f}") + print(f"SMA({args.fast:>3}) : {fast_sma:,.2f}") + print(f"SMA({args.slow:>3}) : {slow_sma:,.2f}") + print() + + if fast_sma > slow_sma: + signal = "BULLISH" + detail = f"SMA({args.fast}) is above SMA({args.slow}) — uptrend in place." + else: + signal = "BEARISH" + detail = f"SMA({args.fast}) is below SMA({args.slow}) — downtrend in place." + + print(f"Signal : {signal}") + print(f" {detail}") + + if crossover: + print(f"\nRecent crossover : {crossover[0]} detected around {str(crossover[1])[:10]}") + + # Print last 10 closes with rolling SMAs + print(f"\nLast 10 sessions:") + print(f" {'Date':<12} {'Close':>10} {'SMA({})'.format(args.fast):>10} {'SMA({})'.format(args.slow):>10}") + print(" " + "-" * 45) + for i in range(max(0, len(closes) - 10), len(closes)): + ts = str(candles[i][0])[:10] + close_val = closes[i] + f_sma = mean(closes[i - args.fast + 1: i + 1]) if i >= args.fast - 1 else None + s_sma = mean(closes[i - args.slow + 1: i + 1]) if i >= args.slow - 1 else None + f_str = f"{f_sma:>10.2f}" if f_sma else f"{'--':>10}" + s_str = f"{s_sma:>10.2f}" if s_sma else f"{'--':>10}" + print(f" {ts:<12} {close_val:>10.2f} {f_str} {s_str}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/stock_correlation.py b/interactive-examples/historical_analysis/stock_correlation.py new file mode 100644 index 0000000..cecfa1e --- /dev/null +++ b/interactive-examples/historical_analysis/stock_correlation.py @@ -0,0 +1,159 @@ +""" +Pairwise Stock Correlation — Pearson correlation of daily returns for 2+ stocks. + +Useful for pairs trading and portfolio diversification: + R close to +1 → stocks move together (correlated) + R close to 0 → no linear relationship (diversified) + R close to -1 → stocks move opposite (natural hedge) + +Usage: + python historical_analysis/stock_correlation.py --token + python historical_analysis/stock_correlation.py --token --queries RELIANCE,TCS,INFY,HDFCBANK --days 90 +""" + +import argparse +import sys +import os +import math +from statistics import mean +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" + + +def daily_returns(closes): + return [(closes[i] / closes[i - 1]) - 1 for i in range(1, len(closes))] + + +def pearson(xs, ys): + n = len(xs) + if n < 3: + return 0 + mx, my = mean(xs), mean(ys) + num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) + dx = math.sqrt(sum((x - mx) ** 2 for x in xs)) + dy = math.sqrt(sum((y - my) ** 2 for y in ys)) + if dx == 0 or dy == 0: + return 0 + return num / (dx * dy) + + +def main(): + parser = argparse.ArgumentParser(description="Pairwise stock return correlation") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--queries", default="RELIANCE,TCS,INFY", + help="Comma-separated stock names (default: RELIANCE,TCS,INFY)") + parser.add_argument("--days", type=int, default=60, + help="Trading days (default: 60)") + args = parser.parse_args() + + queries = [q.strip() for q in args.queries.split(",") if q.strip()] + if len(queries) < 2: + print("Need at least 2 stocks. Use --queries STOCK1,STOCK2,...") + sys.exit(1) + + client = get_api_client(args.token) + + print(f"\nComputing correlation for {', '.join(queries)} ({args.days} days)...\n") + + to_date = date.today().isoformat() + from_date = (date.today() - timedelta(days=int(args.days * 1.6))).isoformat() + + # Resolve and fetch data for each stock + symbols = [] + all_returns = [] + + for query in queries: + resp = search_instrument(client, query, exchanges="NSE", segments="EQ", records=3) + hits = resp.data or [] + if not hits: + print(f" Warning: '{query}' not found on NSE. Skipping.") + continue + + inst_key = hits[0]["instrument_key"] + symbol = hits[0].get("trading_symbol", query.upper()) + + candles = get_historical_candles(client, inst_key, "days", 1, to_date, from_date) + if not candles: + print(f" Warning: no data for '{symbol}'. Skipping.") + continue + + closes = [float(c[4]) for c in reversed(candles) if len(c) > 4] + if len(closes) < 10: + print(f" Warning: insufficient data for '{symbol}' ({len(closes)} days). Skipping.") + continue + + symbols.append(symbol) + all_returns.append(daily_returns(closes)) + + if len(symbols) < 2: + print("Need at least 2 valid stocks with data.") + sys.exit(1) + + # Align all return series to the shortest length + min_len = min(len(r) for r in all_returns) + all_returns = [r[-min_len:] for r in all_returns] + + print(f" Stocks: {', '.join(symbols)}") + print(f" Period: {min_len} trading days\n") + + # Compute and display correlation matrix + n = len(symbols) + max_sym_len = max(len(s) for s in symbols) + col_width = max(max_sym_len, 8) + + # Header + header = " " * (max_sym_len + 2) + for s in symbols: + header += f"{s:>{col_width}} " + print(header) + print(" " * (max_sym_len + 2) + "-" * ((col_width + 2) * n)) + + for i in range(n): + row = f"{symbols[i]:>{max_sym_len}} " + for j in range(n): + r = pearson(all_returns[i], all_returns[j]) + if i == j: + row += f"{DIM}{'1.000':>{col_width}}{RESET} " + elif r > 0.7: + row += f"{GREEN}{r:>{col_width}.3f}{RESET} " + elif r < -0.3: + row += f"{RED}{r:>{col_width}.3f}{RESET} " + else: + row += f"{r:>{col_width}.3f} " + print(row) + + # Highlight strongest and weakest pairs + print() + pairs = [] + for i in range(n): + for j in range(i + 1, n): + r = pearson(all_returns[i], all_returns[j]) + pairs.append((symbols[i], symbols[j], r)) + + if pairs: + pairs.sort(key=lambda p: p[2]) + lowest = pairs[0] + highest = pairs[-1] + print(f" {GREEN}Most correlated{RESET} : {highest[0]} & {highest[1]} " + f"(R = {highest[2]:.3f})") + print(f" {RED}Least correlated{RESET} : {lowest[0]} & {lowest[1]} " + f"(R = {lowest[2]:.3f})") + + if lowest[2] < 0.3: + print(f"\n {CYAN}Diversification opportunity{RESET}: " + f"{lowest[0]} and {lowest[1]} have low correlation — " + f"holding both reduces portfolio volatility.") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/vwap.py b/interactive-examples/historical_analysis/vwap.py new file mode 100644 index 0000000..c32567f --- /dev/null +++ b/interactive-examples/historical_analysis/vwap.py @@ -0,0 +1,135 @@ +""" +VWAP (Volume Weighted Average Price) from Intraday Candles. + +Fetches today's 1-minute candles and computes cumulative VWAP: + + Typical Price = (High + Low + Close) / 3 + VWAP = Sum(Typical Price × Volume) / Sum(Volume) + +Price above VWAP → bullish intraday bias (buyers in control) +Price below VWAP → bearish intraday bias (sellers in control) + +Usage: + python historical_analysis/vwap.py --token + python historical_analysis/vwap.py --token --query RELIANCE + python historical_analysis/vwap.py --token --query INFY --instrument_key NSE_EQ|INE009A01021 +""" + +import argparse +import sys +import os +from datetime import date + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles, get_ltp + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +RESET = "\033[0m" + + +def main(): + parser = argparse.ArgumentParser(description="VWAP from intraday 1-min candles") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="RELIANCE", help="Stock name (default: RELIANCE)") + parser.add_argument("--instrument_key", default="", + help="Direct instrument key (skips search)") + args = parser.parse_args() + + client = get_api_client(args.token) + + # Resolve instrument key + if args.instrument_key: + instrument_key = args.instrument_key + trading_symbol = args.query.upper() + else: + resp = search_instrument(client, args.query, exchanges="NSE", segments="EQ", records=3) + hits = resp.data or [] + if not hits: + print(f"Instrument '{args.query}' not found on NSE.") + sys.exit(1) + instrument_key = hits[0]["instrument_key"] + trading_symbol = hits[0].get("trading_symbol", args.query.upper()) + + print(f"\nComputing VWAP for {trading_symbol}...\n") + + # Fetch today's 1-min candles + to_date = date.today().isoformat() + candles = get_historical_candles(client, instrument_key, "minutes", 1, to_date) + + if not candles: + print("No intraday candle data returned. Market may be closed.") + sys.exit(1) + + # Candles are [timestamp, open, high, low, close, volume, oi] + # Reverse to chronological order + candles = list(reversed(candles)) + + cum_tp_vol = 0.0 + cum_vol = 0 + vwap_points = [] + + for c in candles: + if len(c) < 6: + continue + high = float(c[2]) + low = float(c[3]) + close = float(c[4]) + volume = int(c[5]) + + typical_price = (high + low + close) / 3 + cum_tp_vol += typical_price * volume + cum_vol += volume + vwap = cum_tp_vol / cum_vol if cum_vol else 0 + vwap_points.append((c[0], close, vwap, volume)) + + if not vwap_points: + print("No valid candle data to compute VWAP.") + sys.exit(1) + + current_vwap = vwap_points[-1][2] + current_price = vwap_points[-1][1] + total_volume = cum_vol + + # Also fetch live LTP for most current price + ltp_data = get_ltp(client, instrument_key) + ltp_entry = ltp_data.get(instrument_key, {}) + live_price = ltp_entry.get("last_price") if isinstance(ltp_entry, dict) else getattr(ltp_entry, "last_price", 0) + if live_price: + current_price = live_price + + deviation = (current_price - current_vwap) / current_vwap * 100 if current_vwap else 0 + + # Display summary + print(f" Instrument : {trading_symbol}") + print(f" Candles : {len(vwap_points)} x 1-min") + print(f" Total volume : {total_volume:,}") + print() + print(f" {BOLD}VWAP : {current_vwap:,.2f}{RESET}") + print(f" Current price : {current_price:,.2f}") + print(f" Deviation : {deviation:+.2f}%") + print() + + if current_price > current_vwap: + print(f" {GREEN}Price is ABOVE VWAP{RESET} — bullish intraday bias (buyers in control).") + elif current_price < current_vwap: + print(f" {RED}Price is BELOW VWAP{RESET} — bearish intraday bias (sellers in control).") + else: + print(f" {CYAN}Price is AT VWAP{RESET} — neutral, at fair value.") + + # Show last 10 1-min VWAP snapshots + print(f"\n Last 10 VWAP snapshots (1-min):") + print(f" {'Time':>20} {'Close':>10} {'VWAP':>10} {'Volume':>10}") + print(" " + "-" * 55) + for ts, close, vwap, vol in vwap_points[-10:]: + ts_str = str(ts)[:19] if ts else "--" + above = GREEN if close > vwap else RED + print(f" {ts_str:>20} {above}{close:>10,.2f}{RESET} {vwap:>10,.2f} {vol:>10,}") + + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/historical_analysis/week_52_high_low.py b/interactive-examples/historical_analysis/week_52_high_low.py new file mode 100644 index 0000000..a13deb6 --- /dev/null +++ b/interactive-examples/historical_analysis/week_52_high_low.py @@ -0,0 +1,112 @@ +""" +52-Week High/Low Proximity Check. + +Fetches ~252 daily candles via the analytics token and computes: + - 52-week high and low + - Current price position within the range + - Distance from high and low (absolute and %) + - Signal: near 52-week high (breakout candidate) or near 52-week low (reversal candidate) + +Usage: + python historical_analysis/week_52_high_low.py --token + python historical_analysis/week_52_high_low.py --token --query HDFCBANK + python historical_analysis/week_52_high_low.py --token --query NIFTY --segment INDEX +""" + +import argparse +import sys +import os +from datetime import date + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles + + +def main(): + parser = argparse.ArgumentParser(description="52-week high/low proximity check") + parser.add_argument("--token", required=True, + help="Analytics token or access token") + parser.add_argument("--query", default="RELIANCE", help="Instrument name (default: RELIANCE)") + parser.add_argument("--exchange", default="NSE", help="Exchange (default: NSE)") + parser.add_argument("--segment", default="EQ", help="Segment: EQ or INDEX (default: EQ)") + parser.add_argument("--threshold", type=float, default=5.0, + help="%% proximity threshold for high/low alerts (default: 5)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching 52-week data for '{args.query}' on {args.exchange}...\n") + + resp = search_instrument(client, args.query, exchanges=args.exchange, + segments=args.segment, records=3) + instruments = resp.data or [] + if not instruments: + print(f"'{args.query}' not found.") + sys.exit(1) + + inst = instruments[0] + instrument_key = inst["instrument_key"] + trading_symbol = inst.get("trading_symbol", args.query) + + to_date = date.today().isoformat() + candles = get_historical_candles(client, instrument_key, "days", 1, to_date) + + if not candles: + print("No candle data returned.") + sys.exit(1) + + candles = list(reversed(candles)) + + # Use up to last 252 trading days (~1 year) + year_candles = candles[-252:] + + highs = [float(c[2]) for c in year_candles if len(c) > 2] + lows = [float(c[3]) for c in year_candles if len(c) > 3] + closes = [float(c[4]) for c in year_candles if len(c) > 4] + + if not closes: + print("No data.") + sys.exit(1) + + week52_high = max(highs) + week52_low = min(lows) + current = closes[-1] + prev_close = closes[-2] if len(closes) > 1 else current + + range_width = week52_high - week52_low + position_pct = ((current - week52_low) / range_width * 100) if range_width else 50 + dist_from_high = ((week52_high - current) / week52_high) * 100 + dist_from_low = ((current - week52_low) / week52_low) * 100 + daily_change = ((current - prev_close) / prev_close) * 100 + + # Build position bar + bar_len = 40 + pos = int(position_pct / 100 * bar_len) + bar = "[" + "-" * pos + "●" + "-" * (bar_len - pos) + "]" + + print(f"Instrument : {trading_symbol}") + print(f"Current price : {current:,.2f} ({daily_change:+.2f}% today)") + print() + print(f"52-week high : {week52_high:,.2f} ({dist_from_high:.1f}% below current high)") + print(f"52-week low : {week52_low:,.2f} ({dist_from_low:.1f}% above current low)") + print(f"52-week range : {range_width:,.2f} points") + print() + print(f"Position in range: {position_pct:.1f}%") + print(f" Low {bar} High") + print() + + if dist_from_high <= args.threshold: + print(f"NEAR 52-WEEK HIGH — within {dist_from_high:.1f}% of {week52_high:,.2f}.") + print("Watch for breakout or distribution. Volume confirmation key.") + elif dist_from_low <= args.threshold: + print(f"NEAR 52-WEEK LOW — within {dist_from_low:.1f}% of {week52_low:,.2f}.") + print("Watch for reversal or breakdown. Check fundamentals before buying.") + else: + print(f"Price is mid-range. {position_pct:.0f}% of the 52-week range.") + + print(f"\nData period : {str(year_candles[0][0])[:10]} to {str(year_candles[-1][0])[:10]}") + print(f"Sessions used : {len(year_candles)}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/instrument_search/search_equity.py b/interactive-examples/instrument_search/search_equity.py new file mode 100644 index 0000000..2101e44 --- /dev/null +++ b/interactive-examples/instrument_search/search_equity.py @@ -0,0 +1,63 @@ +""" +Search for equity instruments by name. + +Usage: + python instrument_search/search_equity.py --token --query RELIANCE + python instrument_search/search_equity.py --token --query INFY --exchange BSE +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument + + +def main(): + parser = argparse.ArgumentParser(description="Search equity instruments on Upstox") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="RELIANCE", help="Stock name or symbol to search") + parser.add_argument("--exchange", default="NSE", help="Exchange: NSE or BSE (default: NSE)") + parser.add_argument("--records", type=int, default=10, help="Max results (default: 10)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Searching equity instruments for '{args.query}' on {args.exchange}...\n") + + response = search_instrument( + client, + args.query, + exchanges=args.exchange, + segments="EQ", + records=args.records, + ) + + instruments = response.data or [] + if not instruments: + print("No instruments found.") + return + + print(f"{'Instrument Key':<25} {'Trading Symbol':<20} {'Name':<30} {'ISIN':<15} {'Lot Size'}") + print("-" * 105) + + for inst in instruments: + print( + f"{inst.get('instrument_key', ''):<25} " + f"{inst.get('trading_symbol', ''):<20} " + f"{inst.get('name', ''):<30} " + f"{inst.get('isin', ''):<15} " + f"{inst.get('lot_size', '')}" + ) + + meta = response.meta_data + if meta and meta.page: + p = meta.page + print(f"\nPage {p.page_number} of {p.total_pages} | Showing {p.records} of {p.total_records} total results") + if p.total_pages and int(str(p.total_pages)) > 1: + print("Tip: use --records 30 or add --page to paginate further.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/instrument_search/search_futures.py b/interactive-examples/instrument_search/search_futures.py new file mode 100644 index 0000000..b2628ca --- /dev/null +++ b/interactive-examples/instrument_search/search_futures.py @@ -0,0 +1,71 @@ +""" +Search for futures contracts by underlying symbol. + +Lists all available expiries sorted nearest-first. + +Usage: + python instrument_search/search_futures.py --token + python instrument_search/search_futures.py --token --query BANKNIFTY + python instrument_search/search_futures.py --token --query CRUDEOIL --exchange MCX +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument + + +def main(): + parser = argparse.ArgumentParser(description="Search futures contracts on Upstox") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--exchange", default="NSE", help="Exchange: NSE, BSE, MCX (default: NSE)") + parser.add_argument("--records", type=int, default=10, help="Max results (default: 10)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Searching futures contracts for '{args.query}' on {args.exchange}...\n") + + response = search_instrument( + client, + args.query, + exchanges=args.exchange, + segments="FO" if args.exchange != "MCX" else "COMM", + instrument_types="FUT", + records=args.records, + ) + + instruments = response.data or [] + if not instruments: + print("No futures found.") + return + + # Sort by expiry (nearest first) + instruments.sort(key=lambda x: x.get("expiry", "")) + + print(f"{'#':<4} {'Instrument Key':<25} {'Trading Symbol':<25} {'Expiry':<14} {'Lot Size':>10} {'Tick Size':>10}") + print("-" * 95) + + for i, inst in enumerate(instruments, 1): + label = " <-- near month" if i == 1 else (" <-- far month" if i == 2 else "") + print( + f"{i:<4} " + f"{inst.get('instrument_key', ''):<25} " + f"{inst.get('trading_symbol', ''):<25} " + f"{inst.get('expiry', ''):<14} " + f"{inst.get('lot_size', ''):>10} " + f"{inst.get('tick_size', ''):>10}" + f"{label}" + ) + + meta = response.meta_data + if meta and meta.page: + p = meta.page + print(f"\nShowing {p.records} of {p.total_records} total results") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/instrument_search/search_options.py b/interactive-examples/instrument_search/search_options.py new file mode 100644 index 0000000..b8815ab --- /dev/null +++ b/interactive-examples/instrument_search/search_options.py @@ -0,0 +1,87 @@ +""" +Search for options contracts by underlying symbol. + +Demonstrates the expiry and atm_offset filters of the instrument search API. + +Usage: + python instrument_search/search_options.py --token --query NIFTY + python instrument_search/search_options.py --token --query BANKNIFTY --expiry current_month --atm_offset 2 + python instrument_search/search_options.py --token --query RELIANCE --expiry 2025-04-24 --type CE +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument + + +def main(): + parser = argparse.ArgumentParser(description="Search options contracts on Upstox") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument( + "--expiry", + default="current_month", + help="Expiry: 'current_week', 'current_month', or 'yyyy-MM-dd' (default: current_month)", + ) + parser.add_argument( + "--atm_offset", + type=int, + default=0, + help="ATM offset: 0=ATM, +1=one strike OTM call, -1=one strike OTM put (default: 0)", + ) + parser.add_argument( + "--type", + dest="option_type", + default="CE,PE", + help="Option type: CE, PE, or CE,PE (default: CE,PE)", + ) + parser.add_argument("--records", type=int, default=10, help="Max results (default: 10)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print( + f"Searching {args.option_type} options for '{args.query}' " + f"expiry={args.expiry} atm_offset={args.atm_offset:+d}\n" + ) + + response = search_instrument( + client, + args.query, + exchanges="NSE", + segments="FO", + instrument_types=args.option_type, + expiry=args.expiry, + atm_offset=args.atm_offset, + records=args.records, + ) + + instruments = response.data or [] + if not instruments: + print("No options found. Try a different expiry or symbol.") + return + + print(f"{'Instrument Key':<25} {'Symbol':<25} {'Type':<6} {'Strike':>10} {'Expiry':<14} {'Lot Size'}") + print("-" * 95) + + for inst in instruments: + print( + f"{inst.get('instrument_key', ''):<25} " + f"{inst.get('trading_symbol', ''):<25} " + f"{inst.get('instrument_type', ''):<6} " + f"{inst.get('strike_price', 0):>10.2f} " + f"{inst.get('expiry', ''):<14} " + f"{inst.get('lot_size', '')}" + ) + + meta = response.meta_data + if meta and meta.page: + p = meta.page + print(f"\nShowing {p.records} of {p.total_records} total results") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/intraday_chart.py b/interactive-examples/market_data/intraday_chart.py new file mode 100644 index 0000000..001b3f4 --- /dev/null +++ b/interactive-examples/market_data/intraday_chart.py @@ -0,0 +1,142 @@ +""" +Intraday Candlestick Chart — rendered in the terminal using plotext. + +Fetches today's intraday OHLC candles for any instrument and draws a +colour-coded candlestick chart directly in your terminal window. + +Usage: + python market_data/intraday_chart.py --token + python market_data/intraday_chart.py --token --query NIFTY --exchange NSE --interval 5 + python market_data/intraday_chart.py --token --query RELIANCE --interval 15 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument +import upstox_client +import plotext as plt + + +# ── Instrument resolution ───────────────────────────────────────────────────── + +INDEX_KEYS = { + "SENSEX": "BSE_INDEX|SENSEX", + "NIFTY 50": "NSE_INDEX|Nifty 50", + "NIFTY": "NSE_INDEX|Nifty 50", + "BANKNIFTY": "NSE_INDEX|Nifty Bank", + "NIFTY BANK": "NSE_INDEX|Nifty Bank", +} + + +def resolve_instrument(client, query, exchange): + """Return (instrument_key, display_name) for the query.""" + upper = query.upper() + if upper in INDEX_KEYS: + key = INDEX_KEYS[upper] + return key, query.upper() + + # Search equity + resp = search_instrument(client, query, exchanges=exchange, segments="EQ", records=5) + hits = resp.data or [] + if not hits: + print(f"No instrument found for '{query}' on {exchange}.") + sys.exit(1) + inst = hits[0] + return inst["instrument_key"], inst.get("trading_symbol", query) + + +# ── Chart ───────────────────────────────────────────────────────────────────── + +def draw_chart(candles, title, interval): + """Draw a candlestick chart from candle list [[ts, o, h, l, c, v, oi], ...].""" + if not candles: + print("No intraday candles available yet (market may be closed or not yet open).") + sys.exit(0) + + date_str = candles[0][0][:10] # "YYYY-MM-DD" + times = [c[0][11:16] for c in candles] # "HH:MM" tick labels + opens = [float(c[1]) for c in candles] + highs = [float(c[2]) for c in candles] + lows = [float(c[3]) for c in candles] + closes = [float(c[4]) for c in candles] + vols = [int(c[5]) for c in candles] + + x = list(range(len(candles))) + step = max(1, len(x) // 10) + has_volume = any(v > 0 for v in vols) + + ohlc = {"Open": opens, "High": highs, "Low": lows, "Close": closes} + + plt.clf() + + if has_volume: + plt.subplots(2, 1) + + # ── Price panel — numeric x so plotext won't shift times ── + plt.subplot(1, 1) if has_volume else None + plt.title(f"{title} | {interval}-min intraday candles ({date_str})") + plt.candlestick(x, ohlc) # numeric x, no date_form + plt.xticks(x[::step], times[::step]) + plt.ylabel("Price") + plt.theme("dark") + + # ── Volume panel (skipped for indices with no volume) ── + if has_volume: + bull_x = [i for i in x if closes[i] >= opens[i]] + bull_v = [vols[i] for i in bull_x] + bear_x = [i for i in x if closes[i] < opens[i]] + bear_v = [vols[i] for i in bear_x] + plt.subplot(2, 1) + if bull_x: + plt.bar(bull_x, bull_v, color="green") + if bear_x: + plt.bar(bear_x, bear_v, color="red") + plt.xticks(x[::step], times[::step]) + plt.ylabel("Volume") + plt.theme("dark") + + plt.show() + + # Summary line + day_open = opens[0] + day_close = closes[-1] + day_high = max(highs) + day_low = min(lows) + chg = day_close - day_open + chg_pct = (chg / day_open) * 100 if day_open else 0 + sign = "▲" if chg >= 0 else "▼" + print(f"\n Open {day_open:,.2f} High {day_high:,.2f} Low {day_low:,.2f} " + f"Close {day_close:,.2f} {sign} {chg:+.2f} ({chg_pct:+.2f}%)") + print(f" Candles: {len(candles)} | Interval: {interval} min | Date: {date_str}") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Intraday candlestick chart in the terminal") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="SENSEX", help="Instrument name (default: SENSEX)") + parser.add_argument("--exchange", default="BSE", help="Exchange: NSE or BSE (default: BSE)") + parser.add_argument("--interval", type=int, default=5, + help="Candle interval in minutes: 1, 3, 5, 10, 15, 30 (default: 5)") + args = parser.parse_args() + + client = get_api_client(args.token) + instrument_key, display_name = resolve_instrument(client, args.query, args.exchange) + + print(f"Fetching {args.interval}-min intraday candles for {display_name} ({instrument_key})...\n") + + api = upstox_client.HistoryV3Api(client) + resp = api.get_intra_day_candle_data(instrument_key, "minutes", args.interval) + candles = resp.data.candles if resp.data else [] + # API returns newest-first; reverse for chronological order + candles = list(reversed(candles)) + + draw_chart(candles, display_name, args.interval) + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/live_depth.py b/interactive-examples/market_data/live_depth.py new file mode 100644 index 0000000..e086f27 --- /dev/null +++ b/interactive-examples/market_data/live_depth.py @@ -0,0 +1,284 @@ +""" +Live Market Depth (5-level) — WebSocket streaming using MarketDataStreamerV3. + +Layout: + ┌─ Banner ────────────────────────────────────────────────┐ + │ SENSEX index │ NIFTY 50 index │ NIFTY BANK index │ + └─────────────────────────────────────────────────────────┘ + ┌─ Grid ──────────────────────────────────────────────────┐ + │ NIFTY FUT (5-level depth) │ SENSEX FUT (5-level) │ + ├─────────────────────────────┼───────────────────────────┤ + │ RELIANCE NSE (5-level) │ RELIANCE BSE (5-level) │ + └─────────────────────────────────────────────────────────┘ + +Runs until Ctrl-C (the test_runner aborts it after 5 seconds). + +Usage: + python market_data/live_depth.py --token + +For 30-level depth (requires Plus Pack): use live_depth_d30.py +""" + +import argparse +import re +import sys +import os +import time +import threading + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, get_futures_sorted +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +YELLOW= "\033[33m" +DIM = "\033[2m" +RESET = "\033[0m" +CLEAR = "\033[2J\033[H" + +SENSEX_IDX_KEY = "BSE_INDEX|SENSEX" +NIFTY_IDX_KEY = "NSE_INDEX|Nifty 50" +NIFTYBANK_IDX_KEY = "NSE_INDEX|Nifty Bank" +RELIANCE_NSE_KEY = "NSE_EQ|INE002A01018" +RELIANCE_BSE_KEY = "BSE_EQ|INE002A01018" + +INDEX_KEYS = [SENSEX_IDX_KEY, NIFTY_IDX_KEY, NIFTYBANK_IDX_KEY] +INDEX_NAMES = { + SENSEX_IDX_KEY: "SENSEX", + NIFTY_IDX_KEY: "NIFTY 50", + NIFTYBANK_IDX_KEY: "NIFTY BANK", +} + +depth_store: dict = {} +lock = threading.Lock() + +_ANSI = re.compile(r"\033\[[0-9;]*m") + +def visible_len(s: str) -> int: + return len(_ANSI.sub("", s)) + +def pad_to(s: str, width: int) -> str: + return s + " " * max(0, width - visible_len(s)) + + +# ── Rendering ───────────────────────────────────────────────────────────────── + +COL_WIDTH = 56 +BANNER_WIDTH = 30 # per index in the banner + + +def render_banner_cell(name: str, index_ff: dict) -> list[str]: + """Compact 3-line cell for one index in the banner.""" + ltpc = index_ff.get("ltpc", {}) + ltp = ltpc.get("ltp") + cp = ltpc.get("cp") + + ltp_str = f"{float(ltp):>12,.2f}" if ltp else f"{'—':>12}" + lines = [f"{BOLD}{CYAN}{name}{RESET}"] + lines.append(f" LTP: {BOLD}{ltp_str}{RESET}") + if ltp and cp: + chg = float(ltp) - float(cp) + pct = chg / float(cp) * 100 + col = GREEN if chg >= 0 else RED + lines.append(f" Chg: {col}{chg:+,.2f} ({pct:+.2f}%){RESET}") + else: + lines.append("") + return lines + + +def render_depth(symbol: str, market_ff: dict, max_levels: int = 5) -> list[str]: + lines = [f"{BOLD}{CYAN}{symbol}{RESET}"] + + ltpc = market_ff.get("ltpc", {}) + ltp = ltpc.get("ltp") + cp = ltpc.get("cp") + lines.append(f" LTP: {BOLD}{float(ltp):,.2f}{RESET}" if ltp else " LTP: —") + if ltp and cp: + chg = float(ltp) - float(cp) + pct = chg / float(cp) * 100 + col = GREEN if chg >= 0 else RED + lines.append(f" Chg: {col}{chg:+,.2f} ({pct:+.2f}%){RESET}") + lines.append("") + + quotes = market_ff.get("marketLevel", {}).get("bidAskQuote", []) + if not quotes: + lines.append(" (depth not available)") + return lines + + lines.append(f" {'Qty':>10} {'Bid':>12} {'Ask':>12} {'Qty':>10}") + lines.append(" " + "─" * 50) + + for q in quotes[:max_levels]: + bid_p = f"{float(q.get('bidP', 0)):,.2f}" + bid_q = f"{int(q.get('bidQ', 0)):,}" + ask_p = f"{float(q.get('askP', 0)):,.2f}" + ask_q = f"{int(q.get('askQ', 0)):,}" + lines.append(f" {GREEN}{bid_q:>10}{RESET} {GREEN}{bid_p:>12}{RESET} " + f"{RED}{ask_p:>12}{RESET} {RED}{ask_q:>10}{RESET}") + + return lines + + +def print_banner(store: dict): + """Print the 3-index banner side by side.""" + cells = [] + for key in INDEX_KEYS: + name = INDEX_NAMES[key] + entry = store.get(name) + if entry: + _, ff, _ = entry + cells.append(render_banner_cell(name, ff)) + else: + cells.append([f"{DIM}{name} — waiting...{RESET}", "", ""]) + + # Pad all cells to same height + n = max(len(c) for c in cells) + for c in cells: + c += [""] * (n - len(c)) + + sep = f" {DIM}│{RESET} " + bw = BANNER_WIDTH + for row in zip(*cells): + print(pad_to(row[0], bw) + sep + pad_to(row[1], bw) + sep + row[2]) + print(DIM + "─" * (bw * 3 + 12) + RESET) + + +def redraw(grid: list[list[str]], store: dict): + print(CLEAR, end="") + print(f"{BOLD}Live Market {RESET}{DIM}(Ctrl-C to stop){RESET}\n") + + print_banner(store) + print() + + sep = DIM + "─" * (COL_WIDTH * 2 + 6) + RESET + + for row_idx, row in enumerate(grid): + if row_idx > 0: + print(sep) + + cols = [] + for sym in row: + entry = store.get(sym) + if entry is None: + cols.append([f"{DIM}{sym} — waiting...{RESET}"]) + else: + feed_type, ff_data, levels = entry + cols.append(render_depth(sym, ff_data, levels)) + + left, right = cols[0], cols[1] + n = max(len(left), len(right)) + left += [""] * (n - len(left)) + right += [""] * (n - len(right)) + for l, r in zip(left, right): + print(pad_to(l, COL_WIDTH) + f" {DIM}│{RESET} " + r) + + print() + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Live depth 2×2 + index banner") + parser.add_argument("--token", required=True) + args = parser.parse_args() + + client = get_api_client(args.token) + + print("Resolving NIFTY near-month futures (NSE)...") + nifty_futures = get_futures_sorted(client, "NIFTY", exchange="NSE", exact_symbol=True) + if not nifty_futures: + print("No NIFTY futures found."); sys.exit(1) + nifty_key = nifty_futures[0]["instrument_key"] + nifty_sym = nifty_futures[0]["trading_symbol"] + print(f" → {nifty_sym}") + + print("Resolving SENSEX near-month futures (BSE)...") + sensex_futures = get_futures_sorted(client, "SENSEX", exchange="BSE", exact_symbol=True) + if not sensex_futures: + print("No SENSEX futures found."); sys.exit(1) + sensex_key = sensex_futures[0]["instrument_key"] + sensex_sym = sensex_futures[0]["trading_symbol"] + print(f" → {sensex_sym}\n") + + # key → (display_symbol, feed_type, levels) + key_map = { + nifty_key: (nifty_sym, "market", 5), + sensex_key: (sensex_sym, "market", 5), + RELIANCE_NSE_KEY: ("RELIANCE NSE","market", 5), + RELIANCE_BSE_KEY: ("RELIANCE BSE","market", 5), + SENSEX_IDX_KEY: ("SENSEX", "index", 0), + NIFTY_IDX_KEY: ("NIFTY 50", "index", 0), + NIFTYBANK_IDX_KEY: ("NIFTY BANK", "index", 0), + } + + grid = [ + [nifty_sym, sensex_sym], + ["RELIANCE NSE", "RELIANCE BSE"], + ] + + all_keys = [ + nifty_key, sensex_key, + RELIANCE_NSE_KEY, RELIANCE_BSE_KEY, + SENSEX_IDX_KEY, NIFTY_IDX_KEY, NIFTYBANK_IDX_KEY, + ] + + streamer = upstox_client.MarketDataStreamerV3(client) + + def on_open(): + streamer.subscribe(all_keys, "full") + print("Subscribed. Waiting for data...\n") + + def on_message(msg): + feeds = msg.get("feeds", {}) if isinstance(msg, dict) else {} + if not feeds: + return + + updated = False + for key, feed in feeds.items(): + info = key_map.get(key) + if not info: + continue + symbol, feed_type, levels = info + full_feed = feed.get("fullFeed", {}) if isinstance(feed, dict) else {} + if feed_type == "index": + ff = full_feed.get("indexFF", {}) if isinstance(full_feed, dict) else {} + else: + ff = full_feed.get("marketFF", {}) if isinstance(full_feed, dict) else {} + if not ff: + continue + with lock: + depth_store[symbol] = (feed_type, ff, levels) + updated = True + + if updated: + with lock: + store = dict(depth_store) + redraw(grid, store) + + def on_error(err): + print(f"\n{RED}WebSocket error:{RESET} {err}") + + def on_close(): + print("\nWebSocket connection closed.") + + streamer.on("open", on_open) + streamer.on("message", on_message) + streamer.on("error", on_error) + streamer.on("close", on_close) + + streamer.connect() + + try: + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + streamer.disconnect() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/live_depth_d30.py b/interactive-examples/market_data/live_depth_d30.py new file mode 100644 index 0000000..1a169be --- /dev/null +++ b/interactive-examples/market_data/live_depth_d30.py @@ -0,0 +1,184 @@ +""" +Live Market Depth (30-level) — WebSocket streaming using MarketDataStreamerV3. + +Requires Upstox Plus Pack (full_d30 subscription). + +Subscribes two feeds in FULL_D30 mode (30-level bid/ask depth): + • NIFTY near-month futures + • RELIANCE equity + +Displays both instruments side by side, updated live. +Runs until Ctrl-C (the test_runner aborts it after 5 seconds). + +Usage: + python market_data/live_depth_d30.py --token + python market_data/live_depth_d30.py --token --future BANKNIFTY + +For 5-level depth (no Plus Pack needed): use live_depth.py +""" + +import argparse +import re +import sys +import os +import time +import threading + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, get_futures_sorted +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" +CLEAR = "\033[2J\033[H" + +RELIANCE_KEY = "NSE_EQ|INE002A01018" + +depth_store: dict = {} +lock = threading.Lock() + +_ANSI = re.compile(r"\033\[[0-9;]*m") + +def visible_len(s: str) -> int: + return len(_ANSI.sub("", s)) + +def pad_to(s: str, width: int) -> str: + return s + " " * max(0, width - visible_len(s)) + + +# ── Depth rendering ─────────────────────────────────────────────────────────── + +COL_WIDTH = 60 # visible width of each instrument column (wider for level number) + +def render_depth(symbol: str, market_ff: dict, max_levels: int = 30) -> list[str]: + """Return a list of lines (no embedded newlines) for one instrument.""" + lines = [f"{BOLD}{CYAN}{symbol}{RESET}"] + + ltp = market_ff.get("ltpc", {}).get("ltp") + lines.append(f" LTP: {BOLD}{float(ltp):,.2f}{RESET}" if ltp else " LTP: —") + lines.append("") # blank spacer + + quotes = market_ff.get("marketLevel", {}).get("bidAskQuote", []) + if not quotes: + lines.append(" (depth not available)") + return lines + + lines.append(f" {'#':>3} {'Qty':>10} {'Bid':>12} {'Ask':>12} {'Qty':>10}") + lines.append(" " + "─" * 56) + + for i, q in enumerate(quotes[:max_levels]): + bid_p = f"{float(q.get('bidP', 0)):,.2f}" + bid_q = f"{int(q.get('bidQ', 0)):,}" + ask_p = f"{float(q.get('askP', 0)):,.2f}" + ask_q = f"{int(q.get('askQ', 0)):,}" + lines.append(f" {DIM}{i+1:>3}{RESET} {GREEN}{bid_q:>10}{RESET} {GREEN}{bid_p:>12}{RESET} " + f"{RED}{ask_p:>12}{RESET} {RED}{ask_q:>10}{RESET}") + + return lines + + +def redraw(symbols: list[str]): + """Clear terminal and print both instruments side by side.""" + with lock: + store = dict(depth_store) + + print(CLEAR, end="") + print(f"{BOLD}Live Market Depth — 30 Level{RESET} {DIM}(Plus Pack required · Ctrl-C to stop){RESET}\n") + + cols = [] + for sym in symbols: + if sym in store: + market_ff, levels = store[sym] + cols.append(render_depth(sym, market_ff, levels)) + else: + cols.append([f"{DIM}{sym} — waiting...{RESET}"]) + + if len(cols) == 2: + left, right = cols + n = max(len(left), len(right)) + left += [""] * (n - len(left)) + right += [""] * (n - len(right)) + for l, r in zip(left, right): + print(pad_to(l, COL_WIDTH) + f" {DIM}│{RESET} " + r) + else: + for line in cols[0]: + print(line) + + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Live 30-level depth: NIFTY FUT + RELIANCE (full_d30, Plus Pack required)") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--future", default="NIFTY", help="Futures underlying (default: NIFTY)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Resolving {args.future} near-month futures...") + futures = get_futures_sorted(client, args.future, exact_symbol=True) + if not futures: + print(f"No futures found for {args.future}.") + sys.exit(1) + nifty_fut_key = futures[0]["instrument_key"] + nifty_sym = futures[0]["trading_symbol"] + print(f" → {nifty_sym} ({nifty_fut_key})") + print(f" → RELIANCE ({RELIANCE_KEY})\n") + print("Connecting to WebSocket...\n") + + symbols = [nifty_sym, "RELIANCE"] + + streamer = upstox_client.MarketDataStreamerV3(client) + + def on_open(): + streamer.subscribe([nifty_fut_key, RELIANCE_KEY], "full_d30") + print("Subscribed (full_d30 / 30-level). Waiting for data...\n") + + def on_message(msg): + feeds = msg.get("feeds", {}) if isinstance(msg, dict) else {} + if not feeds: + return + + updated = False + for key, feed in feeds.items(): + full_feed = feed.get("fullFeed", {}) if isinstance(feed, dict) else {} + market_ff = full_feed.get("marketFF", {}) if isinstance(full_feed, dict) else {} + if not market_ff: + continue + symbol = nifty_sym if key == nifty_fut_key else "RELIANCE" + with lock: + depth_store[symbol] = (market_ff, 30) + updated = True + + if updated: + redraw(symbols) + + def on_error(err): + print(f"\n{RED}WebSocket error:{RESET} {err}") + + def on_close(): + print("\nWebSocket connection closed.") + + streamer.on("open", on_open) + streamer.on("message", on_message) + streamer.on("error", on_error) + streamer.on("close", on_close) + + streamer.connect() + + try: + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + streamer.disconnect() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/live_depth_mcx.py b/interactive-examples/market_data/live_depth_mcx.py new file mode 100644 index 0000000..f6a7806 --- /dev/null +++ b/interactive-examples/market_data/live_depth_mcx.py @@ -0,0 +1,225 @@ +""" +MCX Live Depth (5-level) — WebSocket streaming using MarketDataStreamerV3. + +Fetches current-month futures for well-known MCX commodities, ranks them by +today's traded volume, then subscribes the top N in FULL mode (5-level depth). + +Instruments are displayed side by side (2 per row), updated live. +Runs until Ctrl-C (the test_runner aborts it after 5 seconds). + +Usage: + python market_data/live_depth_mcx.py --token + python market_data/live_depth_mcx.py --token --top 6 +""" + +import argparse +import re +import sys +import os +import time +import threading + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" +CLEAR = "\033[2J\033[H" + +# Well-known MCX commodity futures, checked in order +MCX_CANDIDATES = [ + "GOLD", "SILVER", "CRUDEOIL", "NATURALGAS", + "COPPER", "ZINC", "ALUMINIUM", "NICKEL", "LEAD", "COTTON", +] + +depth_store: dict = {} +lock = threading.Lock() + +_ANSI = re.compile(r"\033\[[0-9;]*m") + + +def visible_len(s: str) -> int: + return len(_ANSI.sub("", s)) + + +def pad_to(s: str, width: int) -> str: + return s + " " * max(0, width - visible_len(s)) + + +# ── Instrument discovery ─────────────────────────────────────────────────────── + +def find_top_mcx(client, top_n: int) -> list[dict]: + """Return up to top_n MCX current-month futures sorted by volume (desc).""" + found = [] + for sym in MCX_CANDIDATES: + resp = search_instrument( + client, sym, + exchanges="MCX", + segments="COMM", + instrument_types="FUT", + expiry="current_month", + records=5, + ) + instruments = [i for i in (resp.data or []) + if isinstance(i, dict) and i.get("trading_symbol", "").upper().startswith(sym)] + if instruments: + instruments.sort(key=lambda x: x.get("expiry", "")) + found.append(instruments[0]) + + if not found: + return [] + + # Rank by today's traded volume + keys = [i["instrument_key"] for i in found] + try: + ltp_data = get_ltp(client, *keys) + def _vol(instr): + entry = ltp_data.get(instr["instrument_key"], {}) + v = entry.get("volume") if isinstance(entry, dict) else getattr(entry, "volume", 0) + return v or 0 + found.sort(key=_vol, reverse=True) + except Exception: + pass # if quote call fails, keep discovery order + + return found[:top_n] + + +# ── Depth rendering ──────────────────────────────────────────────────────────── + +COL_WIDTH = 58 # visible width of each instrument column + +def render_depth(symbol: str, market_ff: dict) -> list[str]: + lines = [f"{BOLD}{CYAN}{symbol}{RESET}"] + + ltp = market_ff.get("ltpc", {}).get("ltp") + lines.append(f" LTP: {BOLD}{float(ltp):,.2f}{RESET}" if ltp else " LTP: —") + lines.append("") + + quotes = market_ff.get("marketLevel", {}).get("bidAskQuote", []) + if not quotes: + lines.append(" (depth not available)") + return lines + + lines.append(f" {'Qty':>10} {'Bid':>12} {'Ask':>12} {'Qty':>10}") + lines.append(" " + "─" * 50) + + for q in quotes[:5]: + bid_p = f"{float(q.get('bidP', 0)):,.2f}" + bid_q = f"{int(q.get('bidQ', 0)):,}" + ask_p = f"{float(q.get('askP', 0)):,.2f}" + ask_q = f"{int(q.get('askQ', 0)):,}" + lines.append(f" {GREEN}{bid_q:>10}{RESET} {GREEN}{bid_p:>12}{RESET} " + f"{RED}{ask_p:>12}{RESET} {RED}{ask_q:>10}{RESET}") + + return lines + + +def redraw(instruments: list[dict]): + """Clear terminal and print instruments 2-wide.""" + with lock: + store = dict(depth_store) + + print(CLEAR, end="") + print(f"{BOLD}MCX Live Depth — top {len(instruments)} by volume{RESET} {DIM}(Ctrl-C to stop){RESET}\n") + + cols = [] + for instr in instruments: + key = instr["instrument_key"] + sym = instr["trading_symbol"] + if key in store: + cols.append(render_depth(sym, store[key])) + else: + cols.append([f"{DIM}{sym} — waiting...{RESET}"]) + + # Print in pairs (2 columns per row) + for i in range(0, len(cols), 2): + left = cols[i] + right = cols[i + 1] if i + 1 < len(cols) else [] + n = max(len(left), len(right)) + left += [""] * (n - len(left)) + right += [""] * (n - len(right)) + for l_line, r_line in zip(left, right): + if right: + print(pad_to(l_line, COL_WIDTH) + f" {DIM}│{RESET} " + r_line) + else: + print(l_line) + print() + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="MCX live 5-level depth for top commodities by volume") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--top", type=int, default=4, help="Number of instruments to subscribe (default: 4)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Discovering top {args.top} MCX commodities by volume...") + instruments = find_top_mcx(client, args.top) + if not instruments: + print("No MCX futures found.") + sys.exit(1) + + for instr in instruments: + print(f" → {instr['trading_symbol']} ({instr['instrument_key']})") + print("\nConnecting to WebSocket...\n") + + keys = [i["instrument_key"] for i in instruments] + key_to_instr = {i["instrument_key"]: i for i in instruments} + + streamer = upstox_client.MarketDataStreamerV3(client) + + def on_open(): + streamer.subscribe(keys, "full") + print("Subscribed (full / 5-level). Waiting for data...\n") + + def on_message(msg): + feeds = msg.get("feeds", {}) if isinstance(msg, dict) else {} + if not feeds: + return + + updated = False + for key, feed in feeds.items(): + if key not in key_to_instr: + continue + full_feed = feed.get("fullFeed", {}) if isinstance(feed, dict) else {} + market_ff = full_feed.get("marketFF", {}) if isinstance(full_feed, dict) else {} + if not market_ff: + continue + with lock: + depth_store[key] = market_ff + updated = True + + if updated: + redraw(instruments) + + def on_error(err): + print(f"\n{RED}WebSocket error:{RESET} {err}") + + def on_close(): + print("\nWebSocket connection closed.") + + streamer.on("open", on_open) + streamer.on("message", on_message) + streamer.on("error", on_error) + streamer.on("close", on_close) + + streamer.connect() + + try: + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + streamer.disconnect() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/live_depth_usdinr.py b/interactive-examples/market_data/live_depth_usdinr.py new file mode 100644 index 0000000..6deeb1a --- /dev/null +++ b/interactive-examples/market_data/live_depth_usdinr.py @@ -0,0 +1,197 @@ +""" +USDINR Live Depth (5-level) — WebSocket streaming using MarketDataStreamerV3. + +Resolves the near-month USDINR futures contract from both NSE (CDS) and BSE (BCD), +then subscribes both in FULL mode (5-level depth) and displays them side by side. + +Runs until Ctrl-C (the test_runner aborts it after 5 seconds). + +Usage: + python market_data/live_depth_usdinr.py --token +""" + +import argparse +import re +import sys +import os +import time +import threading + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" +CLEAR = "\033[2J\033[H" + +depth_store: dict = {} +lock = threading.Lock() + +_ANSI = re.compile(r"\033\[[0-9;]*m") + + +def visible_len(s: str) -> int: + return len(_ANSI.sub("", s)) + + +def pad_to(s: str, width: int) -> str: + return s + " " * max(0, width - visible_len(s)) + + +# ── Instrument discovery ─────────────────────────────────────────────────────── + +def find_usdinr(client, exchange: str): + """Return the nearest-expiry USDINR future for the given exchange (CDS or BCD).""" + resp = search_instrument( + client, "USDINR", + exchanges="NSE" if exchange == "CDS" else "BSE", + segments="CURR", + instrument_types="FUT", + expiry="current_month", + records=10, + ) + instruments = resp.data or [] + # Filter to exact USDINR symbol (not USDINR derivatives like options) + futures = [i for i in instruments + if isinstance(i, dict) and "USDINR" in i.get("trading_symbol", "").upper()] + if not futures: + return None + # Sort by expiry (nearest first) + futures.sort(key=lambda x: x.get("expiry", "")) + return futures[0] + + +# ── Depth rendering ──────────────────────────────────────────────────────────── + +COL_WIDTH = 58 + + +def render_depth(label: str, market_ff: dict) -> list[str]: + lines = [f"{BOLD}{CYAN}{label}{RESET}"] + + ltp = market_ff.get("ltpc", {}).get("ltp") + lines.append(f" LTP: {BOLD}{float(ltp):,.4f}{RESET}" if ltp else " LTP: —") + lines.append("") + + quotes = market_ff.get("marketLevel", {}).get("bidAskQuote", []) + if not quotes: + lines.append(" (depth not available)") + return lines + + lines.append(f" {'Qty':>10} {'Bid':>12} {'Ask':>12} {'Qty':>10}") + lines.append(" " + "─" * 50) + + for q in quotes[:5]: + bid_p = f"{float(q.get('bidP', 0)):,.4f}" + bid_q = f"{int(q.get('bidQ', 0)):,}" + ask_p = f"{float(q.get('askP', 0)):,.4f}" + ask_q = f"{int(q.get('askQ', 0)):,}" + lines.append(f" {GREEN}{bid_q:>10}{RESET} {GREEN}{bid_p:>12}{RESET} " + f"{RED}{ask_p:>12}{RESET} {RED}{ask_q:>10}{RESET}") + + return lines + + +def redraw(nse_instr: dict, bse_instr: dict): + with lock: + store = dict(depth_store) + + print(CLEAR, end="") + print(f"{BOLD}USDINR Near-Month Futures — NSE (CDS) vs BSE (BCD){RESET} {DIM}(Ctrl-C to stop){RESET}\n") + + nse_key = nse_instr["instrument_key"] + bse_key = bse_instr["instrument_key"] + + left = render_depth(f"NSE {nse_instr['trading_symbol']}", store[nse_key]) if nse_key in store else [f"{DIM}NSE — waiting...{RESET}"] + right = render_depth(f"BSE {bse_instr['trading_symbol']}", store[bse_key]) if bse_key in store else [f"{DIM}BSE — waiting...{RESET}"] + + n = max(len(left), len(right)) + left += [""] * (n - len(left)) + right += [""] * (n - len(right)) + + for l_line, r_line in zip(left, right): + print(pad_to(l_line, COL_WIDTH) + f" {DIM}│{RESET} " + r_line) + print() + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="USDINR near-month futures depth: NSE CDS vs BSE BCD") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + args = parser.parse_args() + + client = get_api_client(args.token) + + print("Resolving USDINR near-month futures...") + nse_instr = find_usdinr(client, "CDS") + bse_instr = find_usdinr(client, "BCD") + + if not nse_instr: + print("Could not find USDINR futures on NSE (CDS).") + sys.exit(1) + if not bse_instr: + print("Could not find USDINR futures on BSE (BCD).") + sys.exit(1) + + print(f" → NSE: {nse_instr['trading_symbol']} ({nse_instr['instrument_key']})") + print(f" → BSE: {bse_instr['trading_symbol']} ({bse_instr['instrument_key']})") + print("\nConnecting to WebSocket...\n") + + nse_key = nse_instr["instrument_key"] + bse_key = bse_instr["instrument_key"] + + streamer = upstox_client.MarketDataStreamerV3(client) + + def on_open(): + streamer.subscribe([nse_key, bse_key], "full") + print("Subscribed (full / 5-level). Waiting for data...\n") + + def on_message(msg): + feeds = msg.get("feeds", {}) if isinstance(msg, dict) else {} + if not feeds: + return + + updated = False + for key, feed in feeds.items(): + if key not in (nse_key, bse_key): + continue + full_feed = feed.get("fullFeed", {}) if isinstance(feed, dict) else {} + market_ff = full_feed.get("marketFF", {}) if isinstance(full_feed, dict) else {} + if not market_ff: + continue + with lock: + depth_store[key] = market_ff + updated = True + + if updated: + redraw(nse_instr, bse_instr) + + def on_error(err): + print(f"\n{RED}WebSocket error:{RESET} {err}") + + def on_close(): + print("\nWebSocket connection closed.") + + streamer.on("open", on_open) + streamer.on("message", on_message) + streamer.on("error", on_error) + streamer.on("close", on_close) + + streamer.connect() + + try: + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + streamer.disconnect() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/market_holidays.py b/interactive-examples/market_data/market_holidays.py new file mode 100644 index 0000000..fb387f0 --- /dev/null +++ b/interactive-examples/market_data/market_holidays.py @@ -0,0 +1,158 @@ +""" +Market Holidays — split the exchange holiday calendar into past and upcoming, +displayed as two formatted tables. + +Usage: + python market_data/market_holidays.py --token +""" + +import argparse +import sys +import os +from datetime import date, datetime, timezone, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +DIM = "\033[2m" +RESET = "\033[0m" +BLACK = "\033[30m" +BG_ALT = "\033[47m" # light grey stripe +FG_RST = "\033[39m" # reset foreground only (keeps background) +DIM_RST = "\033[22m" # reset bold/dim only (keeps background) + +IST = timezone(timedelta(hours=5, minutes=30)) + + +def fmt_days(delta: int) -> str: + if delta == 0: + return "today" + if delta == 1: + return "tomorrow" + if delta == -1: + return "yesterday" + return f"{abs(delta)}d {'ago' if delta < 0 else 'ahead'}" + + +def ms_to_ist(ms: int) -> str: + return datetime.fromtimestamp(ms / 1000, tz=IST).strftime("%H:%M") + + +def session_label(exchange: str, start_hm: str, end_hm: str) -> str: + """Classify a partial session into a human-readable label.""" + start_min = int(start_hm[:2]) * 60 + int(start_hm[3:]) + end_min = int(end_hm[:2]) * 60 + int(end_hm[3:]) + duration = end_min - start_min + + if exchange in ("MCX", "NSCOM"): + if start_min >= 17 * 60 and duration <= 90: + return "Muhurat trading" + elif start_min >= 17 * 60: + return "evening session open" + elif end_min <= 17 * 60: + return "morning session open" + else: + return "both sessions open" + else: + return "normal trading" + + +def main(): + parser = argparse.ArgumentParser(description="Exchange holiday calendar — past vs upcoming") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + args = parser.parse_args() + + client = get_api_client(args.token) + api = upstox_client.MarketHolidaysAndTimingsApi(client) + + resp = api.get_holidays() + holidays = resp.data if resp.data else [] + if not isinstance(holidays, list): + holidays = [holidays] + + # Collect full exchange universe from all holidays + all_exchanges: set = set() + for h in holidays: + for e in (getattr(h, "closed_exchanges", None) or []): + all_exchanges.add(e) + for entry in (getattr(h, "_open_exchanges", None) or []): + exch = getattr(entry, "exchange", "") or (entry.get("exchange", "") if isinstance(entry, dict) else "") + if exch: + all_exchanges.add(exch) + + today = date.today() + past, upcoming = [], [] + + for h in holidays: + raw_date = getattr(h, "_date", None) or getattr(h, "date", None) or "" + description = getattr(h, "description", None) or getattr(h, "holiday_name", "") or "" + closed_exch = set(getattr(h, "closed_exchanges", None) or []) + open_exch = getattr(h, "_open_exchanges", None) or [] + + partial_exch = set() + normal_exch = set() + label_map: dict = {} + for entry in open_exch: + exch = getattr(entry, "exchange", "") or (entry.get("exchange", "") if isinstance(entry, dict) else "") + start = getattr(entry, "start_time", 0) or (entry.get("start_time", 0) if isinstance(entry, dict) else 0) + end = getattr(entry, "end_time", 0) or (entry.get("end_time", 0) if isinstance(entry, dict) else 0) + if exch and start and end: + label = session_label(exch, ms_to_ist(start), ms_to_ist(end)) + if label in ("normal trading", "both sessions open"): + normal_exch.add(exch) + else: + partial_exch.add(exch) + label_map.setdefault(label, []).append(exch) + + # Fully open = not closed, not partial (normal trading from open_exchanges counts as open) + fully_open = sorted(all_exchanges - closed_exch - partial_exch) + + closed_str = ",".join(sorted(closed_exch)) if closed_exch else "—" + open_str = ",".join(fully_open) if fully_open else "—" + partial_parts = [f"{','.join(exchs)}: {lbl}" for lbl, exchs in label_map.items()] + partial_note = " | ".join(partial_parts) + + try: + if hasattr(raw_date, "date"): + hdate = raw_date.date() + else: + hdate = date.fromisoformat(str(raw_date)[:10]) + except Exception: + continue + + delta = (hdate - today).days + date_str = f"{hdate} {hdate.strftime('%a')}" + row = (date_str, description, open_str, closed_str, partial_note, delta) + if delta < 0: + past.append(row) + else: + upcoming.append(row) + + past.sort(key=lambda r: r[0], reverse=True) + upcoming.sort(key=lambda r: r[0]) + + def print_table(title, rows, colour): + print(f"\n{BOLD}{colour}{title}{RESET}") + print(f"{'Date':<18} {'Holiday':<28} {'Open':<30} {'Closed':<38} {'When'}") + print("─" * 120) + if not rows: + print(" (none)") + return + for i, (date_str, desc, open_s, closed, partial, delta) in enumerate(rows): + bg = BG_ALT if i % 2 == 0 else "" + when = fmt_days(delta) + print(f"{bg}{date_str:<18} {desc[:27]:<28} {GREEN}{open_s:<30}{FG_RST}{bg} {closed:<38} {BLACK}{when}{FG_RST}\033[K{RESET}") + if partial: + print(f"{bg}{'':18} {YELLOW}partial: {partial}{FG_RST}\033[K{RESET}") + + print_table("Past Holidays (this year)", past, "\033[2m") + print_table("Upcoming Holidays", upcoming, GREEN) + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/market_status.py b/interactive-examples/market_data/market_status.py new file mode 100644 index 0000000..130c59e --- /dev/null +++ b/interactive-examples/market_data/market_status.py @@ -0,0 +1,66 @@ +""" +Market Status — print live open/closed/pre-open status for NSE, BSE, and MCX. + +Usage: + python market_data/market_status.py --token +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client +import upstox_client + +EXCHANGES = ["NSE", "BSE", "MCX", "NFO", "BFO", "CDS"] + +STATUS_COLOUR = { + "open": "\033[32m", # green + "pre_open": "\033[33m", # yellow + "closed": "\033[31m", # red +} +RESET = "\033[0m" +BOLD = "\033[1m" + + +def colour(status: str) -> str: + s = (status or "").lower().replace(" ", "_") + c = STATUS_COLOUR.get(s, "") + return f"{c}{status}{RESET}" if c else status + + +def main(): + parser = argparse.ArgumentParser(description="Live market status for NSE, BSE, MCX") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + args = parser.parse_args() + + client = get_api_client(args.token) + api = upstox_client.MarketHolidaysAndTimingsApi(client) + + print(f"\n{BOLD}Market Status{RESET}\n") + print(f"{'Exchange':<12} {'Status'}") + print("─" * 35) + + for exchange in EXCHANGES: + try: + resp = api.get_market_status(exchange) + m = resp.data + if m is None: + print(f"{exchange:<12} {'N/A':<18}") + continue + status = getattr(m, "status", "") or "" + exch = getattr(m, "exchange", exchange) or exchange + print(f"{exchange:<12} {colour(status)}") + except Exception as e: + err = str(e) + if "400" in err or "404" in err or "No data" in err.lower(): + print(f"{exchange:<12} {'—':<18} (not available)") + else: + print(f"{exchange:<12} {'ERROR':<18} {str(e)[:50]}") + + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/market_data/market_timings.py b/interactive-examples/market_data/market_timings.py new file mode 100644 index 0000000..60ae7b0 --- /dev/null +++ b/interactive-examples/market_data/market_timings.py @@ -0,0 +1,84 @@ +""" +Market Timings — show exchange session windows (pre-open, normal, closing, post-close) +for a given date and highlight the currently active session based on IST time. + +Usage: + python market_data/market_timings.py --token + python market_data/market_timings.py --token --date 2026-03-28 +""" + +import argparse +import sys +import os +from datetime import datetime, timezone, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, today_str +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +DIM = "\033[2m" +RESET = "\033[0m" + +IST = timezone(timedelta(hours=5, minutes=30)) + + +def ist_now() -> str: + """Return current IST time as HH:MM.""" + return datetime.now(IST).strftime("%H:%M") + + +def is_active(start: str, end: str, now: str) -> bool: + """True if now falls within [start, end] (all HH:MM strings).""" + try: + return start <= now <= end + except Exception: + return False + + +def main(): + parser = argparse.ArgumentParser(description="Exchange session timings for a date") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--date", default=today_str(), + help="Date in YYYY-MM-DD format (default: today)") + args = parser.parse_args() + + client = get_api_client(args.token) + api = upstox_client.MarketHolidaysAndTimingsApi(client) + + resp = api.get_exchange_timings(args.date) + timings = resp.data if resp.data else [] + if not isinstance(timings, list): + timings = [timings] + + now_ist = ist_now() + print(f"\n{BOLD}Exchange Timings — {args.date} (IST now: {now_ist}){RESET}\n") + print(f"{'Exchange':<12} {'Segment':<20} {'Session':<18} {'Start':>7} {'End':>7} {'Active'}") + print("─" * 80) + + for t in timings: + exchange = getattr(t, "exchange", "") or "" + start_ms = getattr(t, "start_time", 0) or 0 + end_ms = getattr(t, "end_time", 0) or 0 + + # Convert ms epoch → IST HH:MM + def ms_to_ist_hm(ms): + if not ms: + return "—" + return datetime.fromtimestamp(ms / 1000, tz=IST).strftime("%H:%M") + + start_hm = ms_to_ist_hm(start_ms) + end_hm = ms_to_ist_hm(end_ms) + + active = is_active(start_hm, end_hm, now_ist) if start_hm != "—" else False + marker = f"{GREEN}◉ ACTIVE{RESET}" if active else f"{DIM}○{RESET}" + + print(f"{exchange:<12} {'Trading Hours':<20} {'Normal':<18} {start_hm:>7} {end_hm:>7} {marker}") + + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/expiry_decay.py b/interactive-examples/options_analytics/expiry_decay.py new file mode 100644 index 0000000..c70d5a5 --- /dev/null +++ b/interactive-examples/options_analytics/expiry_decay.py @@ -0,0 +1,170 @@ +""" +Expiry Day Premium Decay Tracker — visualise theta decay near expiry. + +Fetches ATM ± N CE and PE premiums for the current/nearest weekly expiry and +shows the premium as a percentage of the underlying spot price. Near expiry, +ATM options lose value rapidly (theta acceleration) — this script helps +quantify how much premium remains. + +Usage: + python options_analytics/expiry_decay.py --token + python options_analytics/expiry_decay.py --token --query BANKNIFTY --strikes 4 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" + +INDEX_LTP_KEYS = { + "NIFTY": "NSE_INDEX|Nifty 50", + "BANKNIFTY": "NSE_INDEX|Nifty Bank", + "FINNIFTY": "NSE_INDEX|Nifty Fin Service", + "MIDCPNIFTY": "NSE_INDEX|NIFTY MID SELECT", + "SENSEX": "BSE_INDEX|SENSEX", +} + + +def main(): + parser = argparse.ArgumentParser(description="Expiry premium decay tracker") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying (default: NIFTY)") + parser.add_argument("--expiry", default="current_week", + help="Expiry filter (default: current_week)") + parser.add_argument("--strikes", type=int, default=3, + help="Strikes each side of ATM (default: 3)") + args = parser.parse_args() + + client = get_api_client(args.token) + query_upper = args.query.upper() + + print(f"\nFetching expiry premiums for {query_upper} ({args.expiry}), " + f"ATM ± {args.strikes}...\n") + + # Get spot price + spot_key = INDEX_LTP_KEYS.get(query_upper) + spot = 0 + if spot_key: + ltp_data = get_ltp(client, spot_key) + entry = ltp_data.get(spot_key, {}) + spot = entry.get("last_price") if isinstance(entry, dict) else getattr(entry, "last_price", 0) + + if not spot: + resp = search_instrument(client, args.query, exchanges="NSE", segments="EQ", records=1) + hits = resp.data or [] + if hits: + eq_key = hits[0]["instrument_key"] + ltp_data = get_ltp(client, eq_key) + entry = ltp_data.get(eq_key, {}) + spot = entry.get("last_price") if isinstance(entry, dict) else getattr(entry, "last_price", 0) + + if not spot: + print("Could not fetch spot price.") + sys.exit(1) + + # Fetch CE and PE instruments across ATM ± N + ce_instruments = [] + pe_instruments = [] + for offset in range(-args.strikes, args.strikes + 1): + for itype, dest in [("CE", ce_instruments), ("PE", pe_instruments)]: + resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=args.expiry, + atm_offset=offset, records=1) + hits = resp.data or [] + if hits: + dest.append(hits[0]) + + if not ce_instruments and not pe_instruments: + print("No options found. Try a different --expiry.") + sys.exit(1) + + # Deduplicate by strike + def dedup(instruments): + seen = set() + result = [] + for inst in instruments: + s = inst.get("strike_price", 0) + if s not in seen: + seen.add(s) + result.append(inst) + return sorted(result, key=lambda x: x.get("strike_price", 0)) + + ce_instruments = dedup(ce_instruments) + pe_instruments = dedup(pe_instruments) + + # Fetch LTPs + all_keys = [i["instrument_key"] for i in ce_instruments + pe_instruments] + ltp_data = get_ltp(client, *all_keys) if all_keys else {} + + def get_price(key): + entry = ltp_data.get(key, {}) + return entry.get("last_price") if isinstance(entry, dict) else getattr(entry, "last_price", 0) + + # Build strike → (ce_premium, pe_premium) map + ce_map = {} + for inst in ce_instruments: + s = inst.get("strike_price", 0) + ce_map[s] = get_price(inst["instrument_key"]) or 0 + + pe_map = {} + for inst in pe_instruments: + s = inst.get("strike_price", 0) + pe_map[s] = get_price(inst["instrument_key"]) or 0 + + all_strikes = sorted(set(list(ce_map.keys()) + list(pe_map.keys()))) + atm_strike = min(all_strikes, key=lambda s: abs(s - spot)) if all_strikes else 0 + + expiry_label = ce_instruments[0].get("expiry", args.expiry) if ce_instruments else args.expiry + + # Display + print(f" Spot: {spot:,.2f} | ATM: {atm_strike:,.0f} | Expiry: {expiry_label}\n") + + print(f" {'Strike':>10} {'CE Prem':>10} {'CE %Spot':>10} " + f"{'PE Prem':>10} {'PE %Spot':>10} {'Straddle':>10} {'Strd %':>8}") + print(" " + "-" * 80) + + for strike in all_strikes: + ce_p = ce_map.get(strike, 0) + pe_p = pe_map.get(strike, 0) + ce_pct = ce_p / spot * 100 if spot else 0 + pe_pct = pe_p / spot * 100 if spot else 0 + straddle = ce_p + pe_p + strd_pct = straddle / spot * 100 if spot else 0 + + is_atm = strike == atm_strike + marker = f"{CYAN}→{RESET}" if is_atm else " " + bold = BOLD if is_atm else "" + + print(f" {marker}{bold}{strike:>10,.0f}{RESET} " + f"{GREEN}{ce_p:>10,.2f}{RESET} {ce_pct:>9.2f}% " + f"{RED}{pe_p:>10,.2f}{RESET} {pe_pct:>9.2f}% " + f"{bold}{straddle:>10,.2f}{RESET} {strd_pct:>7.2f}%") + + print(" " + "-" * 80) + + atm_ce = ce_map.get(atm_strike, 0) + atm_pe = pe_map.get(atm_strike, 0) + atm_total = atm_ce + atm_pe + atm_pct = atm_total / spot * 100 if spot else 0 + + print(f"\n {BOLD}ATM straddle premium: {atm_total:,.2f} ({atm_pct:.2f}% of spot){RESET}") + if atm_pct < 0.5: + print(f" {DIM}Very low premium — theta decay is nearly complete.{RESET}") + elif atm_pct < 1.5: + print(f" {CYAN}Moderate premium — significant decay expected if near expiry.{RESET}") + else: + print(f" {GREEN}Substantial premium remaining — time value still meaningful.{RESET}") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/gamma_exposure.py b/interactive-examples/options_analytics/gamma_exposure.py new file mode 100644 index 0000000..aa182dc --- /dev/null +++ b/interactive-examples/options_analytics/gamma_exposure.py @@ -0,0 +1,130 @@ +""" +Dealer Gamma Exposure (GEX) Estimator. + +GEX estimates how much market makers need to hedge as the underlying moves. +When GEX is positive (net long gamma), market makers are short options and +buy dips / sell rallies (dampening effect on volatility). +When GEX flips negative, market makers are long options and may amplify moves. + +GEX at each strike K (simplified): + GEX(K) = (CE_OI(K) - PE_OI(K)) * lot_size * spot * gamma + +Since we don't have actual gamma from Upstox (no greeks in full quote), +this script uses a proxy: (CE_OI - PE_OI) * lot_size weighted by moneyness. +A more accurate version would use Black-Scholes gamma. + +Usage: + python options_analytics/gamma_exposure.py --token + python options_analytics/gamma_exposure.py --token --query BANKNIFTY +""" + +import argparse +import sys +import os +import math + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_full_quote, get_ltp + + +def fetch_options(client, query, expiry, itype, num_strikes): + instruments = [] + for offset in range(-num_strikes, num_strikes + 1): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=offset, records=1) + data = resp.data or [] + if data: + instruments.append(data[0]) + seen = set() + unique = [] + for inst in instruments: + k = inst.get("strike_price", 0) + if k not in seen: + seen.add(k) + unique.append(inst) + return unique + + +def bs_gamma_approx(spot, strike, dte_days, iv_approx=0.15): + """Rough Black-Scholes gamma approximation for weighting.""" + t = max(dte_days / 365, 0.001) + d1 = (math.log(spot / strike) + 0.5 * iv_approx ** 2 * t) / (iv_approx * math.sqrt(t)) + phi = math.exp(-0.5 * d1 ** 2) / math.sqrt(2 * math.pi) + return phi / (spot * iv_approx * math.sqrt(t)) + + +def main(): + parser = argparse.ArgumentParser(description="Dealer gamma exposure estimator") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--strikes", type=int, default=8, help="Strikes each side of ATM (default: 8)") + parser.add_argument("--dte", type=int, default=15, help="Days to expiry estimate (default: 15)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Estimating GEX for {args.query} ({args.expiry})...\n") + + ce_insts = fetch_options(client, args.query, args.expiry, "CE", args.strikes) + pe_insts = fetch_options(client, args.query, args.expiry, "PE", args.strikes) + + all_keys = [i["instrument_key"] for i in ce_insts + pe_insts] + quote_data = get_full_quote(client, *all_keys) + + ce_data = {} + pe_data = {} + lot_size = 50 # default NIFTY lot size + + for inst in ce_insts: + k = inst.get("strike_price", 0) + q = quote_data.get(inst["instrument_key"]) + ce_data[k] = {"oi": q.oi if q else 0, "ltp": q.last_price if q else 0} + if inst.get("lot_size"): + lot_size = inst["lot_size"] + + for inst in pe_insts: + k = inst.get("strike_price", 0) + q = quote_data.get(inst["instrument_key"]) + pe_data[k] = {"oi": q.oi if q else 0, "ltp": q.last_price if q else 0} + + # Get ATM spot + atm_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, atm_offset=0, records=1) + atm_strike = (atm_resp.data or [{}])[0].get("strike_price", 0) + + all_strikes = sorted(set(list(ce_data.keys()) + list(pe_data.keys()))) + + gex_by_strike = {} + for strike in all_strikes: + ce_oi = ce_data.get(strike, {}).get("oi", 0) or 0 + pe_oi = pe_data.get(strike, {}).get("oi", 0) or 0 + gamma = bs_gamma_approx(atm_strike, strike, args.dte) + gex = (ce_oi - pe_oi) * lot_size * atm_strike * gamma + gex_by_strike[strike] = gex + + total_gex = sum(gex_by_strike.values()) + max_abs = max(abs(v) for v in gex_by_strike.values()) or 1 + + print(f"{'Strike':>10} {'CE OI':>10} {'PE OI':>10} {'GEX':>14} {'Bar':}") + print("-" * 75) + for strike in reversed(all_strikes): + ce_oi = ce_data.get(strike, {}).get("oi", 0) or 0 + pe_oi = pe_data.get(strike, {}).get("oi", 0) or 0 + gex = gex_by_strike[strike] + bar_len = int(abs(gex) / max_abs * 20) + bar = ("+" if gex >= 0 else "-") * bar_len + marker = " <<< ATM" if strike == atm_strike else "" + print(f"{strike:>10.0f} {ce_oi:>10,.0f} {pe_oi:>10,.0f} {gex:>14,.0f} {bar}{marker}") + + print(f"\nTotal GEX : {total_gex:,.0f}") + print(f"ATM strike : {atm_strike:,.0f}") + if total_gex > 0: + print("Positive GEX: Dealers short options → buy dips, sell rallies (stabilising).") + else: + print("Negative GEX: Dealers long options → may amplify directional moves.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/implied_move.py b/interactive-examples/options_analytics/implied_move.py new file mode 100644 index 0000000..6ca9134 --- /dev/null +++ b/interactive-examples/options_analytics/implied_move.py @@ -0,0 +1,134 @@ +""" +Implied / Expected Move Calculator — how much the market expects a stock/index to move. + +The expected move is derived from the ATM straddle premium for the nearest expiry: + + Expected Move = ATM CE premium + ATM PE premium + Expected Move % = Expected Move / Spot Price × 100 + Upper bound = Spot + Expected Move + Lower bound = Spot − Expected Move + +Statistically, the actual price stays within ±1 expected move ~68% of the time +(one standard deviation assumption). + +Usage: + python options_analytics/implied_move.py --token + python options_analytics/implied_move.py --token --query BANKNIFTY +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +RESET = "\033[0m" + +INDEX_LTP_KEYS = { + "NIFTY": "NSE_INDEX|Nifty 50", + "BANKNIFTY": "NSE_INDEX|Nifty Bank", + "FINNIFTY": "NSE_INDEX|Nifty Fin Service", + "MIDCPNIFTY": "NSE_INDEX|NIFTY MID SELECT", + "SENSEX": "BSE_INDEX|SENSEX", +} + + +def main(): + parser = argparse.ArgumentParser(description="Implied/expected move from ATM straddle") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", + help="Expiry filter (default: current_month)") + args = parser.parse_args() + + client = get_api_client(args.token) + query_upper = args.query.upper() + + print(f"\nCalculating expected move for {query_upper}...\n") + + # Get spot price + spot_key = INDEX_LTP_KEYS.get(query_upper) + if spot_key: + ltp_data = get_ltp(client, spot_key) + entry = ltp_data.get(spot_key, {}) + spot = entry.get("last_price") if isinstance(entry, dict) else getattr(entry, "last_price", 0) + else: + resp = search_instrument(client, args.query, exchanges="NSE", segments="EQ", records=1) + hits = resp.data or [] + if not hits: + print(f"Cannot find '{args.query}'.") + sys.exit(1) + eq_key = hits[0]["instrument_key"] + ltp_data = get_ltp(client, eq_key) + entry = ltp_data.get(eq_key, {}) + spot = entry.get("last_price") if isinstance(entry, dict) else getattr(entry, "last_price", 0) + + if not spot: + print("Could not fetch spot price.") + sys.exit(1) + + # Find ATM CE and PE + ce_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, + atm_offset=0, records=1) + pe_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="PE", expiry=args.expiry, + atm_offset=0, records=1) + + ce_hits = ce_resp.data or [] + pe_hits = pe_resp.data or [] + + if not ce_hits or not pe_hits: + print("Could not find ATM options. Try a different --expiry.") + sys.exit(1) + + ce_key = ce_hits[0]["instrument_key"] + pe_key = pe_hits[0]["instrument_key"] + ce_strike = ce_hits[0].get("strike_price", 0) + expiry_date = ce_hits[0].get("expiry", args.expiry) + + # Fetch LTP for both legs + ltp_data = get_ltp(client, ce_key, pe_key) + + def get_price(data, key): + entry = data.get(key, {}) + return entry.get("last_price") if isinstance(entry, dict) else getattr(entry, "last_price", 0) + + ce_premium = get_price(ltp_data, ce_key) or 0 + pe_premium = get_price(ltp_data, pe_key) or 0 + + if not ce_premium and not pe_premium: + print("Could not fetch ATM option premiums.") + sys.exit(1) + + expected_move = ce_premium + pe_premium + expected_move_pct = expected_move / spot * 100 + upper = spot + expected_move + lower = spot - expected_move + + # Display + print(f" Underlying : {query_upper}") + print(f" Spot price : {spot:,.2f}") + print(f" ATM strike : {ce_strike:,.0f}") + print(f" Expiry : {expiry_date}") + print() + print(f" ATM CE premium : {ce_premium:,.2f}") + print(f" ATM PE premium : {pe_premium:,.2f}") + print(f" Straddle cost : {BOLD}{expected_move:,.2f}{RESET}") + print() + print(f" {BOLD}Expected Move : {expected_move:,.2f} ({expected_move_pct:.2f}%){RESET}") + print(f" {GREEN}Upper bound : {upper:,.2f} (+{expected_move_pct:.2f}%){RESET}") + print(f" {RED}Lower bound : {lower:,.2f} (-{expected_move_pct:.2f}%){RESET}") + print() + print(f" The market expects {query_upper} to stay within" + f" {CYAN}{lower:,.2f} – {upper:,.2f}{RESET} by expiry (~68% probability).") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/iv_percentile.py b/interactive-examples/options_analytics/iv_percentile.py new file mode 100644 index 0000000..9619aeb --- /dev/null +++ b/interactive-examples/options_analytics/iv_percentile.py @@ -0,0 +1,193 @@ +""" +IV Percentile & IV Rank — where current implied volatility stands vs history. + +Fetches 1-year daily close history to compute rolling historical volatility (HV), +then fetches the current ATM IV from the option chain. Two metrics are reported: + + IV Percentile = % of trading days in the past year when HV was LOWER than current ATM IV + IV Rank = (current IV - min IV) / (max IV - min IV) × 100 + +High IV Percentile (>80%) → options are expensive → favour selling strategies +Low IV Percentile (<20%) → options are cheap → favour buying strategies + +Usage: + python options_analytics/iv_percentile.py --token + python options_analytics/iv_percentile.py --token --query BANKNIFTY --days 252 +""" + +import argparse +import sys +import os +import math +from statistics import stdev +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_historical_candles +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +RESET = "\033[0m" + +INDEX_KEYS = { + "NIFTY": "NSE_INDEX|Nifty 50", + "BANKNIFTY": "NSE_INDEX|Nifty Bank", + "FINNIFTY": "NSE_INDEX|Nifty Fin Service", + "MIDCPNIFTY": "NSE_INDEX|NIFTY MID SELECT", + "SENSEX": "BSE_INDEX|SENSEX", +} + + +def get_instrument_key(client, query: str) -> str: + upper = query.upper() + if upper in INDEX_KEYS: + return INDEX_KEYS[upper] + resp = search_instrument(client, query, exchanges="NSE", segments="INDEX", records=1) + hits = resp.data or [] + if hits: + return hits[0]["instrument_key"] + resp = search_instrument(client, query, exchanges="NSE", segments="EQ", records=1) + hits = resp.data or [] + if hits: + return hits[0]["instrument_key"] + print(f"Cannot resolve instrument for '{query}'.") + sys.exit(1) + + +def get_nearest_expiry(client, query: str) -> str: + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry="current_month", records=1) + hits = resp.data or [] + return hits[0].get("expiry", "") if hits else "" + + +def extract(obj, *keys, default=0): + for key in keys: + if obj is None: + return default + obj = obj.get(key) if isinstance(obj, dict) else getattr(obj, key, None) + return obj if obj is not None else default + + +def rolling_hv(closes, window=20): + """Compute annualised historical volatility for each rolling window.""" + hvs = [] + for i in range(window, len(closes)): + seg = closes[i - window:i + 1] + log_rets = [math.log(seg[j] / seg[j - 1]) for j in range(1, len(seg))] + if len(log_rets) >= 2: + hvs.append(stdev(log_rets) * math.sqrt(252) * 100) + return hvs + + +def main(): + parser = argparse.ArgumentParser(description="IV Percentile & IV Rank") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying (default: NIFTY)") + parser.add_argument("--days", type=int, default=252, help="Lookback trading days (default: 252)") + parser.add_argument("--window", type=int, default=20, help="HV rolling window (default: 20)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"\nComputing IV Percentile for {args.query.upper()}...\n") + + instrument_key = get_instrument_key(client, args.query) + + # Fetch historical daily closes + to_date = date.today().isoformat() + from_date = (date.today() - timedelta(days=int(args.days * 1.6))).isoformat() + + candles = get_historical_candles(client, instrument_key, "days", 1, to_date, from_date) + if not candles: + print("No historical candle data returned.") + sys.exit(1) + + candles = list(reversed(candles)) + closes = [float(c[4]) for c in candles if len(c) > 4] + + if len(closes) < args.window + 10: + print(f"Insufficient data: {len(closes)} closes, need at least {args.window + 10}.") + sys.exit(1) + + # Compute rolling HV distribution + hv_values = rolling_hv(closes, args.window) + if not hv_values: + print("Could not compute historical volatility.") + sys.exit(1) + + # Fetch current ATM IV from option chain + underlying_key = INDEX_KEYS.get(args.query.upper(), instrument_key) + expiry = get_nearest_expiry(client, args.query) + if not expiry: + print("Could not determine expiry for ATM IV lookup.") + sys.exit(1) + + api = upstox_client.OptionsApi(client) + resp = api.get_put_call_option_chain(underlying_key, expiry) + chain = resp.data if resp.data else [] + if not isinstance(chain, list): + chain = [chain] + + # Find ATM strike and its IV + spot = closes[-1] + atm_entry = None + min_diff = float("inf") + for entry in chain: + strike = extract(entry, "strike_price") + if strike and abs(strike - spot) < min_diff: + min_diff = abs(strike - spot) + atm_entry = entry + + if not atm_entry: + print("Could not find ATM strike in option chain.") + sys.exit(1) + + ce_iv = extract(atm_entry, "call_options", "option_greeks", "iv") + pe_iv = extract(atm_entry, "put_options", "option_greeks", "iv") + atm_iv = ((ce_iv or 0) + (pe_iv or 0)) / 2 * 100 # as percentage + atm_strike = extract(atm_entry, "strike_price") + + if atm_iv <= 0: + print("ATM IV is zero or unavailable.") + sys.exit(1) + + # Compute percentile and rank + hv_min = min(hv_values) + hv_max = max(hv_values) + days_below = sum(1 for hv in hv_values if hv < atm_iv) + iv_percentile = days_below / len(hv_values) * 100 + iv_rank = (atm_iv - hv_min) / (hv_max - hv_min) * 100 if hv_max > hv_min else 50 + + # Display + print(f" Underlying : {args.query.upper()}") + print(f" Spot price : {spot:,.2f}") + print(f" ATM strike : {atm_strike:,.0f}") + print(f" Expiry : {expiry}") + print(f" ATM IV (avg) : {BOLD}{atm_iv:.1f}%{RESET}") + print() + print(f" HV distribution ({args.window}-day rolling, {len(hv_values)} samples):") + print(f" Min HV : {hv_min:.1f}%") + print(f" Max HV : {hv_max:.1f}%") + print(f" Current HV : {hv_values[-1]:.1f}%") + print() + print(f" {BOLD}IV Percentile : {iv_percentile:.0f}%{RESET}") + print(f" {BOLD}IV Rank : {iv_rank:.0f}%{RESET}") + print() + + if iv_percentile > 80: + print(f" {RED}IV is HIGH{RESET} — options are expensive relative to history.") + print(f" Consider: short straddles, iron condors, credit spreads.") + elif iv_percentile < 20: + print(f" {GREEN}IV is LOW{RESET} — options are cheap relative to history.") + print(f" Consider: long straddles, long strangles, debit spreads.") + else: + print(f" {CYAN}IV is NORMAL{RESET} — no extreme relative to history.") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/max_pain_calculator.py b/interactive-examples/options_analytics/max_pain_calculator.py new file mode 100644 index 0000000..badfd0b --- /dev/null +++ b/interactive-examples/options_analytics/max_pain_calculator.py @@ -0,0 +1,121 @@ +""" +Max Pain Calculator. + +Max pain is the strike price at which options buyers (as a whole) suffer the +maximum financial loss at expiry. It's the point where the total payout to +option holders (CE + PE) is minimised. + +Algorithm: + For each candidate strike K: + pain(K) = Σ [max(0, K - strike_i) * CE_OI_i] ← put holders' loss + + Σ [max(0, strike_i - K) * PE_OI_i] ← call holders' loss + Max pain = argmin(pain) + +This script uses get_full_market_quote (which includes OI) for accuracy. + +Usage: + python options_analytics/max_pain_calculator.py --token + python options_analytics/max_pain_calculator.py --token --query BANKNIFTY +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_full_quote + + +def fetch_chain_instruments(client, query, expiry, itype, num_strikes=15): + """Fetch option instruments across a range of ATM offsets.""" + instruments = [] + for offset in range(-num_strikes, num_strikes + 1): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=offset, records=1) + data = resp.data or [] + if data: + instruments.append(data[0]) + # Deduplicate by strike + seen = set() + unique = [] + for inst in instruments: + k = inst.get("strike_price", 0) + if k not in seen: + seen.add(k) + unique.append(inst) + return unique + + +def main(): + parser = argparse.ArgumentParser(description="Options max pain calculator using OI") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--strikes", type=int, default=10, help="Strikes each side of ATM (default: 10)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Calculating max pain for {args.query} ({args.expiry})...\n") + + ce_insts = fetch_chain_instruments(client, args.query, args.expiry, "CE", args.strikes) + pe_insts = fetch_chain_instruments(client, args.query, args.expiry, "PE", args.strikes) + + all_insts = ce_insts + pe_insts + if not all_insts: + print("No data found.") + sys.exit(1) + + all_keys = [i["instrument_key"] for i in all_insts] + # get_full_market_quote supports up to 500 instruments + quote_data = get_full_quote(client, *all_keys) + + # Build OI maps by strike + ce_oi = {} + pe_oi = {} + for inst in ce_insts: + k = inst.get("strike_price", 0) + key = inst["instrument_key"] + q = quote_data.get(key) + ce_oi[k] = q.oi if q else 0 + + for inst in pe_insts: + k = inst.get("strike_price", 0) + key = inst["instrument_key"] + q = quote_data.get(key) + pe_oi[k] = q.oi if q else 0 + + all_strikes = sorted(set(list(ce_oi.keys()) + list(pe_oi.keys()))) + + # Compute pain at each strike + pain = {} + for candidate in all_strikes: + total = 0 + for strike, oi in ce_oi.items(): + total += max(0, candidate - strike) * (oi or 0) + for strike, oi in pe_oi.items(): + total += max(0, strike - candidate) * (oi or 0) + pain[candidate] = total + + max_pain_strike = min(pain, key=pain.get) + min_pain_val = pain[max_pain_strike] + + # Display top 10 strikes around max pain + sorted_strikes = sorted(all_strikes, key=lambda s: abs(s - max_pain_strike))[:10] + sorted_strikes = sorted(sorted_strikes) + + print(f"{'Strike':>10} {'CE OI':>12} {'PE OI':>12} {'Pain Value':>15} {'':}") + print("-" * 65) + for s in sorted_strikes: + marker = " <<< MAX PAIN" if s == max_pain_strike else "" + print(f"{s:>10.0f} {ce_oi.get(s, 0):>12,.0f} {pe_oi.get(s, 0):>12,.0f} " + f"{pain.get(s, 0):>15,.0f}{marker}") + + print(f"\nMax Pain Strike : {max_pain_strike:,.0f}") + print(f"Pain Value : {min_pain_val:,.0f}") + print("\nInterpretation: Underlying tends to gravitate toward max pain at expiry.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/oi_skew.py b/interactive-examples/options_analytics/oi_skew.py new file mode 100644 index 0000000..3a4cece --- /dev/null +++ b/interactive-examples/options_analytics/oi_skew.py @@ -0,0 +1,116 @@ +""" +OI Skew — Put-Call Open Interest Ratio by Strike. + +Compares CE OI vs PE OI across strikes to gauge market sentiment: + - High PE OI vs CE OI at a strike → put writing (support level) + - High CE OI vs PE OI at a strike → call writing (resistance level) + - PCR (Put-Call OI Ratio) > 1 → more puts, slightly bullish (put writers support the market) + - PCR < 1 → more calls, bearish signal + +Usage: + python options_analytics/oi_skew.py --token + python options_analytics/oi_skew.py --token --query BANKNIFTY --strikes 8 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_full_quote + + +def fetch_options(client, query, expiry, itype, num_strikes): + instruments = [] + for offset in range(-num_strikes, num_strikes + 1): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=offset, records=1) + data = resp.data or [] + if data: + instruments.append(data[0]) + seen = set() + unique = [] + for inst in instruments: + k = inst.get("strike_price", 0) + if k not in seen: + seen.add(k) + unique.append(inst) + return unique + + +def main(): + parser = argparse.ArgumentParser(description="OI skew — put-call OI ratio by strike") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--strikes", type=int, default=7, help="Strikes each side of ATM (default: 7)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching OI skew for {args.query} ({args.expiry}), ±{args.strikes} strikes...\n") + + ce_insts = fetch_options(client, args.query, args.expiry, "CE", args.strikes) + pe_insts = fetch_options(client, args.query, args.expiry, "PE", args.strikes) + + all_keys = [i["instrument_key"] for i in ce_insts + pe_insts] + quote_data = get_full_quote(client, *all_keys) + + ce_oi_map = {} + pe_oi_map = {} + for inst in ce_insts: + k = inst.get("strike_price", 0) + q = quote_data.get(inst["instrument_key"]) + ce_oi_map[k] = q.oi if q else 0 + for inst in pe_insts: + k = inst.get("strike_price", 0) + q = quote_data.get(inst["instrument_key"]) + pe_oi_map[k] = q.oi if q else 0 + + all_strikes = sorted(set(list(ce_oi_map.keys()) + list(pe_oi_map.keys()))) + + total_ce_oi = sum(ce_oi_map.values()) + total_pe_oi = sum(pe_oi_map.values()) + overall_pcr = total_pe_oi / total_ce_oi if total_ce_oi else 0 + + print(f"{'Strike':>10} {'CE OI':>12} {'PE OI':>12} {'PCR':>8} {'Signal':}") + print("-" * 65) + + max_ce_oi = max(ce_oi_map.values(), default=1) + max_pe_oi = max(pe_oi_map.values(), default=1) + + for strike in reversed(all_strikes): + ce_oi = ce_oi_map.get(strike, 0) + pe_oi = pe_oi_map.get(strike, 0) + pcr = pe_oi / ce_oi if ce_oi else 0 + + signal = "" + if ce_oi == max_ce_oi: + signal = " <-- MAX CE OI (resistance)" + elif pe_oi == max_pe_oi: + signal = " <-- MAX PE OI (support)" + + print(f"{strike:>10.0f} {ce_oi:>12,.0f} {pe_oi:>12,.0f} {pcr:>8.2f}{signal}") + + print("-" * 65) + print(f"{'TOTAL':>10} {total_ce_oi:>12,.0f} {total_pe_oi:>12,.0f} {overall_pcr:>8.2f}") + + print(f"\nOverall PCR : {overall_pcr:.2f}") + if overall_pcr > 1.2: + print("Interpretation: Heavy put writing — market likely to be supported / bullish bias.") + elif overall_pcr < 0.8: + print("Interpretation: Heavy call writing — market facing resistance / bearish bias.") + else: + print("Interpretation: Balanced OI — no strong directional bias from OI data.") + + if ce_oi_map: + resistance = max(ce_oi_map, key=ce_oi_map.get) + print(f"\nKey resistance (max CE OI) : {resistance:,.0f}") + if pe_oi_map: + support = max(pe_oi_map, key=pe_oi_map.get) + print(f"Key support (max PE OI) : {support:,.0f}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/option_chain_native.py b/interactive-examples/options_analytics/option_chain_native.py new file mode 100644 index 0000000..14d4a27 --- /dev/null +++ b/interactive-examples/options_analytics/option_chain_native.py @@ -0,0 +1,163 @@ +""" +Option Chain (Native API) — full CE/PE chain via the dedicated OptionsApi endpoint. + +This is cleaner and more complete than the search-based approach already in the project. +Returns OI, LTP, and IV for every strike at the given expiry, with the ATM row marked. + +Usage: + python options_analytics/option_chain_native.py --token + python options_analytics/option_chain_native.py --token --query BANKNIFTY --expiry 2026-04-17 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" + +# Underlying instrument key map +INDEX_KEYS = { + "NIFTY": "NSE_INDEX|Nifty 50", + "BANKNIFTY": "NSE_INDEX|Nifty Bank", + "FINNIFTY": "NSE_INDEX|Nifty Fin Service", + "MIDCPNIFTY": "NSE_INDEX|NIFTY MID SELECT", + "SENSEX": "BSE_INDEX|SENSEX", +} + + +def get_underlying_key(client, query: str) -> str: + upper = query.upper() + if upper in INDEX_KEYS: + return INDEX_KEYS[upper] + # Try searching as equity + resp = search_instrument(client, query, exchanges="NSE", segments="EQ", records=1) + hits = resp.data or [] + if hits: + return hits[0]["instrument_key"] + print(f"Cannot resolve underlying for '{query}'.") + sys.exit(1) + + +def get_nearest_expiry(client, query: str) -> str: + """Find the nearest expiry from options search.""" + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry="current_month", records=1) + hits = resp.data or [] + if hits: + return hits[0].get("expiry", "") + return "" + + +def extract(obj, *keys, default=0): + """Safely get a nested attribute/key from SDK object or dict.""" + for key in keys: + if obj is None: + return default + obj = obj.get(key) if isinstance(obj, dict) else getattr(obj, key, None) + return obj if obj is not None else default + + +def main(): + parser = argparse.ArgumentParser(description="Full option chain via OptionsApi") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying (default: NIFTY)") + parser.add_argument("--expiry", default="", + help="Expiry date YYYY-MM-DD (default: current month nearest)") + args = parser.parse_args() + + client = get_api_client(args.token) + + underlying_key = get_underlying_key(client, args.query) + expiry = args.expiry or get_nearest_expiry(client, args.query) + if not expiry: + print("Could not determine expiry. Use --expiry YYYY-MM-DD.") + sys.exit(1) + + print(f"\nFetching option chain for {args.query.upper()} | expiry: {expiry}...\n") + + api = upstox_client.OptionsApi(client) + resp = api.get_put_call_option_chain(underlying_key, expiry) + chain = resp.data if resp.data else [] + if not isinstance(chain, list): + chain = [chain] + + if not chain: + print("No option chain data returned.") + sys.exit(1) + + # Find ATM: strike closest to underlying LTP + # underlying LTP comes from the first chain entry's underlying_spot_price or pcr + spot = None + for entry in chain: + spot = extract(entry, "underlying_spot_price") or extract(entry, "spot_price") + if spot: + break + + rows = [] + for entry in chain: + strike = extract(entry, "strike_price") + ce = extract(entry, "call_options") + pe = extract(entry, "put_options") + + ce_ltp = extract(ce, "market_data", "ltp") + ce_oi = extract(ce, "market_data", "oi") + ce_iv = extract(ce, "option_greeks", "iv") + pe_ltp = extract(pe, "market_data", "ltp") + pe_oi = extract(pe, "market_data", "oi") + pe_iv = extract(pe, "option_greeks", "iv") + + rows.append((strike, ce_oi, ce_ltp, ce_iv, pe_ltp, pe_oi, pe_iv)) + + rows.sort(key=lambda r: r[0]) + + # Determine ATM strike + if spot: + atm_strike = min(rows, key=lambda r: abs(r[0] - spot))[0] + else: + atm_strike = rows[len(rows) // 2][0] + + # Header + print(f"{'':2} {'Strike':>10} {'CE OI':>12} {'CE LTP':>10} {'CE IV':>7} " + f"{'PE LTP':>10} {'PE OI':>12} {'PE IV':>7}") + print("─" * 88) + + for strike, ce_oi, ce_ltp, ce_iv, pe_ltp, pe_oi, pe_iv in rows: + is_atm = strike == atm_strike + marker = f"{CYAN}→{RESET}" if is_atm else " " + ce_col = BOLD if is_atm else "" + pe_col = BOLD if is_atm else "" + + ce_iv_s = f"{ce_iv*100:.1f}%" if ce_iv else "—" + pe_iv_s = f"{pe_iv*100:.1f}%" if pe_iv else "—" + ce_ltp_s = f"{ce_ltp:,.2f}" if ce_ltp else "—" + pe_ltp_s = f"{pe_ltp:,.2f}" if pe_ltp else "—" + ce_oi_s = f"{int(ce_oi):,}" if ce_oi else "—" + pe_oi_s = f"{int(pe_oi):,}" if pe_oi else "—" + + print(f"{marker} {ce_col}{strike:>10,.0f} " + f"{GREEN}{ce_oi_s:>12}{RESET} {ce_col}{ce_ltp_s:>10}{RESET} {ce_iv_s:>7} " + f"{pe_col}{pe_ltp_s:>10}{RESET} {RED}{pe_oi_s:>12}{RESET} {pe_iv_s:>7}") + + print("─" * 88) + if spot: + print(f"\n {BOLD}Spot price: {spot:,.2f}{RESET} | ATM strike: {atm_strike:,.0f} | Expiry: {expiry}") + total_ce_oi = sum(r[1] for r in rows if r[1]) + total_pe_oi = sum(r[5] for r in rows if r[5]) + if total_ce_oi and total_pe_oi: + pcr = total_pe_oi / total_ce_oi + print(f" Total CE OI: {int(total_ce_oi):,} | Total PE OI: {int(total_pe_oi):,} " + f"| PCR: {pcr:.2f} ({'bullish' if pcr > 1 else 'bearish'})") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/option_greeks.py b/interactive-examples/options_analytics/option_greeks.py new file mode 100644 index 0000000..0706ce6 --- /dev/null +++ b/interactive-examples/options_analytics/option_greeks.py @@ -0,0 +1,148 @@ +""" +Option Greeks Dashboard — fetch live delta, gamma, theta, vega, and IV +for ATM ± N strikes using the MarketQuoteV3Api option-greeks endpoint. + +Usage: + python options_analytics/option_greeks.py --token + python options_analytics/option_greeks.py --token --query BANKNIFTY --strikes 7 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" + + +def fetch_options(client, query: str, n: int, expiry: str): + """Return list of (offset, instrument_dict) for CE and PE at ATM ±n.""" + instruments = [] + for offset in range(-n, n + 1): + for itype in ("CE", "PE"): + resp = search_instrument(client, query, + exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=offset, records=1) + hits = resp.data or [] + if hits: + instruments.append((offset, itype, hits[0])) + return instruments + + +def g(obj, attr, default=None): + if obj is None: + return default + return obj.get(attr, default) if isinstance(obj, dict) else getattr(obj, attr, default) + + +def fmt(val, digits=4, pct=False): + if val is None: + return "—" + if pct: + return f"{float(val)*100:.1f}%" + return f"{float(val):.{digits}f}" + + +def main(): + parser = argparse.ArgumentParser(description="Option greeks dashboard: delta/gamma/theta/vega/IV") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--strikes", type=int, default=5, + help="Strikes on each side of ATM (default: 5)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"\nFetching option greeks for {args.query.upper()} ({args.expiry}), " + f"ATM ±{args.strikes} strikes...\n") + + instruments = fetch_options(client, args.query, args.strikes, args.expiry) + if not instruments: + print("No options found.") + sys.exit(1) + + # Collect all instrument keys + keys = [inst["instrument_key"] for _, _, inst in instruments] + # API accepts up to 50 instruments + keys = keys[:50] + + api = upstox_client.MarketQuoteV3Api(client) + resp = api.get_market_quote_option_greek(instrument_key=",".join(keys)) + greeks_data = resp.data or {} + + # Build lookup: instrument_token → greeks object + def token_of(obj): + return g(obj, "instrument_token") + + lookup = {} + for val in greeks_data.values(): + tok = token_of(val) + if tok: + lookup[tok] = val + + # Header + print(f"{'Offset':>7} {'Strike':>10} {'Type':>4} {'LTP':>10} " + f"{'IV':>7} {'Delta':>8} {'Gamma':>8} {'Theta':>8} {'Vega':>8} {'OI':>12}") + print("─" * 98) + + # Group by strike for display + rows = [] + for offset, itype, inst in instruments: + key = inst["instrument_key"] + strike = inst.get("strike_price", 0) + sym = inst.get("trading_symbol", "") + + # Find greek data by instrument_token + gdata = lookup.get(key) + ltp = g(gdata, "last_price") + iv = g(gdata, "iv") + delta = g(gdata, "delta") + gamma = g(gdata, "gamma") + theta = g(gdata, "theta") + vega = g(gdata, "vega") + oi = g(gdata, "oi") + + rows.append((offset, strike, itype, ltp, iv, delta, gamma, theta, vega, oi)) + + rows.sort(key=lambda r: (r[1], r[2])) # sort by strike, then CE before PE + + prev_strike = None + for offset, strike, itype, ltp, iv, delta, gamma, theta, vega, oi in rows: + if prev_strike and strike != prev_strike: + print(f"{'':7} {'─'*10}") + prev_strike = strike + + is_atm = offset == 0 + colour = CYAN if is_atm else (GREEN if itype == "CE" else RED) + off_str = f"ATM" if is_atm else f"{offset:+d}" + oi_str = f"{int(oi):,}" if oi else "—" + ltp_str = f"{float(ltp):,.2f}" if ltp else "—" + + print(f"{colour}{off_str:>7}{RESET} " + f"{BOLD if is_atm else ''}{strike:>10,.0f}{RESET} " + f"{colour}{itype:>4}{RESET} " + f"{ltp_str:>10} " + f"{fmt(iv, pct=True):>7} " + f"{fmt(delta):>8} " + f"{fmt(gamma, 6):>8} " + f"{fmt(theta):>8} " + f"{fmt(vega):>8} " + f"{oi_str:>12}") + + print("─" * 98) + print(f"\n {DIM}Delta: directional sensitivity | Gamma: delta rate-of-change | " + f"Theta: daily time decay | Vega: IV sensitivity{RESET}\n") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/options_chain_builder.py b/interactive-examples/options_analytics/options_chain_builder.py new file mode 100644 index 0000000..770277b --- /dev/null +++ b/interactive-examples/options_analytics/options_chain_builder.py @@ -0,0 +1,94 @@ +""" +Options Chain Builder. + +Builds a mini options chain for a given underlying across multiple strikes. +For each strike, fetches CE and PE LTPs and displays them side-by-side. + +Usage: + python options_analytics/options_chain_builder.py --token + python options_analytics/options_chain_builder.py --token --query BANKNIFTY --strikes 5 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="Build a live options chain") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--strikes", type=int, default=5, + help="Number of strikes on each side of ATM (default: 5)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Building options chain for {args.query} ({args.expiry}), ±{args.strikes} strikes from ATM...\n") + + # Fetch CE and PE across offsets -strikes to +strikes + offsets = range(-args.strikes, args.strikes + 1) + ce_instruments = {} + pe_instruments = {} + + for offset in offsets: + ce_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, + atm_offset=offset, records=1) + pe_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="PE", expiry=args.expiry, + atm_offset=offset, records=1) + ce_list = ce_resp.data or [] + pe_list = pe_resp.data or [] + if ce_list: + inst = ce_list[0] + ce_instruments[inst.get("strike_price", 0)] = inst + if pe_list: + inst = pe_list[0] + pe_instruments[inst.get("strike_price", 0)] = inst + + all_strikes = sorted(set(list(ce_instruments.keys()) + list(pe_instruments.keys()))) + if not all_strikes: + print("No options data found.") + sys.exit(1) + + # Batch LTP fetch + all_keys = [] + for k in all_strikes: + if k in ce_instruments: + all_keys.append(ce_instruments[k]["instrument_key"]) + if k in pe_instruments: + all_keys.append(pe_instruments[k]["instrument_key"]) + + ltp_data = get_ltp(client, *all_keys) + + def get_price(inst): + key = inst["instrument_key"] + return ltp_data[key].last_price if key in ltp_data else 0.0 + + # Determine ATM + atm_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, atm_offset=0, records=1) + atm_strike = (atm_resp.data or [{}])[0].get("strike_price", 0) + + print(f"{'CALL LTP':>12} {'CALL OI':>10} {'STRIKE':^10} {'PUT OI':>10} {'PUT LTP':>12}") + print("-" * 65) + + for strike in reversed(all_strikes): + ce_inst = ce_instruments.get(strike) + pe_inst = pe_instruments.get(strike) + ce_ltp = get_price(ce_inst) if ce_inst else 0.0 + pe_ltp = get_price(pe_inst) if pe_inst else 0.0 + + atm_marker = " <<< ATM" if strike == atm_strike else "" + print(f"{ce_ltp:>12.2f} {'N/A':>10} {strike:^10.0f} {'N/A':>10} {pe_ltp:>12.2f}{atm_marker}") + + print("\nNote: OI data requires get_full_market_quote (see options_analytics/oi_skew.py).") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/pcr_trend.py b/interactive-examples/options_analytics/pcr_trend.py new file mode 100644 index 0000000..cb62320 --- /dev/null +++ b/interactive-examples/options_analytics/pcr_trend.py @@ -0,0 +1,149 @@ +""" +Put-Call Ratio (PCR) Trend — OI-based sentiment gauge via the native Option Chain API. + +Fetches the full option chain for a given underlying and expiry, sums CE OI vs PE OI +across all strikes, and computes the overall PCR plus a per-strike breakdown. + + PCR > 1.2 → heavy put writing → bullish bias (support from put writers) + PCR < 0.8 → heavy call writing → bearish bias (resistance from call writers) + 0.8–1.2 → balanced / neutral + +Usage: + python options_analytics/pcr_trend.py --token + python options_analytics/pcr_trend.py --token --query BANKNIFTY --expiry 2026-04-17 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument +import upstox_client + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +RESET = "\033[0m" + +INDEX_KEYS = { + "NIFTY": "NSE_INDEX|Nifty 50", + "BANKNIFTY": "NSE_INDEX|Nifty Bank", + "FINNIFTY": "NSE_INDEX|Nifty Fin Service", + "MIDCPNIFTY": "NSE_INDEX|NIFTY MID SELECT", + "SENSEX": "BSE_INDEX|SENSEX", +} + + +def get_underlying_key(client, query: str) -> str: + upper = query.upper() + if upper in INDEX_KEYS: + return INDEX_KEYS[upper] + resp = search_instrument(client, query, exchanges="NSE", segments="EQ", records=1) + hits = resp.data or [] + if hits: + return hits[0]["instrument_key"] + print(f"Cannot resolve underlying for '{query}'.") + sys.exit(1) + + +def get_nearest_expiry(client, query: str) -> str: + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry="current_month", records=1) + hits = resp.data or [] + if hits: + return hits[0].get("expiry", "") + return "" + + +def extract(obj, *keys, default=0): + for key in keys: + if obj is None: + return default + obj = obj.get(key) if isinstance(obj, dict) else getattr(obj, key, None) + return obj if obj is not None else default + + +def main(): + parser = argparse.ArgumentParser(description="Put-Call Ratio from option chain OI") + parser.add_argument("--token", required=True, help="Upstox access or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying (default: NIFTY)") + parser.add_argument("--expiry", default="", help="Expiry YYYY-MM-DD (default: current month)") + args = parser.parse_args() + + client = get_api_client(args.token) + + underlying_key = get_underlying_key(client, args.query) + expiry = args.expiry or get_nearest_expiry(client, args.query) + if not expiry: + print("Could not determine expiry. Use --expiry YYYY-MM-DD.") + sys.exit(1) + + print(f"\nFetching PCR for {args.query.upper()} | expiry: {expiry}...\n") + + api = upstox_client.OptionsApi(client) + resp = api.get_put_call_option_chain(underlying_key, expiry) + chain = resp.data if resp.data else [] + if not isinstance(chain, list): + chain = [chain] + + if not chain: + print("No option chain data returned.") + sys.exit(1) + + # Collect per-strike OI + rows = [] + for entry in chain: + strike = extract(entry, "strike_price") + ce_oi = extract(entry, "call_options", "market_data", "oi") + pe_oi = extract(entry, "put_options", "market_data", "oi") + rows.append((strike, ce_oi, pe_oi)) + + rows.sort(key=lambda r: r[0]) + + total_ce_oi = sum(r[1] for r in rows if r[1]) + total_pe_oi = sum(r[2] for r in rows if r[2]) + overall_pcr = total_pe_oi / total_ce_oi if total_ce_oi else 0 + + # Header + print(f"{'Strike':>10} {'CE OI':>12} {'PE OI':>12} {'PCR':>8} Signal") + print("-" * 65) + + max_ce = max((r[1] for r in rows if r[1]), default=0) + max_pe = max((r[2] for r in rows if r[2]), default=0) + + for strike, ce_oi, pe_oi in rows: + pcr = pe_oi / ce_oi if ce_oi else 0 + signal = "" + if ce_oi and ce_oi == max_ce: + signal = " <-- MAX CE OI (resistance)" + elif pe_oi and pe_oi == max_pe: + signal = " <-- MAX PE OI (support)" + + ce_s = f"{int(ce_oi):,}" if ce_oi else "—" + pe_s = f"{int(pe_oi):,}" if pe_oi else "—" + print(f"{strike:>10,.0f} {GREEN}{ce_s:>12}{RESET} {RED}{pe_s:>12}{RESET} {pcr:>8.2f}{signal}") + + print("-" * 65) + print(f"{'TOTAL':>10} {GREEN}{int(total_ce_oi):>12,}{RESET} " + f"{RED}{int(total_pe_oi):>12,}{RESET} {BOLD}{overall_pcr:>8.2f}{RESET}") + + print(f"\n{BOLD}Overall PCR: {overall_pcr:.2f}{RESET}") + if overall_pcr > 1.2: + print(f" {GREEN}Bullish bias{RESET} — heavy put writing, market likely supported.") + elif overall_pcr < 0.8: + print(f" {RED}Bearish bias{RESET} — heavy call writing, market facing resistance.") + else: + print(f" {CYAN}Neutral{RESET} — balanced OI, no strong directional signal.") + + if rows: + resistance = max(rows, key=lambda r: r[1])[0] + support = max(rows, key=lambda r: r[2])[0] + print(f"\n Key resistance (max CE OI): {resistance:,.0f}") + print(f" Key support (max PE OI): {support:,.0f}") + print() + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_analytics/volatility_skew.py b/interactive-examples/options_analytics/volatility_skew.py new file mode 100644 index 0000000..1c04862 --- /dev/null +++ b/interactive-examples/options_analytics/volatility_skew.py @@ -0,0 +1,108 @@ +""" +Volatility Skew — OTM Put Premium vs OTM Call Premium. + +Volatility skew measures the difference in implied volatility (or simply +option premium) between OTM puts and OTM calls at equal distance from ATM. + +In equities/indices, OTM puts typically trade at higher premiums than +equidistant OTM calls (negative skew), reflecting demand for downside protection. + +This script: + - Fetches CE and PE premiums across ATM offsets -N to +N + - Computes the skew ratio: PE_premium / CE_premium at each symmetric pair + - Plots a simple text chart + +Usage: + python options_analytics/volatility_skew.py --token + python options_analytics/volatility_skew.py --token --query BANKNIFTY --depth 5 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def fetch_one(client, query, expiry, itype, offset): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=offset, records=1) + data = resp.data or [] + return data[0] if data else None + + +def main(): + parser = argparse.ArgumentParser(description="Options volatility skew viewer") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--depth", type=int, default=4, help="OTM depth in strikes (default: 4)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Fetching volatility skew for {args.query} ({args.expiry}), depth={args.depth}...\n") + + rows = [] + all_keys = [] + for offset in range(1, args.depth + 1): + ce = fetch_one(client, args.query, args.expiry, "CE", +offset) + pe = fetch_one(client, args.query, args.expiry, "PE", -offset) + if ce and pe: + rows.append((offset, ce, pe)) + all_keys += [ce["instrument_key"], pe["instrument_key"]] + + # Also get ATM + atm_ce = fetch_one(client, args.query, args.expiry, "CE", 0) + atm_pe = fetch_one(client, args.query, args.expiry, "PE", 0) + if atm_ce: + all_keys.append(atm_ce["instrument_key"]) + if atm_pe: + all_keys.append(atm_pe["instrument_key"]) + + if not all_keys: + print("No data found.") + sys.exit(1) + + ltp_data = get_ltp(client, *all_keys) + + def price(inst): + if not inst: + return 0.0 + return ltp_data.get(inst["instrument_key"], type("", (), {"last_price": 0.0})()).last_price + + atm_ce_price = price(atm_ce) + atm_pe_price = price(atm_pe) + atm_strike = atm_ce.get("strike_price", 0) if atm_ce else 0 + + print(f"ATM Strike : {atm_strike:,.0f}") + print(f"ATM CE : {atm_ce_price:.2f}") + print(f"ATM PE : {atm_pe_price:.2f}") + print(f"ATM Skew : PE/CE = {atm_pe_price/atm_ce_price:.3f}" if atm_ce_price else "") + print() + + print(f"{'Offset':<8} {'CE Strike':>10} {'CE LTP':>10} {'PE Strike':>10} {'PE LTP':>10} {'PE/CE Ratio':>12} {'Skew Bar':}") + print("-" * 85) + + for offset, ce, pe in rows: + ce_price = price(ce) + pe_price = price(pe) + ratio = pe_price / ce_price if ce_price else 0 + bar_len = min(int(ratio * 10), 40) + bar = "█" * bar_len + + print(f"{'+' + str(offset) + '/-' + str(offset):<8} " + f"{ce.get('strike_price',0):>10.0f} {ce_price:>10.2f} " + f"{pe.get('strike_price',0):>10.0f} {pe_price:>10.2f} " + f"{ratio:>12.3f} {bar}") + + print("\nInterpretation:") + print(" PE/CE ratio > 1.0 → puts trade at premium over equidistant calls (normal negative skew)") + print(" PE/CE ratio < 1.0 → calls premium over puts (positive/reverse skew — unusual)") + print(" Rising ratio at deeper OTM → heavy demand for tail risk protection") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/bull_call_spread.py b/interactive-examples/options_strategies/bull_call_spread.py new file mode 100644 index 0000000..290df54 --- /dev/null +++ b/interactive-examples/options_strategies/bull_call_spread.py @@ -0,0 +1,89 @@ +""" +Bull Call Spread Pricer. + +A bull call spread = Buy lower-strike Call + Sell higher-strike Call (same expiry). +Limited profit, limited risk. Suitable when moderately bullish. + + Max profit = (high_strike - low_strike) - net_debit + Max loss = net_debit (paid upfront) + Breakeven = low_strike + net_debit + +Usage: + python options_strategies/bull_call_spread.py --token + python options_strategies/bull_call_spread.py --token --query BANKNIFTY --buy_offset 0 --sell_offset 2 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="Bull call spread pricer") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--buy_offset", type=int, default=0, help="ATM offset for the leg to BUY (default: 0=ATM)") + parser.add_argument("--sell_offset", type=int, default=2, help="ATM offset for the leg to SELL (default: 2=2 strikes OTM)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Pricing bull call spread for {args.query} ({args.expiry})...\n") + + buy_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, + atm_offset=args.buy_offset, records=1) + sell_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, + atm_offset=args.sell_offset, records=1) + + buy_list = buy_resp.data or [] + sell_list = sell_resp.data or [] + + if not buy_list or not sell_list: + print("Could not find both legs. Try different offsets.") + sys.exit(1) + + buy_leg = buy_list[0] + sell_leg = sell_list[0] + + keys = [buy_leg["instrument_key"], sell_leg["instrument_key"]] + ltp_data = get_ltp(client, *keys) + + buy_premium = ltp_data[buy_leg["instrument_key"]].last_price + sell_premium = ltp_data[sell_leg["instrument_key"]].last_price + + buy_strike = buy_leg.get("strike_price", 0) + sell_strike = sell_leg.get("strike_price", 0) + lot_size = buy_leg.get("lot_size", 1) + + net_debit = buy_premium - sell_premium + spread_width = sell_strike - buy_strike + max_profit = spread_width - net_debit + breakeven = buy_strike + net_debit + risk_reward = max_profit / net_debit if net_debit > 0 else 0 + + print(f"{'Action':<8} {'Symbol':<30} {'Strike':>10} {'Premium':>10}") + print("-" * 65) + print(f"{'BUY':<8} {buy_leg['trading_symbol']:<30} {buy_strike:>10.2f} {buy_premium:>10.2f}") + print(f"{'SELL':<8} {sell_leg['trading_symbol']:<30} {sell_strike:>10.2f} {sell_premium:>10.2f}") + print("-" * 65) + + print(f"\nNet debit (cost) : {net_debit:>8.2f} per unit") + print(f"Net debit per lot : ₹{net_debit * lot_size:>10,.2f}") + print(f"Spread width : {spread_width:>8.2f} points") + print() + print(f"Breakeven : {breakeven:,.2f}") + print(f"Max profit : {max_profit:>8.2f} per unit (₹{max_profit * lot_size:,.2f}/lot)") + print(f"Max loss : {net_debit:>8.2f} per unit (₹{net_debit * lot_size:,.2f}/lot)") + print(f"Risk/Reward : 1 : {risk_reward:.2f}") + + print(f"\nBullish above {breakeven:,.0f}. Full profit if {args.query} closes above {sell_strike:,.0f} at expiry.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/butterfly_spread.py b/interactive-examples/options_strategies/butterfly_spread.py new file mode 100644 index 0000000..4ffefae --- /dev/null +++ b/interactive-examples/options_strategies/butterfly_spread.py @@ -0,0 +1,102 @@ +""" +Butterfly Spread Pricer. + +A long call butterfly = + Buy 1 lower-strike Call (ATM - offset) + Sell 2 middle-strike Calls (ATM) + Buy 1 upper-strike Call (ATM + offset) + +Net debit or credit depending on skew. Max profit at ATM strike at expiry. + + Max profit = wing_width - net_debit (at middle strike) + Max loss = net_debit (at or beyond outer strikes) + Lower BE = lower_strike + net_debit + Upper BE = upper_strike - net_debit + +Usage: + python options_strategies/butterfly_spread.py --token + python options_strategies/butterfly_spread.py --token --query BANKNIFTY --wing 2 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def fetch_ce(client, query, expiry, offset): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=expiry, + atm_offset=offset, records=1) + opts = resp.data or [] + return opts[0] if opts else None + + +def main(): + parser = argparse.ArgumentParser(description="Butterfly spread pricer") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--wing", type=int, default=1, help="Wing offset in strikes (default: 1)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Pricing butterfly spread for {args.query} ({args.expiry}), wing={args.wing}...\n") + + lower_leg = fetch_ce(client, args.query, args.expiry, -args.wing) + middle_leg = fetch_ce(client, args.query, args.expiry, 0) + upper_leg = fetch_ce(client, args.query, args.expiry, +args.wing) + + for name, leg in [("lower", lower_leg), ("middle", middle_leg), ("upper", upper_leg)]: + if not leg: + print(f"Could not find {name} leg. Try a smaller --wing value.") + sys.exit(1) + + keys = [lower_leg["instrument_key"], middle_leg["instrument_key"], upper_leg["instrument_key"]] + ltp_data = get_ltp(client, *keys) + + lower_prem = ltp_data[lower_leg["instrument_key"]].last_price + middle_prem = ltp_data[middle_leg["instrument_key"]].last_price + upper_prem = ltp_data[upper_leg["instrument_key"]].last_price + + lower_strike = lower_leg.get("strike_price", 0) + middle_strike = middle_leg.get("strike_price", 0) + upper_strike = upper_leg.get("strike_price", 0) + lot_size = middle_leg.get("lot_size", 1) + + net_debit = lower_prem - 2 * middle_prem + upper_prem + wing_width = upper_strike - middle_strike # should equal middle_strike - lower_strike + max_profit = wing_width - net_debit + lower_be = lower_strike + net_debit + upper_be = upper_strike - net_debit + + print(f"{'Action':<10} {'Qty':<5} {'Symbol':<30} {'Strike':>10} {'Premium':>10} {'Net':>10}") + print("-" * 80) + for action, qty, leg, prem in [ + ("BUY", "+1", lower_leg, lower_prem), + ("SELL", "-2", middle_leg, middle_prem), + ("BUY", "+1", upper_leg, upper_prem), + ]: + net = prem if "BUY" in action else -prem + print(f"{action:<10} {qty:<5} {leg['trading_symbol']:<30} " + f"{leg.get('strike_price',0):>10.2f} {prem:>10.2f} {net:>+10.2f}") + print("-" * 80) + + label = "debit" if net_debit > 0 else "credit" + print(f"\nNet {label} : {abs(net_debit):>8.2f} per unit " + f"(₹{abs(net_debit) * lot_size:,.2f}/lot)") + print(f"Wing width : {wing_width:>8.2f} points") + print(f"Max profit : {max_profit:>8.2f} per unit " + f"(₹{max_profit * lot_size:,.2f}/lot) at strike {middle_strike:,.0f}") + print(f"Max loss : {abs(net_debit):>8.2f} per unit " + f"(₹{abs(net_debit) * lot_size:,.2f}/lot)") + print(f"\nLower breakeven : {lower_be:,.2f}") + print(f"Upper breakeven : {upper_be:,.2f}") + print(f"Profit zone : {lower_be:,.0f} – {upper_be:,.0f}") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/calendar_spread_options.py b/interactive-examples/options_strategies/calendar_spread_options.py new file mode 100644 index 0000000..3ee6549 --- /dev/null +++ b/interactive-examples/options_strategies/calendar_spread_options.py @@ -0,0 +1,80 @@ +""" +Options Calendar Spread. + +A calendar spread = Sell near-month option + Buy far-month option (same strike). +Profits from time decay: near-month decays faster than far-month. + +Net debit = far_month_premium - near_month_premium. + +Usage: + python options_strategies/calendar_spread_options.py --token + python options_strategies/calendar_spread_options.py --token --query BANKNIFTY --type PE +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def find_atm_option(client, query, expiry, itype): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=0, records=1) + opts = resp.data or [] + return opts[0] if opts else None + + +def main(): + parser = argparse.ArgumentParser(description="Options calendar spread — same strike, two expiries") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--type", dest="opt_type", default="CE", choices=["CE", "PE"], + help="Option type (default: CE)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Building {args.opt_type} calendar spread for {args.query} (near vs far month)...\n") + + near_opt = find_atm_option(client, args.query, "current_month", args.opt_type) + far_opt = find_atm_option(client, args.query, "next_month", args.opt_type) + + if not near_opt or not far_opt: + print("Could not find both legs.") + sys.exit(1) + + keys = [near_opt["instrument_key"], far_opt["instrument_key"]] + ltp_data = get_ltp(client, *keys) + + near_prem = ltp_data[near_opt["instrument_key"]].last_price + far_prem = ltp_data[far_opt["instrument_key"]].last_price + + net_debit = far_prem - near_prem + lot_size = near_opt.get("lot_size", 1) + + print(f"{'Action':<8} {'Symbol':<30} {'Strike':>10} {'Expiry':<14} {'Premium':>10}") + print("-" * 77) + near_expiry = near_opt.get("expiry", "") + far_expiry = far_opt.get("expiry", "") + + print(f"{'SELL':<8} {near_opt['trading_symbol']:<30} " + f"{near_opt.get('strike_price',0):>10.2f} {near_expiry:<14} {near_prem:>10.2f}") + print(f"{'BUY':<8} {far_opt['trading_symbol']:<30} " + f"{far_opt.get('strike_price',0):>10.2f} {far_expiry:<14} {far_prem:>10.2f}") + print("-" * 77) + + print(f"\nNet debit : {net_debit:>8.2f} per unit") + print(f"Net debit per lot : ₹{net_debit * lot_size:,.2f}") + print(f"\nProfit mechanism : Near-month ({near_expiry}) decays faster.") + print(f"Ideal outcome : Near option expires worthless; far option retains value.") + print(f"Risk : Large underlying move hurts both legs equally.") + + if net_debit < 0: + print(f"\nNote: net credit of {abs(net_debit):.2f} received — unusual, check liquidity.") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/iron_condor_setup.py b/interactive-examples/options_strategies/iron_condor_setup.py new file mode 100644 index 0000000..6e5ab4a --- /dev/null +++ b/interactive-examples/options_strategies/iron_condor_setup.py @@ -0,0 +1,107 @@ +""" +Iron Condor Setup. + +An iron condor = + Sell OTM Put + Buy further-OTM Put (put spread) + Sell OTM Call + Buy further-OTM Call (call spread) + +Net credit received upfront. Profit if underlying stays in range. + + Max profit = net credit (if underlying expires between short strikes) + Max loss = spread width - net credit (if underlying breaches outer strikes) + Upper breakeven = short call strike + net credit + Lower breakeven = short put strike - net credit + +Usage: + python options_strategies/iron_condor_setup.py --token + python options_strategies/iron_condor_setup.py --token --query BANKNIFTY --short_offset 1 --long_offset 3 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def fetch_leg(client, query, expiry, itype, offset, records=1): + resp = search_instrument(client, query, exchanges="NSE", segments="FO", + instrument_types=itype, expiry=expiry, + atm_offset=offset, records=records) + legs = resp.data or [] + return legs[0] if legs else None + + +def main(): + parser = argparse.ArgumentParser(description="Iron condor setup and net credit") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--short_offset", type=int, default=1, help="Offset for short legs (default: 1)") + parser.add_argument("--long_offset", type=int, default=3, help="Offset for long hedge legs (default: 3)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Building iron condor for {args.query} ({args.expiry})...\n") + + # 4 legs + short_call = fetch_leg(client, args.query, args.expiry, "CE", +args.short_offset) + long_call = fetch_leg(client, args.query, args.expiry, "CE", +args.long_offset) + short_put = fetch_leg(client, args.query, args.expiry, "PE", -args.short_offset) + long_put = fetch_leg(client, args.query, args.expiry, "PE", -args.long_offset) + + legs = {"short_call": short_call, "long_call": long_call, + "short_put": short_put, "long_put": long_put} + + missing = [k for k, v in legs.items() if v is None] + if missing: + print(f"Could not find legs: {missing}. Try different offsets.") + sys.exit(1) + + all_keys = [l["instrument_key"] for l in legs.values()] + ltp_data = get_ltp(client, *all_keys) + + def ltp(inst): + return ltp_data[inst["instrument_key"]].last_price + + sc_prem = ltp(short_call) + lc_prem = ltp(long_call) + sp_prem = ltp(short_put) + lp_prem = ltp(long_put) + + net_credit = sc_prem - lc_prem + sp_prem - lp_prem + call_spread_width = long_call.get("strike_price", 0) - short_call.get("strike_price", 0) + put_spread_width = short_put.get("strike_price", 0) - long_put.get("strike_price", 0) + max_loss = max(call_spread_width, put_spread_width) - net_credit + lot_size = short_call.get("lot_size", 1) + + upper_be = short_call.get("strike_price", 0) + net_credit + lower_be = short_put.get("strike_price", 0) - net_credit + + print(f"{'Action':<8} {'Type':<8} {'Symbol':<30} {'Strike':>10} {'Premium':>10}") + print("-" * 75) + for action, itype, inst, prem in [ + ("SELL", "CALL", short_call, sc_prem), + ("BUY", "CALL", long_call, lc_prem), + ("SELL", "PUT", short_put, sp_prem), + ("BUY", "PUT", long_put, lp_prem), + ]: + print(f"{action:<8} {itype:<8} {inst['trading_symbol']:<30} " + f"{inst.get('strike_price',0):>10.2f} {prem:>10.2f}") + print("-" * 75) + + print(f"\nNet credit : {net_credit:>8.2f} per unit (₹{net_credit * lot_size:,.2f}/lot)") + print(f"Call spread width : {call_spread_width:>8.2f} points") + print(f"Put spread width : {put_spread_width:>8.2f} points") + print(f"Max profit : {net_credit:>8.2f} per unit (₹{net_credit * lot_size:,.2f}/lot)") + print(f"Max loss : {max_loss:>8.2f} per unit (₹{max_loss * lot_size:,.2f}/lot)") + print() + print(f"Upper breakeven : {upper_be:,.2f}") + print(f"Lower breakeven : {lower_be:,.2f}") + print(f"Profit range : {lower_be:,.0f} – {upper_be:,.0f} ({upper_be - lower_be:.0f} points wide)") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/put_call_parity.py b/interactive-examples/options_strategies/put_call_parity.py new file mode 100644 index 0000000..69ba773 --- /dev/null +++ b/interactive-examples/options_strategies/put_call_parity.py @@ -0,0 +1,119 @@ +""" +Put-Call Parity Check. + +Put-call parity: C - P = F - K * e^(-rT) + +In practice, for Indian index options (European style): + C - P ≈ F - K (ignoring discounting for short T) + +Where: + C = Call premium + P = Put premium + F = Futures price (near-month) + K = Strike price + +A deviation signals a mispricing or arbitrage opportunity. + +Synthetic long = Buy CE + Sell PE → should equal (F - K). +Synthetic short = Sell CE + Buy PE → reverse. + +Usage: + python options_strategies/put_call_parity.py --token + python options_strategies/put_call_parity.py --token --query BANKNIFTY --strike 48000 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_futures_sorted, get_ltp + + +def get_atm_options(client, query: str, atm_offset: int = 0): + """Fetch CE and PE at a given ATM offset for current month expiry.""" + ce_resp = search_instrument( + client, query, + exchanges="NSE", segments="FO", + instrument_types="CE", + expiry="current_month", + atm_offset=atm_offset, + records=1, + ) + pe_resp = search_instrument( + client, query, + exchanges="NSE", segments="FO", + instrument_types="PE", + expiry="current_month", + atm_offset=atm_offset, + records=1, + ) + ce_list = ce_resp.data or [] + pe_list = pe_resp.data or [] + return ce_list[0] if ce_list else None, pe_list[0] if pe_list else None + + +def main(): + parser = argparse.ArgumentParser(description="Put-call parity deviation checker") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--atm_offset", type=int, default=0, help="ATM offset (default: 0 = ATM)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Checking put-call parity for {args.query} (ATM offset: {args.atm_offset:+d})...\n") + + ce_inst, pe_inst = get_atm_options(client, args.query, args.atm_offset) + if not ce_inst or not pe_inst: + print("Could not find CE/PE pair. Try a different query or offset.") + sys.exit(1) + + if ce_inst.get("strike_price") != pe_inst.get("strike_price"): + print(f"Warning: CE strike {ce_inst.get('strike_price')} != PE strike {pe_inst.get('strike_price')}") + + strike = ce_inst.get("strike_price", 0) + + # Get near-month futures + futures = get_futures_sorted(client, args.query, exchange="NSE") + if not futures: + print("Could not find futures contract.") + sys.exit(1) + near_fut = futures[0] + + keys = [ce_inst["instrument_key"], pe_inst["instrument_key"], near_fut["instrument_key"]] + ltp_data = get_ltp(client, *keys) + + ce_price = ltp_data[ce_inst["instrument_key"]].last_price + pe_price = ltp_data[pe_inst["instrument_key"]].last_price + futures_price = ltp_data[near_fut["instrument_key"]].last_price + + synthetic = ce_price - pe_price # C - P + theoretical = futures_price - strike # F - K + deviation = synthetic - theoretical + + print(f"Strike : {strike:,.2f}") + print(f"Futures ({near_fut['trading_symbol']}) : {futures_price:,.2f}") + print() + print(f"Call ({ce_inst['trading_symbol']}) : {ce_price:,.2f}") + print(f"Put ({pe_inst['trading_symbol']}) : {pe_price:,.2f}") + print() + print(f"Synthetic (C - P) : {synthetic:>+10.2f}") + print(f"Theoretical (F - K) : {theoretical:>+10.2f}") + print(f"Parity Deviation : {deviation:>+10.2f}") + + lot_size = near_fut.get("lot_size", 1) + print(f"Deviation per lot (₹) : {deviation * lot_size:>+10.2f}") + + if abs(deviation) < 5: + print("\nParity holds within ₹5 — no significant mispricing.") + elif deviation > 0: + print(f"\nCall is relatively EXPENSIVE vs Put + Futures by {deviation:.2f} points.") + print("Potential trade: Sell CE, Buy PE, Buy Futures (reverse conversion).") + else: + print(f"\nPut is relatively EXPENSIVE vs Call + Futures by {abs(deviation):.2f} points.") + print("Potential trade: Buy CE, Sell PE, Sell Futures (conversion).") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/straddle_pricer.py b/interactive-examples/options_strategies/straddle_pricer.py new file mode 100644 index 0000000..c97a77b --- /dev/null +++ b/interactive-examples/options_strategies/straddle_pricer.py @@ -0,0 +1,84 @@ +""" +ATM Straddle Pricer. + +A long straddle = Buy ATM Call + Buy ATM Put (same strike, same expiry). +Profit if the underlying moves sharply in either direction. + +This script: + 1. Finds the ATM CE and PE using instrument search + 2. Fetches their live LTPs + 3. Computes total straddle cost, breakeven points, and max loss + +Usage: + python options_strategies/straddle_pricer.py --token + python options_strategies/straddle_pricer.py --token --query BANKNIFTY --expiry current_week +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="ATM straddle cost and breakevens") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Pricing ATM straddle for {args.query} ({args.expiry})...\n") + + ce_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, atm_offset=0, records=1) + pe_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="PE", expiry=args.expiry, atm_offset=0, records=1) + + ce_list = ce_resp.data or [] + pe_list = pe_resp.data or [] + + if not ce_list or not pe_list: + print("Could not find ATM options. Try different symbol or expiry.") + sys.exit(1) + + ce = ce_list[0] + pe = pe_list[0] + strike = ce.get("strike_price", 0) + lot_size = ce.get("lot_size", 1) + expiry = ce.get("expiry", "") + + keys = [ce["instrument_key"], pe["instrument_key"]] + ltp_data = get_ltp(client, *keys) + + ce_price = ltp_data[ce["instrument_key"]].last_price + pe_price = ltp_data[pe["instrument_key"]].last_price + + straddle_cost = ce_price + pe_price + upper_breakeven = strike + straddle_cost + lower_breakeven = strike - straddle_cost + max_loss = straddle_cost * lot_size + + print(f"{'Leg':<10} {'Symbol':<30} {'Strike':>10} {'LTP':>10}") + print("-" * 65) + print(f"{'Call':<10} {ce['trading_symbol']:<30} {strike:>10.2f} {ce_price:>10.2f}") + print(f"{'Put':<10} {pe['trading_symbol']:<30} {strike:>10.2f} {pe_price:>10.2f}") + print("-" * 65) + + print(f"\nExpiry : {expiry}") + print(f"Strike : {strike:,.2f}") + print(f"Total Straddle : {straddle_cost:,.2f} (CE {ce_price:.2f} + PE {pe_price:.2f})") + print(f"Lot size : {lot_size}") + print(f"Total cost/lot : ₹{max_loss:,.2f}") + print() + print(f"Upper breakeven : {upper_breakeven:,.2f} (+{straddle_cost:.2f} from strike)") + print(f"Lower breakeven : {lower_breakeven:,.2f} (-{straddle_cost:.2f} from strike)") + print(f"Max loss (at exp) : ₹{max_loss:,.2f} per lot (if underlying stays at {strike:.0f})") + print(f"Required move : {(straddle_cost / strike * 100):.2f}% in either direction to break even") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/options_strategies/strangle_pricer.py b/interactive-examples/options_strategies/strangle_pricer.py new file mode 100644 index 0000000..2490efa --- /dev/null +++ b/interactive-examples/options_strategies/strangle_pricer.py @@ -0,0 +1,86 @@ +""" +OTM Strangle Pricer. + +A long strangle = Buy OTM Call + Buy OTM Put (different strikes, same expiry). +Cheaper than a straddle but requires a larger move to profit. + +This script uses atm_offset to select OTM strikes: + atm_offset=+1 → one strike above ATM for CE + atm_offset=-1 → one strike below ATM for PE + +Usage: + python options_strategies/strangle_pricer.py --token + python options_strategies/strangle_pricer.py --token --query BANKNIFTY --offset 2 +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + + +def main(): + parser = argparse.ArgumentParser(description="OTM strangle cost and breakevens") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument("--query", default="NIFTY", help="Underlying symbol (default: NIFTY)") + parser.add_argument("--expiry", default="current_month", help="Expiry (default: current_month)") + parser.add_argument("--offset", type=int, default=1, help="OTM offset strikes (default: 1)") + args = parser.parse_args() + + client = get_api_client(args.token) + + print(f"Pricing {args.offset}-strike OTM strangle for {args.query} ({args.expiry})...\n") + + # OTM call = ATM + offset, OTM put = ATM - offset + ce_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="CE", expiry=args.expiry, + atm_offset=args.offset, records=1) + pe_resp = search_instrument(client, args.query, exchanges="NSE", segments="FO", + instrument_types="PE", expiry=args.expiry, + atm_offset=-args.offset, records=1) + + ce_list = ce_resp.data or [] + pe_list = pe_resp.data or [] + + if not ce_list or not pe_list: + print("Could not find strangle legs. Try different symbol, expiry, or offset.") + sys.exit(1) + + ce = ce_list[0] + pe = pe_list[0] + ce_strike = ce.get("strike_price", 0) + pe_strike = pe.get("strike_price", 0) + lot_size = ce.get("lot_size", 1) + + keys = [ce["instrument_key"], pe["instrument_key"]] + ltp_data = get_ltp(client, *keys) + + ce_price = ltp_data[ce["instrument_key"]].last_price + pe_price = ltp_data[pe["instrument_key"]].last_price + + strangle_cost = ce_price + pe_price + upper_breakeven = ce_strike + strangle_cost + lower_breakeven = pe_strike - strangle_cost + profit_zone_width = upper_breakeven - lower_breakeven + + print(f"{'Leg':<10} {'Symbol':<30} {'Strike':>10} {'LTP':>10}") + print("-" * 65) + print(f"{'Call (+{0})':<10} {ce['trading_symbol']:<30} {ce_strike:>10.2f} {ce_price:>10.2f}".format(args.offset)) + print(f"{'Put (-{0})':<10} {pe['trading_symbol']:<30} {pe_strike:>10.2f} {pe_price:>10.2f}".format(args.offset)) + print("-" * 65) + + print(f"\nStrangle width : {ce_strike - pe_strike:.2f} points ({ce_strike:.0f} CE / {pe_strike:.0f} PE)") + print(f"Total premium : {strangle_cost:.2f} (CE {ce_price:.2f} + PE {pe_price:.2f})") + print(f"Lot size : {lot_size}") + print(f"Total cost/lot : ₹{strangle_cost * lot_size:,.2f}") + print() + print(f"Upper breakeven : {upper_breakeven:,.2f}") + print(f"Lower breakeven : {lower_breakeven:,.2f}") + print(f"Profit zone width : {profit_zone_width:,.2f} points") + print(f"Max loss (at exp) : ₹{strangle_cost * lot_size:,.2f} per lot (if underlying stays between {pe_strike:.0f}–{ce_strike:.0f})") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/portfolio_screening/futures_oi_buildup.py b/interactive-examples/portfolio_screening/futures_oi_buildup.py new file mode 100644 index 0000000..40f55e0 --- /dev/null +++ b/interactive-examples/portfolio_screening/futures_oi_buildup.py @@ -0,0 +1,141 @@ +""" +Futures OI Buildup Scanner. + +Searches for near-month futures of multiple stocks, fetches their +full market quotes, and ranks them by Open Interest (OI) to identify: + + - Long buildup : Price ↑ + OI ↑ → fresh long positions being added (bullish) + - Short buildup : Price ↓ + OI ↑ → fresh short positions being added (bearish) + - Long unwinding : Price ↓ + OI ↓ → longs exiting (bearish) + - Short covering : Price ↑ + OI ↓ → shorts covering (bullish) + +Usage: + python portfolio_screening/futures_oi_buildup.py --token + python portfolio_screening/futures_oi_buildup.py --token --sort oi +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_full_quote + +DEFAULT_STOCKS = [ + "RELIANCE", "HDFCBANK", "INFY", "TCS", "ICICIBANK", + "BHARTIARTL", "SBIN", "WIPRO", "HCLTECH", "AXISBANK", + "LT", "KOTAKBANK", "TITAN", "ASIANPAINT", "MARUTI", +] + + +def find_near_future(client, symbol): + """Find the near-month futures contract for a stock.""" + resp = search_instrument( + client, symbol, + exchanges="NSE", + segments="FO", + instrument_types="FUT", + expiry="current_month", + records=1, + ) + data = resp.data or [] + return data[0] if data else None + + +def classify_buildup(chg_pct, oi): + """Classify OI buildup signal based on price and OI direction.""" + # We only have current OI snapshot, not OI change. + # Use price change as proxy: + if chg_pct > 0.5: + return "Long Buildup" + elif chg_pct < -0.5: + return "Short Buildup" + elif chg_pct > 0: + return "Short Covering" + else: + return "Long Unwinding" + + +def main(): + parser = argparse.ArgumentParser(description="Futures OI buildup scanner") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument( + "--stocks", + default=",".join(DEFAULT_STOCKS), + help="Comma-separated stock symbols", + ) + parser.add_argument( + "--sort", default="oi", choices=["oi", "change", "volume"], + help="Sort by: oi, change, volume (default: oi)", + ) + parser.add_argument("--top", type=int, default=10, help="Show top N (default: 10)") + args = parser.parse_args() + + client = get_api_client(args.token) + symbols = [s.strip() for s in args.stocks.split(",")] + + print(f"Scanning near-month futures OI for {len(symbols)} stocks...\n") + + inst_map = {} + for sym in symbols: + fut = find_near_future(client, sym) + if fut: + inst_map[sym] = fut + else: + print(f" No futures found for {sym}, skipping.") + + if not inst_map: + print("No futures found.") + sys.exit(1) + + all_keys = [inst["instrument_key"] for inst in inst_map.values()] + quote_data = get_full_quote(client, *all_keys) + + rows = [] + for sym, inst in inst_map.items(): + key = inst["instrument_key"] + q = quote_data.get(key) + if not q: + continue + ltp = q.last_price or 0 + prev = q.ohlc.close if q.ohlc else 0 + chg_pct = ((ltp - prev) / prev * 100) if prev else 0 + oi = q.oi or 0 + volume = q.volume or 0 + signal = classify_buildup(chg_pct, oi) + rows.append({ + "symbol": sym, + "contract": inst.get("trading_symbol", ""), + "ltp": ltp, + "chg_pct": chg_pct, + "oi": oi, + "volume": volume, + "signal": signal, + "expiry": inst.get("expiry", ""), + }) + + sort_key = {"oi": "oi", "change": "chg_pct", "volume": "volume"}[args.sort] + rows.sort(key=lambda r: r[sort_key], reverse=True) + rows = rows[: args.top] + + print(f"{'#':<4} {'Symbol':<12} {'Contract':<22} {'LTP':>10} {'Chg%':>8} {'OI':>14} {'Volume':>12} {'Signal':}") + print("-" * 100) + + for i, r in enumerate(rows, 1): + arrow = "▲" if r["chg_pct"] >= 0 else "▼" + print( + f"{i:<4} {r['symbol']:<12} {r['contract']:<22} " + f"{r['ltp']:>10,.2f} {r['chg_pct']:>+7.2f}% " + f"{r['oi']:>14,.0f} {r['volume']:>12,} {r['signal']} {arrow}" + ) + + # Summary by signal type + print("\nSignal summary:") + for sig in ["Long Buildup", "Short Buildup", "Short Covering", "Long Unwinding"]: + count = sum(1 for r in rows if r["signal"] == sig) + if count: + print(f" {sig:<20}: {count} stocks") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/portfolio_screening/sector_index_comparison.py b/interactive-examples/portfolio_screening/sector_index_comparison.py new file mode 100644 index 0000000..aa4cc8a --- /dev/null +++ b/interactive-examples/portfolio_screening/sector_index_comparison.py @@ -0,0 +1,112 @@ +""" +Sector Index Comparison. + +Fetches live LTPs for major NSE sector indices and ranks them by +daily performance (% change from previous close). + +Sectors tracked: Nifty 50, Bank Nifty, Nifty IT, Nifty Pharma, +Nifty Auto, Nifty FMCG, Nifty Metal, Nifty Realty, Nifty Energy, Nifty Media. + +Usage: + python portfolio_screening/sector_index_comparison.py --token + python portfolio_screening/sector_index_comparison.py --token --sectors "NIFTY 50,NIFTY BANK,NIFTY IT" +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_ltp + +DEFAULT_SECTORS = [ + "NIFTY 50", + "NIFTY BANK", + "NIFTY IT", + "NIFTY PHARMA", + "NIFTY AUTO", + "NIFTY FMCG", + "NIFTY METAL", + "NIFTY REALTY", + "NIFTY ENERGY", + "NIFTY MEDIA", +] + + +def find_index(client, name): + resp = search_instrument(client, name, exchanges="NSE", segments="INDEX", records=3) + data = resp.data or [] + for inst in data: + if name.upper() in inst.get("trading_symbol", "").upper(): + return inst + return data[0] if data else None + + +def main(): + parser = argparse.ArgumentParser(description="Compare live NSE sector index performance") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument( + "--sectors", + default=",".join(DEFAULT_SECTORS), + help="Comma-separated sector index names", + ) + args = parser.parse_args() + + client = get_api_client(args.token) + sector_names = [s.strip() for s in args.sectors.split(",")] + + print("Fetching sector indices...\n") + + # Resolve instrument keys + index_map = {} + for name in sector_names: + inst = find_index(client, name) + if inst: + index_map[name] = inst + else: + print(f" Warning: '{name}' not found, skipping.") + + if not index_map: + print("No indices found.") + sys.exit(1) + + all_keys = [inst["instrument_key"] for inst in index_map.values()] + ltp_data = get_ltp(client, *all_keys) + + # Compute change% and sort + results = [] + for name, inst in index_map.items(): + key = inst["instrument_key"] + q = ltp_data.get(key) + if not q: + continue + ltp = q.last_price + prev = q.cp # close price = previous day close + chg = ltp - prev + chg_pct = (chg / prev * 100) if prev else 0 + results.append((name, inst.get("trading_symbol", name), ltp, prev, chg, chg_pct)) + + results.sort(key=lambda x: x[5], reverse=True) + + print(f"{'Rank':<5} {'Index':<20} {'Symbol':<20} {'LTP':>10} {'Prev':>10} {'Chg':>10} {'Chg%':>8} {'Bar':}") + print("-" * 95) + + for rank, (name, symbol, ltp, prev, chg, chg_pct) in enumerate(results, 1): + bar_len = min(int(abs(chg_pct) * 3), 20) + bar = ("▲" if chg >= 0 else "▼") * bar_len + arrow = "▲" if chg >= 0 else "▼" + print( + f"{rank:<5} {name:<20} {symbol:<20} " + f"{ltp:>10,.2f} {prev:>10,.2f} {chg:>+10.2f} {chg_pct:>+7.2f}% {bar}" + ) + + best = results[0] + worst = results[-1] + print(f"\nBest performer : {best[0]} ({best[5]:+.2f}%)") + print(f"Worst performer : {worst[0]} ({worst[5]:+.2f}%)") + breadth_pos = sum(1 for r in results if r[5] > 0) + print(f"Market breadth : {breadth_pos}/{len(results)} sectors up") + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/portfolio_screening/top_volume_stocks.py b/interactive-examples/portfolio_screening/top_volume_stocks.py new file mode 100644 index 0000000..7a7038f --- /dev/null +++ b/interactive-examples/portfolio_screening/top_volume_stocks.py @@ -0,0 +1,111 @@ +""" +Top Volume Stocks Scanner. + +Searches for a list of well-known stocks, fetches their full market quotes, +and ranks them by trading volume (and optionally by % change). + +This demonstrates using instrument search + get_full_market_quote together +to build a simple market scanner. + +Usage: + python portfolio_screening/top_volume_stocks.py --token + python portfolio_screening/top_volume_stocks.py --token --sort change +""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from utils import get_api_client, search_instrument, get_full_quote + +# Representative large-cap Nifty 50 stocks +DEFAULT_STOCKS = [ + "RELIANCE", "HDFCBANK", "INFY", "TCS", "ICICIBANK", + "BHARTIARTL", "SBIN", "WIPRO", "HCLTECH", "AXISBANK", + "LT", "KOTAKBANK", "TITAN", "ASIANPAINT", "MARUTI", +] + + +def find_equity(client, symbol): + resp = search_instrument(client, symbol, exchanges="NSE", segments="EQ", records=3) + data = resp.data or [] + for inst in data: + if symbol.upper() == inst.get("trading_symbol", "").upper(): + return inst + return data[0] if data else None + + +def main(): + parser = argparse.ArgumentParser(description="Rank stocks by volume or price change") + parser.add_argument("--token", required=True, help="Upstox access token or analytics token") + parser.add_argument( + "--stocks", + default=",".join(DEFAULT_STOCKS), + help="Comma-separated list of stock symbols", + ) + parser.add_argument( + "--sort", default="volume", choices=["volume", "change", "ltp"], + help="Sort by: volume, change (%% change), ltp (default: volume)", + ) + parser.add_argument("--top", type=int, default=10, help="Show top N results (default: 10)") + args = parser.parse_args() + + client = get_api_client(args.token) + symbols = [s.strip() for s in args.stocks.split(",")] + + print(f"Resolving {len(symbols)} instruments on NSE...\n") + + inst_map = {} + for sym in symbols: + inst = find_equity(client, sym) + if inst: + inst_map[sym] = inst + + if not inst_map: + print("No instruments found.") + sys.exit(1) + + all_keys = [inst["instrument_key"] for inst in inst_map.values()] + quote_data = get_full_quote(client, *all_keys) + + rows = [] + for sym, inst in inst_map.items(): + key = inst["instrument_key"] + q = quote_data.get(key) + if not q: + continue + ltp = q.last_price or 0 + prev = q.ohlc.close if q.ohlc else 0 + chg = ltp - prev + chg_pct = (chg / prev * 100) if prev else 0 + volume = q.volume or 0 + oi = q.oi or 0 + rows.append({ + "symbol": sym, + "ltp": ltp, + "prev": prev, + "chg": chg, + "chg_pct": chg_pct, + "volume": volume, + "oi": oi, + }) + + sort_key = {"volume": "volume", "change": "chg_pct", "ltp": "ltp"}[args.sort] + rows.sort(key=lambda r: abs(r[sort_key]) if sort_key == "chg_pct" else r[sort_key], reverse=True) + rows = rows[: args.top] + + print(f"Top {args.top} stocks by {args.sort.upper()}\n") + print(f"{'#':<4} {'Symbol':<15} {'LTP':>10} {'Prev':>10} {'Chg':>10} {'Chg%':>8} {'Volume':>15} {'OI':>12}") + print("-" * 90) + + for i, r in enumerate(rows, 1): + arrow = "▲" if r["chg"] >= 0 else "▼" + print( + f"{i:<4} {r['symbol']:<15} {r['ltp']:>10,.2f} {r['prev']:>10,.2f} " + f"{r['chg']:>+10.2f} {r['chg_pct']:>+7.2f}% {r['volume']:>15,} {r['oi']:>12,.0f} {arrow}" + ) + + +if __name__ == "__main__": + main() diff --git a/interactive-examples/requirements.txt b/interactive-examples/requirements.txt new file mode 100644 index 0000000..7a189ad --- /dev/null +++ b/interactive-examples/requirements.txt @@ -0,0 +1,2 @@ +upstox-python-sdk +plotext diff --git a/interactive-examples/test_runner.py b/interactive-examples/test_runner.py new file mode 100644 index 0000000..bd19118 --- /dev/null +++ b/interactive-examples/test_runner.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Test runner for all Upstox API examples. + +Usage: + python test_runner.py +""" + +import subprocess +import sys +import os + +def validate_token(token): + """Make a lightweight API call to confirm the token works. Returns (ok, message).""" + here = os.path.dirname(os.path.abspath(__file__)) + script = "\n".join([ + "import sys", + "sys.path.insert(0, '.')", + "from utils import get_api_client, search_instrument", + f"client = get_api_client({token!r})", + "resp = search_instrument(client, 'NIFTY', exchanges='NSE', segments='EQ', records=1)", + "sys.exit(0 if resp and resp.data is not None else 1)", + ]) + result = subprocess.run( + [PYTHON, "-c", script], + cwd=here, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode == 0: + return True, "Token valid." + stderr = result.stderr + if "401" in stderr or "Unauthorized" in stderr or "UDAPI100068" in stderr: + return False, "Token rejected (401 Unauthorized)." + if "403" in stderr: + return False, "Token rejected (403 Forbidden)." + if stderr.strip(): + return False, stderr.strip().splitlines()[-1] + return False, "Token validation failed." + +def _find_python(): + """Use venv Python if one exists in the project root, else fall back to current interpreter.""" + here = os.path.dirname(os.path.abspath(__file__)) + for candidate in ( + os.path.join(here, "bin", "python3"), + os.path.join(here, "bin", "python"), + os.path.join(here, ".venv", "bin", "python3"), + os.path.join(here, "venv", "bin", "python3"), + ): + if os.path.isfile(candidate): + return candidate + return sys.executable + +PYTHON = _find_python() + +# ── All examples in order ──────────────────────────────────────────────────── + +EXAMPLES = [ + # (category, script, extra_args) + ("Instrument Search", "instrument_search/search_equity.py", ["--query", "RELIANCE"]), + ("Instrument Search", "instrument_search/search_futures.py", ["--query", "NIFTY"]), + ("Instrument Search", "instrument_search/search_options.py", ["--query", "NIFTY"]), + + ("Futures & Basis", "futures_basis/nifty_futures_spread.py", []), + ("Futures & Basis", "futures_basis/banknifty_futures_spread.py", []), + ("Futures & Basis", "futures_basis/cash_futures_basis.py", []), + ("Futures & Basis", "futures_basis/futures_roll_cost.py", []), + ("Futures & Basis", "futures_basis/mcx_crude_spread.py", []), + + ("Options Strategies", "options_strategies/straddle_pricer.py", ["--query", "NIFTY"]), + ("Options Strategies", "options_strategies/strangle_pricer.py", ["--query", "NIFTY"]), + ("Options Strategies", "options_strategies/bull_call_spread.py", ["--query", "NIFTY"]), + ("Options Strategies", "options_strategies/iron_condor_setup.py", ["--query", "NIFTY"]), + ("Options Strategies", "options_strategies/butterfly_spread.py", ["--query", "NIFTY"]), + ("Options Strategies", "options_strategies/calendar_spread_options.py", ["--query", "NIFTY"]), + ("Options Strategies", "options_strategies/put_call_parity.py", ["--query", "NIFTY"]), + + ("Options Analytics", "options_analytics/options_chain_builder.py", ["--query", "NIFTY", "--strikes", "3"]), + ("Options Analytics", "options_analytics/max_pain_calculator.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/oi_skew.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/volatility_skew.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/gamma_exposure.py", ["--query", "NIFTY"]), + + ("Arbitrage", "arbitrage/nse_bse_arbitrage.py", ["--query", "RELIANCE"]), + ("Arbitrage", "arbitrage/etf_vs_index.py", []), + ("Arbitrage", "arbitrage/currency_futures_spread.py", []), + + ("Historical Analysis", "historical_analysis/historical_candle.py", ["--query", "RELIANCE"]), + ("Historical Analysis", "historical_analysis/moving_average.py", ["--query", "RELIANCE"]), + ("Historical Analysis", "historical_analysis/historical_volatility.py", ["--query", "RELIANCE"]), + ("Historical Analysis", "historical_analysis/week_52_high_low.py", ["--query", "RELIANCE"]), + + ("Portfolio Screening", "portfolio_screening/sector_index_comparison.py", []), + ("Portfolio Screening", "portfolio_screening/top_volume_stocks.py", []), + ("Portfolio Screening", "portfolio_screening/futures_oi_buildup.py", []), + + ("Market Data", "market_data/intraday_chart.py", ["--query", "SENSEX"]), + ("Market Data", "market_data/market_status.py", []), + ("Market Data", "market_data/market_holidays.py", []), + ("Market Data", "market_data/market_timings.py", []), + ("Market Data", "market_data/live_depth.py", []), # streaming — auto-aborted after 5s + ("Market Data", "market_data/live_depth_d30.py", []), # streaming — auto-aborted after 5s (Plus Pack) + ("Market Data", "market_data/live_depth_mcx.py", []), # streaming — auto-aborted after 5s + ("Market Data", "market_data/live_depth_usdinr.py", []), # streaming — auto-aborted after 5s + + ("Options Analytics", "options_analytics/option_chain_native.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/option_greeks.py", ["--query", "NIFTY", "--strikes", "3"]), + ("Options Analytics", "options_analytics/pcr_trend.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/iv_percentile.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/implied_move.py", ["--query", "NIFTY"]), + ("Options Analytics", "options_analytics/expiry_decay.py", ["--query", "NIFTY", "--strikes", "3"]), + + ("Historical Analysis", "historical_analysis/vwap.py", ["--query", "RELIANCE"]), + ("Historical Analysis", "historical_analysis/beta_calculator.py", ["--query", "RELIANCE"]), + ("Historical Analysis", "historical_analysis/stock_correlation.py", ["--queries", "RELIANCE,TCS,INFY"]), +] + +# Scripts that run indefinitely — killed after this many seconds and counted as PASS +STREAMING_SCRIPTS = { + "market_data/live_depth.py", + "market_data/live_depth_d30.py", + "market_data/live_depth_mcx.py", + "market_data/live_depth_usdinr.py", +} +STREAMING_TIMEOUT = 5 + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +BOLD = "\033[1m" +GREEN = "\033[32m" +RED = "\033[31m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" + +def hr(char="─", width=70): + print(char * width) + +def run_example(script, token, extra_args): + """Run a single example script and stream output live.""" + cmd = [PYTHON, script, "--token", token] + extra_args + is_streaming = script in STREAMING_SCRIPTS + try: + timeout = STREAMING_TIMEOUT if is_streaming else None + result = subprocess.run(cmd, cwd=os.path.dirname(__file__), timeout=timeout) + return result.returncode == 0 + except subprocess.TimeoutExpired: + # Streaming script ran for the full timeout window — counts as pass + print(f"\n{DIM} (streaming script auto-stopped after {STREAMING_TIMEOUT}s){RESET}") + return True + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + hr("═") + print(f"{BOLD} Upstox API Examples — Test Runner{RESET}") + print(f" {len(EXAMPLES)} examples across 8 categories (including 7 new analytics scripts)") + hr("═") + print() + + # Ask for token + try: + token = input(" Paste your Upstox analytics or access token: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nAborted.") + sys.exit(0) + + if not token: + print(f"{RED} No token provided. Exiting.{RESET}") + sys.exit(1) + + print(f" Validating token...", end=" ", flush=True) + ok, msg = validate_token(token) + if ok: + print(f"{GREEN}✓ {msg}{RESET}") + else: + print(f"{RED}✗ {msg}{RESET}") + sys.exit(1) + + print() + + passed = [] + failed = [] + current_category = None + + for i, (category, script, extra_args) in enumerate(EXAMPLES, start=1): + # Print category header when it changes + if category != current_category: + current_category = category + print() + print(f"{CYAN}{BOLD} ── {category} {'─' * (50 - len(category))}{RESET}") + + # Print test header + print() + print(f"{BOLD} [{i}/{len(EXAMPLES)}] {script}{RESET}") + if extra_args: + print(f"{DIM} args: {' '.join(extra_args)}{RESET}") + hr() + + # Run it + ok = run_example(script, token, extra_args) + + if ok: + print(f"\n{GREEN} ✓ PASSED{RESET}") + passed.append(script) + else: + print(f"\n{RED} ✗ FAILED (exit code non-zero){RESET}") + failed.append(script) + + # Summary + print() + hr("═") + print(f"{BOLD} Results: {GREEN}{len(passed)} passed{RESET} {RED}{len(failed)} failed{RESET} out of {len(passed)+len(failed)} run") + hr("═") + + if failed: + print(f"\n{RED} Failed scripts:{RESET}") + for s in failed: + print(f" • {s}") + print() + +if __name__ == "__main__": + main() diff --git a/interactive-examples/utils.py b/interactive-examples/utils.py new file mode 100644 index 0000000..704416b --- /dev/null +++ b/interactive-examples/utils.py @@ -0,0 +1,154 @@ +""" +Shared helpers for all Upstox API examples. + +All examples accept a --token argument (access token or analytics token). +Analytics tokens are 1-year, read-only tokens that skip the OAuth flow — ideal +for data pipelines and dashboards. +""" + +import sys +from datetime import date +import upstox_client + + +def get_api_client(token: str) -> upstox_client.ApiClient: + """Build an authenticated SDK client from an access or analytics token.""" + config = upstox_client.Configuration() + config.access_token = token + return upstox_client.ApiClient(config) + + +def search_instrument(api_client: upstox_client.ApiClient, query: str, **kwargs): + """ + Search instruments by name/keyword. + + Common kwargs: + exchanges - comma-separated: NSE, BSE, MCX (default ALL) + segments - comma-separated: EQ, FO, CURR, COMM, INDEX (default ALL) + instrument_types - comma-separated: CE, PE, FUT, EQ, INDEX + expiry - 'current_week', 'current_month', or 'yyyy-MM-dd' + atm_offset - int, 0=ATM, +1=one strike above, -1=one below + page_number - int, starts at 1 + records - int, max 30 per page + + Returns the SearchInstrumentResponse (response.data is a list of dicts). + """ + api = upstox_client.InstrumentsApi(api_client) + return api.search_instrument(query, **kwargs) + + +def _rekey_by_instrument_token(data: dict) -> dict: + """ + The market-quote APIs return data keyed as 'EXCHANGE:SYMBOL' + (e.g. 'NSE_EQ:RELIANCE') but callers always use 'EXCHANGE|ISIN' + (e.g. 'NSE_EQ|INE002A01018'). Re-key by the instrument_token field + that lives inside each entry so lookups work with the original key. + """ + if not data: + return {} + result = {} + for entry in data.values(): + # entry is either a dict or a model object + token = entry.get("instrument_token") if isinstance(entry, dict) else getattr(entry, "instrument_token", None) + if token: + result[token] = entry + return result + + +def get_ltp(api_client: upstox_client.ApiClient, *instrument_keys: str): + """ + Fetch last traded price for one or more instruments (up to 500). + + Returns dict keyed by instrument_key (e.g. 'NSE_EQ|INE002A01018'), + each value is a dict/object with last_price, volume, cp, ltq fields. + """ + api = upstox_client.MarketQuoteV3Api(api_client) + response = api.get_ltp(instrument_key=",".join(instrument_keys)) + return _rekey_by_instrument_token(response.data) + + +def get_full_quote(api_client: upstox_client.ApiClient, *instrument_keys: str): + """ + Fetch full market quote for one or more instruments. + + Returns dict keyed by instrument_key (e.g. 'NSE_EQ|INE002A01018'), + each value is a dict/object with last_price, ohlc, oi, volume, + net_change, total_buy_quantity, total_sell_quantity. + """ + api = upstox_client.MarketQuoteApi(api_client) + response = api.get_full_market_quote(",".join(instrument_keys), "2.0") + return _rekey_by_instrument_token(response.data) + + +def get_historical_candles( + api_client: upstox_client.ApiClient, + instrument_key: str, + unit: str, + interval: int, + to_date: str, + from_date: str = None, +): + """ + Fetch historical OHLC candles. + + unit - 'minutes', 'hours', 'days', 'weeks', 'months' + interval - numeric interval (e.g. 1, 5, 15, 30) + to_date - 'yyyy-MM-dd' + from_date- 'yyyy-MM-dd' (optional, uses get_historical_candle_data1) + + Returns list of candles, each candle is: + [timestamp, open, high, low, close, volume, oi] + """ + api = upstox_client.HistoryV3Api(api_client) + if from_date: + response = api.get_historical_candle_data1( + instrument_key, unit, interval, to_date, from_date + ) + else: + response = api.get_historical_candle_data( + instrument_key, unit, interval, to_date + ) + return response.data.candles # list[list[object]] + + +def get_futures_sorted( + api_client: upstox_client.ApiClient, + query: str, + exchange: str = "NSE", + exact_symbol: bool = False, +): + """ + Search for futures contracts and return them sorted by expiry (nearest first). + + If exact_symbol=True, only instruments whose underlying_symbol exactly + matches *query* (case-insensitive) are returned — useful when searching + 'NIFTY' to avoid picking up NIFTYNXT50, BANKNIFTY, etc. + + Returns list of instrument dicts, each with keys like: + instrument_key, trading_symbol, expiry, lot_size, underlying_symbol + """ + response = search_instrument( + api_client, + query, + exchanges=exchange, + segments="FO", + instrument_types="FUT", + records=30, + ) + instruments = response.data or [] + if exact_symbol: + instruments = [ + inst for inst in instruments + if inst.get("underlying_symbol", "").upper() == query.upper() + ] + # Sort by expiry date string (yyyy-MM-dd sorts lexicographically) + return sorted(instruments, key=lambda x: x.get("expiry", "")) + + +def today_str() -> str: + return date.today().isoformat() + + +def die(msg: str): + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1)