| import pandas as pd |
| import requests |
| import uvicorn |
| import hashlib |
| import json |
| import time |
| import urllib.parse |
| import os |
| import threading |
| from datetime import datetime, timedelta, timezone |
| from fastapi import FastAPI, Request, Response, Cookie, BackgroundTasks, Query |
| from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse |
| from typing import Optional, List, Dict |
|
|
| |
| |
| |
| CLIENT_ID = "J4PNT5QI22-100" |
| SECRET_KEY = "ABRSWC1PMR" |
| REDIRECT_URI = "https://topsecrettraders-depthchainindia.hf.space/callback" |
|
|
| CACHE_FILE = "master_cache.json" |
|
|
| |
| SUPABASE_URL = "https://tuyhtqxfvjndbyjazosi.supabase.co" |
| SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InR1eWh0cXhmdmpuZGJ5amF6b3NpIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODE0NjAyMiwiZXhwIjoyMDgzNzIyMDIyfQ.YsmGS67vL5TghB5OOi6pmsPL6rrgx7SNpARHmQ9b5cw" |
|
|
| app = FastAPI() |
|
|
| |
| |
| |
|
|
| from supabase import create_client, Client |
| supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) |
|
|
| 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(' ') |
| root = parts[0].upper() |
| return root.replace(':', '').replace('-', '') |
| except: |
| if ':' in symbol: |
| return symbol.split(':')[1] |
| return 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..." |
| print(">>> [BG TASK] STARTING DOWNLOAD...") |
| |
| headers = { "User-Agent": "Mozilla/5.0" } |
| |
| |
| url_nse = "https://public.fyers.in/sym_details/NSE_FO.csv" |
| df_nse = pd.read_csv(url_nse, 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' |
| |
| |
| url_mcx = "https://public.fyers.in/sym_details/MCX_COM.csv" |
| df_mcx = pd.read_csv(url_mcx, 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' |
|
|
| |
| url_bse = "https://public.fyers.in/sym_details/BSE_FO.csv" |
| df_bse = pd.read_csv(url_bse, 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' |
|
|
| SYSTEM_STATUS["message"] = "Processing Data..." |
| |
| 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 = row['Symbol'] |
| desc = row['Desc'] |
| exch = row['Exch'] |
| root = get_root_symbol(desc, sym) |
| |
| if root not in temp_db: |
| spot_sym = "" |
| if root in INDEX_MAP: spot_sym = INDEX_MAP[root] |
| elif exch == "MCX": spot_sym = "MCX" |
| elif exch == "BSE": spot_sym = f"BSE:{root}" |
| else: spot_sym = f"NSE:{root}-EQ" |
| temp_db[root] = { "spot": spot_sym, "exch": exch, "items": [] } |
|
|
| try: exp = int(row['Expiry']) |
| except: exp = 0 |
| |
| strike = 0.0 |
| opt_type = "FUT" |
| |
| if "CE" in sym or "PE" in sym: |
| parts = desc.strip().split(' ') |
| if len(parts) >= 2: |
| try: |
| val = parts[-2] |
| val_clean = val.replace('.', '', 1) |
| if val_clean.isdigit(): strike = float(val) |
| 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(): |
| sorted_items = sorted(data["items"], key=lambda x: (x['e'], x['k'])) |
| final_db[root] = { "spot": data["spot"], "exch": data["exch"], "items": sorted_items } |
| search_list.append({ "root": root, "exch": data["exch"], "display": f"{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["ready"] = True |
| SYSTEM_STATUS["message"] = "Ready" |
| print(f">>> [BG TASK] COMPLETE. Loaded {len(SEARCH_INDEX)} symbols.") |
|
|
| except Exception as e: |
| print(f">>> [BG TASK] ERROR: {e}") |
| SYSTEM_STATUS["message"] = f"Error: {str(e)}" |
| finally: |
| UPDATE_LOCK.release() |
|
|
| @app.on_event("startup") |
| def startup_event(): |
| global MASTER_DB, SEARCH_INDEX |
| if os.path.exists(CACHE_FILE): |
| try: |
| print(">>> STARTUP: Loading Cache...") |
| with open(CACHE_FILE, "r") as f: |
| data = json.load(f) |
| MASTER_DB = data["db"] |
| SEARCH_INDEX = data["idx"] |
| SYSTEM_STATUS["ready"] = True |
| SYSTEM_STATUS["message"] = "Ready (Cached)" |
| except: pass |
| |
| if not SYSTEM_STATUS["ready"]: |
| thread = threading.Thread(target=update_master_db) |
| thread.start() |
|
|
| |
| |
| |
|
|
| @app.get("/") |
| def home(access_token: Optional[str] = Cookie(None)): |
| if not access_token: |
| state = "init" |
| enc_redirect = urllib.parse.quote(REDIRECT_URI, safe="") |
| auth_url = ( |
| f"https://api-t1.fyers.in/api/v3/generate-authcode?" |
| f"client_id={CLIENT_ID}&redirect_uri={enc_redirect}&response_type=code&state={state}" |
| ) |
| return HTMLResponse(f""" |
| <div style="height:100vh; display:flex; flex-direction:column; justify-content:center; align-items:center; font-family:sans-serif; background:#f5f5f5;"> |
| <div style="background:white; padding:40px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,0.1); text-align:center;"> |
| <h2 style="margin-top:0;">Authentication Required</h2> |
| <a href="{auth_url}" style="display:inline-block; background:#2962ff; color:white; padding:12px 25px; text-decoration:none; border-radius:5px; font-weight:bold; margin-top:10px;">LOGIN WITH FYERS</a> |
| </div> |
| </div> |
| """) |
| return HTMLResponse(HTML_TEMPLATE.replace("{{USER_TOKEN}}", access_token)) |
|
|
| @app.get("/callback") |
| 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"}) |
| app_id_hash = hashlib.sha256(f"{CLIENT_ID}:{SECRET_KEY}".encode()).hexdigest() |
| resp = requests.post("https://api-t1.fyers.in/api/v3/validate-authcode", json={ |
| "grant_type": "authorization_code", "appIdHash": app_id_hash, "code": final_code |
| }) |
| data = resp.json() |
| if "access_token" in data: |
| full_token = f"{CLIENT_ID}:{data['access_token']}" |
| response = RedirectResponse(url="/") |
| response.set_cookie(key="access_token", value=full_token, httponly=True) |
| return response |
| else: return HTMLResponse(f"<h3>Login Failed</h3><pre>{json.dumps(data, indent=2)}</pre>") |
|
|
| @app.get("/logout") |
| def logout(): |
| response = RedirectResponse(url="/") |
| response.delete_cookie("access_token") |
| return response |
|
|
| @app.get("/api/status") |
| def get_status(): return JSONResponse(SYSTEM_STATUS) |
|
|
| @app.get("/api/search") |
| 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]) |
|
|
| @app.get("/api/chain") |
| 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) |
|
|
| @app.get("/api/refresh") |
| def force_refresh(background_tasks: BackgroundTasks): |
| SYSTEM_STATUS["ready"] = False |
| SYSTEM_STATUS["message"] = "Refreshing..." |
| background_tasks.add_task(update_master_db) |
| return JSONResponse({"s": "ok"}) |
|
|
| |
|
|
| def parse_bucket_data(bucket_rows): |
| slots = [{"s": f"{i*10:02d}", "d": {}} for i in range(6)] |
| for row in bucket_rows: |
| data = row.get('data') |
| if not data or not isinstance(data, dict): continue |
| for symbol, metrics in data.items(): |
| p_arr = metrics.get('p', []) |
| b_arr = metrics.get('b', []) |
| s_arr = metrics.get('s', []) |
| v_arr = metrics.get('v', []) |
| o_arr = metrics.get('o', []) |
| for i in range(6): |
| try: |
| ltp = p_arr[i] if i < len(p_arr) else None |
| if ltp is None: continue |
| slots[i]['d'][symbol] = { |
| "ltp": ltp, |
| "totalbuyqty": b_arr[i] if i < len(b_arr) else 0, |
| "totalsellqty": s_arr[i] if i < len(s_arr) else 0, |
| "v": v_arr[i] if i < len(v_arr) else 0, |
| "oi": o_arr[i] if i < len(o_arr) else 0, |
| "expiry": 0 |
| } |
| except: continue |
| return slots |
|
|
| @app.get("/api/hist/dates") |
| def get_hist_dates(symbol: str): |
| try: |
| res = supabase.table('history_buckets').select('minute').ilike('root', f"%{symbol}%").order('minute', desc=True).limit(2000).execute() |
| dates = set() |
| if res.data: |
| for item in res.data: |
| if item.get('minute'): dates.add(item.get('minute').split('T')[0].split(' ')[0]) |
| return JSONResponse(sorted(list(dates), reverse=True)) |
| except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) |
|
|
| @app.get("/api/hist/minutes") |
| def get_hist_minutes(symbol: str, date: str): |
| try: |
| res = supabase.table('history_buckets').select('minute').ilike('root', f"%{symbol}%").gte('minute', f"{date}T00:00:00").lte('minute', f"{date}T23:59:59").order('minute', desc=True).limit(1000).execute() |
| minutes = set() |
| if res.data: |
| for item in res.data: |
| clean = item.get('minute').replace('T', ' ').split(' ') |
| if len(clean)>1: minutes.add(clean[1][:5]) |
| return JSONResponse(sorted(list(minutes), reverse=True)) |
| except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) |
|
|
| @app.get("/api/hist/expiries") |
| def get_hist_expiries(symbol: str, date: str, minute: str): |
| try: |
| q1 = f"{date} {minute}:00" |
| q2 = f"{date}T{minute}:00" |
| res = supabase.table('history_buckets').select('expiry').ilike('root', f"%{symbol}%").or_(f"minute.eq.{q1},minute.eq.{q2}").neq('expiry', 'MARKET').execute() |
| expiries = set() |
| if res.data: |
| for item in res.data: |
| if item.get('expiry') and item.get('expiry') != 'MARKET': expiries.add(item.get('expiry')) |
| return JSONResponse(sorted(list(expiries))) |
| except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) |
|
|
| @app.get("/api/hist/load") |
| def get_hist_data(symbol: str, date: str, minute: str, expiry: Optional[str] = None): |
| try: |
| q1 = f"{date} {minute}:00" |
| q2 = f"{date}T{minute}:00" |
| res = supabase.table('history_buckets').select('data, expiry').ilike('root', f"%{symbol}%").or_(f"minute.eq.{q1},minute.eq.{q2}").execute() |
| rows = res.data |
| if not rows: return JSONResponse({"s": "ok", "timeline": []}) |
| filtered_rows = [] |
| for r in rows: |
| e = r.get('expiry') |
| if not expiry or e == 'MARKET' or e == expiry: filtered_rows.append(r) |
| return JSONResponse({"s": "ok", "timeline": parse_bucket_data(filtered_rows)}) |
| except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) |
|
|
|
|
| |
| |
| |
|
|
| HTML_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>PRO CHAIN V3</title> |
| <meta name="viewport" content="width=1100"> |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;800&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --bg: #ffffff; --panel: #f4f6f8; --border: #e0e0e0; |
| --text-main: #1a1a1a; --text-sub: #555555; |
| --accent: #2962ff; --green: #00c853; --red: #d50000; |
| --green-soft: rgba(0, 200, 83, 0.27); --red-soft: rgba(213, 0, 0, 0.18); |
| --atm-bg: rgba(41, 98, 255, 0.08); |
| --badge-nse: #00c853; --badge-bse: #ff6d00; --badge-mcx: #2962ff; |
| } |
| * { 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-nav { padding: 0 20px; background: #fff; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; height: 60px; z-index: 10; box-shadow: 0 2px 5px rgba(0,0,0,0.03); } |
| .btn { background: #fff; border: 1px solid var(--border); padding: 8px 16px; font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: 700; display: flex; gap: 8px; align-items: center; transition:0.2s; white-space:nowrap; color: var(--text-sub); } |
| .btn:hover { border-color: var(--accent); color: var(--accent); background:#f0f7ff; } |
| .btn.active { background: var(--accent); color: white; border-color: var(--accent); } |
| .icon-btn { padding: 8px; border-radius: 50%; border: 1px solid transparent; background: transparent; cursor: pointer; color: var(--text-sub); transition:0.2s; } |
| .icon-btn:hover { background: #f0f0f0; color: var(--text-main); } |
| |
| .search-box { position: relative; width: 350px; margin-right: 20px; } |
| .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; transition: 0.3s; background: #f9f9f9; } |
| .search-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px rgba(41, 98, 255, 0.1); } |
| .search-dropdown { position: absolute; top: 105%; left: 0; width: 100%; background: white; border: 1px solid var(--border); max-height: 400px; overflow-y: auto; display: none; box-shadow: 0 10px 20px rgba(0,0,0,0.1); border-radius: 6px; z-index: 100; } |
| .search-item { padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #f5f5f5; font-size: 13px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; } |
| .search-item:hover { background: #f0f7ff; color: var(--accent); } |
| .exch-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; color: white; font-weight: 800; min-width: 40px; text-align: center; } |
| .badge-NSE { background: var(--badge-nse); } .badge-BSE { background: var(--badge-bse); } .badge-MCX { background: var(--badge-mcx); } |
| |
| /* HISTORY CONTROLS */ |
| .hist-controls { display: none; align-items: center; gap: 8px; margin-left: 20px; padding-left: 20px; border-left: 1px solid #eee; } |
| .hist-sel { padding: 6px; border-radius: 4px; border: 1px solid #ddd; font-weight: bold; font-size: 11px; background: #f9f9f9; outline:none; } |
| .sec-bar-cont { display: flex; gap: 2px; overflow-x: auto; max-width: 250px; padding: 2px; background: #f0f0f0; border-radius: 4px; } |
| .sec-pill { padding: 3px 6px; font-size: 10px; cursor: pointer; border-radius: 2px; color: #666; font-family:'JetBrains Mono'; } |
| .sec-pill:hover { background: #ddd; color: #000; } |
| .sec-pill.active { background: var(--accent); color: white; font-weight: bold; } |
| .sec-pill.bad { background: #ff5252; color: white; opacity: 0.8; } |
| .sec-pill.bad:hover { background: #d50000; } |
| .sec-pill.bad.active { background: #b71c1c; border: 1px solid #fff; opacity:1; } |
| |
| .play-btn { width:24px; height:24px; display:flex; align-items:center; justify-content:center; background:#eee; border-radius:4px; cursor:pointer; font-size:12px; } |
| .play-btn:hover { background:#ddd; } |
| .play-btn.active { background:var(--green); color:white; } |
| |
| .grid-layout { display: flex; height: calc(100vh - 60px); } |
| .chain-panel { flex: 1; display: flex; flex-direction: column; position: relative; } |
| .chain-header { display: grid; grid-template-columns: 1fr 1fr; background: #f8f9fa; border-bottom: 1px solid var(--border); font-size: 11px; font-weight: 800; text-align: center; color: var(--text-sub); text-transform: uppercase; letter-spacing: 0.5px; } |
| .header-cell { padding: 12px 5px; display: flex; align-items: center; justify-content: center; gap: 8px; position:relative; border-right: 1px solid var(--border); } |
| .header-icon { opacity: 0.3; cursor: pointer; transition: 0.2s; } |
| .header-icon:hover { opacity: 1; color: var(--accent); } |
| .chain-body { flex: 1; overflow-y: auto; background: #fff; scroll-behavior: smooth; } |
| |
| .row { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid #f0f0f0; height: 42px; align-items: center; font-family: 'JetBrains Mono'; font-size: 12px; } |
| .row:hover { background: #fafafa; } |
| .row.atm { background: var(--atm-bg); border-top: 1px solid var(--accent); border-bottom: 1px solid var(--accent); } |
| .graph-col { position: relative; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; border-right: 1px solid var(--border); cursor: crosshair; } |
| .visual-bar { position: absolute; top: 8px; bottom: 8px; z-index: 0; border-radius: 4px; transition: width 0.3s, left 0.3s; opacity: 0.9; } |
| .strike-txt { z-index: 2; font-weight: 800; font-size: 13px; color: var(--text-main); } |
| .delta-txt { z-index: 2; font-size: 10px; font-weight: 700; background: rgba(255,255,255,0.85); padding: 0 5px; border-radius: 3px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); margin-top: -2px; } |
| |
| .fut-panel { width: 300px; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; } |
| .panel-section { padding: 15px; border-bottom: 1px solid var(--border); background: #fff; margin-bottom: 10px; } |
| .panel-title { font-size: 11px; font-weight: 800; color: #888; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; display:flex; justify-content:space-between; align-items:center; } |
| .fut-card { padding: 12px; background: #fff; border: 1px solid #eee; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); } |
| .meter-bg { height: 6px; background: #eee; border-radius: 3px; display: flex; overflow: hidden; margin-top: 8px; } |
| |
| .netflow-card { background: #fff; padding: 15px; border-radius: 8px; border: 1px solid var(--border); text-align: center; box-shadow: 0 4px 10px rgba(0,0,0,0.03); } |
| .nf-sec { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; } |
| .nf-sec:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } |
| .nf-title { font-size: 10px; font-weight: 800; color: #888; text-transform: uppercase; margin-bottom: 8px; text-align: left; } |
| .aggr-row { display: flex; width: 100%; border: 1px solid transparent; } |
| .aggr-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; } |
| .aggr-val { font-size: 20px; font-family: 'JetBrains Mono'; font-weight: 800; line-height: 1; margin-bottom: 2px; } |
| .aggr-lbl { font-size: 9px; font-weight: 700; color: #aaa; background: transparent; padding: 0; border-radius: 0; margin-top: 2px; display:flex; align-items:center; gap:3px; } |
| .info-bubble { width:12px; height:12px; background:#eee; color:#666; border-radius:50%; font-size:8px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; font-family:'Inter'; font-weight:800; } |
| .info-bubble:hover { background:var(--accent); color:white; } |
| |
| .modal { position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(0,0,0,0.5); z-index: 1000; display: none; justify-content: center; align-items: center; backdrop-filter: blur(2px); } |
| .modal-box { background: #fff; width: 420px; border-radius: 12px; box-shadow: 0 20px 50px rgba(0,0,0,0.2); padding: 25px; display:flex; flex-direction:column; animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
| @keyframes popIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 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: 10px; text-align: center; border: 1px solid #eee; border-radius: 6px; cursor: pointer; font-weight:600; font-size:12px; transition:0.2s; background:#f9f9f9; } |
| .opt-btn:hover { background: #fff; border-color: var(--accent); box-shadow: 0 2px 5px rgba(0,0,0,0.05); } |
| .opt-btn.active { background: var(--accent); color: white; border-color: var(--accent); } |
| .inp-group { margin-top:15px; } |
| .inp-label { font-size:11px; font-weight:800; color:#888; margin-bottom:5px; display:block; } |
| .modal-inp { width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; font-family:'JetBrains Mono'; font-weight:bold; font-size:14px; } |
| .modal-action { background:var(--accent); color:white; border:none; padding:10px 20px; border-radius:6px; font-weight:bold; cursor:pointer; width:100%; margin-top:20px; } |
| .modal-action.sec { background:#eee; color:#333; margin-top:10px; } |
| |
| .info-table { width:100%; font-family:'JetBrains Mono'; font-size:10px; border-collapse: collapse; margin-top:10px; } |
| .info-table th { text-align:left; color:#888; border-bottom:1px solid #eee; padding:5px; } |
| .info-table td { padding:5px; border-bottom:1px solid #f9f9f9; color:#333; } |
| .info-row-res { background: #f0f7ff; font-weight:800; } |
| .preset-pill { display:inline-block; padding:4px 8px; background:#f0f0f0; border-radius:4px; font-size:10px; font-weight:700; margin-right:5px; cursor:pointer; color:#555; border:1px solid transparent; } |
| .preset-pill:hover { border-color:#aaa; background:#e0e0e0; } |
| |
| .conf-list { max-height: 250px; overflow-y: auto; margin-bottom: 15px; border: 1px solid #eee; border-radius: 6px; background: #fafafa; } |
| .conf-item { background: #fff; border-bottom: 1px solid #eee; padding: 10px; } |
| .conf-item:last-child { border-bottom: none; } |
| .conf-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; } |
| .conf-body { margin-top: 10px; display: none; padding-top: 10px; border-top: 1px solid #f5f5f5; } |
| .conf-item.expanded .conf-body { display: block; } |
| .conf-item.expanded .conf-header { color: var(--accent); } |
| .del-btn { color: var(--red); cursor: pointer; font-size: 10px; font-weight: bold; border: 1px solid var(--red-soft); padding: 2px 6px; border-radius: 4px; } |
| .add-btn { width: 100%; border: 1px dashed #ccc; padding: 10px; text-align: center; font-size: 11px; font-weight: bold; color: #888; cursor: pointer; border-radius: 6px; margin-bottom: 15px; transition: 0.2s; background: #fff; } |
| .add-btn:hover { border-color: var(--accent); color: var(--accent); background: #f0f7ff; } |
| |
| .loader-ov { position: fixed; top:0; left:0; width:100%; height:100%; background:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; z-index: 9999; } |
| .spinner { width: 30px; height: 30px; border: 3px solid #f3f3f3; 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); } } |
| @keyframes blink { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } } |
| .live-dot { width:8px; height:8px; background:#ccc; border-radius:50%; transition:0.3s; } |
| .live-dot.active { background: var(--green); animation: blink 1.5s infinite; box-shadow: 0 0 5px var(--green); } |
| .live-dot.error { background: var(--red); } |
| |
| ::-webkit-scrollbar { width: 6px; height: 6px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; } |
| ::-webkit-scrollbar-thumb:hover { background: #bbb; } |
| |
| .code-box { background:#f4f4f4; padding:10px; border-radius:6px; font-family:'JetBrains Mono'; font-size:11px; color:#555; margin-top:5px; display:none; } |
| .var-tag { color:var(--accent); font-weight:bold; } |
| </style> |
| </head> |
| <body> |
| |
| <div class="loader-ov" id="loader"> |
| <div class="spinner"></div> |
| <div id="loaderMsg" style="font-weight:700; color:var(--text-main); font-size:14px; letter-spacing:1px;">CONNECTING...</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 #eee; padding-bottom:15px;"> |
| <span>SELECT EXPIRY</span> |
| <span style="cursor:pointer; color:#999;" onclick="closeModal('expModal')">✕</span> |
| </div> |
| <div class="modal-grid" id="expGrid"></div> |
| </div> |
| </div> |
| |
| <div class="modal" id="infoModal"> |
| <div class="modal-box" style="width: 500px; max-width:95%;"> |
| <div style="display:flex; justify-content:space-between; font-weight:800; font-size:16px; border-bottom:1px solid #eee; padding-bottom:15px;"> |
| <span>CALCULATION BREAKDOWN</span> |
| <span style="cursor:pointer; color:#999;" onclick="closeModal('infoModal')">✕</span> |
| </div> |
| <div style="margin-top:15px; font-size:12px;"> |
| <div><strong>SNAPSHOT SPOT:</strong> <span id="infoSpot" style="font-family:'JetBrains Mono'; color:var(--accent);">0.00</span></div> |
| <div style="margin-top:5px;"><strong>FORMULA:</strong> <span id="infoForm" style="font-family:'JetBrains Mono'; background:#f4f4f4; padding:2px 6px; border-radius:4px;"></span></div> |
| </div> |
| <div style="overflow-y:auto; max-height:400px; margin-top:10px;"> |
| <table class="info-table" id="infoTable"></table> |
| </div> |
| </div> |
| </div> |
| |
| <div class="modal" id="confModal"> |
| <div class="modal-box"> |
| <div style="display:flex; justify-content:space-between; font-weight:800; font-size:16px; border-bottom:1px solid #eee; padding-bottom:15px;"> |
| <span id="confTitle">COLUMN SETTINGS</span> |
| <span style="cursor:pointer; color:#999;" onclick="closeModal('confModal')">✕</span> |
| </div> |
| <div class="inp-group"><span class="inp-label">DISPLAY TITLE</span><input type="text" id="confName" class="modal-inp"></div> |
| <div class="inp-group"><span class="inp-label">CUSTOM FORMULA</span><input type="text" id="confForm" class="modal-inp"></div> |
| <button class="modal-action" onclick="saveConfig()">SAVE CONFIGURATION</button> |
| <button class="modal-action sec" onclick="resetConfig()">RESET TO DEFAULTS</button> |
| </div> |
| </div> |
| |
| <div class="modal" id="sideConfModal"> |
| <div class="modal-box"> |
| <div style="display:flex; justify-content:space-between; font-weight:800; font-size:16px; border-bottom:1px solid #eee; padding-bottom:15px;"> |
| <span>SIDEBAR CONFIG</span> |
| <span style="cursor:pointer; color:#999;" onclick="closeModal('sideConfModal')">✕</span> |
| </div> |
| <div style="margin-top:10px; font-size:11px; font-weight:700; color:#888; margin-bottom:10px; display:flex; justify-content:space-between; align-items:center;"> |
| <span>CONFIGURE CARDS & RANGES</span> |
| <span onclick="toggleVarHelp()" style="color:var(--accent); cursor:pointer; font-weight:800;">[?] VARIABLES</span> |
| </div> |
| <div class="code-box" id="sideVarHelp"> |
| <div>VARIABLES AVAILABLE:</div> |
| <div style="margin-top:4px;"> |
| <span class="var-tag">CS</span> = Call Sell <span class="var-tag">CB</span> = Call Buy <span class="var-tag">CO</span> = Call OI <span class="var-tag">CLTP</span> = Call LTP <span class="var-tag">CV</span> = Call Vol<br> |
| <span class="var-tag">PS</span> = Put Sell <span class="var-tag">PB</span> = Put Buy <span class="var-tag">PO</span> = Put OI <span class="var-tag">PLTP</span> = Put LTP <span class="var-tag">PV</span> = Put Vol |
| </div> |
| <div style="margin-top:4px; font-size:10px; color:#888;">Supports +, -, *, /, ( ) arithmetic.</div> |
| </div> |
| <div class="conf-list" id="sideListContainer"></div> |
| <div class="add-btn" onclick="addSideItem()">+ ADD NEW AGGREGATE CARD</div> |
| <button class="modal-action" onclick="saveSideConfig()">SAVE SETTINGS</button> |
| <button class="modal-action sec" onclick="resetSideConfig()">RESET DEFAULTS</button> |
| </div> |
| </div> |
| |
| <div class="top-nav"> |
| <div style="display:flex; align-items:center;"> |
| <div class="search-box"> |
| <input type="text" class="search-input" id="searchInp" placeholder="SEARCH (e.g. NIFTY)..." autocomplete="off"> |
| <div class="search-dropdown" id="searchRes"></div> |
| </div> |
| <button class="btn" id="modeBtn" onclick="toggleMode()"><span id="modeTxt">LIVE</span></button> |
| |
| <div class="hist-controls" id="histControls"> |
| <select class="hist-sel" id="histDate" onchange="loadHistMinutes()"><option>DATE</option></select> |
| <select class="hist-sel" id="histTime" onchange="loadHistExpiries()"><option>TIME</option></select> |
| <div class="play-btn" id="btnPlay" onclick="togglePlay()" title="Play/Pause">▶</div> |
| <select class="hist-sel" id="playSpeed" style="width:50px;"> |
| <option value="1000">1x</option> |
| <option value="500">2x</option> |
| <option value="200">5x</option> |
| </select> |
| <div class="play-btn" onclick="jumpHistory(-5)" title="-5 Minutes">«</div> |
| <div class="play-btn" onclick="jumpHistory(5)" title="+5 Minutes">»</div> |
| <div class="sec-bar-cont" id="secBar"></div> |
| </div> |
| </div> |
| |
| <div style="display:flex; gap:20px; align-items:center;"> |
| <button class="icon-btn" onclick="refreshDB()" title="Refresh Master DB"><svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg></button> |
| <div style="text-align:right;"> |
| <div style="font-size:10px; font-weight:700; color:#aaa; letter-spacing:1px;" id="spotLabel">SPOT</div> |
| <div id="spotPrice" style="font-family:'JetBrains Mono'; font-weight:800; font-size:17px; color:var(--text-main);">0.00</div> |
| </div> |
| <button class="btn" onclick="openExpModal()"><span style="color:#888">EXP:</span> <span id="selExpTxt" style="color:var(--text-main)">SELECT</span><svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"></path></svg></button> |
| <div class="live-dot" id="statusDot" title="Connection Status"></div> |
| <button class="btn" onclick="location.href='/logout'" style="border:none; color:var(--red); padding:0;"><svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg></button> |
| </div> |
| </div> |
| |
| <div class="grid-layout"> |
| <div class="chain-panel"> |
| <div class="chain-header"> |
| <div class="header-cell"><span id="headL_txt">NET FLOW</span><svg class="header-icon" onclick="openConfig('left')" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg></div> |
| <div class="header-cell"><span id="headR_txt">OVERALL FLOW</span><svg class="header-icon" onclick="openConfig('right')" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg></div> |
| </div> |
| <div class="chain-body" id="chainBody"> |
| <div style="padding:40px; text-align:center; color:#999; font-weight:600; font-size:14px;">USE SEARCH BAR TO LOAD DATA</div> |
| </div> |
| </div> |
| <div class="fut-panel"> |
| <div class="panel-section"> |
| <div class="panel-title"><span>AGGREGATE FLOW</span><div style="display:flex; align-items:center; gap:5px;"><svg onclick="openSideConfig()" style="cursor:pointer; opacity:0.5;" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg></div></div> |
| <div class="netflow-card" id="sideContainer"><div style="padding:20px; color:#ccc; font-size:10px;">LOADING CONFIG...</div></div> |
| </div> |
| <div class="panel-section"> |
| <div class="panel-title">FUTURES CONTRACTS</div> |
| <div id="futBody"></div> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| const AUTH_TOKEN = "{{USER_TOKEN}}"; |
| let CURRENT_ROOT="", CHAIN_DATA=null, ACTIVE_EXP=0, SPOT_SYM="", SPOT_PRICE=0, FUT_SYMS=[], OPT_SYMS=[]; |
| let POLLER=null, IS_POLLING=false, SEARCH_TIMER=null; |
| let IS_HISTORY=false, CACHED_TIMELINE=[], HIST_EXPIRIES=[], SELECTED_HIST_EXP=null; |
| let LATEST_ROW_DATA={}; |
| let PLAY_TIMER=null, IS_PLAYING=false, CUR_SEC_IDX=0; |
| |
| const DEFAULTS = { |
| left: { name: "NET FLOW", formula: "PS - CS" }, |
| right: { name: "OVERALL FLOW", formula: "CB + PS - (PB + CS)" }, |
| side: [ |
| { id: 1, name: "NET FLOW", formula: "PS - CS", ranges: [7,10], _expanded: true }, |
| { id: 2, name: "OVERALL", formula: "CB + PS - (PB + CS)", ranges: [7,10], _expanded: false }, |
| { id: 3, name: "NET OI", formula: "PO - CO", ranges: [10], _expanded: false } |
| ] |
| }; |
| let CONFIG = JSON.parse(localStorage.getItem('tst_chain_config')) || JSON.parse(JSON.stringify(DEFAULTS)); |
| if(!Array.isArray(CONFIG.side)) CONFIG.side = JSON.parse(JSON.stringify(DEFAULTS.side)); |
| let EDITING_COL = null; |
| |
| window.onload = () => { |
| if(AUTH_TOKEN.indexOf("{{")!==-1){alert("Auth Error"); return;} |
| applyConfig(); 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'; }); |
| }; |
| |
| function toggleMode() { |
| IS_HISTORY = !IS_HISTORY; |
| const btn = document.getElementById('modeBtn'); |
| const ctrls = document.getElementById('histControls'); |
| const dot = document.getElementById('statusDot'); |
| |
| if(IS_HISTORY) { |
| btn.classList.add('active'); document.getElementById('modeTxt').innerText="HISTORY"; |
| ctrls.style.display="flex"; |
| if(POLLER) clearInterval(POLLER); |
| dot.style.background="#2962ff"; |
| if(CURRENT_ROOT) loadHistDates(); |
| } else { |
| btn.classList.remove('active'); document.getElementById('modeTxt').innerText="LIVE"; |
| ctrls.style.display="none"; |
| dot.style.background="#ccc"; |
| pauseHistory(); |
| if(CURRENT_ROOT) loadSymbol(CURRENT_ROOT); |
| } |
| } |
| |
| async function loadHistDates() { |
| if(!CURRENT_ROOT) return; |
| const sel = document.getElementById('histDate'); sel.innerHTML="<option>LOADING...</option>"; |
| try { |
| const res = await fetch(`/api/hist/dates?symbol=${CURRENT_ROOT}`); |
| const dates = await res.json(); |
| sel.innerHTML = "<option value=''>SELECT DATE</option>" + dates.map(d=>`<option value="${d}">${d}</option>`).join(''); |
| } catch(e){ sel.innerHTML="<option>ERROR</option>"; } |
| } |
| |
| async function loadHistMinutes() { |
| const date = document.getElementById('histDate').value; |
| if(!date) return; |
| const sel = document.getElementById('histTime'); sel.innerHTML="<option>LOADING...</option>"; |
| HIST_EXPIRIES=[]; SELECTED_HIST_EXP=null; updateExpText(0); |
| try { |
| const res = await fetch(`/api/hist/minutes?symbol=${CURRENT_ROOT}&date=${date}`); |
| const mins = await res.json(); |
| sel.innerHTML = "<option value=''>SELECT TIME</option>" + mins.map(m=>`<option value="${m}">${m}</option>`).join(''); |
| } catch(e){} |
| } |
| |
| async function loadHistExpiries() { |
| const date = document.getElementById('histDate').value; |
| const time = document.getElementById('histTime').value; |
| if(!date || !time) return; |
| try { |
| const res = await fetch(`/api/hist/expiries?symbol=${CURRENT_ROOT}&date=${date}&minute=${time}`); |
| HIST_EXPIRIES = await res.json(); |
| if(HIST_EXPIRIES.length>0) { SELECTED_HIST_EXP=HIST_EXPIRIES[0]; } |
| else { alert("Spot/Fut Only. No Options."); SELECTED_HIST_EXP=null; } |
| updateHistExpUI(); loadHistData(); |
| } catch(e){} |
| } |
| |
| function updateHistExpUI() { document.getElementById('selExpTxt').innerText = SELECTED_HIST_EXP || "NONE"; } |
| |
| async function loadHistData(bg=false) { |
| const date = document.getElementById('histDate').value; |
| const time = document.getElementById('histTime').value; |
| if(!date || !time) return; |
| if(!bg) { document.getElementById('loaderMsg').innerText="FETCHING SNAPSHOTS..."; document.getElementById('loader').style.display='flex'; } |
| |
| let url = `/api/hist/load?symbol=${CURRENT_ROOT}&date=${date}&minute=${time}`; |
| if(SELECTED_HIST_EXP) url += `&expiry=${SELECTED_HIST_EXP}`; |
| |
| try { |
| const res = await fetch(url); |
| const json = await res.json(); |
| CACHED_TIMELINE = json.timeline; |
| renderSecondBar(); |
| |
| // Auto-select first green snapshot |
| if(CACHED_TIMELINE.length>0) { |
| let found=0; |
| for(let i=0; i<CACHED_TIMELINE.length; i++) { |
| if(validateSnapshot(CACHED_TIMELINE[i].d)) { found=i; break; } |
| } |
| CUR_SEC_IDX = found; |
| selectSnapshot(found); |
| } |
| } catch(e){ console.error(e); } |
| if(!bg) document.getElementById('loader').style.display='none'; |
| } |
| |
| function validateSnapshot(data) { |
| // 1. Ref Price Check |
| let ref = 0; |
| if(SPOT_SYM && data[SPOT_SYM] && data[SPOT_SYM].ltp>0) ref = data[SPOT_SYM].ltp; |
| if(ref===0 && FUT_SYMS.length>0) { |
| for(let f of FUT_SYMS) if(data[f.s] && data[f.s].ltp>0) { ref = data[f.s].ltp; break; } |
| } |
| if(ref===0) return false; |
| |
| // 2. Strict ATM +/- 7 Logic (Red Seconds Check) |
| if(SELECTED_HIST_EXP && CHAIN_DATA) { |
| const expStr = SELECTED_HIST_EXP; |
| const relevant = CHAIN_DATA.filter(x => { |
| const dStr = new Date(x.e*1000).toISOString().split('T')[0]; |
| return dStr === expStr && (x.t==='CE' || x.t==='PE'); |
| }); |
| // Find ATM |
| const strikes = [...new Set(relevant.map(x=>x.k))].sort((a,b)=>a-b); |
| const atm = strikes.reduce((prev, curr) => Math.abs(curr - ref) < Math.abs(prev - ref) ? curr : prev); |
| const atmIdx = strikes.indexOf(atm); |
| const start = Math.max(0, atmIdx-7); |
| const end = Math.min(strikes.length-1, atmIdx+7); |
| |
| // Map symbols to check |
| for(let i=start; i<=end; i++) { |
| const k = strikes[i]; |
| const ce = relevant.find(x => x.k===k && x.t==='CE'); |
| const pe = relevant.find(x => x.k===k && x.t==='PE'); |
| |
| if(ce) { |
| if(!data[ce.s] || ( (data[ce.s].totalbuyqty||0) + (data[ce.s].totalsellqty||0) === 0 )) return false; |
| } |
| if(pe) { |
| if(!data[pe.s] || ( (data[pe.s].totalbuyqty||0) + (data[pe.s].totalsellqty||0) === 0 )) return false; |
| } |
| } |
| } |
| return true; |
| } |
| |
| function renderSecondBar() { |
| document.getElementById('secBar').innerHTML = CACHED_TIMELINE.map((item, idx) => { |
| const isValid = validateSnapshot(item.d); |
| return `<div class="sec-pill ${isValid?'':'bad'}" onclick="manualSelect(${idx})" id="pill-${idx}">:${item.s}</div>`; |
| }).join(''); |
| } |
| |
| function manualSelect(idx) { |
| CUR_SEC_IDX = idx; |
| selectSnapshot(idx); |
| } |
| |
| function selectSnapshot(idx) { |
| document.querySelectorAll('.sec-pill').forEach(p => p.classList.remove('active')); |
| const el = document.getElementById(`pill-${idx}`); |
| if(el) el.classList.add('active'); |
| if(CACHED_TIMELINE[idx]) renderSnapshot(CACHED_TIMELINE[idx]); |
| } |
| |
| function renderSnapshot(item) { |
| const data = item.d; |
| // Important: Update SPOT_PRICE Global for Info Modal |
| let ref = 0; |
| if(SPOT_SYM && data[SPOT_SYM]) ref = data[SPOT_SYM].ltp; |
| else if(FUT_SYMS.length>0) for(let f of FUT_SYMS) if(data[f.s]) { ref=data[f.s].ltp; break; } |
| SPOT_PRICE = ref; // Update global for Info Modal |
| |
| // Rebuild rows if expiry changed logic |
| if(SELECTED_HIST_EXP && CHAIN_DATA) { |
| const expStr = SELECTED_HIST_EXP; |
| const relevant = CHAIN_DATA.filter(x => new Date(x.e*1000).toISOString().split('T')[0] === expStr && (x.t==='CE'||x.t==='PE')); |
| if(relevant.length > 0) { OPT_SYMS = relevant; buildChain(); } |
| } |
| updateUI(data); |
| } |
| |
| // PLAYBACK |
| function togglePlay() { |
| if(IS_PLAYING) pauseHistory(); |
| else playHistory(); |
| } |
| function playHistory() { |
| if(!IS_HISTORY) return; |
| IS_PLAYING = true; |
| document.getElementById('btnPlay').innerHTML = "❚❚"; |
| document.getElementById('btnPlay').classList.add('active'); |
| tickHistory(); |
| } |
| function pauseHistory() { |
| IS_PLAYING = false; |
| clearTimeout(PLAY_TIMER); |
| document.getElementById('btnPlay').innerHTML = "▶"; |
| document.getElementById('btnPlay').classList.remove('active'); |
| } |
| async function tickHistory() { |
| if(!IS_PLAYING) return; |
| const spd = parseInt(document.getElementById('playSpeed').value); |
| |
| CUR_SEC_IDX++; |
| if(CUR_SEC_IDX >= CACHED_TIMELINE.length) { |
| // Move to next minute |
| await jumpHistory(1); |
| CUR_SEC_IDX = 0; |
| } else { |
| selectSnapshot(CUR_SEC_IDX); |
| } |
| PLAY_TIMER = setTimeout(tickHistory, spd); |
| } |
| |
| async function jumpHistory(mins) { |
| const dEl = document.getElementById('histDate'); |
| const tEl = document.getElementById('histTime'); |
| if(!dEl.value || !tEl.value) return; |
| |
| let currentDt = new Date(dEl.value + 'T' + tEl.value + ':00'); |
| currentDt.setMinutes(currentDt.getMinutes() + mins); |
| |
| const nextTime = currentDt.toTimeString().substring(0,5); |
| // Check if exists in dropdown |
| let exists = false; |
| for(let i=0; i<tEl.options.length; i++) { |
| if(tEl.options[i].value === nextTime) { |
| tEl.selectedIndex = i; |
| exists = true; |
| break; |
| } |
| } |
| if(exists) { |
| await loadHistExpiries(); // Will trigger loadHistData |
| } else { |
| pauseHistory(); // Stop if end of data |
| } |
| } |
| |
| // --- STANDARD APP LOGIC --- |
| function applyConfig() { |
| document.getElementById('headL_txt').innerText = CONFIG.left.name; |
| document.getElementById('headR_txt').innerText = CONFIG.right.name; |
| renderSidebar(); |
| } |
| |
| function renderSidebar() { |
| const con = document.getElementById('sideContainer'); |
| if(!CONFIG.side || CONFIG.side.length === 0) { con.innerHTML = '<div style="padding:10px; color:#aaa; font-size:10px;">NO METRICS</div>'; return; } |
| con.innerHTML = CONFIG.side.map((item, idx) => { |
| const boxes = item.ranges.map((r, rIdx) => ` |
| <div class="aggr-item"><span class="aggr-val" id="sv-${idx}-${rIdx}">0</span> |
| <span class="aggr-lbl">ATM ±${r} <span class="info-bubble" onclick="showInfo(${idx}, ${rIdx})">i</span></span></div> |
| `).join(''); |
| return `<div class="nf-sec"><div class="nf-title">${item.name}</div><div class="aggr-row">${boxes}</div></div>`; |
| }).join(''); |
| } |
| |
| function openConfig(col) { |
| EDITING_COL = col; |
| const cfg = CONFIG[col]; |
| document.getElementById('confTitle').innerText = (col==='left'?"LEFT":"RIGHT")+" SETTINGS"; |
| document.getElementById('confName').value = cfg.name; |
| document.getElementById('confForm').value = cfg.formula; |
| document.getElementById('confModal').style.display = 'flex'; |
| } |
| function saveConfig() { |
| const name = document.getElementById('confName').value; |
| const form = document.getElementById('confForm').value.toUpperCase(); |
| if(!name || !form) { alert("Empty fields"); return; } |
| CONFIG[EDITING_COL] = { name, formula: form }; |
| saveAndReload(); closeModal('confModal'); |
| } |
| function resetConfig() { if(confirm("Reset?")){CONFIG.left=DEFAULTS.left; CONFIG.right=DEFAULTS.right; saveAndReload(); closeModal('confModal');} } |
| |
| let TEMP_SIDE_CONFIG = []; |
| function openSideConfig() { TEMP_SIDE_CONFIG = JSON.parse(JSON.stringify(CONFIG.side)); renderSideConfigList(); document.getElementById('sideConfModal').style.display = 'flex'; } |
| function renderSideConfigList() { |
| const list = document.getElementById('sideListContainer'); |
| list.innerHTML = TEMP_SIDE_CONFIG.map((item, idx) => ` |
| <div class="conf-item ${item._expanded?'expanded':''}"> |
| <div class="conf-header" onclick="toggleSideItem(${idx})"> |
| <span style="font-weight:700; font-size:12px;">${idx+1}. ${item.name}</span> |
| <div style="display:flex; align-items:center; gap:10px;"><span style="font-size:10px; color:#aaa;">${item.ranges.length} RANGES</span><span class="del-btn" onclick="removeSideItem(event, ${idx})">DEL</span></div> |
| </div> |
| <div class="conf-body" onclick="event.stopPropagation()"> |
| <div class="inp-group" style="margin-top:0;"><span class="inp-label">TITLE</span><input type="text" class="modal-inp" value="${item.name}" onchange="updateSideItem(${idx}, 'name', this.value)"></div> |
| <div class="inp-group"><span class="inp-label">FORMULA</span><input type="text" class="modal-inp" value="${item.formula}" onchange="updateSideItem(${idx}, 'formula', this.value)"></div> |
| <div class="inp-group"><span class="inp-label">RANGES (Comma sep)</span><input type="text" class="modal-inp" value="${item.ranges.join(', ')}" onchange="updateSideItem(${idx}, 'ranges', this.value)"></div> |
| </div> |
| </div>`).join(''); |
| } |
| function toggleSideItem(idx){ TEMP_SIDE_CONFIG[idx]._expanded = !TEMP_SIDE_CONFIG[idx]._expanded; renderSideConfigList(); } |
| function toggleVarHelp(){ const b = document.getElementById('sideVarHelp'); b.style.display = (b.style.display==='none'||b.style.display==='')?'block':'none'; } |
| function addSideItem(){ TEMP_SIDE_CONFIG.push({ id: Date.now(), name: "NEW", formula: "PS-CS", ranges: [10], _expanded: true }); renderSideConfigList(); } |
| function removeSideItem(e, idx){ e.stopPropagation(); if(confirm("Del?")){ TEMP_SIDE_CONFIG.splice(idx, 1); renderSideConfigList(); } } |
| function updateSideItem(idx, f, v){ |
| if(f==='ranges') TEMP_SIDE_CONFIG[idx][f] = v.split(',').map(s=>parseInt(s.trim())).filter(n=>!isNaN(n)&&n>0&&n<=50); |
| else if(f==='formula') TEMP_SIDE_CONFIG[idx][f] = v.toUpperCase(); |
| else TEMP_SIDE_CONFIG[idx][f] = v; |
| } |
| function saveSideConfig(){ CONFIG.side = TEMP_SIDE_CONFIG.map(({_expanded,...r})=>r); saveAndReload(); closeModal('sideConfModal'); } |
| function resetSideConfig(){ if(confirm("Reset Sidebar?")){ CONFIG.side = JSON.parse(JSON.stringify(DEFAULTS.side)); saveAndReload(); closeModal('sideConfModal'); } } |
| function saveAndReload(){ localStorage.setItem('tst_chain_config', JSON.stringify(CONFIG)); applyConfig(); if(!IS_HISTORY) pollData(); } |
| function closeModal(id){ document.getElementById(id).style.display='none'; } |
| |
| function evaluateFormula(formula, vars) { |
| try { |
| let exp = formula.replace(/CS/g,vars.CS||0).replace(/CB/g,vars.CB||0).replace(/PS/g,vars.PS||0).replace(/PB/g,vars.PB||0) |
| .replace(/CO/g,vars.CO||0).replace(/PO/g,vars.PO||0).replace(/CLTP/g,vars.CLTP||0).replace(/PLTP/g,vars.PLTP||0) |
| .replace(/CV/g,vars.CV||0).replace(/PV/g,vars.PV||0); |
| if(/[^0-9+\-*/(). ]/.test(exp)) return 0; |
| return new Function('return '+exp)(); |
| } catch(e){ return 0; } |
| } |
| |
| function showInfo(cardIdx, rangeIdx) { |
| if(!CONFIG.side[cardIdx]) return; |
| const cfg = CONFIG.side[cardIdx]; |
| const range = cfg.ranges[rangeIdx]; |
| |
| document.getElementById('infoSpot').innerText = SPOT_PRICE.toFixed(2); |
| document.getElementById('infoForm').innerText = cfg.formula; |
| |
| const kList = Object.keys(LATEST_ROW_DATA).map(parseFloat).sort((a,b)=>a-b); |
| const lowers = kList.filter(k => k < SPOT_PRICE); |
| const uppers = kList.filter(k => k >= SPOT_PRICE); |
| const allSel = [...lowers.slice(-range), ...uppers.slice(0, range)].sort((a,b)=>a-b); |
| let grandTotal = 0; |
| |
| document.getElementById('infoTable').innerHTML = `<tr><th style="width:50px;">STRIKE</th><th>CS</th><th>CB</th><th>PS</th><th>PB</th><th>CALC</th></tr>` + |
| allSel.map(k => { |
| const d = LATEST_ROW_DATA[k]||{}; |
| const val = evaluateFormula(cfg.formula, d); |
| grandTotal += val; |
| const style = (k >= SPOT_PRICE && lowers.slice(-range).includes(k-(kList[1]-kList[0]))) ? "border-top:2px solid var(--accent);" : ""; |
| return `<tr style="${style}"><td style="font-weight:bold;">${k}</td><td>${d.CS||0}</td><td>${d.CB||0}</td><td>${d.PS||0}</td><td>${d.PB||0}</td><td style="font-weight:bold; color:${val>=0?'var(--green)':'var(--red)'}">${val}</td></tr>`; |
| }).join('') + `<tr class="info-row-res"><td colspan="5" style="text-align:right;">TOTAL</td><td style="color:${grandTotal>=0?'var(--green)':'var(--red)'}">${grandTotal}</td></tr>`; |
| |
| document.getElementById('infoModal').style.display='flex'; |
| } |
| |
| 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 refreshDB() { |
| if(!confirm("Download fresh Master Data?")) return; |
| document.getElementById('loaderMsg').innerText = "DOWNLOADING..."; document.getElementById('loader').style.display='flex'; |
| await fetch('/api/refresh'); checkStatus(); |
| } |
| |
| async function doSearch(q) { |
| 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}')"><span>${i.display}</span><span class="exch-badge badge-${i.exch}">${i.exch}</span></div>`).join(''); |
| d.style.display='block'; |
| } catch(e){} |
| } |
| |
| async function loadSymbol(root) { |
| document.getElementById('searchInp').value = root; document.getElementById('searchRes').style.display='none'; |
| document.getElementById('loaderMsg').innerText = "LOADING CHAIN..."; document.getElementById('loader').style.display='flex'; |
| document.getElementById('histDate').innerHTML="<option>DATE</option>"; document.getElementById('histTime').innerHTML="<option>TIME</option>"; |
| document.getElementById('secBar').innerHTML=""; HIST_EXPIRIES=[]; SELECTED_HIST_EXP=null; |
| |
| try { |
| const r = await fetch(`/api/chain?symbol=${root}`); if(!r.ok) throw new Error("Symbol not found"); |
| const d = await r.json(); |
| CURRENT_ROOT=root; CHAIN_DATA=d.items; SPOT_SYM=d.spot; SPOT_PRICE=0; |
| FUT_SYMS = CHAIN_DATA.filter(x => x.t === 'FUT').sort((a,b)=>a.e-b.e).slice(0,3); |
| if(SPOT_SYM==="MCX" && FUT_SYMS.length>0) SPOT_SYM = FUT_SYMS[0].s; |
| document.getElementById('spotLabel').innerText = (SPOT_SYM.includes('INDEX')||SPOT_SYM.includes('-EQ')||SPOT_SYM.startsWith('BSE:'))?"SPOT PRICE":"FUT REF"; |
| setupExpiries(); document.getElementById('loader').style.display='none'; |
| if(IS_HISTORY) loadHistDates(); |
| } catch(e){ alert("Error: "+e.message); document.getElementById('loader').style.display='none'; } |
| } |
| |
| function openExpModal() { |
| if(IS_HISTORY) { |
| if(HIST_EXPIRIES.length===0){alert("No History Expiries"); return;} |
| document.getElementById('expGrid').innerHTML = HIST_EXPIRIES.map(e=>`<div class="opt-btn ${e===SELECTED_HIST_EXP?'active':''}" onclick="selectHistExp('${e}')">${e}</div>`).join(''); |
| document.getElementById('expModal').style.display='flex'; |
| } else { |
| document.getElementById('expModal').style.display='flex'; |
| } |
| } |
| function selectHistExp(e) { SELECTED_HIST_EXP=e; updateHistExpUI(); closeModal('expModal'); loadHistData(); } |
| |
| function setupExpiries() { |
| if(IS_HISTORY) return; |
| const opts = CHAIN_DATA.filter(x=>x.t==='CE'||x.t==='PE'); |
| const exps = [...new Set(opts.map(i=>i.e))].sort((a,b)=>a-b); |
| const today = Math.floor(Date.now()/1000)-86400; |
| const valids = exps; |
| if(valids.length===0){ document.getElementById('chainBody').innerHTML='<div style="padding:20px;text-align:center;">NO DATA</div>'; return; } |
| ACTIVE_EXP = valids.find(t=>t>today) || valids[0]; |
| updateExpText(ACTIVE_EXP); |
| document.getElementById('expGrid').innerHTML = valids.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(); |
| } |
| function selectExp(ts) { ACTIVE_EXP=ts; updateExpText(ts); closeModal('expModal'); document.querySelectorAll('.opt-btn').forEach(b=>b.classList.remove('active')); buildChain(); } |
| function updateExpText(ts) { document.getElementById('selExpTxt').innerText = ts===0?"SELECT":new Date(ts*1000).toLocaleDateString('en-GB',{day:'2-digit',month:'short'}); } |
| |
| function buildChain() { |
| let rel = IS_HISTORY ? OPT_SYMS : CHAIN_DATA.filter(x=>x.e===ACTIVE_EXP&&(x.t==='CE'||x.t==='PE')); |
| if(!IS_HISTORY) OPT_SYMS = rel; |
| const ks = [...new Set(rel.map(x=>x.k))].sort((a,b)=>a-b); |
| if(ks.length===0) { document.getElementById('chainBody').innerHTML='<div style="padding:20px;text-align:center;">NO DATA</div>'; return; } |
| document.getElementById('chainBody').innerHTML = ks.map(k=>` |
| <div class="row" id="row-${k}" data-k="${k}"> |
| <div class="graph-col"><div style="width:160px;height:100%;position:relative;display:flex;flex-direction:column;justify-content:center;align-items:center;"> |
| <div class="visual-bar" id="barL-${k}"></div><span class="strike-txt">${k}</span><span class="delta-txt" id="valL-${k}">-</span> |
| </div></div> |
| <div class="graph-col"><div style="width:160px;height:100%;position:relative;display:flex;flex-direction:column;justify-content:center;align-items:center;"> |
| <div class="visual-bar" id="barR-${k}"></div><span class="strike-txt">${k}</span><span class="delta-txt" id="valR-${k}">-</span> |
| </div></div> |
| </div>`).join(''); |
| window.scrolled=false; |
| if(!IS_HISTORY) restartPoller(); |
| } |
| function restartPoller() { if(POLLER) clearInterval(POLLER); POLLER=setInterval(pollData, 1500); pollData(); } |
| |
| async function pollData() { |
| if(IS_POLLING || !CURRENT_ROOT || IS_HISTORY) return; |
| IS_POLLING=true; |
| try { |
| let syms = [SPOT_SYM, ...FUT_SYMS.map(x=>x.s)]; |
| const ks = Array.from(document.querySelectorAll('.row')).map(r=>parseFloat(r.dataset.k)); |
| if(ks.length>0) { |
| const ref = SPOT_PRICE>0?SPOT_PRICE:ks[Math.floor(ks.length/2)]; |
| let sk = ks.find(k=>k>=ref); if(sk===undefined) sk=ks[ks.length-1]; |
| ATM_STRIKE=sk; const idx = ks.indexOf(sk); |
| document.querySelectorAll('.atm').forEach(e=>e.classList.remove('atm')); |
| const r = document.getElementById(`row-${sk}`); |
| if(r) { r.classList.add('atm'); if(!window.scrolled && SPOT_PRICE>0){r.scrollIntoView({block:'center'}); window.scrolled=true;} } |
| |
| let maxR = 10; |
| if(CONFIG.side) CONFIG.side.forEach(i=>{if(i.ranges){const m=Math.max(...i.ranges); if(m>maxR) maxR=m;}}); |
| const vKs = ks.slice(Math.max(0,idx-20), Math.min(ks.length,idx+20)); |
| const cKs = ks.slice(Math.max(0,idx-maxR), Math.min(ks.length,idx+maxR+1)); |
| const all = new Set([...vKs, ...cKs]); |
| OPT_SYMS.forEach(o=>{if(all.has(o.k)) syms.push(o.s)}); |
| } |
| const u = [...new Set(syms)].filter(s=>s&&s.length>2); |
| if(u.length===0) return; |
| |
| const req = await fetch(`https://api-t1.fyers.in/data/depth?symbol=${u.join(',')}&ohlcv_flag=1`, {headers:{'Authorization':AUTH_TOKEN}}); |
| if(req.status===401) { location.href='/logout'; return; } |
| const j = await req.json(); |
| if(j.s==='ok') { updateUI(j.d); document.getElementById('statusDot').classList.add('active'); document.getElementById('statusDot').classList.remove('error'); } |
| } catch(e){ document.getElementById('statusDot').classList.remove('active'); document.getElementById('statusDot').classList.add('error'); } |
| finally { IS_POLLING=false; } |
| } |
| |
| function updateUI(data) { |
| if(data[SPOT_SYM] && typeof data[SPOT_SYM].ltp==='number') { SPOT_PRICE=data[SPOT_SYM].ltp; document.getElementById('spotPrice').innerText=SPOT_PRICE.toFixed(2); } |
| document.getElementById('futBody').innerHTML = FUT_SYMS.map(f=>{ |
| const d=data[f.s]; if(!d) return ''; |
| const t = (d.totalbuyqty+d.totalsellqty)||1; const bp = (d.totalbuyqty/t)*100; |
| return `<div class="fut-card"><div style="display:flex;justify-content:space-between;font-weight:700;"><span>${new Date(f.e*1000).toLocaleDateString('en-GB',{day:'numeric',month:'short'})} FUT</span><span style="color:${d.ch>=0?'var(--green)':'var(--red)'}">${d.ltp}</span></div><div class="meter-bg"><div style="width:${bp}%;background:var(--green)"></div><div style="width:${100-bp}%;background:var(--red)"></div></div><div style="display:flex;justify-content:space-between;font-size:10px;color:#666;margin-top:4px;"><span>B: ${fmt(d.totalbuyqty)}</span><span>S: ${fmt(d.totalsellqty)}</span></div></div>`; |
| }).join(''); |
| |
| const rd = {}; |
| for(const i of OPT_SYMS) { |
| const d=data[i.s]; if(!d) continue; |
| if(!rd[i.k]) rd[i.k]={CS:0,CB:0,PS:0,PB:0,CO:0,PO:0,CLTP:0,PLTP:0,CV:0,PV:0}; |
| if(i.t==='CE'){ rd[i.k].CS=d.totalsellqty; rd[i.k].CB=d.totalbuyqty; rd[i.k].CO=d.oi; rd[i.k].CLTP=d.ltp; rd[i.k].CV=d.v; } |
| if(i.t==='PE'){ rd[i.k].PS=d.totalsellqty; rd[i.k].PB=d.totalbuyqty; rd[i.k].PO=d.oi; rd[i.k].PLTP=d.ltp; rd[i.k].PV=d.v; } |
| } |
| LATEST_ROW_DATA = rd; |
| |
| let mL=0, mR=0; const pR={}; |
| for(const k in rd) { |
| pR[k] = { l: evaluateFormula(CONFIG.left.formula, rd[k]), r: evaluateFormula(CONFIG.right.formula, rd[k]) }; |
| mL = Math.max(mL, Math.abs(pR[k].l)); mR = Math.max(mR, Math.abs(pR[k].r)); |
| } |
| for(const k in pR) { |
| const r = pR[k]; |
| const vl = document.getElementById(`valL-${k}`); |
| if(vl) { vl.innerText=(r.l>0?"+":"")+fmt(r.l); vl.style.color=r.l>=0?'var(--green)':'var(--red)'; |
| const bl=document.getElementById(`barL-${k}`); if(mL>0){ bl.style.left='0%'; bl.style.width=(Math.abs(r.l)/mL)*100+'%'; bl.style.background=r.l>=0?'var(--green-soft)':'var(--red-soft)'; } } |
| const vr = document.getElementById(`valR-${k}`); |
| if(vr) { vr.innerText=(r.r>0?"+":"")+fmt(r.r); vr.style.color=r.r>=0?'var(--green)':'var(--red)'; |
| const br=document.getElementById(`barR-${k}`); if(mR>0){ br.style.left='0%'; br.style.width=(Math.abs(r.r)/mR)*100+'%'; br.style.background=r.r>=0?'var(--green-soft)':'var(--red-soft)'; } } |
| } |
| |
| if(IS_HISTORY && SPOT_PRICE>0) { |
| const ks = Object.keys(rd).map(parseFloat).sort((a,b)=>a-b); |
| let sk = ks.find(k=>k>=SPOT_PRICE); if(sk===undefined) sk=ks[ks.length-1]; |
| document.querySelectorAll('.atm').forEach(e=>e.classList.remove('atm')); |
| const r = document.getElementById(`row-${sk}`); if(r) r.classList.add('atm'); |
| } |
| |
| if(SPOT_PRICE>0 && CONFIG.side) { |
| const ks = Object.keys(rd).map(parseFloat).sort((a,b)=>a-b); |
| const ls = ks.filter(k=>k<SPOT_PRICE); const us = ks.filter(k=>k>=SPOT_PRICE); |
| CONFIG.side.forEach((it,ix)=>{ |
| it.ranges.forEach((rng,rx)=>{ |
| const el=document.getElementById(`sv-${ix}-${rx}`); |
| if(el) { |
| const ts = [...ls.slice(-rng), ...us.slice(0,rng)]; |
| let t=0; for(const k of ts) if(rd[k]) t+=evaluateFormula(it.formula, rd[k]); |
| el.innerText=(t>0?"+":"")+fmt(t); el.style.color=t>=0?'var(--green)':'var(--red)'; |
| } |
| }); |
| }); |
| } |
| } |
| function fmt(n) { |
| if(!n) return '0'; 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(); |
| } |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| if __name__ == "__main__": |
| uvicorn.run(app, host="0.0.0.0", port=7860) |