Spaces:
Sleeping
Sleeping
| # ============================================================================== | |
| # PRO OPTIONS CHAIN V4 - REAL-TIME WEBSOCKET EDITION (FYERS LIGHT MODE) | |
| # ============================================================================== | |
| import os | |
| import time | |
| import json | |
| import threading | |
| import asyncio | |
| from collections import defaultdict | |
| from typing import Optional | |
| import pandas as pd | |
| import uvicorn | |
| from fastapi import FastAPI, Cookie, Query, WebSocket, WebSocketDisconnect | |
| from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fyers_apiv3 import fyersModel | |
| from fyers_apiv3.FyersWebsocket import data_ws | |
| # ========================================== | |
| # 1. EXACT CONFIGURATION (DO NOT CHANGE) | |
| # ========================================== | |
| CLIENT_ID = "FV3BVDFQTO-100" | |
| SECRET_KEY = "ZALDI5GCDF" | |
| REDIRECT_URI = "https://topindianhacker-optionschart.hf.space/callback" | |
| CACHE_FILE = "master_cache.json" | |
| app = FastAPI() | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| # ========================================== | |
| # 2. MASTER DB LOADER | |
| # ========================================== | |
| MASTER_DB = {} | |
| SEARCH_INDEX =[] | |
| SYSTEM_STATUS = {"ready": False, "message": "Booting..."} | |
| UPDATE_LOCK = threading.Lock() | |
| INDEX_MAP = { | |
| "NIFTY": "NSE:NIFTY50-INDEX", "BANKNIFTY": "NSE:NIFTYBANK-INDEX", | |
| "FINNIFTY": "NSE:FINNIFTY-INDEX", "MIDCPNIFTY": "NSE:MIDCPNIFTY-INDEX", | |
| "SENSEX": "BSE:SENSEX-INDEX", "BANKEX": "BSE:BANKEX-INDEX", | |
| "SENSEX50": "BSE:SENSEX50-INDEX" | |
| } | |
| def get_root_symbol(desc: str, symbol: str) -> str: | |
| try: | |
| parts = desc.strip().split(' ') | |
| return parts[0].upper().replace(':', '').replace('-', '') | |
| except: | |
| return symbol.split(':')[1] if ':' in symbol else symbol | |
| def update_master_db(): | |
| global SYSTEM_STATUS, MASTER_DB, SEARCH_INDEX | |
| if not UPDATE_LOCK.acquire(blocking=False): return | |
| try: | |
| SYSTEM_STATUS["message"] = "Downloading Master Data..." | |
| headers = { "User-Agent": "Mozilla/5.0" } | |
| df_nse = pd.read_csv("https://public.fyers.in/sym_details/NSE_FO.csv", usecols=[0, 1, 8, 9, 13], names=['Token', 'Desc', 'Expiry', 'Symbol', 'Inst'], header=0, on_bad_lines='skip', storage_options=headers) | |
| df_nse['Exch'] = 'NSE' | |
| df_mcx = pd.read_csv("https://public.fyers.in/sym_details/MCX_COM.csv", usecols=[0, 1, 8, 9, 13], names=['Token', 'Desc', 'Expiry', 'Symbol', 'Inst'], header=0, on_bad_lines='skip', storage_options=headers) | |
| df_mcx['Exch'] = 'MCX' | |
| df_bse = pd.read_csv("https://public.fyers.in/sym_details/BSE_FO.csv", usecols=[0, 1, 8, 9, 13], names=['Token', 'Desc', 'Expiry', 'Symbol', 'Inst'], header=0, on_bad_lines='skip', storage_options=headers) | |
| df_bse['Exch'] = 'BSE' | |
| df = pd.concat([df_nse, df_mcx, df_bse], ignore_index=True) | |
| df['Symbol'] = df['Symbol'].astype(str) | |
| df['Desc'] = df['Desc'].astype(str) | |
| temp_db = {} | |
| for _, row in df.iterrows(): | |
| sym, desc, exch = row['Symbol'], row['Desc'], row['Exch'] | |
| root = get_root_symbol(desc, sym) | |
| if root not in temp_db: | |
| spot_sym = INDEX_MAP.get(root, f"NSE:{root}-EQ") | |
| if exch == "MCX": spot_sym = "MCX" | |
| elif exch == "BSE": spot_sym = f"BSE:{root}" | |
| temp_db[root] = { "spot": spot_sym, "exch": exch, "items":[] } | |
| try: exp = int(row['Expiry']) | |
| except: exp = 0 | |
| strike, opt_type = 0.0, "FUT" | |
| if "CE" in sym or "PE" in sym: | |
| parts = desc.strip().split(' ') | |
| if len(parts) >= 2: | |
| try: | |
| val_clean = parts[-2].replace('.', '', 1) | |
| if val_clean.isdigit(): strike = float(parts[-2]) | |
| opt_type = parts[-1] | |
| except: pass | |
| temp_db[root]["items"].append({ "s": sym, "e": exp, "k": strike, "t": opt_type }) | |
| final_db = {} | |
| search_list =[] | |
| for root, data in temp_db.items(): | |
| final_db[root] = { "spot": data["spot"], "exch": data["exch"], "items": sorted(data["items"], key=lambda x: (x['e'], x['k'])) } | |
| search_list.append({ "root": root, "exch": data["exch"], "display": root }) | |
| MASTER_DB = final_db | |
| SEARCH_INDEX = sorted(search_list, key=lambda x: x['root']) | |
| with open(CACHE_FILE, "w") as f: | |
| json.dump({"db": final_db, "idx": SEARCH_INDEX}, f) | |
| SYSTEM_STATUS.update({"ready": True, "message": "Ready"}) | |
| except Exception as e: | |
| SYSTEM_STATUS["message"] = f"Error: {str(e)}" | |
| finally: | |
| UPDATE_LOCK.release() | |
| def startup_event(): | |
| global MASTER_DB, SEARCH_INDEX | |
| if os.path.exists(CACHE_FILE): | |
| try: | |
| with open(CACHE_FILE, "r") as f: | |
| data = json.load(f) | |
| MASTER_DB, SEARCH_INDEX = data["db"], data["idx"] | |
| SYSTEM_STATUS.update({"ready": True, "message": "Ready (Cached)"}) | |
| except: pass | |
| if not SYSTEM_STATUS["ready"]: | |
| threading.Thread(target=update_master_db).start() | |
| # ========================================== | |
| # 3. FYERS WEBSOCKET ENGINE (BACKEND) | |
| # ========================================== | |
| STATE_MEMORY = defaultdict(lambda: {"ltp": 0, "b": 0, "s": 0}) | |
| FS = None | |
| SOCKET_RUNNING = False | |
| ACTIVE_SYMBOLS = set() | |
| GLOBAL_TOKEN = None | |
| def on_ws_message(msg): | |
| ticks = msg if isinstance(msg, list) else [msg] | |
| for t in ticks: | |
| sym = t.get('symbol') | |
| if sym: | |
| if 'ltp' in t: STATE_MEMORY[sym]['ltp'] = t['ltp'] | |
| if 'tot_buy_qty' in t: STATE_MEMORY[sym]['b'] = t['tot_buy_qty'] | |
| if 'tot_sell_qty' in t: STATE_MEMORY[sym]['s'] = t['tot_sell_qty'] | |
| def on_ws_error(e): print(f"[Fyers WS] Error: {e}") | |
| def on_ws_close(c): | |
| global SOCKET_RUNNING | |
| SOCKET_RUNNING = False | |
| print("[Fyers WS] Closed") | |
| def on_ws_open(): | |
| global SOCKET_RUNNING, ACTIVE_SYMBOLS | |
| SOCKET_RUNNING = True | |
| print("[Fyers WS] Connected Successfully.") | |
| if ACTIVE_SYMBOLS: | |
| FS.subscribe(symbols=list(ACTIVE_SYMBOLS), data_type="SymbolUpdate") | |
| def start_fyers_ws(token): | |
| global FS, SOCKET_RUNNING, GLOBAL_TOKEN | |
| GLOBAL_TOKEN = token | |
| if FS and SOCKET_RUNNING: return | |
| FS = data_ws.FyersDataSocket( | |
| access_token=token, log_path="", litemode=False, write_to_file=False, reconnect=True, | |
| on_connect=on_ws_open, on_close=on_ws_close, on_error=on_ws_error, on_message=on_ws_message | |
| ) | |
| threading.Thread(target=FS.connect, daemon=True).start() | |
| def stop_fyers_ws(): | |
| global FS, SOCKET_RUNNING | |
| if FS and SOCKET_RUNNING: | |
| FS.close_connection() | |
| SOCKET_RUNNING = False | |
| def update_subscriptions(symbols_list): | |
| global ACTIVE_SYMBOLS, FS, SOCKET_RUNNING | |
| new_set = set(symbols_list) | |
| to_add = list(new_set - ACTIVE_SYMBOLS) | |
| to_remove = list(ACTIVE_SYMBOLS - new_set) | |
| ACTIVE_SYMBOLS = new_set | |
| if SOCKET_RUNNING and FS: | |
| if to_add: | |
| for i in range(0, len(to_add), 50): FS.subscribe(symbols=to_add[i:i+50], data_type="SymbolUpdate") | |
| if to_remove: | |
| for i in range(0, len(to_remove), 50): FS.unsubscribe(symbols=to_remove[i:i+50], data_type="SymbolUpdate") | |
| # ========================================== | |
| # 4. API & AUTH ROUTES | |
| # ========================================== | |
| def get_fyers_session(): | |
| return fyersModel.SessionModel( | |
| client_id=CLIENT_ID, | |
| secret_key=SECRET_KEY, | |
| redirect_uri=REDIRECT_URI, | |
| response_type="code", | |
| grant_type="authorization_code" | |
| ) | |
| def home(access_token: Optional[str] = Cookie(None)): | |
| if not access_token: | |
| auth_url = get_fyers_session().generate_authcode() | |
| return HTMLResponse(f""" | |
| <body style="background:#f8fafc; display:flex; justify-content:center; align-items:center; height:100vh; font-family:sans-serif; margin:0;"> | |
| <div style="background:#ffffff; padding:40px; border-radius:12px; text-align:center; border:1px solid #e2e8f0; box-shadow: 0 10px 25px rgba(0,0,0,0.05);"> | |
| <h2 style="margin-top:0; color:#0f172a;">Fyers Authentication Required</h2> | |
| <a href="{auth_url}" style="display:inline-block; background:#2563eb; color:white; padding:12px 25px; text-decoration:none; border-radius:6px; font-weight:bold; margin-top: 15px; transition:0.2s;">LOGIN WITH FYERS</a> | |
| </div> | |
| </body> | |
| """) | |
| return HTMLResponse(HTML_TEMPLATE.replace("{{USER_TOKEN}}", access_token)) | |
| def callback(auth_code: Optional[str] = None, code: Optional[str] = None): | |
| final_code = auth_code if auth_code else code | |
| if not final_code: return JSONResponse({"error": "No Code Provided"}) | |
| session = get_fyers_session() | |
| session.set_token(final_code) | |
| try: | |
| response = session.generate_token() | |
| if response.get("s") == "ok" and "access_token" in response: | |
| full_token = f"{CLIENT_ID}:{response['access_token']}" | |
| resp = RedirectResponse(url="/") | |
| resp.set_cookie(key="access_token", value=full_token, httponly=True) | |
| return resp | |
| else: | |
| return HTMLResponse(f"<h3>Login Failed</h3><pre>{json.dumps(response, indent=2)}</pre>") | |
| except Exception as e: | |
| return HTMLResponse(f"<h3>Exception Triggered</h3><pre>{str(e)}</pre>") | |
| def logout(): | |
| response = RedirectResponse(url="/") | |
| response.delete_cookie("access_token") | |
| stop_fyers_ws() | |
| return response | |
| def get_status(): return JSONResponse(SYSTEM_STATUS) | |
| def search_symbol(q: str = Query(..., min_length=1)): | |
| if not SYSTEM_STATUS["ready"]: return JSONResponse([]) | |
| query = q.upper() | |
| results = [x for x in SEARCH_INDEX if x['root'].startswith(query)] | |
| if len(results) < 10: results +=[x for x in SEARCH_INDEX if query in x['root'] and x not in results] | |
| return JSONResponse(results[:20]) | |
| def get_chain(symbol: str): | |
| root = symbol.upper() | |
| if root in MASTER_DB: return JSONResponse(MASTER_DB[root]) | |
| return JSONResponse({"error": "Symbol not found"}, status_code=404) | |
| # ========================================== | |
| # 5. FASTAPI WEBSOCKET (STREAM TO BROWSER) | |
| # ========================================== | |
| async def websocket_endpoint(websocket: WebSocket): | |
| await websocket.accept() | |
| active_client_symbols =[] | |
| try: | |
| while True: | |
| try: | |
| data = await asyncio.wait_for(websocket.receive_json(), timeout=0.3) | |
| action = data.get("action") | |
| if action == "init": | |
| token = data.get("token") | |
| if token: start_fyers_ws(token) | |
| elif action == "subscribe": | |
| active_client_symbols = data.get("symbols",[]) | |
| update_subscriptions(active_client_symbols) | |
| elif action == "stop": | |
| stop_fyers_ws() | |
| elif action == "start": | |
| if GLOBAL_TOKEN: start_fyers_ws(GLOBAL_TOKEN) | |
| except asyncio.TimeoutError: | |
| pass | |
| if SOCKET_RUNNING and active_client_symbols: | |
| payload = {sym: STATE_MEMORY[sym] for sym in active_client_symbols if sym in STATE_MEMORY} | |
| if payload: | |
| await websocket.send_json({"type": "data", "payload": payload}) | |
| except WebSocketDisconnect: | |
| print("[Client WS] Disconnected") | |
| # ========================================== | |
| # 6. FRONTEND UI (WORLD'S BEST LIGHT MODE) | |
| # ========================================== | |
| HTML_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Pro Live Options Chain</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;700;800&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #f8fafc; --panel: #ffffff; --border: #e2e8f0; | |
| --text-main: #0f172a; --text-sub: #64748b; | |
| --accent: #2563eb; --green: #16a34a; --red: #dc2626; | |
| --buy-bg: #dcfce7; --sell-bg: #fee2e2; | |
| --atm-bg: #eff6ff; --strike-bg: #f1f5f9; | |
| } | |
| * { box-sizing: border-box; } | |
| body { margin: 0; background: var(--bg); color: var(--text-main); font-family: 'Inter', sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; } | |
| /* Top Navigation */ | |
| .top-nav { padding: 0 20px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; height: 70px; z-index: 50; flex-shrink:0; box-shadow: 0 2px 4px rgba(0,0,0,0.02);} | |
| .btn { background: var(--panel); border: 1px solid var(--border); padding: 8px 16px; font-size: 13px; border-radius: 6px; cursor: pointer; font-weight: 700; display: flex; gap: 8px; align-items: center; transition:0.2s; color: var(--text-main); box-shadow: 0 1px 2px rgba(0,0,0,0.02); } | |
| .btn:hover { border-color: var(--accent); color: var(--accent); background: #f0f7ff; } | |
| .btn-control { border:none; padding: 10px 20px; font-size: 14px; border-radius: 6px; color: white; transition: 0.3s; box-shadow: 0 2px 5px rgba(0,0,0,0.1); } | |
| .btn-control.playing { background: var(--green); } | |
| .btn-control.playing:hover { background: #15803d; } | |
| .btn-control.stopped { background: var(--red); } | |
| .btn-control.stopped:hover { background: #b91c1c; } | |
| .search-box { position: relative; width: 280px; margin-right: 15px; } | |
| .search-input { width: 100%; padding: 10px 15px; border: 1px solid var(--border); border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-weight: bold; font-size: 14px; outline: none; background: #f8fafc; color: var(--text-main); transition:0.3s;} | |
| .search-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); background: #fff; } | |
| .search-dropdown { position: absolute; top: 110%; left: 0; width: 100%; background: var(--panel); border: 1px solid var(--border); max-height: 300px; overflow-y: auto; display: none; border-radius: 6px; z-index: 100; box-shadow: 0 10px 25px rgba(0,0,0,0.1); } | |
| .search-item { padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #f1f5f9; font-size: 13px; font-weight: 700; color:var(--text-main); } | |
| .search-item:hover { background: #f0f7ff; color: var(--accent); } | |
| .exch-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; color: white; font-weight: 800; float:right; background: #94a3b8;} | |
| /* Ref Menu (Spot/Fut Toggle) */ | |
| .ref-wrap { position: relative; cursor: pointer; user-select: none; padding-right:15px;} | |
| .ref-dropdown { position: absolute; top: 100%; right: 0; width: 180px; background: white; border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 20px rgba(0,0,0,0.1); display: none; z-index: 100; } | |
| .ref-item { padding: 10px; font-size: 11px; font-weight: 700; border-bottom: 1px solid #f1f5f9; transition: 0.2s; color:var(--text-main);} | |
| .ref-item:hover { background: #f0f7ff; color: var(--accent); } | |
| .ref-item.active { background: var(--accent); color: white; } | |
| /* Chain Layout */ | |
| .chain-container { flex: 1; display: flex; flex-direction: column; background: var(--panel); position:relative; overflow: hidden; } | |
| /* Stopped Overlay */ | |
| .stopped-overlay { position: absolute; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.6); backdrop-filter: blur(4px); z-index: 20; display: none; flex-direction: column; justify-content: center; align-items: center; text-align:center; } | |
| .scroll-wrapper { flex: 1; overflow: auto; position: relative; } | |
| /* 7 Column Layout - Horizontal Min Width for responsiveness */ | |
| .chain-table { min-width: 950px; display: flex; flex-direction: column; } | |
| .chain-header { display: grid; grid-template-columns: 1.2fr 1.2fr 1fr 120px 1fr 1.2fr 1.2fr; background: var(--strike-bg); border-bottom: 1px solid var(--border); font-size: 11px; font-weight: 800; text-align: center; color: var(--text-sub); text-transform: uppercase; letter-spacing: 1px; position: sticky; top: 0; z-index: 15; box-shadow: 0 2px 5px rgba(0,0,0,0.05);} | |
| .header-cell { padding: 12px 15px; border-right: 1px solid var(--border); display:flex; justify-content:center; align-items:center; } | |
| .header-cell.strike { background: #e2e8f0; border-right: 1px solid var(--border); border-left: 1px solid var(--border); color:var(--text-main); } | |
| .chain-body { display: flex; flex-direction: column; } | |
| .row { display: grid; grid-template-columns: 1.2fr 1.2fr 1fr 120px 1fr 1.2fr 1.2fr; border-bottom: 1px solid var(--border); height: 42px; align-items: center; font-family: 'JetBrains Mono', monospace; font-size: 13px; transition: background 0.1s;} | |
| .row:hover { background: #f8fafc; } | |
| .row.atm { background: var(--atm-bg); border-top: 1px solid var(--accent); border-bottom: 1px solid var(--accent); } | |
| .cell { position: relative; height: 100%; display: flex; align-items: center; padding: 0 15px; font-weight: 700; border-right: 1px solid #f1f5f9; } | |
| .cell.call-col { justify-content: flex-end; } | |
| .cell.put-col { justify-content: flex-start; } | |
| .cell.strike { justify-content: center; background: var(--strike-bg); font-size: 14px; font-weight: 800; color: var(--text-main); border-right: 1px solid var(--border); border-left: 1px solid var(--border); } | |
| .val-txt { z-index: 2; } | |
| .val-buy { color: #15803d; } /* Darker green for text */ | |
| .val-sell { color: #b91c1c; } /* Darker red for text */ | |
| /* Depth Visualizer Bars */ | |
| .bar-bg { position: absolute; top: 5px; bottom: 5px; z-index: 0; border-radius: 4px; transition: width 0.3s ease; opacity: 0.8;} | |
| .bar-call { right: 5px; } /* Anchor to right, grows left */ | |
| .bar-put { left: 5px; } /* Anchor to left, grows right */ | |
| /* Modals & Overlays */ | |
| .loader-ov { position: fixed; top:0; left:0; width:100%; height:100%; background:var(--panel); display:flex; flex-direction:column; justify-content:center; align-items:center; z-index: 9999; } | |
| .spinner { width: 40px; height: 40px; border: 3px solid #f1f5f9; border-top: 3px solid var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 15px; } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| .modal { position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(0,0,0,0.4); z-index: 1000; display: none; justify-content: center; align-items: center; backdrop-filter: blur(2px); } | |
| .modal-box { background: var(--panel); width: 420px; border-radius: 12px; border: 1px solid var(--border); padding: 25px; display:flex; flex-direction:column; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } | |
| .modal-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 15px; max-height: 300px; overflow-y: auto; } | |
| .opt-btn { padding: 12px; text-align: center; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight:700; font-size:13px; transition:0.2s; background:#f8fafc; color:var(--text-sub); } | |
| .opt-btn:hover { background: #f0f7ff; color: var(--accent); border-color:var(--accent); } | |
| .opt-btn.active { background: var(--accent); color: white; border-color: var(--accent); box-shadow: 0 4px 6px rgba(37, 99, 235, 0.2); } | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } | |
| .live-dot { display:inline-block; width:10px; height:10px; background:rgba(255,255,255,0.4); border-radius:50%; margin-right:8px; } | |
| .playing .live-dot { background: white; box-shadow: 0 0 6px white; animation: pulse 1.5s infinite; } | |
| @keyframes pulse { 0% { opacity:0.6; } 50% { opacity:1; } 100% { opacity:0.6; } } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="loader-ov" id="loader"> | |
| <div class="spinner"></div> | |
| <div id="loaderMsg" style="font-weight:800; color:var(--text-main); font-size:14px; letter-spacing:1px;">INITIALIZING...</div> | |
| </div> | |
| <div class="modal" id="expModal"> | |
| <div class="modal-box"> | |
| <div style="display:flex; justify-content:space-between; font-weight:800; font-size:16px; border-bottom:1px solid var(--border); padding-bottom:15px; color:var(--text-main);"> | |
| <span>SELECT EXPIRY</span> | |
| <span style="cursor:pointer; color:var(--text-sub);" onclick="document.getElementById('expModal').style.display='none'">✕</span> | |
| </div> | |
| <div class="modal-grid" id="expGrid"></div> | |
| </div> | |
| </div> | |
| <div class="top-nav"> | |
| <div style="display:flex; align-items:center;"> | |
| <div style="font-size:20px; font-weight:800; color:var(--text-main); margin-right:20px; letter-spacing: -1px;"> | |
| <span style="color:var(--accent)">PRO</span>CHAIN<span style="font-size:10px; vertical-align:top; color:var(--green)">LIVE</span> | |
| </div> | |
| <div class="search-box"> | |
| <input type="text" class="search-input" id="searchInp" placeholder="Search (e.g. BANKNIFTY)..." autocomplete="off"> | |
| <div class="search-dropdown" id="searchRes"></div> | |
| </div> | |
| <button class="btn" id="fmtToggleBtn" onclick="toggleFormat()" style="margin-right:15px;">FMT: COMPACT</button> | |
| <button class="btn btn-control playing" id="wsBtn" onclick="toggleConnection()"> | |
| <div class="live-dot" id="wsStatusDot"></div> <span id="wsBtnTxt">LIVE WEBSOCKET</span> | |
| </button> | |
| </div> | |
| <div style="display:flex; gap:20px; align-items:center;"> | |
| <div class="ref-wrap" onclick="toggleRefMenu()"> | |
| <div style="text-align:right;"> | |
| <div style="font-size:11px; font-weight:800; color:var(--text-sub); letter-spacing:1px; display:flex; align-items:center; justify-content:flex-end; gap:4px;"> | |
| <span id="spotLabel">SPOT PRICE</span> <span style="font-size:9px;">▼</span> | |
| </div> | |
| <div id="spotPrice" style="font-family:'JetBrains Mono'; font-weight:800; font-size:19px; color:var(--text-main);">0.00</div> | |
| </div> | |
| <div class="ref-dropdown" id="refMenu"></div> | |
| </div> | |
| <button class="btn" onclick="openExpModal()"> | |
| <span style="color:var(--text-sub)">EXP:</span> <span id="selExpTxt" style="color:var(--text-main)">SELECT</span> | |
| </button> | |
| <button class="btn" onclick="location.href='/logout'" style="border:none; color:var(--red); padding:0; background:transparent; box-shadow:none;">LOGOUT</button> | |
| </div> | |
| </div> | |
| <div class="chain-container"> | |
| <div class="stopped-overlay" id="stoppedOverlay"> | |
| <h2 style="color:var(--red); margin-bottom:5px;">CONNECTION STOPPED</h2> | |
| <p style="color:var(--text-sub); font-weight:600;">Click "START WEBSOCKET" at the top to resume live data and interactions.</p> | |
| </div> | |
| <div class="scroll-wrapper"> | |
| <div class="chain-table"> | |
| <div class="chain-header"> | |
| <div class="header-cell">CALL BUY QTY</div> | |
| <div class="header-cell">CALL SELL QTY</div> | |
| <div class="header-cell">CALL LTP</div> | |
| <div class="header-cell strike">STRIKE</div> | |
| <div class="header-cell">PUT LTP</div> | |
| <div class="header-cell">PUT SELL QTY</div> | |
| <div class="header-cell">PUT BUY QTY</div> | |
| </div> | |
| <div class="chain-body" id="chainBody"> | |
| <div style="padding:60px; text-align:center; color:var(--text-sub); font-weight:600; font-size:14px;">SEARCH & SELECT A SYMBOL TO LOAD LIVE CHAIN</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const AUTH_TOKEN = "{{USER_TOKEN}}"; | |
| let CURRENT_ROOT="", CHAIN_DATA=null, ACTIVE_EXP=0, SPOT_SYM="", SELECTED_REF_SYM="", SPOT_PRICE=0; | |
| let FUT_SYMS = [], OPT_SYMS=[]; | |
| let WS = null, IS_RUNNING = true, SEARCH_TIMER=null; | |
| let GLOBAL_MAX_QTY = 1; | |
| let FORMAT_MODE = 'compact'; // 'compact' or 'absolute' | |
| window.onload = () => { | |
| if(AUTH_TOKEN.indexOf("{{")!==-1){alert("Auth Error"); return;} | |
| checkStatus(); | |
| document.getElementById('searchInp').addEventListener('input', (e)=>{ clearTimeout(SEARCH_TIMER); SEARCH_TIMER=setTimeout(()=>doSearch(e.target.value),300); }); | |
| document.addEventListener('click', (e)=>{ | |
| if(!e.target.closest('.search-box')) document.getElementById('searchRes').style.display='none'; | |
| if(!e.target.closest('.ref-wrap')) document.getElementById('refMenu').style.display='none'; | |
| }); | |
| connectWebSocket(); | |
| }; | |
| // --- NUMBER FORMATTING --- | |
| function toggleFormat() { | |
| FORMAT_MODE = FORMAT_MODE === 'compact' ? 'absolute' : 'compact'; | |
| document.getElementById('fmtToggleBtn').innerText = "FMT: " + FORMAT_MODE.toUpperCase(); | |
| // UI will naturally update on the next tick | |
| } | |
| function fmt(n) { | |
| if(!n) return '-'; | |
| if(FORMAT_MODE === 'absolute') return n.toLocaleString('en-IN'); | |
| let a = Math.abs(n); | |
| if(a >= 10000000) return (n/10000000).toFixed(2)+'Cr'; | |
| if(a >= 100000) return (n/100000).toFixed(2)+'L'; | |
| if(a >= 1000) return (n/1000).toFixed(1)+'k'; | |
| return n.toLocaleString('en-IN'); | |
| } | |
| // --- WEBSOCKET ENGINE --- | |
| function connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| WS = new WebSocket(`${protocol}//${window.location.host}/ws`); | |
| WS.onopen = () => { | |
| console.log("Browser WS Connected."); | |
| WS.send(JSON.stringify({ action: "init", token: AUTH_TOKEN })); | |
| if(OPT_SYMS.length > 0) subscribeCurrentChain(); | |
| }; | |
| WS.onmessage = (event) => { | |
| const msg = JSON.parse(event.data); | |
| if(msg.type === "data" && IS_RUNNING) { | |
| renderLiveData(msg.payload); | |
| } | |
| }; | |
| WS.onclose = () => { | |
| setTimeout(connectWebSocket, 2000); | |
| }; | |
| } | |
| function toggleConnection() { | |
| IS_RUNNING = !IS_RUNNING; | |
| const btn = document.getElementById('wsBtn'); | |
| const overlay = document.getElementById('stoppedOverlay'); | |
| if(IS_RUNNING) { | |
| btn.classList.remove('stopped'); | |
| btn.classList.add('playing'); | |
| document.getElementById('wsBtnTxt').innerText = "LIVE WEBSOCKET"; | |
| overlay.style.display = 'none'; | |
| if(WS && WS.readyState === WebSocket.OPEN) WS.send(JSON.stringify({ action: "start" })); | |
| } else { | |
| btn.classList.add('stopped'); | |
| btn.classList.remove('playing'); | |
| document.getElementById('wsBtnTxt').innerText = "START WEBSOCKET"; | |
| overlay.style.display = 'flex'; | |
| if(WS && WS.readyState === WebSocket.OPEN) WS.send(JSON.stringify({ action: "stop" })); | |
| } | |
| } | |
| function subscribeCurrentChain() { | |
| if(!WS || WS.readyState !== WebSocket.OPEN) return; | |
| // Make sure we subscribe to Options AND the selected Reference Symbol (Spot/Fut) | |
| const syms =[SELECTED_REF_SYM, ...OPT_SYMS.map(x=>x.s)].filter(Boolean); | |
| WS.send(JSON.stringify({ action: "subscribe", symbols: syms })); | |
| } | |
| // --- DATA FETCHING & UI --- | |
| async function checkStatus() { | |
| try { | |
| const res = await fetch('/api/status'); const s = await res.json(); | |
| if(s.ready) { document.getElementById('loader').style.display='none'; if(!CURRENT_ROOT) loadSymbol('NIFTY'); } | |
| else { document.getElementById('loaderMsg').innerText = s.message.toUpperCase(); setTimeout(checkStatus, 2000); } | |
| } catch(e){ setTimeout(checkStatus, 2000); } | |
| } | |
| async function doSearch(q) { | |
| if(!IS_RUNNING) return; | |
| const d = document.getElementById('searchRes'); | |
| if(q.length<2) { d.style.display='none'; return; } | |
| try { | |
| const r = await fetch(`/api/search?q=${q}`); const j = await r.json(); | |
| d.innerHTML = j.map(i => `<div class="search-item" onclick="loadSymbol('${i.root}')">${i.display} <span class="exch-badge">${i.exch}</span></div>`).join(''); | |
| d.style.display='block'; | |
| } catch(e){} | |
| } | |
| async function loadSymbol(root) { | |
| if(!IS_RUNNING) return; | |
| try { | |
| document.getElementById('searchInp').value = root; | |
| document.getElementById('searchRes').style.display='none'; | |
| document.getElementById('loaderMsg').innerText = "LOADING CHAIN..."; | |
| document.getElementById('loader').style.display='flex'; | |
| const r = await fetch(`/api/chain?symbol=${root}`); | |
| if(!r.ok) throw new Error("Symbol not found on server"); | |
| const d = await r.json(); | |
| if(d.error || !d.items || d.items.length === 0) throw new Error(d.error || "No options data found for this symbol"); | |
| CURRENT_ROOT=root; CHAIN_DATA=d.items; SPOT_SYM=d.spot || ""; SPOT_PRICE=0; GLOBAL_MAX_QTY=1; | |
| FUT_SYMS = CHAIN_DATA.filter(x => x.t === 'FUT').sort((a,b)=>a.e-b.e).slice(0,3); | |
| // Dynamic Reference Logic | |
| if(SPOT_SYM && SPOT_SYM !== "MCX") { | |
| SELECTED_REF_SYM = SPOT_SYM; | |
| } else if(FUT_SYMS.length > 0) { | |
| SELECTED_REF_SYM = FUT_SYMS[0].s; | |
| } else { | |
| SELECTED_REF_SYM = ""; | |
| } | |
| buildRefMenu(); | |
| const opts = CHAIN_DATA.filter(x=>x.t==='CE'||x.t==='PE'); | |
| if(opts.length === 0) throw new Error("No Options contracts found for this symbol"); | |
| const exps =[...new Set(opts.map(i=>i.e))].sort((a,b)=>a-b); | |
| const today = Math.floor(Date.now()/1000)-86400; | |
| ACTIVE_EXP = exps.find(t=>t>today) || exps[0]; | |
| document.getElementById('selExpTxt').innerText = new Date(ACTIVE_EXP*1000).toLocaleDateString('en-GB',{day:'2-digit',month:'short'}); | |
| document.getElementById('expGrid').innerHTML = exps.map(ts=>`<div class="opt-btn ${ts===ACTIVE_EXP?'active':''}" onclick="selectExp(${ts})">${new Date(ts*1000).toLocaleDateString('en-GB',{day:'2-digit',month:'short'})}</div>`).join(''); | |
| buildChain(); | |
| } catch(e) { | |
| console.error(e); | |
| alert("Error: " + e.message); | |
| } finally { | |
| document.getElementById('loader').style.display='none'; | |
| } | |
| } | |
| // --- REFERENCE DROPDOWN LOGIC --- | |
| function toggleRefMenu() { | |
| if(!IS_RUNNING) return; | |
| const m = document.getElementById('refMenu'); | |
| m.style.display = (m.style.display === 'block') ? 'none' : 'block'; | |
| } | |
| function buildRefMenu() { | |
| const menu = document.getElementById('refMenu'); | |
| let html = ''; | |
| if(SPOT_SYM && SPOT_SYM !== "MCX") { | |
| const isSel = (SELECTED_REF_SYM === SPOT_SYM); | |
| html += `<div class="ref-item ${isSel?'active':''}" onclick="selectRef('${SPOT_SYM}', 'SPOT PRICE')">SPOT PRICE</div>`; | |
| if(isSel) document.getElementById('spotLabel').innerText = "SPOT PRICE"; | |
| } | |
| FUT_SYMS.forEach(f => { | |
| const label = new Date(f.e*1000).toLocaleDateString('en-GB', {day:'numeric', month:'short'}) + " FUT"; | |
| const isSel = (SELECTED_REF_SYM === f.s); | |
| html += `<div class="ref-item ${isSel?'active':''}" onclick="selectRef('${f.s}', '${label}')">${label}</div>`; | |
| if(isSel) document.getElementById('spotLabel').innerText = label; | |
| }); | |
| menu.innerHTML = html; | |
| document.getElementById('spotPrice').innerText = "0.00"; | |
| document.getElementById('spotPrice').style.color = "var(--text-main)"; | |
| } | |
| function selectRef(key, label) { | |
| if(!IS_RUNNING) return; | |
| SELECTED_REF_SYM = key; | |
| document.getElementById('spotLabel').innerText = label; | |
| buildRefMenu(); | |
| SPOT_PRICE = 0; | |
| document.getElementById('spotPrice').innerText = "0.00"; | |
| subscribeCurrentChain(); // Re-subscribe to new reference | |
| } | |
| function openExpModal() { | |
| if(!IS_RUNNING) return; | |
| document.getElementById('expModal').style.display='flex'; | |
| } | |
| function selectExp(ts) { | |
| if(!IS_RUNNING) return; | |
| ACTIVE_EXP=ts; GLOBAL_MAX_QTY=1; | |
| document.getElementById('selExpTxt').innerText = new Date(ts*1000).toLocaleDateString('en-GB',{day:'2-digit',month:'short'}); | |
| document.getElementById('expModal').style.display='none'; | |
| document.querySelectorAll('.opt-btn').forEach(b=>b.classList.remove('active')); | |
| const b = document.querySelector(`.opt-btn[onclick="selectExp(${ts})"]`); | |
| if(b) b.classList.add('active'); | |
| buildChain(); | |
| } | |
| function buildChain() { | |
| OPT_SYMS = CHAIN_DATA.filter(x=>x.e===ACTIVE_EXP&&(x.t==='CE'||x.t==='PE')); | |
| const ks =[...new Set(OPT_SYMS.map(x=>x.k))].sort((a,b)=>a-b); | |
| if(ks.length===0) { document.getElementById('chainBody').innerHTML='<div style="padding:40px;text-align:center; color:var(--text-sub);">NO DATA FOR EXPIRY</div>'; return; } | |
| // Construct perfectly mirrored layout | |
| document.getElementById('chainBody').innerHTML = ks.map(k=>` | |
| <div class="row" id="row-${k}" data-k="${k}"> | |
| <div class="cell call-col"><div class="bar-bg bar-call" style="background:var(--buy-bg)" id="cb-bar-${k}"></div><span class="val-txt val-buy" id="cb-${k}">-</span></div> | |
| <div class="cell call-col"><div class="bar-bg bar-call" style="background:var(--sell-bg)" id="cs-bar-${k}"></div><span class="val-txt val-sell" id="cs-${k}">-</span></div> | |
| <div class="cell call-col"><span class="val-txt" id="cltp-${k}" style="color:var(--text-main)">-</span></div> | |
| <div class="cell strike">${k}</div> | |
| <div class="cell put-col"><span class="val-txt" id="pltp-${k}" style="color:var(--text-main)">-</span></div> | |
| <div class="cell put-col"><div class="bar-bg bar-put" style="background:var(--sell-bg)" id="ps-bar-${k}"></div><span class="val-txt val-sell" id="ps-${k}">-</span></div> | |
| <div class="cell put-col"><div class="bar-bg bar-put" style="background:var(--buy-bg)" id="pb-bar-${k}"></div><span class="val-txt val-buy" id="pb-${k}">-</span></div> | |
| </div>`).join(''); | |
| window.scrolled = false; | |
| subscribeCurrentChain(); | |
| } | |
| // --- REALTIME RENDER ENGINE --- | |
| function renderLiveData(data) { | |
| if(!IS_RUNNING) return; | |
| // Update Reference Price | |
| if(SELECTED_REF_SYM && data[SELECTED_REF_SYM] && data[SELECTED_REF_SYM].ltp > 0) { | |
| const newSpot = data[SELECTED_REF_SYM].ltp; | |
| const spotEl = document.getElementById('spotPrice'); | |
| if(newSpot > SPOT_PRICE) spotEl.style.color = "var(--green)"; | |
| else if(newSpot < SPOT_PRICE) spotEl.style.color = "var(--red)"; | |
| SPOT_PRICE = newSpot; | |
| spotEl.innerText = SPOT_PRICE.toLocaleString('en-IN', {minimumFractionDigits: 2, maximumFractionDigits: 2}); | |
| } | |
| const rowUpdates = {}; | |
| // 1. Process Raw Data | |
| OPT_SYMS.forEach(opt => { | |
| const d = data[opt.s]; | |
| if(!d) return; | |
| if(!rowUpdates[opt.k]) rowUpdates[opt.k] = { cb:0, cs:0, cltp:0, pb:0, ps:0, pltp:0 }; | |
| if(opt.t === 'CE') { | |
| rowUpdates[opt.k].cb = d.b; rowUpdates[opt.k].cs = d.s; rowUpdates[opt.k].cltp = d.ltp; | |
| GLOBAL_MAX_QTY = Math.max(GLOBAL_MAX_QTY, d.b, d.s); | |
| } else if(opt.t === 'PE') { | |
| rowUpdates[opt.k].pb = d.b; rowUpdates[opt.k].ps = d.s; rowUpdates[opt.k].pltp = d.ltp; | |
| GLOBAL_MAX_QTY = Math.max(GLOBAL_MAX_QTY, d.b, d.s); | |
| } | |
| }); | |
| // 2. Fast DOM Update | |
| for(const [k, v] of Object.entries(rowUpdates)) { | |
| // Update Texts | |
| if(v.cb) document.getElementById(`cb-${k}`).innerText = fmt(v.cb); | |
| if(v.cs) document.getElementById(`cs-${k}`).innerText = fmt(v.cs); | |
| if(v.cltp) document.getElementById(`cltp-${k}`).innerText = v.cltp.toFixed(2); | |
| if(v.pb) document.getElementById(`pb-${k}`).innerText = fmt(v.pb); | |
| if(v.ps) document.getElementById(`ps-${k}`).innerText = fmt(v.ps); | |
| if(v.pltp) document.getElementById(`pltp-${k}`).innerText = v.pltp.toFixed(2); | |
| // Update Visual Depth Bars | |
| if(v.cb) document.getElementById(`cb-bar-${k}`).style.width = `${(v.cb / GLOBAL_MAX_QTY) * 95}%`; | |
| if(v.cs) document.getElementById(`cs-bar-${k}`).style.width = `${(v.cs / GLOBAL_MAX_QTY) * 95}%`; | |
| if(v.pb) document.getElementById(`pb-bar-${k}`).style.width = `${(v.pb / GLOBAL_MAX_QTY) * 95}%`; | |
| if(v.ps) document.getElementById(`ps-bar-${k}`).style.width = `${(v.ps / GLOBAL_MAX_QTY) * 95}%`; | |
| } | |
| // 3. Highlight ATM & Auto-Scroll (Only once per chain load) | |
| if(SPOT_PRICE > 0) { | |
| const ks = Object.keys(rowUpdates).map(Number).sort((a,b)=>a-b); | |
| if(ks.length > 0) { | |
| const atmK = ks.reduce((prev, curr) => Math.abs(curr - SPOT_PRICE) < Math.abs(prev - SPOT_PRICE) ? curr : prev); | |
| document.querySelectorAll('.atm').forEach(e => e.classList.remove('atm')); | |
| const atmRow = document.getElementById(`row-${atmK}`); | |
| if(atmRow) { | |
| atmRow.classList.add('atm'); | |
| if(!window.scrolled) { | |
| setTimeout(() => { | |
| atmRow.scrollIntoView({block:'center', behavior:'auto'}); | |
| }, 50); | |
| window.scrolled = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=7860) |